Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c9ce72b51 | |||
| a1d70f287f | |||
| 6d295aa7d9 | |||
| 1871bc2971 | |||
| cd758d5d46 | |||
| c9e4f930c4 | |||
| aff1974a65 | |||
| 795dec9227 | |||
| a0387425e1 | |||
| f8695a0e36 | |||
| c8e741ca41 | |||
| 78e9bc2583 | |||
| bf7b5d9442 | |||
| 9519b37788 | |||
| d8af0aeb44 | |||
| 53da67eacf | |||
| c2d9adc45f | |||
| 0963c140a3 | |||
| 3a24eafcc2 | |||
| dc9a23f801 | |||
| 0eb616e322 | |||
| 88ac4361d2 | |||
| 6388f2f4da | |||
| 9a0df8d8c9 | |||
| 1ceef16af4 | |||
| dbf3f8a93a | |||
| 1942c43270 | |||
| b42e892c3f | |||
| f9550d8330 | |||
| 4fc8ebbd03 | |||
| f13d1507ad | |||
| 39d5001bd6 | |||
| 0cf27a981a | |||
| 9f893969ca | |||
| c5e50354ba | |||
| c2574acaba | |||
| b2b0a13557 | |||
| 19c72ad2bf | |||
| fe815db348 | |||
| 3a133c349d | |||
| cff6e61d2f | |||
| 1cc592bef6 | |||
| 7d6ee0271d | |||
| d0801ef0ff | |||
| a04588740b | |||
| 8d21390294 | |||
| 9f9091f6fb | |||
| 1e24fd5354 | |||
| 870e0a40d8 | |||
| 41d143458f | |||
| 444bd3e42f | |||
| 03d1de2954 | |||
| 4daf611f05 | |||
| e3b458d774 | |||
| 080dae3e74 | |||
| 021f16cf14 | |||
| 79b127d406 | |||
| 577093b9fa | |||
| f0e38b9776 | |||
| ab8f15d49d | |||
| dd5999b51f | |||
| 40fa9797e8 | |||
| 64fe6327d8 | |||
| 705310da28 | |||
| 6e2c1f79d3 | |||
| 45cd18af99 | |||
| 9bfef3bf2d | |||
| cd4e6f0c82 | |||
| 9f9e952c4e | |||
| f19e2bfe6d | |||
| 85cee6b295 | |||
| 57a96044bc | |||
| 0f297185e4 | |||
| cf8a2e0ce5 | |||
| c9b0d2ffc9 | |||
| 17ec871d7c | |||
| 09692edf06 | |||
| 02c29f8692 | |||
| 14f8db0a32 | |||
| a38a729137 | |||
| b7b1daa035 | |||
| 4ef7b00c16 | |||
| 8bcce02368 | |||
| 7d4322f975 | |||
| 5257c50243 | |||
| 76e163098b | |||
| 5543c0514a | |||
| fef63c1224 | |||
| 337cabe180 | |||
| 3c2b4ce544 | |||
| deacb8b9a0 | |||
| 68a2dbfa04 | |||
| 1464ea326e | |||
| 778aa04904 | |||
| 43516d6dab | |||
| 521c3dc394 | |||
| d3366ca7ef | |||
| d6c3725b02 | |||
| 2c9ebf139f | |||
| 688b9e1927 | |||
| 0dd4460e35 | |||
| 14081a3d44 | |||
| 223ae57929 | |||
| 0f992bf3a8 | |||
| c94090c4b5 | |||
| 95361be6f4 | |||
| b7a8f4f60d |
+837
-264
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -42,25 +43,116 @@ def get_settings() -> dict[str, Any]:
|
||||
tg_api_id = os.getenv("TG_API_ID")
|
||||
tg_api_hash = os.getenv("TG_API_HASH")
|
||||
if not tg_api_id or not tg_api_hash:
|
||||
print("TG_API_ID and TG_API_HASH must be set")
|
||||
os._exit(1)
|
||||
print("TG_API_ID and TG_API_HASH must be set", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Validate and convert TG_API_ID to integer
|
||||
try:
|
||||
tg_api_id_int = int(tg_api_id)
|
||||
except ValueError:
|
||||
print(f"TG_API_ID must be a valid integer, got: {tg_api_id!r}", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
# Build MTProto proxy config if proxy host is provided.
|
||||
# Telegram MTProto proxy (telegrammessenger/proxy) exposes a SOCKS5 interface,
|
||||
# so Pyrogram connects to it via SOCKS5 scheme.
|
||||
proxy_host = os.getenv("TG_PROXY_HOST")
|
||||
proxy: dict[str, Any] | None = None
|
||||
if proxy_host:
|
||||
# Validate and convert TG_PROXY_PORT to integer
|
||||
try:
|
||||
proxy_port = int(os.getenv("TG_PROXY_PORT") or 1080)
|
||||
except ValueError:
|
||||
print(f"TG_PROXY_PORT must be a valid integer, got: {os.getenv('TG_PROXY_PORT')!r}", flush=True)
|
||||
sys.exit(1)
|
||||
proxy = {
|
||||
"scheme": "SOCKS5",
|
||||
"hostname": proxy_host,
|
||||
"port": proxy_port,
|
||||
"username": os.getenv("TG_PROXY_USERNAME") or None,
|
||||
"password": os.getenv("TG_PROXY_PASSWORD") or None,
|
||||
}
|
||||
|
||||
# Validate and convert API_PORT to integer
|
||||
try:
|
||||
api_port = int(os.getenv("API_PORT") or 8000)
|
||||
except ValueError:
|
||||
print(f"API_PORT must be a valid integer, got: {os.getenv('API_PORT')!r}", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Local helper to parse int env vars with a default and exit on a bad value
|
||||
def _parse_int_env(name: str, default: int, minimum: int = 1) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw.strip() == "":
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
print(f"{name} must be a valid integer, got: {raw!r}", flush=True)
|
||||
sys.exit(1)
|
||||
if value < minimum:
|
||||
print(f"{name} must be >= {minimum}, got: {value}", flush=True)
|
||||
sys.exit(1)
|
||||
return value
|
||||
|
||||
return {
|
||||
"tg_api_id": int(tg_api_id),
|
||||
"tg_api_id": tg_api_id_int,
|
||||
"tg_api_hash": tg_api_hash,
|
||||
"session_path": os.getenv("SESSION_PATH", "data") or "data",
|
||||
"api_host": os.getenv("API_HOST", "0.0.0.0"),
|
||||
"api_port": int(os.getenv("API_PORT") or 8000),
|
||||
"api_port": api_port,
|
||||
"pyrogram_bridge_url": os.getenv("PYROGRAM_BRIDGE_URL", ""),
|
||||
"log_level": log_level,
|
||||
"debug": os.getenv("DEBUG", "False") == "True",
|
||||
"debug": os.getenv("DEBUG", "False").strip() in ["True", "true"],
|
||||
"token": os.getenv("TOKEN", ""),
|
||||
"trusted_proxies": [ip.strip() for ip in os.getenv("TRUSTED_PROXIES", "").split(",") if ip.strip()],
|
||||
"time_based_merge": os.getenv("TIME_BASED_MERGE", "False").strip() in ["True", "true"],
|
||||
"show_bridge_link": os.getenv("SHOW_BRIDGE_LINK", "False").strip() in ["True", "true"],
|
||||
"show_post_flags": os.getenv("SHOW_POST_FLAGS", "False").strip() in ["True", "true"],
|
||||
"rss_cache_update_interval": int(os.getenv("RSS_CACHE_UPDATE_INTERVAL", "3600")),
|
||||
"rss_cache_update_delay": int(os.getenv("RSS_CACHE_UPDATE_DELAY", "60")),
|
||||
"rss_cache_max_age_days": int(os.getenv("RSS_CACHE_MAX_AGE_DAYS", "30")),
|
||||
}
|
||||
"proxy": proxy,
|
||||
# Hard cap (seconds) on any single live Telegram RPC held under the global RPC gate,
|
||||
# so a hung MTProto call can never pin the gate (and the whole app) indefinitely.
|
||||
"tg_rpc_timeout": _parse_int_env("TG_RPC_TIMEOUT", 60),
|
||||
"tg_watchdog_enabled": os.getenv("TG_WATCHDOG_ENABLED", "true").strip().lower() not in ["false", "0", "no", "off", "disable", "disabled"],
|
||||
"tg_watchdog_interval": _parse_int_env("TG_WATCHDOG_INTERVAL", 60),
|
||||
"tg_watchdog_timeout": _parse_int_env("TG_WATCHDOG_TIMEOUT", 10),
|
||||
"tg_watchdog_failures": _parse_int_env("TG_WATCHDOG_FAILURES", 3),
|
||||
"tg_watchdog_restart_timeout": _parse_int_env("TG_WATCHDOG_RESTART_TIMEOUT", 90),
|
||||
"tg_watchdog_heartbeat_every": _parse_int_env("TG_WATCHDOG_HEARTBEAT_EVERY", 30),
|
||||
"tg_disconnect_flap_limit": _parse_int_env("TG_DISCONNECT_FLAP_LIMIT", 3),
|
||||
"tg_disconnect_flap_window": _parse_int_env("TG_DISCONNECT_FLAP_WINDOW", 120),
|
||||
# /ping reports TG as unhealthy once the last successful watchdog probe is older than
|
||||
# this many seconds. Default is derived from the watchdog cadence: it is roughly how
|
||||
# long the watchdog itself would take to give up and trigger a restart —
|
||||
# interval * (failures + 1) + timeout. With the defaults (60,3,10) that is 250s, so a
|
||||
# transient slow probe never flaps /ping, but a genuinely stuck session (no successful
|
||||
# probe for ~4 min) surfaces as 503 before/around the time the watchdog restarts.
|
||||
"tg_ping_unhealthy_after": _parse_int_env(
|
||||
"TG_PING_UNHEALTHY_AFTER",
|
||||
_parse_int_env("TG_WATCHDOG_INTERVAL", 60) * (_parse_int_env("TG_WATCHDOG_FAILURES", 3) + 1)
|
||||
+ _parse_int_env("TG_WATCHDOG_TIMEOUT", 10),
|
||||
),
|
||||
# Media download timeout scales with file size (large videos): the per-download
|
||||
# timeout is clamped to [min, max] seconds, with an effective floor of
|
||||
# `media_download_min_speed` bytes/s (timeout ≈ file_size / min_speed).
|
||||
"media_download_timeout_min": _parse_int_env("MEDIA_DOWNLOAD_TIMEOUT_MIN", 120),
|
||||
"media_download_timeout_max": _parse_int_env("MEDIA_DOWNLOAD_TIMEOUT_MAX", 1800),
|
||||
"media_download_min_speed": _parse_int_env("MEDIA_DOWNLOAD_MIN_SPEED", 256 * 1024),
|
||||
# Size of the asyncio default ThreadPoolExecutor. SQLite/python-magic/pickle/os.walk
|
||||
# all run via asyncio.to_thread; the interpreter default (min(32, cpu+4)) is only 5-6
|
||||
# on a 1-2 CPU container, which starves those under load. 32 gives ample headroom.
|
||||
"io_thread_pool_size": _parse_int_env("IO_THREAD_POOL_SIZE", 32),
|
||||
# Max concurrent Telegram file transmissions (Pyrogram get_file/save_file
|
||||
# semaphores). Kurigram's default is 1, which lets a single hung download block
|
||||
# ALL media downloads process-wide; 3 aligns with HTTP_DOWNLOAD_SEMAPHORE. This is
|
||||
# only a blast-radius limiter — the real cure for a zombie media connection is the
|
||||
# download-timeout-triggered restart below.
|
||||
"tg_max_concurrent_transmissions": _parse_int_env("TG_MAX_CONCURRENT_TRANSMISSIONS", 3),
|
||||
# After this many CONSECUTIVE media-download timeouts, force an in-process client
|
||||
# restart to rebuild the (zombie) media-DC connection. Any successful download
|
||||
# resets the streak, so this only fires on a genuine death loop, not on the odd
|
||||
# slow large-video timeout. The watchdog cannot catch this — it probes the main DC.
|
||||
"media_timeout_restart_threshold": _parse_int_env("MEDIA_TIMEOUT_RESTART_THRESHOLD", 5),
|
||||
}
|
||||
|
||||
+31
-3
@@ -8,6 +8,29 @@ services:
|
||||
environment:
|
||||
TG_API_ID: XXX
|
||||
TG_API_HASH: XXX
|
||||
# TG_PROXY_HOST: 10.0.0.1 # MTProto proxy host (SOCKS5). Prefer a literal IP over a hostname:
|
||||
# # a hostname is re-resolved on every reconnect, so flaky DNS during a
|
||||
# # network blip can wedge Pyrogram's reconnect.
|
||||
# TG_PROXY_PORT: 1080 # MTProto proxy SOCKS5 port (default: 1080)
|
||||
# TG_PROXY_USERNAME: XXX # SOCKS5 username (optional)
|
||||
# TG_PROXY_PASSWORD: XXX # SOCKS5 password (optional)
|
||||
# TG_WATCHDOG_ENABLED: "True" # Active liveness watchdog: detects 'zombie' sessions and restarts in-process (default: True)
|
||||
# TG_WATCHDOG_INTERVAL: 60 # Seconds between liveness probes (default: 60)
|
||||
# TG_WATCHDOG_TIMEOUT: 10 # Seconds to wait for each get_me probe (default: 10)
|
||||
# TG_WATCHDOG_FAILURES: 3 # Consecutive failed probes before restart (default: 3)
|
||||
# TG_WATCHDOG_HEARTBEAT_EVERY: 30 # Emit an INFO watchdog heartbeat every N successful probes (default: 30)
|
||||
# TG_DISCONNECT_FLAP_LIMIT: 3 # Disconnect events within the flap window before an in-process restart (default: 3)
|
||||
# TG_DISCONNECT_FLAP_WINDOW: 120 # Flap detection window in seconds (default: 120)
|
||||
# TG_CHAT_CACHE_TTL_HOURS: 12 # TTL for cached channel info (title/username/id); removes GetFullChannel from the poll hot path (default: 12)
|
||||
# TG_RPC_CONCURRENCY: 1 # Max concurrent live Telegram RPC calls — global throttle (default: 1)
|
||||
# TG_RPC_MIN_INTERVAL_MS: 500 # Minimum gap between live Telegram RPC starts, ms (default: 500)
|
||||
# TG_RPC_TIMEOUT: 60 # Max seconds a single live Telegram RPC may run before timing out (default: 60)
|
||||
# MEDIA_DOWNLOAD_TIMEOUT_MIN: 120 # Min per-download timeout, seconds — also the timeout for regular (non-large) files (default: 120)
|
||||
# MEDIA_DOWNLOAD_TIMEOUT_MAX: 1800 # Max per-download timeout, seconds — cap for the largest videos (default: 1800)
|
||||
# MEDIA_DOWNLOAD_MIN_SPEED: 262144 # Assumed floor download speed, bytes/s — large-video timeout ≈ file_size / this, clamped to [MIN,MAX] (default: 262144 = 256 KB/s)
|
||||
# IO_THREAD_POOL_SIZE: 32 # Size of the asyncio default threadpool for blocking I/O (SQLite/python-magic/pickle/os.walk); raise on a busy 1-2 CPU box (default: 32)
|
||||
# TG_MAX_CONCURRENT_TRANSMISSIONS: 3 # Max concurrent Telegram file transmissions (Pyrogram get_file semaphore). Kurigram default is 1, so one hung download blocks ALL media (default: 3)
|
||||
# MEDIA_TIMEOUT_RESTART_THRESHOLD: 5 # Consecutive media-download timeouts before an in-process restart rebuilds the zombie media-DC connection (the main-DC watchdog can't see this) (default: 5)
|
||||
PYROGRAM_BRIDGE_URL: https://pgbridge.example.com
|
||||
API_PORT: 80
|
||||
TOKEN: ХХХ
|
||||
@@ -32,10 +55,15 @@ services:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
autoheal: true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-s", "-f", "-o", "/dev/null", "http://127.0.0.1:80/rss/vvzvlad_lytdybr?limit=1"]
|
||||
interval: 30m
|
||||
# Lightweight process/loop liveness probe. /ping never touches Telegram or the
|
||||
# filesystem, so it answers instantly even while a TG RPC is hung — unlike the old
|
||||
# /rss?limit=1 check, which could exceed the 5s timeout on a cold cache and get the
|
||||
# container restarted mid-download (corrupted temp files). TG liveness is now judged
|
||||
# by the in-process watchdog, which /ping reports via its last-probe age.
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:80/ping"]
|
||||
interval: 5m
|
||||
timeout: 5s
|
||||
retries: 2
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
start_interval: 5s
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Стадия 7 — сквозная верификация плана стабилизации
|
||||
|
||||
Дата: 2026-07-05. Ветка: `fix/stage-7-verification` (на базе `fix/stage-6-healthcheck`,
|
||||
кумулятивно содержит стадии 1–6). Эта стадия **ничего не меняет в поведении** прод-кода —
|
||||
только автоматизирует проверки и фиксирует, что должен наблюдать оператор после деплоя.
|
||||
|
||||
## Итог автотестов
|
||||
|
||||
Каноничный прогон (изоляция `config` через `sys.modules`): `python -m pytest tests/`.
|
||||
|
||||
```
|
||||
260 passed
|
||||
```
|
||||
|
||||
Раскладка по стадиям (все зелёные):
|
||||
|
||||
| Стадия | Файл тестов | Кол-во | Что покрывает |
|
||||
|--------|-------------|--------|----------------|
|
||||
| 1 — анти-зависания | `tests/test_stage1_hangs.py` | 6 | таймаут RPC-гейта без утечки пермита; воркер переживает Exception/FloodWait, `task_done` сбалансирован; отмена в spacing-ожидании не теряет пермит |
|
||||
| 2 — статика/большие видео | `tests/test_stage2_static.py` | 15 | атомарный `_download_atomic` (publish-on-rename, чистка `.part.` при таймауте/zero-size/гонке); дедуп конкурентных скачиваний; FloodWait→429; touch mtime у `temp_*`; свипер чистит `.part.`+legacy `.tmp.`; баланс HTTP-семафора |
|
||||
| 3 — FileResponse | `tests/test_stage3_fileresponse.py` | 19 | матрица Range (0-499/500-/-500/за EOF→416/мусор→400/мульти-range→206 multipart); сохранены mtime-touch, delete_after-BackgroundTask, MIME-кэш; чистый ASGI-логгер |
|
||||
| 4 — гигиена event loop | `tests/test_stage4_eventloop.py` | 19 | ленивый `raw_message`; вынос side-effect IO из `process_message` (bulk upsert media id); рендер-пайплайн ушёл в поток без create_task/get_running_loop; XSS вычищен во всех 4 выводах ровно одним проходом |
|
||||
| 5 — батчинг SQLite | `tests/test_stage5_sqlite.py` | 8 | кэш-хит пишет в аккумулятор, а не в SQLite; flush→DB; snapshot-then-clear не теряет поздние апдейты; re-queue при сбое; str(channel)-ключи |
|
||||
| 6 — healthcheck | `tests/test_stage6_healthcheck.py` | 11 | `/ping` 200/503 по connected+age; ноль TG RPC; watchdog отключён→чистая проверка коннекта; `watchdog_last_ok_age()` |
|
||||
| 7 — интеграция | `tests/test_stage7_integration.py` | 8 | сквозные сценарии (см. ниже) |
|
||||
| — регрессия парсера | `tests/test_postparser_*.py` | 174 | заголовки/флаги/автор (существовавшие до плана) |
|
||||
|
||||
## Новые интеграционные тесты стадии 7 (DoD → сквозное доказательство)
|
||||
|
||||
`tests/test_stage7_integration.py` драйвит **реальные точки входа** (`get_media`, `ping`,
|
||||
flush), чтобы поймать регрессии, которые проявляются только при взаимодействии стадий:
|
||||
|
||||
1. **Range на уровне маршрута `/media`** — `test_media_route_range_prefix_0_99`,
|
||||
`_range_suffix_last_100`, `_range_unsatisfiable_416`. Прогоняют `get_media` (кэш-хит) →
|
||||
`prepare_file_response` → `FileResponse` через реальный ASGI: `bytes=0-99`→206 с точным
|
||||
Content-Range/длиной и байтами, `bytes=-100`→206 (суффикс), `bytes=999999999-`→416 (`*/size`).
|
||||
Ловит: если кэш-хит перестанет доходить до FileResponse (ре-буферизация тела, ручные
|
||||
заголовки) или сломается связка digest-гейта — Range перестанет работать. (Стадия 3
|
||||
пинит это на `prepare_file_response` напрямую; здесь — сшивка стадий 2+3.)
|
||||
2. **`/ping` быстр и без RPC при висящей операции** — `test_ping_prompt_and_rpc_free_while_slow_op_pending`
|
||||
и `_reports_degraded_promptly_while_slow_op_pending`. Пока фейковая медленная корутина
|
||||
(модель зависшего hot-path) висит на `Event`, `ping()` возвращает 200/503 корректно и
|
||||
**до** завершения медленной операции, при нуле вызовов `get_me`/`safe_get_messages`.
|
||||
Ловит: рекаплинг `/ping` к TG RPC или к любому awaitable, который может застопориться.
|
||||
3. **Дедуп + очистка при disconnect через реальный `get_media`** —
|
||||
`test_get_media_concurrent_shares_one_download_and_drains_registry` (2 конкурентных
|
||||
запроса → одна скачка, `_inflight` пуст) и `_request_cancel_does_not_stick_registry_or_hang_sibling`
|
||||
(отмена запроса-«клиента» не оставляет застрявший ключ и не вешает соседа). Ловит:
|
||||
возврат скачивания в корутину запроса (отмена убила бы download) или потерю `finally`-pop.
|
||||
4. **str(channel)-ключ access-time end-to-end** — `test_media_cache_hit_flush_updates_str_keyed_db_row`.
|
||||
Кэш-хит `/media` записывает str-ключ, flush обновляет ту же строку в реальной SQLite
|
||||
(hit→flush→DB). Сшивает две половины: ключ, который пишет hot-path, и WHERE, по которому
|
||||
апдейтит flush. Ловит любое расхождение ключа аккумулятора и WHERE bulk-UPDATE (напр.
|
||||
перестановку колонок в SQL — проверено мутацией: тест краснеет, `added` остаётся stale).
|
||||
(int/str-хазард самого канала живёт в `download_media_file` и закрыт стадией 5 —
|
||||
`test_cache_hit_keys_channel_as_str`; здесь покрыта связка get_media+flush.)
|
||||
|
||||
## Соответствие DoD стадий → доказательство
|
||||
|
||||
- **DoD 1** «ни один путь не ждёт TG без таймаута; воркер не умирает молча» →
|
||||
`test_stage1_hangs.py` (гейт-таймаут, живучесть воркера). Живая проверка supervision
|
||||
под нагрузкой — **наблюдение оператора** (лог `supervised_task_crashed/…_exited`).
|
||||
- **DoD 2** «клиенту никогда не отдаётся неполный файл; флуд→429; обрезки не живут >часа» →
|
||||
`test_stage2_static.py` целиком + интеграционный дедуп-тест стадии 7.
|
||||
- **DoD 3** «Range-тесты зелёные; поведение эндпоинта эквивалентно (± RFC-допущения)» →
|
||||
`test_stage3_fileresponse.py` + Range на уровне `/media` (стадия 7). «Нет потока-на-чанк»
|
||||
— **наблюдение оператора** (нагрузочный запрос большого файла, число io-потоков).
|
||||
- **DoD 4** «генерация 100-сообщ. фида не блокирует луп (параллельный /ping <100 мс); XSS
|
||||
зелёный; media id сохраняются» → `test_stage4_eventloop.py` (рендер в потоке, XSS,
|
||||
bulk upsert) + `/ping`-decoupling стадии 7. Живой замер «<100 мс на проде» —
|
||||
**наблюдение оператора**.
|
||||
- **DoD 5** «на кэш-хит /media ноль обращений к SQLite; фоновая запись раз в минуту» →
|
||||
`test_stage5_sqlite.py` + str-ключ end-to-end стадии 7.
|
||||
- **DoD 6** «во время зависшего TG RPC /ping отвечает мгновенно (503); контейнер не
|
||||
рестартится от медленного фида» → `test_stage6_healthcheck.py` + `/ping`-under-slow-op
|
||||
стадии 7. Отсутствие autoheal-рестартов на холодном кэше — **наблюдение оператора**.
|
||||
|
||||
## Ручные curl-сценарии для оператора (пост-деплой, локально в контейнере)
|
||||
|
||||
Подставить реальный `{channel}/{post_id}/{fid}/{digest}` (валидная подпись обязательна).
|
||||
|
||||
```bash
|
||||
# 1. Range-тройка (ожидания: 206 / 206 / 416)
|
||||
curl -s -D- -o /dev/null -H "Range: bytes=0-99" "http://127.0.0.1:80/media/{ch}/{pid}/{fid}/{digest}"
|
||||
curl -s -D- -o /dev/null -H "Range: bytes=-100" "http://127.0.0.1:80/media/{ch}/{pid}/{fid}/{digest}"
|
||||
curl -s -D- -o /dev/null -H "Range: bytes=999999999-" "http://127.0.0.1:80/media/{ch}/{pid}/{fid}/{digest}"
|
||||
|
||||
# 2. Параллельные запросы одного БОЛЬШОГО видео (>100 MB), пока не в кэше:
|
||||
# оба должны получить ПОЛНЫЙ файл (одинаковый размер), без частичной отдачи.
|
||||
URL="http://127.0.0.1:80/media/{ch}/{pid}/{bigfid}/{digest}"
|
||||
curl -s -o /tmp/a "$URL" & curl -s -o /tmp/b "$URL" & wait
|
||||
ls -l /tmp/a /tmp/b # размеры совпадают и равны полному файлу; на диске нет *.part.*
|
||||
|
||||
# 3. Обрыв на середине стрима — нет утечки тасков/фд:
|
||||
# считать fd до/после и убедиться, что не растут монотонно.
|
||||
lsof -p $(pgrep -f api_server) | wc -l # baseline
|
||||
timeout 2 curl -s -o /dev/null "$URL"; sleep 5 # оборвать скачку на середине (несколько раз)
|
||||
lsof -p $(pgrep -f api_server) | wc -l # не выросло относительно baseline
|
||||
|
||||
# 4. Генерация большого фида + параллельный /ping (<100 мс во время генерации):
|
||||
curl -s -o /dev/null "http://127.0.0.1:80/rss/{big_channel}" &
|
||||
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{time_total}\n" "http://127.0.0.1:80/ping"; done
|
||||
wait # все замеры /ping должны быть заметно < 0.100 s
|
||||
```
|
||||
|
||||
> Сценарии 2 и 3 (реальная скачка большого видео + реальный подсчёт fd через `lsof`) в
|
||||
> headless-тестах **намеренно не подделаны** — им нужен живой сервер, реальные загрузки и
|
||||
> реальные файловые дескрипторы. Дедуп-инвариант и disconnect-очистка доказаны на уровне
|
||||
> реестра/`get_media` (тесты стадии 7 №3), но «нет утечки fd на проде» проверяет оператор.
|
||||
|
||||
## Diag-логи для наблюдения после деплоя
|
||||
|
||||
Ожидаемая динамика (сравнить с до-деплойным baseline):
|
||||
|
||||
- `diag_semaphore_wait` — ожидание HTTP-семафора должно **упасть** (реже/меньше секунд).
|
||||
- `diag_download_timing` — время скачивания стабилизируется; нет длинных «зависаний».
|
||||
- `diag_sanitize_slow` — почти **исчезнуть** (один sanitize на выходную границу, стадия 4).
|
||||
- `watchdog: heartbeat` — **продолжаются** штатно (живость TG-сессии).
|
||||
- На момент FloodWait — **нет всплеска 404** на `/media`; вместо этого `media_flood_wait`
|
||||
и ответы **429** с `Retry-After` (стадия 2.3).
|
||||
- Признаки supervision: любые `supervised_task_crashed` / `supervised_task_exited` на
|
||||
CRITICAL — сигнал разобраться, но задача при этом перезапускается (не тихая смерть).
|
||||
|
||||
## План отката (rollback)
|
||||
|
||||
Единица отката — **стадия целиком**, не отдельный коммит. Каждая стадия живёт на своей
|
||||
ветке/PR (`fix/stage-1-hangs` … `fix/stage-7-verification`) и, как правило, состоит из
|
||||
**двух коммитов**: feature-коммит + фикс review-раунда (последний нередко чинит реальный
|
||||
баг — напр. fail-closed XSS в стадии 4, watchdog-gate в стадии 6). Поэтому `git revert`
|
||||
одного коммита осиротит фикс review-раунда и даст несогласованный откат. Откатывать нужно
|
||||
на гранулярности стадии; порядок стадий = порядок деплоя.
|
||||
|
||||
- **Как откатывать стадию** — зависит от того, как ветки влиты в `fix/stability`/`main`:
|
||||
- если стадия влита **squash-merge** (один коммит на стадию) — `git revert <squash-commit>`
|
||||
откатывает её целиком, однозначно;
|
||||
- если стадия влита **merge-коммитом** — `git revert -m 1 <merge-commit>` откатывает весь
|
||||
вклад ветки (оба коммита) разом;
|
||||
- если история линейная (rebase/fast-forward) — реверт **всех** коммитов стадии
|
||||
(`git revert <feature>..<review-fix>` включительно), а не одного.
|
||||
- Стадии 1 и 2 — низкорисковые, деплоятся первыми; откатываются независимо от остальных.
|
||||
- Стадия 3 (FileResponse) независима — реверт возвращает прежний ручной стриминг.
|
||||
- Стадия 4 требует, чтобы 4.2 откатывалась не позже 4.3 (иначе рендер-в-потоке остался бы
|
||||
без безопасного пути сохранения media id) — откатывать стадию 4 целиком.
|
||||
- Стадии 5 и 6 независимы, откатываются по отдельности.
|
||||
- Стадия 7 — только тесты и этот документ: реверт ничего не меняет в проде.
|
||||
|
||||
Прод-деплой, живое наблюдение diag-логов и обновление прод-compose (healthcheck→`/ping`,
|
||||
вне репозитория) — **зона ответственности оператора** (пункты 3–4 плана стадии 7).
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# flake8: noqa
|
||||
# pylint: disable=missing-function-docstring
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import List
|
||||
|
||||
# Path to SQLite database file
|
||||
DB_PATH = os.path.join(os.path.abspath("./data"), "media_file_ids.db")
|
||||
|
||||
|
||||
def _open_db(db_path: str) -> sqlite3.Connection:
|
||||
"""Open a SQLite connection with WAL journal mode enabled."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout = 5000") # Wait up to 5 seconds on lock
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _db_connection(db_path: str):
|
||||
"""Context manager that opens a WAL-mode SQLite connection and ensures it is closed."""
|
||||
conn = _open_db(db_path)
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db_sync(db_path: str) -> None:
|
||||
"""Create the media_file_ids table if it does not exist."""
|
||||
with _db_connection(db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS media_file_ids (
|
||||
channel TEXT NOT NULL,
|
||||
post_id INTEGER NOT NULL,
|
||||
file_unique_id TEXT NOT NULL,
|
||||
added REAL NOT NULL,
|
||||
PRIMARY KEY (channel, post_id, file_unique_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Add mime_type column if it does not exist yet (idempotent migration)
|
||||
try:
|
||||
conn.execute("ALTER TABLE media_file_ids ADD COLUMN mime_type TEXT")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column name" not in str(e):
|
||||
raise # Re-raise any OperationalError that is not "duplicate column name"
|
||||
|
||||
|
||||
def upsert_media_file_id_sync(db_path: str, channel: str, post_id: int, file_unique_id: str, added: float) -> None:
|
||||
"""Insert or replace a single media file ID record."""
|
||||
with _db_connection(db_path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO media_file_ids (channel, post_id, file_unique_id, added)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(channel, post_id, file_unique_id)
|
||||
DO UPDATE SET added = excluded.added""",
|
||||
(channel, post_id, file_unique_id, added),
|
||||
)
|
||||
|
||||
|
||||
def upsert_media_file_ids_bulk_sync(db_path: str, entries: List[tuple]) -> None:
|
||||
"""Insert or replace multiple media file ID records in a single transaction.
|
||||
|
||||
entries: iterable of (channel, post_id, file_unique_id, added) tuples.
|
||||
Uses executemany for batched upserts (one connection, one commit).
|
||||
"""
|
||||
if not entries:
|
||||
return
|
||||
with _db_connection(db_path) as conn:
|
||||
conn.executemany(
|
||||
"""INSERT INTO media_file_ids (channel, post_id, file_unique_id, added)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(channel, post_id, file_unique_id)
|
||||
DO UPDATE SET added = excluded.added""",
|
||||
entries,
|
||||
)
|
||||
|
||||
|
||||
def update_media_file_access_sync(db_path: str, channel: str, post_id: int, file_unique_id: str, added: float) -> None:
|
||||
"""Update the access timestamp for an existing media file ID record."""
|
||||
with _db_connection(db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE media_file_ids SET added = ? WHERE channel = ? AND post_id = ? AND file_unique_id = ?",
|
||||
(added, channel, post_id, file_unique_id),
|
||||
)
|
||||
|
||||
|
||||
def update_media_file_access_bulk_sync(db_path: str, entries: List[tuple]) -> None:
|
||||
"""Update the access timestamp for multiple existing media file ID records.
|
||||
|
||||
entries: iterable of (channel, post_id, file_unique_id, added) tuples.
|
||||
Uses executemany (one connection, one commit) so a batch of cache-hit access
|
||||
updates costs a single SQLite transaction instead of one connect+UPDATE per hit.
|
||||
Rows that do not exist are simply not matched by the WHERE clause (no-op), mirroring
|
||||
the single-row update_media_file_access_sync. An empty batch is a no-op.
|
||||
"""
|
||||
if not entries:
|
||||
return
|
||||
with _db_connection(db_path) as conn:
|
||||
conn.executemany(
|
||||
"UPDATE media_file_ids SET added = ? WHERE channel = ? AND post_id = ? AND file_unique_id = ?",
|
||||
# Reorder each (channel, post_id, file_unique_id, added) tuple to match the
|
||||
# UPDATE's placeholder order (added first, then the WHERE key columns).
|
||||
[(added, channel, post_id, file_unique_id)
|
||||
for (channel, post_id, file_unique_id, added) in entries],
|
||||
)
|
||||
|
||||
|
||||
def get_all_media_file_ids_sync(db_path: str) -> List[dict]:
|
||||
"""Return all rows from media_file_ids as a list of dicts."""
|
||||
with _db_connection(db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute("SELECT channel, post_id, file_unique_id, added FROM media_file_ids")
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def remove_media_file_ids_sync(db_path: str, entries: List[tuple]) -> None:
|
||||
"""Remove media file ID records identified by (channel, post_id, file_unique_id) tuples."""
|
||||
with _db_connection(db_path) as conn:
|
||||
conn.executemany(
|
||||
"DELETE FROM media_file_ids WHERE channel = ? AND post_id = ? AND file_unique_id = ?",
|
||||
entries,
|
||||
)
|
||||
|
||||
|
||||
def get_mime_type_sync(db_path: str, channel: str, post_id: int, file_unique_id: str) -> str | None:
|
||||
"""Return the cached MIME type for a given media key, or None if not stored yet."""
|
||||
with _db_connection(db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT mime_type FROM media_file_ids WHERE channel = ? AND post_id = ? AND file_unique_id = ?",
|
||||
(channel, post_id, file_unique_id),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return row[0] # May be None if the column value was never set
|
||||
|
||||
|
||||
def set_mime_type_sync(db_path: str, channel: str, post_id: int, file_unique_id: str, mime_type: str) -> None:
|
||||
"""Persist a detected MIME type for an existing media file ID record."""
|
||||
with _db_connection(db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE media_file_ids SET mime_type = ? WHERE channel = ? AND post_id = ? AND file_unique_id = ?",
|
||||
(mime_type, channel, post_id, file_unique_id),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
|
||||
### Где и почему может виснуть
|
||||
|
||||
- Синхронный диск/JSON в обработчиках запросов
|
||||
- `_save_media_file_ids` делает `open/json.load/json.dump` прямо в пути запроса, без `to_thread`. При большом `data/media_file_ids.json` это блокирует event loop.
|
||||
```950:979:post_parser.py
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
existing_data = json.load(f)
|
||||
...
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(existing_data, f, ensure_ascii=False, indent=2)
|
||||
```
|
||||
- Кеш истории также читает/пишет файлы синхронно:
|
||||
```71:99:tg_cache.py
|
||||
with open(cache_file, 'rb') as f:
|
||||
cache_data = pickle.load(f)
|
||||
...
|
||||
with open(cache_file, 'wb') as f:
|
||||
pickle.dump(cache_data, f)
|
||||
```
|
||||
- Итог: под нагрузкой любой долгий sync I/O останавливает обработку всех запросов на время операции.
|
||||
|
||||
- CPU‑тяжёлое формирование RSS/HTML в event loop
|
||||
- Парсинг/санитизация/генерация RSS идут синхронно в корутинах (bleach, feedgen, склейка больших строк). На больших лимитах это “съедает” цикл.
|
||||
```185:299:rss_generator.py
|
||||
final_posts = await _render_messages_groups(...)
|
||||
...
|
||||
rss_feed = fg.rss_str(pretty=True)
|
||||
```
|
||||
```511:533:rss_generator.py
|
||||
final_posts = await _render_messages_groups(...)
|
||||
...
|
||||
html = '\n<hr class="post-divider">\n'.join(html_posts)
|
||||
```
|
||||
|
||||
- Глобальный `python-magic` в потоках
|
||||
- Один общий `Magic()` используется из разных потоков — это не потокобезопасно, возможны зависания внутри libmagic.
|
||||
```198:206:api_server.py
|
||||
media_type = await asyncio.to_thread(magic_mime.from_file, file_path)
|
||||
```
|
||||
|
||||
- BaseHTTPMiddleware над стримингом файлов
|
||||
- `BaseHTTPMiddleware` оборачивает `call_next`. Для `FileResponse` это может ломать/буферизовать стриминг и усиливать задержки.
|
||||
```48:62:api_server.py
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
...
|
||||
response = await call_next(request)
|
||||
```
|
||||
|
||||
- Отсутствие таймаутов на вызовах Telegram API
|
||||
- Если `get_messages/download_media` “подвисли”, запрос к `/media` висит долго.
|
||||
```748:758:api_server.py
|
||||
file_path, delete_after = await download_media_file(...)
|
||||
```
|
||||
```232:258:api_server.py
|
||||
message = await client.client.get_messages(...)
|
||||
file_path = await client.client.download_media(...)
|
||||
```
|
||||
|
||||
- Шумное логирование в middleware
|
||||
- Лог всех заголовков и запросов при каждом хите может создавать I/O‑бутылочное горлышко под нагрузкой.
|
||||
```49:59:api_server.py
|
||||
logger.info(f"Request: {request.method} {request.url}")
|
||||
logger.info(f"Headers: {dict(request.headers)}")
|
||||
```
|
||||
|
||||
- Конкурентная запись и разрастание `media_file_ids.json`
|
||||
- Записи без блокировок → гонки и порча JSON. В фоне потом ремонт json-repair (тяжёлый CPU).
|
||||
```503:511:api_server.py
|
||||
except json.JSONDecodeError:
|
||||
media_files = await asyncio.to_thread(fix_corrupted_json, file_path)
|
||||
```
|
||||
|
||||
- Один воркер uvicorn
|
||||
- Любая тяжёлая задача “монополизирует” процесс — остальное, включая отдачу файлов, ждёт.
|
||||
```114:121:api_server.py
|
||||
uvicorn.run(..., reload=True, loop="uvloop")
|
||||
```
|
||||
|
||||
### Что сделать (короткий чеклист)
|
||||
|
||||
- [x] Убрать sync I/O из пути запроса
|
||||
- [x] Обернуть чтение/запись JSON и pickle в `asyncio.to_thread`.
|
||||
- [x] Для `media_file_ids.json` везде использовать единые sync‑хелперы с временным файлом и заменить прямые `open/json.dump`:
|
||||
- [x] Вынести в отдельный модуль функции уже имеющихся `read_json_file_sync`/`write_json_file_sync` и звать их через `to_thread`.
|
||||
- [x] Добавить один `asyncio.Lock` на операции записи `media_file_ids.json` чтобы не было гонок.
|
||||
|
||||
- Разгрузить CPU из event loop
|
||||
- В RSS/HTML:
|
||||
- [x] После того как данные получены из Telegram, вынести “склейку HTML”, bleach‑санитизацию и `fg.rss_str` в `asyncio.to_thread`.
|
||||
- Ограничить `limit` по умолчанию до 50–80. Для больших значений — отдавать 429 или 400 с рекомендацией.
|
||||
- Опционально — кэшировать итоговые RSS/HTML на пару минут.
|
||||
|
||||
- Исправить `python-magic`
|
||||
- Создавать `Magic()` per‑call внутри `to_thread` ИЛИ защищать глобальный объект `threading.Lock`.
|
||||
- Если не критично — падать на `mimetypes` и не вызывать `magic` для известных расширений.
|
||||
|
||||
- Middleware для логов
|
||||
- В проде отключить текущий `RequestLoggingMiddleware` или перевести на ASGI‑middleware без вмешательства в стриминг.
|
||||
- Снизить уровень и объём логов (заголовки — только на DEBUG, семплирование).
|
||||
|
||||
- Таймауты телеграма
|
||||
- Оборачивать `get_messages`/`download_media` в `asyncio.wait_for(..., timeout=30–60)` с понятной ошибкой/503.
|
||||
- На FloodWait в `/media` — отдавать 429 с `Retry-After`.
|
||||
|
||||
- Запись `media_file_ids.json`
|
||||
- Причесать размер: периодически чистить/ротация по размеру/возрасту.
|
||||
- В фоне перепись файла уже есть — не трогать event loop; убедиться, что на записи тоже используется temp‑файл + `os.replace` (как в `write_json_file_sync`).
|
||||
|
||||
- Процессный параллелизм
|
||||
- Запускать с несколькими воркерами, без `reload`:
|
||||
- пример: `uvicorn api_server:app --host 0.0.0.0 --port 80 --workers 2 --loop uvloop --http httptools`
|
||||
- Или за Gunicorn: `--workers 2 --worker-class uvicorn.workers.UvicornWorker`.
|
||||
|
||||
- Пул потоков
|
||||
- Явно ограничить/увеличить пул для `to_thread` если есть много фоновых задач, чтобы не выжирался из‑за долгих операций.
|
||||
|
||||
### Минимальные целевые правки (по месту)
|
||||
|
||||
- `_save_media_file_ids` — переписать на `await asyncio.to_thread(read_json_file_sync/ write_json_file_sync)` + общий `asyncio.Lock`. Убрать прямые `open/json.dump`.
|
||||
- `tg_cache` — обернуть `pickle.load/dump` в `to_thread`.
|
||||
- `prepare_file_response` — перестать шарить `magic_mime`; создавать локальный `magic.Magic(mime=True)` внутри `to_thread` или защитить глобальный объект `Lock`.
|
||||
- `RequestLoggingMiddleware` — удалить/заменить на ASGI‑middleware и логировать только метод+путь, без заголовков по умолчанию.
|
||||
- `get_messages/download_media` — таймауты через `asyncio.wait_for`.
|
||||
- `uvicorn.run` — убрать `reload=True`, добавить `workers=2+`.
|
||||
|
||||
### Как быстро проверить
|
||||
- Нагрузить `/rss` c большим `limit` параллельно с `/media` и измерить задержку отдачи файлов до/после:
|
||||
- до фиксов статик “замирает” во время генерации;
|
||||
- после выноса CPU/I/O в `to_thread` и увеличения воркеров — файлы продолжают отдаваться.
|
||||
|
||||
Коротко: основные причины — синхронный диск и CPU в корутинах, общий `python-magic`, и единичный воркер. Перенос I/O/CPU в `to_thread`, фиксы гонок JSON, таймауты на Telegram и 2–4 воркера решат подвисания.
|
||||
|
||||
- Нашёл проблемные места: sync I/O и CPU в обработчиках, `python-magic`, middleware, таймауты и один воркер. Дал точечные действия для устранения подвисаний.
|
||||
@@ -0,0 +1,792 @@
|
||||
# Рефакторинг пайплайна рендера: спецификация (v6)
|
||||
|
||||
Статус: пять раундов адверсариального ревью — четыре у постоянного критика +
|
||||
независимый холодный проход (журнал в §8), блокеров нет. Холодный проход
|
||||
независимо подтвердил все line-refs и эмпирические утверждения спеки.
|
||||
v6: golden-корпус переведён с синтетики на записанные реальные сообщения
|
||||
(решение владельца, обоснование в этапе 0 и §8).
|
||||
База (обновлено 2026-07-05, после мёржа PR #21): ветка
|
||||
`refactor/render-pipeline` режется от **актуального `main`** (merge-коммит
|
||||
`88ac436`+), НЕ от голого f9550d8 — main теперь содержит kurigram-код
|
||||
(f9550d8) + review-fix `6388f2f` + саму эту спеку и корпус фикстур
|
||||
(коммиты docs). Все line-refs спеки по-прежнему действительны: `6388f2f` —
|
||||
чисто тестовый (только `tests/test_new_media_types.py`), source
|
||||
post_parser.py / rss_generator.py / api_server.py не сдвинулся ни на строку
|
||||
относительно f9550d8. Предусловие «kurigram в main» — ВЫПОЛНЕНО.
|
||||
Ишью: эпик [#34](https://gitea.vvzvlad.xyz/vvzvlad/pyrogram-bridge/issues/34),
|
||||
этапы 0–6 → [#27](https://gitea.vvzvlad.xyz/vvzvlad/pyrogram-bridge/issues/27),
|
||||
[#28](https://gitea.vvzvlad.xyz/vvzvlad/pyrogram-bridge/issues/28),
|
||||
[#29](https://gitea.vvzvlad.xyz/vvzvlad/pyrogram-bridge/issues/29),
|
||||
[#30](https://gitea.vvzvlad.xyz/vvzvlad/pyrogram-bridge/issues/30),
|
||||
[#31](https://gitea.vvzvlad.xyz/vvzvlad/pyrogram-bridge/issues/31),
|
||||
[#32](https://gitea.vvzvlad.xyz/vvzvlad/pyrogram-bridge/issues/32),
|
||||
[#33](https://gitea.vvzvlad.xyz/vvzvlad/pyrogram-bridge/issues/33).
|
||||
Устаревшее [#22](https://gitea.vvzvlad.xyz/vvzvlad/pyrogram-bridge/issues/22)
|
||||
(этап 1 по v1) закрыто как superseded.
|
||||
|
||||
## 1. Проблема
|
||||
|
||||
Рендер фидов (RSS/HTML) — не однонаправленный пайплайн, а набор циклов с
|
||||
обратными ходами и дублированием:
|
||||
|
||||
- `generate_channel_rss` и `generate_channel_html` — близнецы на ~80%
|
||||
(rss_generator.py:347–527 и 578–717).
|
||||
- Обратный ход данных: `processed_message_to_tg_message`
|
||||
(rss_generator.py:156–201) конвертирует отрендеренный dict обратно в
|
||||
мок-Message ради повторного рендера футера — при живых `Message` в `group`.
|
||||
- Санитайз-конфиг скопирован трижды (post_parser.py:702, rss_generator.py:468,
|
||||
679) и разъехался: `s`/`del` разрешены только в post_parser.
|
||||
- RSS-путь: отдельный `asyncio.to_thread` + новый `CSSSanitizer` + вложенная
|
||||
функция НА КАЖДЫЙ пост — при том, что `_render_pipeline` уже в worker-треде.
|
||||
- `_create_time_based_media_groups`: `copy.deepcopy` всех сообщений на каждый
|
||||
запрос (rss_generator.py:43) ради защиты кэша от мутации `media_group_id`.
|
||||
- post_parser: три параллельные лестницы выбора media-объекта
|
||||
(`_get_file_unique_id`, `_save_media_file_ids`, `_generate_html_media`),
|
||||
синхронизируемые вручную.
|
||||
|
||||
Латентные баги, найденные при анализе и ревью:
|
||||
|
||||
- `<hr class="post-divider">` добавляется ДО финального санитайза, `hr` нет в
|
||||
whitelist → bleach вырезает все разделители HTML-фида.
|
||||
- `_sanitize_html` при исключении bleach возвращает сырой HTML (fail-open) —
|
||||
потенциальный stored-XSS; фидовые копии fail-closed.
|
||||
- `_generate_html_media`: при `file_unique_id is None` (в т.ч. массовый случай
|
||||
WEB_PAGE без фото) открытый `<div class="message-media">` не закрывается;
|
||||
при whole-feed санитайзе html5lib «заглатывает» в него все последующие посты.
|
||||
- **Naive/aware краш в дефолтном пути**: kurigram-даты naive-local
|
||||
(`datetime.fromtimestamp`), фолбэк в sort-ключе групп — aware UTC
|
||||
(rss_generator.py:141). Один None-date пост в фиде с реальными датами даёт
|
||||
TypeError → 500 при ЛЮБОМ `time_based_merge` (проверено эмпирически);
|
||||
при `time_based_merge=True` тот же класс краша дополнительно в
|
||||
тайм-кластеризации (строки 45–46, 61–62). Тесты слепы: мокают aware-даты.
|
||||
- FloodWait из `cached_get_chat_history` перехватывается `except Exception` и
|
||||
превращается в ValueError → HTTP 400 вместо 429 (rss_generator.py:416–422,
|
||||
636–642; маппинг ValueError→400 — api_server.py:1334–1337).
|
||||
- `_reactions_views_links`: reactions-объект с пустым списком реакций даёт
|
||||
`first_line_parts.append("")` → ведущий разделитель `…|…` в футере
|
||||
(post_parser.py:1081–1104, branch).
|
||||
|
||||
## 2. Целевая архитектура
|
||||
|
||||
```text
|
||||
┌───────────── async-зона (event loop) ─────────────┐
|
||||
api_server ──► generate_channel_rss ─┐
|
||||
├─► _prepare_feed_posts(...) ──► PreparedFeed
|
||||
api_server ──► generate_channel_html ┘ │
|
||||
│ fetch (cached_get_chat / history)
|
||||
│ enrich (_reply_enrichment, опция)
|
||||
▼
|
||||
┌───────── to_thread: _render_pipeline (sync) ──────┐
|
||||
│ сортировка date-ASC (naive-safe) при time_merge │
|
||||
│ _compute_time_based_group_ids (чистая, без мутаций)│
|
||||
│ _create_messages_groups(msgs, group_ids) │
|
||||
│ [:limit] → _render_messages_groups │
|
||||
│ (рендер → фильтры → sort — внутри неё) │
|
||||
│ затем sanitize_html на каждый ОТФИЛЬТРОВАННЫЙ пост │
|
||||
└────────────────────────────────────── санитайз ───┘
|
||||
│
|
||||
RSS: feedgen из готовых постов HTML: '\n<hr>\n'.join(...)
|
||||
```
|
||||
|
||||
Решения:
|
||||
|
||||
1. Санитайз внутри `_render_pipeline`, per-post, ПОСЛЕ фильтров (не тратить
|
||||
bleach на отфильтровываемые посты).
|
||||
2. Один модуль `sanitizer.py` — единственный источник конфига bleach, включая
|
||||
`protocols=['http','https','tg']` и `strip=True` (оба отличаются от
|
||||
дефолтов bleach!).
|
||||
3. Футер merged-групп из настоящего main-сообщения; конвертер удаляется.
|
||||
4. Группировка — чистая функция: маппинг `msg.id → effective_group_id`;
|
||||
кэшированные Message пайплайном не мутируются.
|
||||
5. Общий препроцессинг `_prepare_feed_posts`; различия путей — явные параметры.
|
||||
6. Единая таблица медиа-типов в post_parser (селектор + renderer на kind).
|
||||
|
||||
Одиночный пост (`get_post` → `process_message(sanitize=True)` → `_format_html`)
|
||||
не меняется, за исключением реестра §3 пп. 2, 13, 14, 15 (fail-closed,
|
||||
guard >100 МБ, закрытие div, пустые реакции — эти механизмы общие с фидовым
|
||||
путём).
|
||||
|
||||
## 3. Реестр намеренных изменений поведения
|
||||
|
||||
Всё, что не перечислено здесь, обязано остаться бит-в-бит идентичным.
|
||||
Golden-эталоны (§5, этап 0) обновляются только коммитом со ссылкой на пункт
|
||||
этого реестра.
|
||||
|
||||
| № | Изменение | Тип | Этап |
|
||||
| --- | --------- | --- | ---- |
|
||||
| 1 | `s`, `del` разрешены и в фидах | багфикс | 1 |
|
||||
| 2 | `sanitize_html`: fail-open → fail-closed (`html.escape`); затрагивает и single-post/JSON путь | security | 1 |
|
||||
| 3 | `<hr class="post-divider">` реально виден в HTML-фиде | багфикс | 1 |
|
||||
| 4 | Несбалансированный HTML-фрагмент нормализуется в границах СВОЕГО поста и не искажает DOM последующих (только HTML-фид; RSS уже per-post и не меняется) | багфикс | 1 |
|
||||
| 5 | Fail-closed гранулярность HTML-фида: эскейпится упавший пост, а не весь фид | улучшение | 1 |
|
||||
| 6 | Merged-футер: custom-emoji реакции больше не агрегируются в один «❓ N» — отдельный span на каждую, как у одиночных постов | унификация | 2 |
|
||||
| 7 | Merged-футер: дата печатается из naive-local даты реального Message вместо UTC-мока — на серверах с TZ≠UTC видимый сдвиг. Golden (TZ=UTC) эту дельту НЕ видит — верифицируется выделенным тестом с не-UTC TZ | унификация | 2 |
|
||||
| 8 | Порядок флагов merged-поста детерминирован (first-seen order) | детерминизм | 2 |
|
||||
| 9 | FloodWait при получении истории пробрасывается → HTTP 429 (было: ValueError → 400) | фикс HTTP | 3 |
|
||||
| 10 | Тексты `detail` в 400-ответах унифицируются (исчезает суффикс «in HTML generation») | минорное | 3 |
|
||||
| 11 | None-date сообщения исключаются из ТАЙМ-кластеризации (не получают entry в маппинге; их собственный `media_group_id` продолжает действовать). Было: смешанный naive+None вход (прод-даты kurigram) ронял фид TypeError'ом; полностью-None и aware+None входы (возникают только в тестах с aware-моками) выживали и кластеризовали None-date хвост по порядку вставки, включая adoption truthy id. ВСЕ эти поведения заменяются; новое поведение закрепляется отдельными тестами как сознательное | багфикс | 4 |
|
||||
| 12 | Sort-ключи naive-безопасны (timestamp-based): None-date больше не роняет сортировку групп (краш жил в ДЕФОЛТНОМ пути, см. §1). Позиция None-date групп: фолбэк `+inf` в сортировке ГРУПП — детерминированно переживают `[:limit]`-срез как новейшие (теоретическая дельта: при постах с датой в будущем старый aware-путь ставил None-date «на сейчас», т.е. ниже них; прод-naive путь всё равно падал); позиция в итоговом выводе не меняется — существующий фолбэк `0.0` финальной сортировки постов ставит их в конец фида | багфикс | 4 |
|
||||
| 13 | Правило «>100 МБ не кэшируем» применяется к любому медиа-объекту, не только `message.video` | унификация | 5b |
|
||||
| 14 | Незакрытый `<div class="message-media">` закрывается во всех ветках | багфикс | 5b |
|
||||
| 15 | Пустой reactions-объект больше не даёт ведущий разделитель в футере (`_reactions_views_links` не добавляет пустую строку); затрагивает и одиночные посты | багфикс | 2 |
|
||||
| 16 | Логи ошибок санитайза объединяются под ОДНИМ именем `html_sanitization_error` + log_context (было три grep-имени: `html_sanitization_error` / `rss_html_sanitization_error` / `html_final_sanitization_error`) — правила grep-мониторинга обновить при деплое | наблюдаемость | 1 |
|
||||
|
||||
## 4. Общие правила всех этапов
|
||||
|
||||
- База: `refactor/render-pipeline` от актуального main (88ac436+, содержит
|
||||
f9550d8 + спеку + корпус). Один этап = один коммит
|
||||
(этап 5 — два: 5a/5b); после каждого — `pytest` полностью зелёный.
|
||||
- Правки существующих тестов допустимы в двух случаях: (а) тест закрепляет
|
||||
старое поведение из реестра §3 — правка с комментарием-ссылкой на номер
|
||||
пункта; (б) чистое перемещение API (импорты, monkeypatch-цели) — правка с
|
||||
пометкой «API relocation, no behavior change».
|
||||
- Два слоя эталонов, не смешивать: **feed-level** golden-снапшоты (этап 0,
|
||||
санитайженный выход RSS/HTML) и **fragment-level** снапшоты
|
||||
`_generate_html_media` (этап 5a, до санитайза). Этап 5b не «чинит» то,
|
||||
что уже изменил этап 1 на feed-уровне.
|
||||
- Комментарии в коде — только на английском. Точечные правки. Устаревшие
|
||||
комментарии («4.4 coverage map» и т.п.) обновлять по ходу.
|
||||
- Наблюдаемость: имена существующих лог-строк и их контекст (счётчики
|
||||
сообщений, `rss_date_range`, channel/message_id в ошибках санитайза)
|
||||
сохраняются — grep-мониторинг не должен ослепнуть. Единственное
|
||||
зарегистрированное исключение — объединение трёх имён санитайз-ошибок
|
||||
(§3.16).
|
||||
- Новые тесты используют **naive**-даты (как отдаёт kurigram на проде), а не
|
||||
aware-UTC.
|
||||
|
||||
## 5. Этапы
|
||||
|
||||
### Этап 0 — golden-эталоны фидов (до любых правок кода)
|
||||
|
||||
**Корпус — записанные РЕАЛЬНЫЕ сообщения с прод-сервера, не синтетика (v6).**
|
||||
Источник: рабочий кэш бриджа `data/tgcache/` на сервере — там уже лежат
|
||||
пиклы ровно нужного формата: `{channel}.cache` =
|
||||
`{'timestamp', 'limit', 'messages': List[Message]}`
|
||||
(`tg_cache._save_history_to_cache`) и `{channel}.chatinfo` (для
|
||||
`cached_get_chat`). Отобранные ПАРЫ файлов лежат в
|
||||
`tests/test_data/recorded/` и коммитятся как замороженный корпус (контент
|
||||
этих публичных каналов попадает в репо).
|
||||
|
||||
Реплей: тестовый загрузчик распикливает файлы напрямую (`timestamp`/`limit`
|
||||
игнорируются — никакой проверки свежести) и monkeypatch'ит
|
||||
`tg_cache.cached_get_chat_history` / `cached_get_chat` на возврат записанных
|
||||
объектов. Это буквально прод-путь cache-hit: сериализация и структура
|
||||
объектов — те же, что видит рендер в бою.
|
||||
|
||||
Почему реальные, а не моки: (а) настоящие kurigram-объекты закрывают класс
|
||||
ложной зелени «мок и код согласованно ждут атрибут, которого нет у реального
|
||||
объекта» — холодный проход (§8, раунд 5) пометил его как неловимый на моках;
|
||||
(б) naive-даты, реальные entities/reactions/webpage-структуры и комбинации
|
||||
форматирования достаются бесплатно.
|
||||
|
||||
**Первое действие этапа — проверка совместимости пиклов**: кэш сервера
|
||||
записан прод-версией kurigram, корпус распикливается под 2.2.23 (база
|
||||
рефакторинга f9550d8). ВЫПОЛНЕНО — см. «Статус» ниже: 90/90 + 89/89 без
|
||||
ошибок, фолбэк не понадобился.
|
||||
|
||||
**Инвентаризация покрытия**: мини-скрипт проходит по корпусу и печатает
|
||||
media-типы, наличие media_groups / time-кластеров / forward / reply /
|
||||
реакций (обычные, custom, paid, пустой объект) / webpage с фото и без /
|
||||
зачёркивания (`<s>`/`<del>` в entities). Дыры покрытия закрываются
|
||||
СИНТЕТИЧЕСКОЙ добавкой — только для недостижимого в записанном.
|
||||
|
||||
**Статус (2026-07-05): снапшот, проверка, инвентарь И ОТБОР уже выполнены.**
|
||||
Снапшот прод-кэша: `data/tgcache_prod_2026-07-05/` (90 `.cache` +
|
||||
89 `.chatinfo`, 53 МБ, вне git — `data/` в .gitignore). Отбор сделан жадно
|
||||
по матрице признаков, 4 канала (2.7 МБ) уже лежат в
|
||||
`tests/test_data/recorded/` (untracked; коммитятся первым коммитом этапа 0):
|
||||
|
||||
- `bladerunnerblues` — единственный источник GIVEAWAY + GIVEAWAY_WINNERS;
|
||||
плюс POLL, pdf, DOCUMENT, ANIMATION, r_paid (71), wp_nophoto;
|
||||
- `theyforcedme` — вся редкая медиа-палитра разом: STICKER, AUDIO, VOICE,
|
||||
VIDEO_NOTE, POLL, ANIMATION; богат forward (13) и reply (10);
|
||||
- `embedoka` — r_empty + r_custom (35) + strike (7) + POLL + pdf +
|
||||
wp_nophoto; forward 20, reply 14;
|
||||
- `meow_design` — 51 time-кластерная пара (ядро для time_based_merge),
|
||||
media_groups 22, pdf, POLL, strike.
|
||||
|
||||
Суммарно закрыта ВСЯ матрица: PHOTO 246, VIDEO 40, media_groups 66,
|
||||
forward 35, reply 27, wp_photo 11 + все редкие признаки выше.
|
||||
Совместимость подтверждена: ВСЕ файлы распикливаются под kurigram
|
||||
2.2.23 без ошибок. Инвентарь (8582 сообщения): PHOTO 5397, text-only 1609,
|
||||
WEB_PAGE 764 (с фото 581 / без 183), VIDEO 653, DOCUMENT 51, ANIMATION 33,
|
||||
POLL 31, VIDEO_NOTE 15, STICKER 13, AUDIO 8, VOICE 6, GIVEAWAY и
|
||||
GIVEAWAY_WINNERS по 1; media_group-членов 3159, forward 670, reply 357,
|
||||
time-кластерных пар (gap≤5s) ~294; реакции: обычных 17981, custom 1018,
|
||||
paid 593, ПУСТЫХ reactions-объектов 17 (§3.15-edge покрыт реальными
|
||||
данными!); зачёркиваний 150 (§3.1 покрыт); все 8582 даты naive (посылка
|
||||
спеки подтверждена); None-date — 0. Синтетика нужна ТОЛЬКО для: None-date
|
||||
(этап 4) и отсутствующих в корпусе типов — PAID_MEDIA, STORY, LIVE_PHOTO,
|
||||
CHECKLIST, CONTACT/LOCATION/VENUE/DICE/GAME/INVOICE/UNSUPPORTED
|
||||
(fragment-уровень этапа 5a).
|
||||
|
||||
Сценарии exclude_flags/exclude_text в golden НЕ входят: фильтры не меняют
|
||||
байты выживших постов; их семантика (membership / regex) закрепляется парой
|
||||
unit-тестов — дешевле и меньше площадь флаки-рисков.
|
||||
|
||||
**None-date пост в этап 0 НЕ входит** (в записанных данных его и не бывает):
|
||||
текущий код падает на нём в ДЕФОЛТНОМ пути (naive/aware TypeError в
|
||||
sort-ключе групп, см. §1). Синтетическая None-date фикстура добавляется в
|
||||
HTML-golden коммитом этапа 4 со ссылкой на §3.11–12.
|
||||
|
||||
Снимаются и кладутся в `tests/test_data/golden/`:
|
||||
|
||||
- RSS XML — полный выход `generate_channel_rss` (tg_cache замокан);
|
||||
- HTML-фид — полный выход `generate_channel_html`.
|
||||
|
||||
**Детерминизм снапшотов (обязательные меры):**
|
||||
|
||||
- TZ раннера пинится: `os.environ['TZ'] = 'UTC'; time.tzset()` в conftest
|
||||
(naive-даты фикстур интерпретируются `.timestamp()`-ом в локальной TZ —
|
||||
без пина pubDate плавает между машинами);
|
||||
- волатильные строки RSS XML нормализуются перед сравнением:
|
||||
`<lastBuildDate>` (feedgen 1.0.0 ставит now() ОДИН раз в конструкторе
|
||||
`FeedGenerator` — байты стабильны внутри процесса, но меняются между
|
||||
прогонами) вырезается regex'ом и в golden, и в актуальном выводе;
|
||||
`<generator>` в feedgen 1.0.0 версии НЕ содержит — его нормализация не
|
||||
обязательна, оставлена как дешёвая страховка от апгрейда библиотеки;
|
||||
- корпус не содержит постов, где `pubDate` берётся из `datetime.now()`
|
||||
(это только None-date случай — он исключён, см. выше);
|
||||
- порядок merged-флагов ДО этапа 2 недетерминирован: `list(set(...))`
|
||||
(rss_generator.py:260–265) зависит от PYTHONHASHSEED процесса, который из
|
||||
conftest не запинить — содержимое `<div class="message-flags">`
|
||||
нормализуется сортировкой при сравнении golden; нормализация снимается
|
||||
коммитом этапа 2 со ссылкой на §3.8;
|
||||
- ключ подписи media-URL пинится autouse-фикстурой
|
||||
`monkeypatch.setattr(KeyManager, "signing_key", ...)` (образец —
|
||||
tests/test_new_media_types.py): иначе golden, снятый на dev-машине,
|
||||
краснеет на CI — свежий checkout генерирует новый `secrets.token_hex`
|
||||
в `data/media_digest.key`, и все digest'ы в URL меняются;
|
||||
- корпус подаётся monkeypatch'ем `tg_cache.cached_get_chat` /
|
||||
`cached_get_chat_history` (возврат распикленных записанных объектов) —
|
||||
lazy-import внутри фид-функций разрешает имена поздно, поэтому патч модуля
|
||||
tg_cache работает (образец — test_stage4_eventloop.py);
|
||||
- запись media-id пинится: monkeypatch `upsert_media_file_ids_bulk_sync`
|
||||
(или `DB_PATH` → tmp_path) — иначе golden-тесты с медиа-фикстурами пишут
|
||||
в реальную `./data/media_file_ids.db` (`DB_PATH` cwd-относителен,
|
||||
file_io.py:13); байты фида это не меняет (flush глотает ошибки), но
|
||||
side-effect вне tests/ недопустим; образец — test_stage4_eventloop.py:164;
|
||||
- фикстура time-based кластера требует `time_based_merge=True`: ключ читает
|
||||
только `rss_generator.Config` — его патча достаточно; `post_parser.Config` —
|
||||
независимый dict из того же `get_settings()`, патчить оба — дешёвая
|
||||
страховка от будущего дрейфа, но не необходимость.
|
||||
|
||||
Следствие пина TZ=UTC: дельту §3.7 (сдвиг TZ даты merged-футера) golden
|
||||
физически не видит — она верифицируется выделенным тестом этапа 2 с не-UTC TZ.
|
||||
|
||||
Оракул эквивалентности всех этапов. Обновление golden — только коммитом со
|
||||
ссылкой на пункт §3. Ожидаемые изменения: этап 1 (пп.1, 3, 4; пп.2 и 5 —
|
||||
fail-closed ветки, golden их НЕ видит — верифицируются юнит-тестами
|
||||
sanitizer), этап 2 (пп.6, 8, 15 + снятие нормализации флагов), этап 4
|
||||
(добавление None-date фикстуры в HTML-golden, пп.11–12), этап 5b (п.14 — фикстура
|
||||
webpage-без-фото меняет и feed-level байты: до 5b незакрытый div
|
||||
дозакрывался bleach'ем в конце фрагмента).
|
||||
|
||||
DoD: записанный корпус + отчёт инвентаризации + снапшоты в репо; тест
|
||||
сравнения зелёный и детерминированный (два прогона подряд — идентичные
|
||||
байты); любое изменение рендера валит его с внятным диффом. Пиклы корпуса
|
||||
сцеплены с версией kurigram — при апгрейде библиотеки корпус перезаписывается
|
||||
с сервера (зафиксировать в README тестов).
|
||||
|
||||
### Этап 1 — `sanitizer.py` + санитайз в пайплайне (ишью #22, обновить)
|
||||
|
||||
```python
|
||||
# sanitizer.py — the ONLY bleach configuration in the project.
|
||||
ALLOWED_TAGS = ['p', 'a', 'b', 'i', 'strong', 'em', 's', 'del',
|
||||
'ul', 'ol', 'li', 'br', 'div', 'span',
|
||||
'img', 'video', 'audio', 'source'] # union of the 3 old copies
|
||||
ALLOWED_ATTRIBUTES = { ... } # identical in all 3 copies — move as-is
|
||||
ALLOWED_CSS_PROPERTIES = ["max-width", "max-height", "object-fit", "width", "height"]
|
||||
ALLOWED_PROTOCOLS = ['http', 'https', 'tg'] # non-default! tg:// links in footers
|
||||
|
||||
# Shared instance is safe: CSSSanitizer only holds the allowed-properties list
|
||||
# (stateless config); bleach builds a fresh Cleaner per clean() call anyway.
|
||||
_CSS_SANITIZER = CSSSanitizer(allowed_css_properties=ALLOWED_CSS_PROPERTIES)
|
||||
|
||||
def sanitize_html(html_raw: str, log_context: str = "") -> str:
|
||||
"""Sanitize one HTML fragment. FAIL-CLOSED: on any bleach error the
|
||||
fragment is html.escape()d, never returned raw (stored-XSS guard).
|
||||
log_context (e.g. "channel X, message_id Y") is included in error/slow
|
||||
logs to keep operational grep-ability."""
|
||||
# The FULL call — both non-default params are load-bearing:
|
||||
# clean(html_raw, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES,
|
||||
# protocols=ALLOWED_PROTOCOLS, css_sanitizer=_CSS_SANITIZER,
|
||||
# strip=True)
|
||||
# strip=True: disallowed tags are REMOVED (bleach default False would
|
||||
# escape them into visible text). strip_comments stays default (True),
|
||||
# matching all current call sites. Keep the >0.05s diag_sanitize_slow
|
||||
# warning with input_len here.
|
||||
# Error log name: html_sanitization_error (SINGLE name replacing the
|
||||
# three per-path names — registry §3.16); log_context distinguishes
|
||||
# the call sites, tests assert the name.
|
||||
```
|
||||
|
||||
Правки:
|
||||
|
||||
1. `post_parser._sanitize_html` (branch:702–737) → делегат в
|
||||
`sanitizer.sanitize_html`. Fail-open ветка исчезает (§3.2).
|
||||
2. `_render_pipeline` получает параметр `channel` (только для логов) и после
|
||||
`_render_messages_groups` (т.е. после фильтров) выполняет
|
||||
`p['html'] = sanitize_html(p['html'], log_context=f"channel {channel}, message_id {p['message_id']}")`.
|
||||
Комментарий: the pipeline already runs in a worker thread.
|
||||
ВАЖНО: в этапах 1–2 близнецы ещё не слиты — аргумент `channel` добавляется
|
||||
в ОБА вызова `to_thread(_render_pipeline, …)` (rss_generator.py:433 и 657).
|
||||
3. RSS-цикл (branch:458–497): удалить вложенный `_sanitize_sync` + `to_thread`;
|
||||
`fe.content(content=post['html'], type='CDATA')` напрямую.
|
||||
4. HTML-путь (branch:671–705): удалить `_concat_html`/`_sanitize_sync` +
|
||||
оба `to_thread`; `html = '\n<hr class="post-divider">\n'.join(...)` —
|
||||
join ПОСЛЕ санитайза (§3.3, §3.4).
|
||||
5. Импорты bleach/CSSSanitizer из rss_generator удалить. Тест
|
||||
test_stage4_eventloop.py:474 monkeypatch'ит `rss_module.HTMLSanitizer` —
|
||||
перенацелить на `sanitizer` (API relocation).
|
||||
6. Обновить golden (§3 пп.1–5) и комментарии о границах санитайза.
|
||||
|
||||
Тесты: `tests/test_sanitizer.py` — s/del выживают; script/onerror ВЫРЕЗАЮТСЯ
|
||||
(не эскейпятся в текст — проверка strip=True); `tg://` href выживает;
|
||||
fail-closed при исключении; hr-разделитель в HTML-фиде.
|
||||
|
||||
DoD: одно определение allowed_tags в репо; в rss_generator нет bleach;
|
||||
pytest + golden зелёные.
|
||||
|
||||
### Этап 2 — выпил `processed_message_to_tg_message`
|
||||
|
||||
```python
|
||||
# Select the main RAW message with the same criterion the processed dicts used:
|
||||
# first message that has text or caption, else the first of the group.
|
||||
main_idx = next((i for i, m in enumerate(group) if (m.text or m.caption)), 0)
|
||||
main_raw = group[main_idx]
|
||||
main_message = processed_messages[main_idx]
|
||||
|
||||
# Deterministic merged flags: first-seen order, then 'merged'.
|
||||
merged_flags = list(dict.fromkeys(f for msg in processed_messages for f in msg['flags']))
|
||||
merged_flags.append("merged")
|
||||
|
||||
footer_html = post_parser.generate_html_footer(main_raw, flags_list=merged_flags)
|
||||
```
|
||||
|
||||
Дополнительно (§3.15): в `_reactions_views_links` пустая `reactions_html` НЕ
|
||||
добавляется в `first_line_parts` (иначе после перехода на реальный Message
|
||||
merged-посты с пустым reactions-объектом получили бы ведущий разделитель —
|
||||
артефакт, который сегодня есть и у одиночных постов; чиним для всех).
|
||||
|
||||
Удалить конвертер целиком и импорт `SimpleNamespace`. Обновить golden
|
||||
(§3 пп.6, 8, 15) и СНЯТЬ сорт-нормализацию `message-flags` в
|
||||
golden-сравнении, введённую этапом 0: порядок флагов теперь детерминирован
|
||||
(§3.8), нормализация больше не нужна и лишь маскировала бы регрессии.
|
||||
|
||||
Тесты: футер merged-группы — реальные ссылки главного сообщения; несколько
|
||||
custom-emoji → отдельные «❓ N» span'ы; paid → «⭐ N»; пустой reactions-объект →
|
||||
нет ведущего разделителя (и для одиночного поста); порядок флагов стабилен;
|
||||
**выделенный тест §3.7 с не-UTC TZ** (например `TZ=Europe/Moscow` + `tzset`):
|
||||
дата merged-футера совпадает с датой футера одиночного поста того же
|
||||
сообщения. TZ ОБЯЗАТЕЛЬНО восстанавливается в teardown (fixture/try-finally),
|
||||
иначе не-UTC протечёт в golden-тесты, которым conftest запинил UTC.
|
||||
|
||||
DoD: функция удалена, pytest + golden зелёные.
|
||||
|
||||
### Этап 3 — слияние близнецов вокруг `_prepare_feed_posts`
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PreparedFeed:
|
||||
channel_username: str
|
||||
channel_title: str # used by RSS metadata only
|
||||
posts: list[dict] # rendered, filtered, sorted, SANITIZED
|
||||
|
||||
class ChannelNotFound(Exception):
|
||||
def __init__(self, channel_identifier): # prepared id, for create_error_feed
|
||||
self.channel_identifier = channel_identifier
|
||||
|
||||
# NO timed() abstraction: it could not carry the paired rss_/html_ log-line
|
||||
# names nor the extra context (message counts) anyway. Keep today's explicit
|
||||
# logger.debug lines, prefixed via log_prefix.
|
||||
|
||||
async def _prepare_feed_posts(channel, client, *,
|
||||
limit, exclude_flags, exclude_text, merge_seconds,
|
||||
history_limit, enrich_replies: bool,
|
||||
log_prefix: str) -> PreparedFeed:
|
||||
# log_prefix: 'rss' | 'html' — preserves today's paired log-line names
|
||||
# (f"{log_prefix}_channel_info_timing", f"{log_prefix}_messages_retrieval_timing", ...)
|
||||
# 1) validate limit (1..200)
|
||||
# 2) channel_name_prepare + cached_get_chat:
|
||||
# UsernameInvalid/UsernameNotOccupied/no-username -> ChannelNotFound(prepared)
|
||||
# FloodWait -> re-raise (api_server maps to 429)
|
||||
# other errors -> ValueError chain (unified text, §3.10)
|
||||
# 3) cached_get_chat_history(limit=history_limit):
|
||||
# FloodWait -> re-raise BEFORE the ValueError wrap (§3.9)
|
||||
# NOTE: keep `from tg_cache import ...` INSIDE this function — feed tests
|
||||
# monkeypatch tg_cache and rely on late name resolution.
|
||||
# 4) if enrich_replies: messages = await _reply_enrichment(client, messages)
|
||||
# 5) try: to_thread(_render_pipeline, ...) finally: flush_pending_media_ids()
|
||||
```
|
||||
|
||||
Форматтеры:
|
||||
|
||||
- `generate_channel_rss`: `history_limit=limit*2, enrich_replies=False,
|
||||
log_prefix="rss"` (RSS over-fetches so merging still yields ~limit posts) →
|
||||
fg-метаданные + entries (`rss_date_range`-лог сохраняется) →
|
||||
`to_thread(fg.rss_str)`.
|
||||
- `generate_channel_html`: `history_limit=limit, enrich_replies=True,
|
||||
log_prefix="html"` (enrichment is HTML-only to keep RSS polling cheap —
|
||||
deliberate) → join с `<hr>`.
|
||||
- Оба: `except ChannelNotFound as e: return create_error_feed(str(e.channel_identifier), base_url)`
|
||||
(байт-эквивалентно текущему выводу: prepared-значение уже используется
|
||||
сегодня, int/str интерполируются одинаково — проверено в ревью).
|
||||
- Внешние catch-логи ОСТАЮТСЯ в форматтерах со своими сегодняшними именами
|
||||
(`generate_channel_rss: …` — rss_generator.py:526, `html_generation_error:
|
||||
…` — 716): каждый форматтер сохраняет собственный внешний try/except.
|
||||
|
||||
Тесты: golden без изменений (главный критерий); ChannelNotFound → error feed
|
||||
в обоих путях; FloodWait из get_chat → 429; FloodWait из истории → 429
|
||||
(новый, §3.9); ValueError из истории → 400.
|
||||
|
||||
DoD: один блок get_chat/get_history/render; diffstat отрицательный;
|
||||
pytest + golden зелёные.
|
||||
|
||||
### Этап 4 — группировка без deepcopy и мутаций
|
||||
|
||||
```python
|
||||
def _compute_time_based_group_ids(messages, merge_seconds) -> dict[int, str | int]:
|
||||
"""Return {message.id: effective_media_group_id} WITHOUT mutating messages.
|
||||
|
||||
Contract: all messages belong to ONE chat (message.id is unique only
|
||||
per chat); callers must not mix chats in a single call.
|
||||
|
||||
Reproduces the old algorithm for every input the old code survived on
|
||||
PRODUCTION data (naive kurigram dates). Inputs only aware-date test mocks
|
||||
could produce (fully-None and aware+None mixes, where the old code
|
||||
clustered the None-date tail by insertion order, incl. truthy-id
|
||||
adoption) are deliberately replaced — registry §3.11:
|
||||
- messages WITHOUT a date do not participate in time clustering and get
|
||||
NO mapping entry (their own media_group_id still applies downstream) —
|
||||
registry §3.11;
|
||||
- sort by date ascending, timestamp-based key (naive-safe);
|
||||
- a message joins the current cluster if the gap to the PREVIOUS message
|
||||
is <= merge_seconds; the gap is computed by NAIVE datetime subtraction
|
||||
(msg.date - prev.date).total_seconds(), exactly as the old code — NOT
|
||||
via timestamps (they diverge during a DST fold, and the old behavior
|
||||
is the contract);
|
||||
- effective id of a cluster = the FIRST TRUTHY media_group_id seen in
|
||||
cluster order (old code used truthiness, not `is not None`; it also
|
||||
overwrote members' own differing ids — keep that);
|
||||
- if no member has a truthy id and len(cluster) >= 2:
|
||||
synthetic id f"time_{min(dates)}" (keep the exact format);
|
||||
- singleton clusters and clusters with no effective id produce NO
|
||||
entries. Every member of a cluster with an effective id gets an entry.
|
||||
"""
|
||||
```
|
||||
|
||||
Правки:
|
||||
|
||||
- `_create_messages_groups(messages, group_ids=None)`:
|
||||
`effective = (group_ids or {}).get(message.id, message.media_group_id)`;
|
||||
sort-ключ групп — timestamp-based (naive-безопасный), фолбэк для None-date
|
||||
групп `float('inf')` → детерминированно переживают `[:limit]`-срез как
|
||||
новейшие; финальная сортировка постов в `_render_messages_groups`
|
||||
(фолбэк `0.0`) НЕ меняется — None-date посты выводятся в конце фида (§3.12).
|
||||
- `_render_pipeline` при `time_based_merge`: маппинг + пред-сортировка входа
|
||||
date-ASC тем же timestamp-ключом (фолбэк `+inf` — None-date в конце);
|
||||
стабильный sorted на fetch-order входе воспроизводит порядок старого
|
||||
`messages_sorted`, включая ties (старый sorted работал на deepcopy того же
|
||||
fetch-order списка, мутации происходили после сортировки — проверено).
|
||||
`sorted()` не мутирует вход — deepcopy не нужен.
|
||||
- Удалить `_create_time_based_media_groups` и импорт `copy`; поправить
|
||||
модульный импорт в test_stage4_eventloop.py:34–42 (API relocation).
|
||||
- Добавить None-date фикстуру ТОЛЬКО в HTML-golden (§3.11–12): в RSS
|
||||
None-date пост получает `pubDate = datetime.now()` (rss_generator.py:
|
||||
503–507) — недетерминированно, и новую нормализацию для этого не вводим;
|
||||
RSS-семантика None-date закрепляется unit-тестом (pubDate присутствует,
|
||||
entry сгенерирован), вне golden.
|
||||
|
||||
Тесты (`tests/test_group_ids.py`, naive-даты): time-кластер без id →
|
||||
синтетический; усыновление truthy id с backfill и перезаписью чужого id;
|
||||
falsy id (`0`, `""`) игнорируется как в старом коде; одиночка без entry;
|
||||
None-date не получает entry в маппинге, но его собственный `media_group_id`
|
||||
продолжает действовать (медиагруппа с None-датами собирается); смешанный
|
||||
None-date вход не падает (§3.11); полностью-None вход: новое поведение
|
||||
закреплено явно; None-date группы переживают `[:limit]`-срез (ключ групп
|
||||
`+inf`), в итоговом выводе — в конце фида (финальный фолбэк `0.0`, §3.12);
|
||||
вход не мутирован;
|
||||
ties при равных датах → порядок как у старого кода; кластеризация через
|
||||
DST-fold — как у старого кода (naive-вычитание).
|
||||
|
||||
DoD: `copy.deepcopy` отсутствует; pytest + golden зелёные (golden-дифф — только
|
||||
добавленная None-date фикстура, §3.11–12).
|
||||
|
||||
### Этап 5 — таблица медиа-типов в post_parser (два коммита)
|
||||
|
||||
**5a — рефакторинг байт-в-байт.**
|
||||
|
||||
Шаг 0: fragment-level снапшоты `_generate_html_media` (ДО санитайза) для всех
|
||||
типов: PHOTO, VIDEO, ANIMATION, VIDEO_NOTE, AUDIO, VOICE, STICKER img/video,
|
||||
DOCUMENT pdf/обычный, LIVE_PHOTO, STORY video/photo, POLL с description_media,
|
||||
PAID_MEDIA, WEB_PAGE с фото (пустой media-div!), **и edge-ветки**: WEB_PAGE
|
||||
без фото (незакрытый div — воспроизводится в 5a буквально), file_unique_id
|
||||
is None, channel_username is None, гейт webpage-превью «text ≤ 10».
|
||||
|
||||
Источник сообщений для fragment-снапшотов (v6): ЗАПИСАННЫЙ КОРПУС этапа 0 —
|
||||
для всех типов, которые в нём есть (по инвентарю: PHOTO, VIDEO, ANIMATION,
|
||||
VIDEO_NOTE, AUDIO, VOICE, STICKER, DOCUMENT, POLL, WEB_PAGE, GIVEAWAY,
|
||||
GIVEAWAY_WINNERS). Моки — ТОЛЬКО для отсутствующих в корпусе типов
|
||||
(PAID_MEDIA, STORY, LIVE_PHOTO, CHECKLIST и прочая экзотика) — ограничение
|
||||
инвариант-теста «ложная зелень на моках» сужается до этих типов.
|
||||
|
||||
```python
|
||||
# Single source of truth: media type -> (object selector, render kind).
|
||||
# kind=None means "selector-only entry": the object participates in file-id
|
||||
# extraction/collection, but rendering happens outside the dispatcher.
|
||||
# The ONLY kind=None entry is WEB_PAGE (rendered by _format_webpage).
|
||||
# PAID_MEDIA has NO entry at all: its info block stays a separate branch in
|
||||
# _generate_html_media, and it is deliberately not collected/downloadable.
|
||||
MEDIA_SOURCES: dict[MessageMediaType, Callable[[Message], tuple[Any, str | None]]] = {
|
||||
MessageMediaType.PHOTO: lambda m: (m.photo, 'img_400'),
|
||||
MessageMediaType.DOCUMENT: _select_document, # returns kind 'pdf' OR 'img_400' by mime_type
|
||||
MessageMediaType.STICKER: _select_sticker, # video_loop_200 vs img_200_sticker
|
||||
MessageMediaType.STORY: _select_story, # maps helper kinds video->'video_400', img->'img_400'
|
||||
MessageMediaType.POLL: _select_poll_media, # same helper-kind mapping
|
||||
MessageMediaType.WEB_PAGE: lambda m: (getattr(m.web_page, 'photo', None), None),
|
||||
# VIDEO/ANIMATION/VIDEO_NOTE -> video_400; AUDIO/VOICE -> audio;
|
||||
# LIVE_PHOTO -> video_loop_400
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class RenderCtx:
|
||||
url: str # signed /media URL — assembled by _generate_html_media,
|
||||
# which KEEPS the digest call and the channel_username
|
||||
# guard inline (they are not the renderer's business)
|
||||
tg_link: str | None = None # t.me deep link — only the 'pdf' renderer uses it
|
||||
emoji: str = '' # sticker alt text
|
||||
mime: str | None = None # audio/voice source type; the DEFAULT is chosen
|
||||
# by the ctx builder in _generate_html_media BY
|
||||
# MEDIA TYPE (AUDIO -> audio/mpeg, VOICE ->
|
||||
# audio/ogg, branch:907/912) — the renderer
|
||||
# receives a ready value and never guesses
|
||||
|
||||
# Renderers, NOT naked format strings: a renderer returns list[str] so the
|
||||
# byte structure of '\n'.join is preserved (audio emits TWO items: tag + <br>;
|
||||
# pdf emits its two-append div block). Renderer bodies are lifted VERBATIM
|
||||
# from the existing if/elif branches — including the `src="{url}"style=`
|
||||
# concatenation artifacts — byte-for-byte fidelity is the 5a contract.
|
||||
RENDERERS: dict[str, Callable[[RenderCtx], list[str]]] = { ... }
|
||||
```
|
||||
|
||||
PDF идёт ЧЕРЕЗ таблицу: `_select_document` возвращает kind `'pdf'` для
|
||||
`application/pdf`, `RENDERERS['pdf']` использует `ctx.tg_link` (сборка ссылки
|
||||
`t.me/c/...` vs `t.me/...` — в `_generate_html_media`, как сейчас).
|
||||
|
||||
Потребители: `_get_file_unique_id`, `_save_media_file_ids`,
|
||||
`_generate_html_media` — все через селектор. Ограничение: `flags.append(...)`
|
||||
НЕ выносить из `_extract_flags` (эндпоинт `/flags` парсит его исходник через
|
||||
`inspect.getsource`); тест: `get_all_possible_flags()` непуст и содержит
|
||||
известные флаги.
|
||||
|
||||
Межмодульный инвариант-тест (границу с api_server закрепить машинно):
|
||||
для каждого типа из MEDIA_SOURCES объект, выбранный селектором, находится
|
||||
`api_server.find_file_id_in_message` по своему `file_unique_id` — новые entry
|
||||
таблицы не могут дать URL, который download-путь не разрешит (иначе /media
|
||||
404). Ограничение теста: обе стороны работают на моках — класс багов «обе
|
||||
функции ждут атрибут, которого нет у реального kurigram-объекта» он не ловит;
|
||||
опциональная best-effort митигация — сверка атрибутов моков с
|
||||
`pyrogram.types` (сама интроспекция хрупка при апгрейдах kurigram; ядро
|
||||
теста ценно и без неё, обязательной её не делать).
|
||||
Сама `find_file_id_in_message` НЕ сливается с таблицей: её контракт шире —
|
||||
поиск любого скачиваемого объекта независимо от `message.media`, включая
|
||||
`explanation_media`, который рендер сознательно игнорирует (§7).
|
||||
|
||||
**5b — зарегистрированные фиксы** (отдельный коммит): закрыть
|
||||
`</div>` во всех ветках (§3.14); guard «>100 МБ» на любой медиа-объект
|
||||
(§3.13); обновить fragment-снапшоты с diff-комментарием. Feed-level golden
|
||||
5b не трогает сверх этих пунктов.
|
||||
|
||||
DoD: прямые атрибутные ЦЕПОЧКИ вида `m.photo.file_unique_id` — только в
|
||||
селекторах таблицы, хелперах `_poll_media_object`/`_story_media_object`
|
||||
(это и есть реализация селекторов) и `_format_webpage`. Потребители
|
||||
извлекают uid единообразно — `getattr(selected_obj, 'file_unique_id', None)`
|
||||
от объекта, ВОЗВРАЩЁННОГО селектором; санкционированные точки извлечения:
|
||||
`_get_file_unique_id`, `_save_media_file_ids`, инвариант-тест. Три лестницы
|
||||
удалены; снапшоты обоих уровней + pytest зелёные; инвариант-тест с
|
||||
api_server зелёный.
|
||||
|
||||
### Этап 6 — косметика
|
||||
|
||||
1. `_wrap_post_html(body, footer)` — единая обёртка (сейчас три копии:
|
||||
`_format_html` и обе ветки `_render_messages_groups`).
|
||||
2. Инлайн-стили 400px/200px → константы (если не закрыто этапом 5).
|
||||
3. `_trim_messages_groups` → инлайн-срез `[:limit]`; поправить импорт в
|
||||
test_stage4_eventloop.py (API relocation).
|
||||
4. Комментарии «stage-4»/«4.4» — переписать под новую границу санитайза.
|
||||
5. Фильтр exclude_flags → comprehension; фильтр exclude_text — оставить
|
||||
циклом или обернуть предикатом, но `excluded_post`-debug-лог СОХРАНИТЬ
|
||||
(единственный след, почему пост выпал из фида).
|
||||
|
||||
## 6. Порядок и зависимости
|
||||
|
||||
`0 → 1 → 2 → 3` строго последовательно. `4` и `5` независимы, после 3.
|
||||
`6` — последним. Каждый этап мержибелен сам по себе.
|
||||
|
||||
## 7. Сознательно НЕ делаем (отложено)
|
||||
|
||||
- Унификация глубины истории RSS (`limit*2`) vs HTML (`limit`).
|
||||
Симптом: кэш истории — ОДИН файл на канал, limit хранится внутри и при
|
||||
несовпадении — miss (tg_cache.py:43–52, 106–109); канал с обоими
|
||||
потребителями живёт в вечном miss-цикле со взаимным вытеснением и
|
||||
удвоенным RPC. НЕ отложено — чинится в отдельном эпике кешей (ишью #23,
|
||||
Package B: префиксная выдача истории, `cached_limit >= limit` → hit).
|
||||
Здесь ничего делать не нужно, но при мёрже учитывать пересечение (см.
|
||||
комментарий к эпику #34).
|
||||
- `_reply_enrichment` в RSS-пути (RPC-нагрузка от частых опросов ридеров).
|
||||
- HTML-страница ошибки для `generate_channel_html` (сейчас RSS-XML).
|
||||
- Кэширование/демутация `_reply_enrichment` (мутирует кэшированные Message).
|
||||
- `api_server.find_file_id_in_message`: остаётся отдельной (контракт шире
|
||||
рендера — см. этап 5a), граница закрыта инвариант-тестом.
|
||||
- Валидация `merge_seconds` в api_server (сейчас принимает `<=0` из query).
|
||||
- Валидация `exclude_text` (невалидный regex → `re.error` → 500 и сейчас,
|
||||
и после этапа 3 — api_server пробрасывает параметр как есть).
|
||||
- CDATA-риск feedgen: html5lib не эскейпит `>` в атрибутах, `]]>` внутри href
|
||||
теоретически рвёт CDATA. Пре-существующий, вне скоупа.
|
||||
- `/flags` через `inspect.getsource` — хрупкая механика, замена отложена.
|
||||
|
||||
## 8. Журнал адверсариального ревью
|
||||
|
||||
### Раунд 1 (по v1): 26 претензий (2 blocker, 8 major, 12 minor, 4 nit)
|
||||
|
||||
- Приняты полностью и внесены: №3 (protocols), №4 (правки тестов при API
|
||||
relocation), №5 (golden-оракул → этап 0), №6 (TZ merged-футера, §3.7),
|
||||
№7 (FloodWait истории → 429, §3.9), №11 (§3.10), №12 (lazy-import пин),
|
||||
№13 (наблюдаемость, log_context), №14 (реальная дельта реакций, §3.6),
|
||||
№16+№15 (naive/aware краш → §3.11–12, truthiness), №18 (edge-матрица
|
||||
снапшотов), №19 (payload ChannelNotFound), №20 (§3.5), №21 (обоснование
|
||||
шареного CSSSanitizer), №22 (/flags-мина), №23 (excluded_post-лог),
|
||||
№24 (контракт one-chat), №25 (§2), №26 (CDATA в §7).
|
||||
- №1 (blocker): снято разделением этапа 5 на 5a/5b и двумя слоями эталонов.
|
||||
- №2 (blocker): понижено до пункта реестра §3.4 по согласованной формулировке.
|
||||
- №8: контракты `find_file_id_in_message` и MEDIA_SOURCES признаны разными;
|
||||
граница закрыта инвариант-тестом по предложению критика.
|
||||
- №9: закрыт пред-сортировкой входа naive-безопасным ключом.
|
||||
- №10: база запинена на f9550d8.
|
||||
- №17: `TAG_TEMPLATES: dict[str, str]` заменён на renderer-функции.
|
||||
|
||||
### Раунд 2 (по v2): 13 претензий (1 blocker, 4 major, 5 minor, 3 nit)
|
||||
|
||||
- №1 (blocker): краш naive/aware живёт в ДЕФОЛТНОМ пути сортировки групп, а
|
||||
не только в тайм-кластеризации (подтверждено эмпирически) → §1 исправлен,
|
||||
None-date фикстура перенесена из этапа 0 в этап 4 (вариант «а» критика),
|
||||
§3.12 уточнён.
|
||||
- №2: направление дельты пустых реакций было инвертировано → выбран
|
||||
fix-вариант: §3.15 (чинится для всех постов), формулировка §3.6 очищена.
|
||||
- №3: `strip=True` внесён в спеку полным вызовом `clean()` (тот же класс
|
||||
дыры, что protocols в раунде 1).
|
||||
- №4: детерминизм golden обеспечен (TZ-пин, нормализация lastBuildDate/
|
||||
generator); зафиксировано, что §3.7 golden не верифицирует → выделенный
|
||||
не-UTC тест в этапе 2.
|
||||
- №5: противоречие DOCUMENT/PDF разрешено в пользу «PDF через таблицу»,
|
||||
ctx расширен полем tg_link.
|
||||
- №6: RenderCtx специфицирован (поля, владелец сборки URL, verbatim-правило).
|
||||
- №7: базис gap зафиксирован — naive-вычитание как в старом коде (timestamp
|
||||
расходится в DST-fold).
|
||||
- №8: позиция None-date групп зарегистрирована (§3.12: новейшие, `+inf`).
|
||||
- №9: формулировка теста исправлена («нет entry в маппинге», а не «синглтон»).
|
||||
- №10: ограничение инвариант-теста (ложная зелень на моках) проговорено +
|
||||
митигация.
|
||||
- №11: `_render_pipeline` получает `channel` для log_context (этап 1 п.2).
|
||||
- №12: диаграмма §2 исправлена (фильтры/sort внутри `_render_messages_groups`,
|
||||
санитайз после фильтров).
|
||||
- №13: DoD 5a включает хелперы `_poll_media_object`/`_story_media_object`.
|
||||
- Чистыми признаны: эквивалентность error feed (B5), §3.9 против tg_cache и
|
||||
429/Retry-After (B6), эквивалентность пред-сортировки (B3), внесение всех
|
||||
исходов раунда 1.
|
||||
|
||||
### Раунд 3 (верификация дельты v3)
|
||||
|
||||
- Пять из шести выборов подтверждены (None-date → этап 4; fix пустых реакций;
|
||||
PDF через таблицу; naive-gap; полный clean()).
|
||||
- Возражение по №5 принято: `+inf` определяет только выживание группы при
|
||||
`[:limit]`-срезе, итоговую позицию задаёт финальная сортировка с фолбэком
|
||||
`0.0` (None-date посты — в конце фида, как и раньше) — §3.12, этап 4 и его
|
||||
тест переформулированы.
|
||||
- Криво внесённое исправлено: список ожидаемых golden-изменений дополнен
|
||||
этапом 5b (п.14); исключения single-post пути в §2 расширены до
|
||||
пп. 2, 13, 14, 15.
|
||||
- Практические заметки внесены: teardown TZ в не-UTC тесте §3.7; два
|
||||
независимых Config-дикта при monkeypatch `time_based_merge`.
|
||||
|
||||
### Раунд 4 (по v3): 17 пунктов (2 major, 4 minor, остальное nit/экономика)
|
||||
|
||||
- Детерминизм golden добит двумя major-находками: порядок merged-флагов до
|
||||
этапа 2 зависит от PYTHONHASHSEED (`list(set)`) → сорт-нормализация
|
||||
`message-flags` до этапа 2; ключ подписи media-URL (`secrets.token_hex` в
|
||||
`data/media_digest.key`) → пин autouse-фикстурой.
|
||||
- Согласован состав фикстур: добавлен strikethrough-пост (иначе §3.1
|
||||
невидим), пп.2/5 помечены как невидимые для golden; exclude-сценарии
|
||||
вынесены из golden в unit-тесты (экономическая критика).
|
||||
- `timed()` выкинут: не мог сохранить парные `rss_/html_` имена логов и не
|
||||
окупался — заменён параметром `log_prefix` у `_prepare_feed_posts`.
|
||||
- Кодер-готовность: оба call-site'а `_render_pipeline` в этапах 1–2
|
||||
проговорены; владелец mime-дефолта — ctx-билдер по media-типу; указатель
|
||||
на паттерн monkeypatch tg_cache в этапе 0; обоснование двойного
|
||||
Config-патча исправлено (достаточно rss_generator.Config).
|
||||
- Митигация инвариант-теста через интроспекцию `pyrogram.types` понижена до
|
||||
опциональной (сама хрупка при апгрейдах).
|
||||
- §7 пополнен: вечный miss-цикл кэша истории у dual-consumer каналов (цена
|
||||
отложенной унификации глубины), валидация exclude_text.
|
||||
- C-остатки признаны чистыми: частичный flush и дубли upsert закреплены
|
||||
существующими тестами; лог одиночного санитайза контекста и сегодня не
|
||||
имеет; api_server пробрасывает exclude_* без валидации — поведение не
|
||||
меняется.
|
||||
- Экономический вердикт критика: аппарат тяжёл для ~700 строк рефакторинга,
|
||||
но каждый элемент отвечает конкретному найденному багу; срезаны только
|
||||
`timed()`, exclude-фикстуры и обязательность интроспекции. Этапы не
|
||||
сливать: раздельная мержибельность дешевле.
|
||||
|
||||
### Раунд 5 (по v4): независимый холодный проход, 8 пунктов (1 blocker, 1 major)
|
||||
|
||||
Второй критик — без контекста предыдущих раундов, с запретом доверять
|
||||
журналу и реестру на слово. Итог валидации: ВСЕ line-refs §1 и ключевые
|
||||
эмпирические утверждения спеки подтверждены независимо (включая вырезание
|
||||
hr, заглатывание постов незакрытым div, strip/protocols-семантику, TypeError
|
||||
в дефолтном пути, miss-цикл кэша, соответствие MEDIA_SOURCES реальной
|
||||
лестнице вплоть до артефактов, находимость всех селекторных объектов в
|
||||
find_file_id_in_message); архитектурные решения признаны чистыми. Найдены
|
||||
дефекты исполнимости:
|
||||
|
||||
- Blocker: None-date фикстура в RSS-golden недетерминированна
|
||||
(`pubDate = now()` для None-date entry) → фикстура включается только в
|
||||
HTML-golden, RSS-семантика — unit-тестом (этап 4 переписан).
|
||||
- Major: три имени санитайз-логов физически сливаются в одно при делегате —
|
||||
противоречие с §4 → зарегистрировано как §3.16 (единое
|
||||
`html_sanitization_error` + log_context); внешние catch-логи форматтеров
|
||||
явно оставлены в этапе 3.
|
||||
- Механизм волатильности feedgen уточнён (lastBuildDate — один раз в
|
||||
конструкторе, не при сериализации; generator без версии — нормализация
|
||||
опциональна).
|
||||
- §3.11/контракт этапа 4: «Было» дополнено выжившими aware+None и
|
||||
fully-None входами (кластеризация None-хвоста с adoption), заголовок
|
||||
docstring сужен до «survived on production data».
|
||||
- DoD 5a переписан: единообразное извлечение uid через
|
||||
`getattr(selected_obj, ...)` с санкционированными точками (сигнатура
|
||||
селектора uid не возвращает — старая формулировка была невыполнима).
|
||||
- Этап 0: добавлен пин записи media-id (cwd-относительный DB_PATH писал бы
|
||||
в реальную data/ из golden-тестов); поправлен line-ref api_server;
|
||||
каветт §3.12 про будущие даты.
|
||||
|
||||
Отклонённых претензий нет; по всем спорным пунктам достигнут консенсус.
|
||||
|
||||
### v6 — golden-корпус: записанные реальные сообщения (решение владельца)
|
||||
|
||||
Не раунд ревью — изменение дизайна по решению владельца проекта, с
|
||||
немедленной эмпирической проверкой:
|
||||
|
||||
- Корпус этапа 0 переведён с синтетики на записанные кэши прод-сервера
|
||||
(`data/tgcache/*.cache|.chatinfo` — пиклы того же формата, что читает
|
||||
прод-путь cache-hit). Мотив: холодный проход (раунд 5) пометил класс
|
||||
«мок и код согласованно ждут несуществующий атрибут» как неловимый на
|
||||
моках — реальные объекты закрывают его целиком.
|
||||
- Снапшот кэша снят (90 каналов), совместимость пиклов со старым kurigram
|
||||
проверена под 2.2.23: 179/179 файлов без ошибок.
|
||||
- Инвентарь покрытия: 8582 сообщения, все даты naive (посылка спеки
|
||||
подтверждена данными); покрыты в т.ч. edge-кейсы §3.1 (зачёркивания: 150)
|
||||
и §3.15 (пустые reactions-объекты: 17), webpage с/без фото, ~294
|
||||
time-кластерные пары. Синтетика сузилась до None-date и отсутствующих
|
||||
типов (PAID_MEDIA, STORY, LIVE_PHOTO, CHECKLIST, прочая экзотика).
|
||||
- Fragment-снапшоты этапа 5a — тоже на корпусе, где тип доступен;
|
||||
ограничение инвариант-теста сузилось до mock-only типов.
|
||||
@@ -0,0 +1,347 @@
|
||||
# План стабилизации pyrogram-bridge: статика + зависания
|
||||
|
||||
Дата: 2026-07-05. База: аудит api_server.py / telegram_client.py / tg_throttle.py / tg_cache.py / file_io.py / rss_generator.py / post_parser.py.
|
||||
|
||||
Проверенные факты, на которых строится план:
|
||||
- `FloodWait` — подкласс `RPCError` (проверено на установленном Kurigram).
|
||||
- Starlette 0.45.3 поддерживает Range в `FileResponse` из коробки (проверено по исходникам в venv).
|
||||
- Дефолтный executor `asyncio.to_thread` = min(32, cpu+4) потоков; в контейнере на 1–2 CPU это 5–6.
|
||||
- В venv стоит Kurigram 2.2.4, в requirements закреплён 2.2.22 — локальное окружение не соответствует прод-образу.
|
||||
|
||||
## Целевые инварианты (что должно стать верным после ремонта)
|
||||
|
||||
1. Любой файл в кэше с финальным именем (`{file_unique_id}` или `temp_{file_unique_id}`) — гарантированно полный. Частичным может быть только `*.part.*`.
|
||||
2. Каждый Telegram RPC ограничен таймаутом. Глобальный RPC-гейт не может удерживаться дольше таймаута.
|
||||
3. Временные ошибки Telegram (FloodWait) никогда не превращаются в постоянные HTTP-ответы (404).
|
||||
4. Event loop не выполняет CPU-работу > ~50 мс за раз.
|
||||
5. Каждая фоновая задача supervised: её смерть видна в логах на CRITICAL и/или она перезапускается.
|
||||
6. Healthcheck не зависит от Telegram RPC и от обхода файловой системы.
|
||||
|
||||
## Процесс
|
||||
|
||||
- Ветка `fix/stability` от `main`, коммит на каждую стадию (на `main` не коммитим).
|
||||
- Порядок стадий = порядок деплоя: после стадий 1 и 2 уже можно выкатываться и наблюдать.
|
||||
- После каждой стадии: pytest + ручные сценарии из стадии 7 + код-ревью.
|
||||
- Перед началом: пересоздать/обновить venv под requirements (Kurigram 2.2.22), прогнать 174 существующих теста как baseline. Добавить dev-зависимости: pytest-asyncio, httpx (для TestClient).
|
||||
|
||||
---
|
||||
|
||||
## Стадия 1 — устранение зависаний (минимальный диф, максимальный эффект)
|
||||
|
||||
### 1.1 Таймауты на все RPC под глобальным гейтом
|
||||
|
||||
Файлы: tg_cache.py, config.py.
|
||||
|
||||
- Добавить в config `tg_rpc_timeout` (env `TG_RPC_TIMEOUT`, default 60).
|
||||
- `cached_get_chat_history` (tg_cache.py:142-144): итерацию истории собрать в корутину и обернуть в `asyncio.wait_for` **внутри** `async with tg_rpc()`:
|
||||
```python
|
||||
async with tg_rpc():
|
||||
async def _collect():
|
||||
# Full paginated history; bounded by the outer wait_for
|
||||
return [m async for m in client.get_chat_history(channel_id, limit=limit)]
|
||||
messages = await asyncio.wait_for(_collect(), timeout=Config["tg_rpc_timeout"])
|
||||
```
|
||||
- `cached_get_chat` (tg_cache.py:216-217): `await asyncio.wait_for(client.get_chat(channel_id), timeout=...)` внутри гейта.
|
||||
|
||||
**Где не наебаться:**
|
||||
- `wait_for` должен оборачивать **только RPC**, не `_sem.acquire()`. Ожидание в очереди гейта — легитимный backpressure; если холдер ограничен таймаутом, очередь всегда дренируется. Обернёшь acquire — получишь ложные таймауты при штатной очереди из 47 фидов miniflux.
|
||||
- `async for` нельзя завернуть в wait_for напрямую — только через промежуточную корутину (как в эскизе).
|
||||
- CancelledError не глотать: `except Exception` в вызывающих местах её и так не ловит (это BaseException в 3.11) — не «улучшать» до `except BaseException`.
|
||||
|
||||
### 1.2 Таймауты и гейт на остальные живые RPC
|
||||
|
||||
- `_reply_enrichment` (rss_generator.py:505): обернуть `client.get_messages` в `async with tg_rpc()` + `wait_for`.
|
||||
- `PostParser.get_post` (post_parser.py:98): `wait_for(..., 30)`; заодно удалить `print(...)` (post_parser.py:95).
|
||||
- `/health` (api_server.py:906): `wait_for(client.client.get_me(), 10)`.
|
||||
|
||||
### 1.3 Починка фонового воркера и очереди
|
||||
|
||||
Файл: api_server.py.
|
||||
|
||||
- `background_download_worker` (620-637): `task_done()` вызывать только если элемент был реально получен:
|
||||
```python
|
||||
while True:
|
||||
item = await download_queue.get() # cancellation propagates cleanly here
|
||||
channel, post_id, file_unique_id = item
|
||||
try:
|
||||
async with BACKGROUND_DOWNLOAD_SEMAPHORE:
|
||||
await download_media_file(channel, post_id, file_unique_id)
|
||||
await asyncio.sleep(2)
|
||||
except errors.FloodWait as e:
|
||||
logger.warning(f"bg_download_floodwait: sleeping {e.value}s")
|
||||
await asyncio.sleep(min(int(e.value) + 5, 900))
|
||||
except Exception as e:
|
||||
logger.error(f"Background download error ...: {e}")
|
||||
finally:
|
||||
download_queue.task_done()
|
||||
```
|
||||
- `download_new_files` (605-609): `await download_queue.put(...)` → `download_queue.put_nowait(...)` — иначе `except asyncio.QueueFull` остаётся мёртвым кодом, а заполненная очередь навсегда блокирует весь `cache_media_files` (включая удаление старых файлов).
|
||||
|
||||
**Где не наебаться:**
|
||||
- Сейчас смерть воркера убивает и уборку кэша (цепочка: воркер умер → очередь заполнилась → `await put()` завис → свипер больше не крутится). После фикса проверить тестом: воркер, у которого download постоянно бросает исключение, продолжает жить и `queue.join()` завершается.
|
||||
- FloodWait в воркере надо ловить **до** Exception и спать, иначе воркер будет молотить телеграм под флудом.
|
||||
|
||||
### 1.4 Supervision фоновых задач
|
||||
|
||||
- В lifespan повесить `add_done_callback` на `background_task` и `worker_task`: если таск завершился не через CancelledError — лог CRITICAL с exception + перезапуск таска (обёртка `_supervised(factory)` с ограничением частоты рестартов, например не чаще раза в 60 с).
|
||||
|
||||
### Тесты стадии 1
|
||||
- Гейт: мок «зависшего» RPC (asyncio.Event, который никогда не сеттится) → первый вызов отваливается по таймауту, второй проходит; пермит не утёк.
|
||||
- Воркер: download бросает Exception/FloodWait → воркер жив, task_done сбалансирован.
|
||||
- `_TgRpcGate`: отмена во время ожидания spacing не теряет пермит (уже реализовано — закрепить тестом).
|
||||
|
||||
### DoD стадии 1
|
||||
Ни один путь кода не ждёт Telegram без таймаута; воркер не умирает молча; pytest зелёный.
|
||||
|
||||
---
|
||||
|
||||
## Стадия 2 — статика: большие видео, FloodWait, семафор
|
||||
|
||||
### 2.1 Единый путь скачивания с атомарным rename (главный фикс флаки)
|
||||
|
||||
Файл: api_server.py, telegram_client.py.
|
||||
|
||||
Сейчас большие видео (>100MB) качаются напрямую в финальное имя `temp_{fid}` (379-396): конкурентный запрос видит частичный файл и отдаёт его; при таймауте обрезок не удаляется и целый час отдаётся как «готовый».
|
||||
|
||||
- Выделить хелпер:
|
||||
```python
|
||||
async def _download_atomic(file_id: str, final_path: str, timeout: float) -> str:
|
||||
# Download to a unique partial path, validate, atomically rename.
|
||||
part_path = f"{final_path}.part.{uuid.uuid4().hex}"
|
||||
try:
|
||||
await client.safe_download_media(file_id, part_path, timeout=timeout)
|
||||
if not os.path.exists(part_path) or os.path.getsize(part_path) == 0:
|
||||
raise ZeroSizeFileError(...)
|
||||
if not os.path.exists(final_path):
|
||||
os.rename(part_path, final_path) # atomic on POSIX
|
||||
return final_path
|
||||
finally:
|
||||
# Always clean up our partial file (timeout, cancel, race loser)
|
||||
if os.path.exists(part_path):
|
||||
try: os.remove(part_path)
|
||||
except OSError: pass
|
||||
```
|
||||
- Использовать его и для обычных файлов, и для больших видео (финальное имя больших остаётся `temp_{fid}` — семантика «не кэшировать постоянно, чистить по TTL» сохраняется).
|
||||
- `safe_download_media` научить принимать `timeout` параметром; для больших видео таймаут от размера: `min(1800, max(120, file_size // (256*1024)))` (≈256 KB/s минимально допустимая скорость), env-конфигурируемо.
|
||||
- Проверку `if os.path.exists(temp_file_path): return` (382-384) заменить: существующий `temp_{fid}` теперь **гарантированно полный** (появляется только через rename) — отдавать можно смело.
|
||||
|
||||
**Где не наебаться:**
|
||||
- Единый суффикс `.part.{hex}` вместо старого `.tmp.{hex}`. Обновить regex свипера (api_server.py:536) на новый суффикс, **оставив** и старый паттерн на переходный период (на диске могут лежать старые обрезки).
|
||||
- Инвариант «финальное имя = полный файл» ломается, если хоть один путь пишет мимо `.part.` — после правки grep-ом убедиться, что `download_media` больше нигде не получает финальный путь.
|
||||
- В `download_new_files` (598-600) проверка «temp существует → скипаем» остаётся корректной: полный файл есть — качать не надо. Частичные `.part.` под неё не попадают — это правильно.
|
||||
- Первый запрос большого видео всё ещё отвечает только после полного скачивания (минуты). Это осознанное ограничение; прогрессивный стриминг через `client.stream_media` — отдельная большая фича, в этот план не входит (пометить как future work).
|
||||
|
||||
### 2.2 Дедупликация конкурентных скачиваний (in-flight registry)
|
||||
|
||||
- Модульный `_inflight: dict[tuple[str, int, str], asyncio.Future]`.
|
||||
- Первый запрос: создаёт future, запускает скачивание **отдельным** `asyncio.create_task` (не в контексте HTTP-запроса!), в finally сеттит result/exception и удаляет ключ. Остальные: `await`ят future.
|
||||
|
||||
**Где не наебаться (классическая ловушка):**
|
||||
- Если качать прямо в корутине первого HTTP-запроса, отключение его клиента отменит скачивание, future никогда не завершится, и все ожидающие повиснут. Поэтому: скачивание — в detached task; ожидающие делают `await asyncio.wait_for(fut, timeout)`.
|
||||
- В finally у task обязательно и `set_exception`, и `pop` ключа — иначе после первой ошибки ключ навсегда «занят» отработавшим future.
|
||||
- `set_exception` на future, который никто не await-ит, даст "exception was never retrieved" warning — допустимо, но можно гасить через `fut.exception()` в done-callback.
|
||||
|
||||
### 2.3 FloodWait → 429 в /media
|
||||
|
||||
- В `get_media` добавить обработчик **до** `except errors.RPCError` (api_server.py:1001):
|
||||
```python
|
||||
except errors.FloodWait as e:
|
||||
retry_after = min(int(e.value) + random.randint(1, 30), 300)
|
||||
return Response(status_code=429, content="Telegram flood wait",
|
||||
headers={"Retry-After": str(retry_after)})
|
||||
```
|
||||
|
||||
**Где не наебаться:** порядок except-веток решает: FloodWait — подкласс RPCError, поставишь после — не сработает никогда. Закрепить тестом (мок download_media_file бросает FloodWait → ответ 429, не 404).
|
||||
|
||||
### 2.4 Не удалять большое видео «из-под зрителя»
|
||||
|
||||
- При каждой отдаче файла с именем `temp_*` — `await asyncio.to_thread(os.utime, path)` (touch mtime). Свипер (порог 1 час по mtime) перестанет удалять активно просматриваемые файлы.
|
||||
|
||||
### 2.5 Ограничить ожидание HTTP-семафора
|
||||
|
||||
- Вместо `async with HTTP_DOWNLOAD_SEMAPHORE`: явный `acquire` под `wait_for(30)`; таймаут → 503 + `Retry-After: 30`. Release — только если acquire удался (try/finally вокруг критической секции, а не вокруг acquire).
|
||||
|
||||
### Тесты стадии 2
|
||||
- Конкурентные запросы большого видео с медленным фейковым download: второй запрос НЕ получает частичный файл (либо ждёт future, либо 503/504), после завершения оба получают полный.
|
||||
- Таймаут download → `.part.` удалён, финального имени нет.
|
||||
- FloodWait → 429 c Retry-After.
|
||||
- Свипер: чистит и `.part.`, и старые `.tmp.`, не трогает свежие.
|
||||
|
||||
### DoD стадии 2
|
||||
Ни при каком сценарии клиенту не может быть отдан неполный файл; флуд отдаёт 429; обрезки не живут дольше часа.
|
||||
|
||||
---
|
||||
|
||||
## Стадия 3 — отдача файлов через FileResponse, чистка HTTP-слоя
|
||||
|
||||
### 3.1 Заменить самодельный стриминг на `FileResponse`
|
||||
|
||||
Файл: api_server.py (`prepare_file_response`, 203-335).
|
||||
|
||||
Starlette 0.45.3 сам обрабатывает Range/If-Range/206/416/multipart, ставит Accept-Ranges/ETag/Last-Modified и читает файл эффективно (без нашего to_thread на каждые 64KB):
|
||||
|
||||
```python
|
||||
return FileResponse(
|
||||
file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path), # sets Content-Disposition: inline; filename*
|
||||
content_disposition_type="inline",
|
||||
headers={"Cache-Control": "public, max-age=86400, immutable"},
|
||||
background=background,
|
||||
)
|
||||
```
|
||||
|
||||
Оставить: пре-чек существования (404), MIME-логику (magic + SQLite-кэш типа). Удалить: ручной парсинг Range, `file_chunk_generator`, ручные заголовки.
|
||||
|
||||
**Где не наебаться:**
|
||||
- **Сначала тесты, потом свап.** Написать httpx TestClient-тесты на текущее поведение (bytes=0-499, bytes=500-, bytes=-500, start за EOF → 416, мусорный заголовок), затем перейти на FileResponse и осознанно принять расхождения (например, Starlette на кривой заголовок может отдать 200-полный вместо 416 — это допустимо по RFC 7233).
|
||||
- FileResponse проверяет файл на этапе отправки: пре-чек на 404 оставить обязательно.
|
||||
- `filename*=UTF-8''...` FileResponse формирует сам — руками Content-Disposition не собирать, иначе задвоится.
|
||||
|
||||
### 3.2 Убрать BaseHTTPMiddleware
|
||||
|
||||
- `RequestLoggingMiddleware` (api_server.py:54-64) удалить. Логирование запросов: либо `--access-log` uvicorn на debug-уровне, либо чистый ASGI-middleware из ~10 строк (без BaseHTTPMiddleware): меньше оверхеда на каждый чанк стриминга и никаких сюрпризов с отменой/фоновыми тасками.
|
||||
|
||||
### 3.3 Расширить дефолтный executor
|
||||
|
||||
- В lifespan: `loop.set_default_executor(ThreadPoolExecutor(max_workers=32, thread_name_prefix="io"))`. Даже после ухода per-chunk чтений остаются SQLite/magic/pickle/os.walk — 5–6 дефолтных потоков в контейнере мало.
|
||||
|
||||
### DoD стадии 3
|
||||
Range-тесты зелёные; profiling-запрос большого файла не порождает поток-на-чанк; поведение эндпоинта эквивалентно (кроме осознанных RFC-допущений).
|
||||
|
||||
---
|
||||
|
||||
## Стадия 4 — гигиена event loop (генерация фидов)
|
||||
|
||||
### 4.1 `raw_message` — лениво
|
||||
|
||||
- `process_message(..., include_raw: bool = False)`: `str(message)` (полная сериализация каждого поста! post_parser.py:523) выполнять только для JSON-выдачи и debug-HTML. В фидах — никогда.
|
||||
|
||||
### 4.2 Убрать side-effect IO из process_message (обезвредить ловушку до 4.3)
|
||||
|
||||
Сейчас `_generate_html_media` → `_save_media_file_ids` (post_parser.py:676, 977-1028) делает `asyncio.get_running_loop()` + `create_task`. **Если перенести рендер в поток (4.3) без этой правки, `get_running_loop()` бросит RuntimeError, ветка упадёт в `except` с логом — и записи media id молча перестанут сохраняться, а фоновый прогрев кэша умрёт.**
|
||||
|
||||
- Рефакторинг: `_save_media_file_ids` не пишет в БД, а складывает `(channel, post_id, file_unique_id, ts)` в `self._pending_media_ids` инстанса PostParser (инстанс создаётся на запрос — потокобезопасно, т.к. рендер идёт в одном потоке).
|
||||
- После рендера вызывающий код (rss_generator / get_post) один раз делает `await asyncio.to_thread(upsert_media_file_ids_bulk_sync, DB_PATH, entries)` — новая функция в file_io.py с `executemany`. Это заодно даёт батчинг апсертов (часть стадии 5).
|
||||
- Счётчик `_persist_pending_count` и create_task-механику удалить.
|
||||
|
||||
### 4.3 Рендер фида — в поток
|
||||
|
||||
- `_create_time_based_media_groups`, `_create_messages_groups`, `_trim_messages_groups`, `_render_messages_groups` — внутри нет ни одного await; превратить в обычные sync-функции и выполнять одним `await asyncio.to_thread(_render_pipeline, ...)` из `generate_channel_rss` / `generate_channel_html`.
|
||||
- `copy.deepcopy(messages)` (rss_generator.py:38) уезжает в поток вместе со всем остальным.
|
||||
|
||||
**Где не наебаться:**
|
||||
- GIL: перенос CPU-работы в поток не делает её бесплатной — луп перестаёт стоять колом, но замедляется. Поэтому 4.1/4.4 (сокращение работы) обязательны, а не опциональны.
|
||||
- Внутри потока не должно остаться ни `create_task`, ни `get_running_loop` (это ровно 4.2), ни обращений к asyncio-примитивам.
|
||||
- deepcopy живых Pyrogram-объектов сегодня работает — код не менять, только переместить; отдельным тестом убедиться, что deepcopy Message из pickle-кэша не падает. Полный отказ от deepcopy (не-мутирующая группировка через map message_id → group_id) — отдельный опциональный рефакторинг, в эту стадию не тащить.
|
||||
|
||||
### 4.4 Сократить sanitize-проходы
|
||||
|
||||
Сейчас на каждое сообщение bleach вызывается 4+ раз (body: post_parser.py:672, media: 752, footer: 833, reactions: 920), плюс финальный проход по всему фиду. Bleach — самая дорогая CPU-часть (есть diag_sanitize_slow > 50 мс на вызов).
|
||||
|
||||
- Правило: **один sanitize на каждую выходную границу**, не на каждый фрагмент:
|
||||
- RSS/HTML-фиды: финальный проход уже есть (rss_generator.py:443, 643) → внутренние per-message проходы убрать.
|
||||
- `/html/{channel}/{post_id}`: единственный проход добавить в `_format_html` (или в эндпоинте) — сейчас он полагается на внутренние.
|
||||
- `/json/...`: html-поля внутри JSON должны оставаться чищеными → один проход по body+footer в `process_message`.
|
||||
|
||||
**Где не наебаться (XSS!):**
|
||||
- Перед удалением внутренних проходов построить карту «выходная точка → где чистится», и только потом резать. Ни один выходной путь не должен остаться без ровно одного прохода.
|
||||
- Добавить тесты безопасности: мок-сообщение с `<script>`, `onerror=`, `javascript:`-ссылкой → во всех трёх выводах (rss, html, json) — вычищено.
|
||||
- Помнить, что `debug=true` HTML вставляет `raw_message` в `<pre>` без экранирования — при 4.1 заодно прогнать через `html.escape`.
|
||||
|
||||
### DoD стадии 4
|
||||
Генерация фида на 100 сообщений не блокирует луп заметно (проверка: параллельный запрос `/ping` из стадии 6 отвечает < 100 мс во время генерации); XSS-тесты зелёные; media id продолжают сохраняться (тест на bulk upsert после рендера).
|
||||
|
||||
---
|
||||
|
||||
## Стадия 5 — SQLite: батчинг вместо записи на каждый чих
|
||||
|
||||
### 5.1 Access-time аккумулятор
|
||||
|
||||
- Сейчас каждый кэш-хит /media = поток + connect + UPDATE (api_server.py:963-973). Заменить на модульный `dict[(channel, post_id, fid)] = ts` (обновление словаря на лупе — дёшево и атомарно в asyncio), и периодический flush-таск раз в 60 с: `executemany UPDATE` одним соединением.
|
||||
- Flush также в shutdown (lifespan) — не терять хвост.
|
||||
- Убрать fire-and-forget `asyncio.create_task(_update_access(...))` целиком.
|
||||
|
||||
### 5.2 Батч-апсерты media id
|
||||
|
||||
- Сделаны в 4.2 (`upsert_media_file_ids_bulk_sync`).
|
||||
|
||||
**Где не наебаться:**
|
||||
- Flush-таск — под supervision из 1.4.
|
||||
- Ключи как и раньше: channel — строкой (`str(channel)`), не смешивать str/int в ключах — иначе UPDATE не найдёт строку и timestamp тихо перестанет обновляться (файлы начнут выпадать из кэша через 20 дней при живом трафике).
|
||||
- Порядок в тесте: хит → flush → значение `added` в БД обновилось.
|
||||
|
||||
### DoD стадии 5
|
||||
На кэш-хит /media — ноль обращений к SQLite в горячем пути (кроме кэша MIME); фоновая запись раз в минуту.
|
||||
|
||||
---
|
||||
|
||||
## Стадия 6 — healthcheck и деплой
|
||||
|
||||
### 6.1 Лёгкий `/ping`
|
||||
|
||||
- Новый эндпоинт без токена, без TG RPC, без os.walk:
|
||||
```python
|
||||
@app.get("/ping")
|
||||
async def ping():
|
||||
age = client.watchdog_last_ok_age() # seconds since last successful probe, None if never
|
||||
healthy = client.client.is_connected and (age is None or age < unhealthy_threshold)
|
||||
return JSONResponse({"status": "ok" if healthy else "degraded", ...},
|
||||
status_code=200 if healthy else 503)
|
||||
```
|
||||
- В TelegramClient добавить публичный метод возраста `_wd_last_ok_monotonic`.
|
||||
|
||||
### 6.2 Обновить docker-compose пример
|
||||
|
||||
- healthcheck → `curl -sf http://127.0.0.1:80/ping`, interval 5m, timeout 5s.
|
||||
- Прод-компоуз (вне репозитория) обновить руками — отметить в отчёте деплоя.
|
||||
|
||||
**Где не наебаться:**
|
||||
- Текущий healthcheck (`/rss/...?limit=1`, timeout 5 с) сам создаёт флаки: на холодном кэше генерация легко > 5 с → autoheal рестартует контейнер посреди докачек → битые temp-файлы. После перехода на /ping «живость TG» проверяет вотчдог, а healthcheck — только живость процесса/лупа. Не оставлять старый URL в проде.
|
||||
- `/ping` не должен дергать `get_me()` — иначе вернули ту же проблему через другую дверь.
|
||||
|
||||
### DoD стадии 6
|
||||
Во время зависшего TG RPC `/ping` отвечает мгновенно (503 degraded), контейнер не рестартится от медленного фида.
|
||||
|
||||
---
|
||||
|
||||
## Стадия 7 — сквозная верификация
|
||||
|
||||
1. Полный pytest (старые 174 + новые).
|
||||
2. Ручные сценарии (локально, curl):
|
||||
- Range: `curl -H "Range: bytes=0-99" / "bytes=-100" / "bytes=999999999-"` → 206/206/416.
|
||||
- Параллельные запросы одного большого видео (fake slow client) → нет частичной отдачи.
|
||||
- Отключение клиента на середине стрима → нет утечки тасков/фд (смотреть логи и lsof).
|
||||
- Генерация фида на 100+ сообщений + параллельный /ping → ping < 100 мс.
|
||||
3. Деплой на прод и наблюдение по существующим diag-логам:
|
||||
- `diag_semaphore_wait`, `diag_download_timing` — ожидание должно упасть;
|
||||
- `diag_sanitize_slow` — должен почти исчезнуть;
|
||||
- `watchdog: heartbeat` — продолжаются;
|
||||
- отсутствие 404-всплесков на /media в момент FloodWait (теперь 429).
|
||||
4. Rollback-план: каждая стадия — отдельный коммит (или PR) → откатывается индивидуально.
|
||||
|
||||
---
|
||||
|
||||
## Сводка главных ловушек (checklist перед каждым ревью)
|
||||
|
||||
1. `wait_for` — вокруг RPC, не вокруг acquire гейта/семафора.
|
||||
2. In-flight future: скачивание в detached task, `set_exception` + `pop` в finally, иначе вечно занятый ключ или зависшие ожидающие.
|
||||
3. `_save_media_file_ids` внутри потока = молчаливая потеря записей (RuntimeError → except → лог). Сначала 4.2, потом 4.3.
|
||||
4. Убирая sanitize-проходы — карта выходных точек; каждый выход имеет ровно один проход; XSS-тесты.
|
||||
5. Перенос в поток ≠ ускорение (GIL): обязательно сокращать объём работы (lazy raw_message, один sanitize).
|
||||
6. FileResponse: сначала зафиксировать текущую Range-семантику тестами, потом менять.
|
||||
7. Переименование `.tmp.` → `.part.`: обновить regex свипера, старый паттерн оставить на переходный период.
|
||||
8. `task_done()` только после успешного `get()`; `put_nowait` вместо `await put()` там, где ловится QueueFull.
|
||||
9. Ключи SQLite: channel всегда `str(...)` — рассинхрон типов тихо ломает обновление timestamp.
|
||||
10. Большие видео: touch mtime при отдаче, иначе свипер удалит файл под зрителем.
|
||||
11. Venv ≠ прод: выровнять Kurigram до 2.2.22 до начала работ.
|
||||
12. Ветка `fix/stability`; на `main` не коммитить.
|
||||
|
||||
## Порядок и зависимости
|
||||
|
||||
- Стадия 1 → деплой возможен сразу (низкий риск, убирает зависания).
|
||||
- Стадия 2 → деплой вторым (убирает флаки статики). Зависит от 1.3 (FloodWait в воркере).
|
||||
- Стадия 3 — независима, но лучше после 2 (меньше конфликтов в prepare_file_response/download-путях).
|
||||
- Стадия 4 требует 4.2 строго до 4.3; 4.4 можно отдельным коммитом.
|
||||
- Стадия 5 частично делается в 4.2; аккумулятор access-time — независим.
|
||||
- Стадия 6 — в любой момент, но эффект от неё максимален после 1–2.
|
||||
@@ -0,0 +1,619 @@
|
||||
# Спецификация: рефакторинг и оптимизация кешей pyrogram-bridge
|
||||
|
||||
Статус: **принята владельцем 2026-07-05. Адверсарное ревью — 4 раунда, возражений не осталось.**
|
||||
История ревью — раздел 12. Реализация разбита на ишью в gitea (по PR-юнитам: A+B+F, C, D, E).
|
||||
|
||||
## 0. Контекст и цели
|
||||
|
||||
В проекте три слоя кеша:
|
||||
|
||||
1. **История сообщений и инфо о канале** — pickle-файлы в `data/tgcache/` (`tg_cache.py`).
|
||||
2. **Медиафайлы** — файлы в `data/cache/<channel>/<post_id>/<file_unique_id>` + метаданные в SQLite `data/media_file_ids.db` (`api_server.py`, `file_io.py`).
|
||||
3. **Runtime-структуры** — `_access_updates`, `_inflight`, `download_queue` в `api_server.py`.
|
||||
|
||||
Проблемы, которые закрывает спецификация (по результатам аудита):
|
||||
|
||||
| # | Проблема | Пакет |
|
||||
|---|---|---|
|
||||
| 1 | Pickle полных `Message` — формат привязан к версии pyrogram/kurigram, тихий дрейф схемы, opaque-формат | A |
|
||||
| 2 | Строгое `cached_limit == limit` + разные limit у RSS (`limit*2`) и HTML (`limit`) → кеш истории фактически не работает | B |
|
||||
| 3 | Один канал живёт под ключами разного регистра/формы записи (`Durov`/`durov`, `@name`/`name`) в трёх слоях → дубли кеша. Унификация ID↔username — сознательная НЕ-цель (см. раздел 8) | C |
|
||||
| 4 | Два почти идентичных pickle-кеша в `tg_cache.py` (история/chatinfo) — дублирование кода | A |
|
||||
| 5 | Legacy-мусор (`*_history.cache`, двойной pickle, `data/media_file_ids.json`); чистка `data/tgcache` вводится как стартовая + периодический age-sweep (мгновенной GC по событию не будет — файлы мёртвых каналов живут до 7 суток) | A, F |
|
||||
| 6 | Свипер медиа-кеша: полный проход таблицы + `os.walk` каждые 60 с при политиках «20 дней»/«1 час» | D |
|
||||
| 7 | Баг: чистка «мёртвых» строк SQLite выполняется только при `files_removed > 0` | D |
|
||||
| 8 | Чтение MIME из SQLite (новое соединение + threadpool-hop) на каждом хите `/media` | E |
|
||||
| 9 | Джиттер TTL перебрасывается на каждом чтении → недетерминированное поведение у границы TTL (усложняет рассуждения и тесты) | F |
|
||||
| 10 | Очередь фоновых загрузок без дедупа — повторная постановка одного файла каждым проходом свипа | D |
|
||||
| 11 | Путь медиа-кеша (`./data/cache` + join) собирается вручную в 7 местах | E |
|
||||
|
||||
## 1. Состав пакетов и порядок
|
||||
|
||||
| Пакет | Задания | Что | Зависимости | Файлы |
|
||||
|---|---|---|---|---|
|
||||
| **A** | 1–5 | Снапшот-словари вместо pickle + generic JSON-store + чистка legacy tgcache | — | `message_snapshot.py` (новый), `tg_cache.py`, `api_server.py` (1 строка), тесты |
|
||||
| **B** | 6–7 | Префиксная выдача истории вместо строгого `limit` | после A | `tg_cache.py`, тесты |
|
||||
| **C** | 8–11 | Единая канонизация ключа канала + one-shot миграция | **после A** (задание 9.1 канонизирует ключ внутри `_cache_file_path`, который создаётся заданием 2) | `channel_key.py` (новый), `tg_cache.py`, `post_parser.py`, `api_server.py` |
|
||||
| **D** | 12–14 | Фиксы свипера медиа-кеша + дедуп очереди | независим | `api_server.py`, `config.py` |
|
||||
| **E** | 15–16 | Хелпер путей медиа-кеша + in-memory MIME-кеш | независим | `api_server.py` |
|
||||
| **F** | 17–18 | Джиттер TTL при записи + age-sweep tgcache + добить legacy | после A | `tg_cache.py`, `api_server.py` |
|
||||
|
||||
Рекомендуемая сборка: A → B → F одним PR (все правят `tg_cache.py`); C — отдельный PR (меняет генерацию URL и содержит миграцию); D и E — параллельно с любым.
|
||||
|
||||
---
|
||||
|
||||
## 2. Пакет A — кеш истории на извлечённых словарях
|
||||
|
||||
### 2.1. Принцип
|
||||
|
||||
Кеш истории перестаёт хранить pickle живых объектов pyrogram. При записи из `Message`
|
||||
извлекается JSON-словарь по явному allowlist полей (снапшот); при чтении из него
|
||||
восстанавливается лёгкий duck-typed объект `CachedMessage`, неотличимый для конвейера
|
||||
рендеринга (`rss_generator.py`, `post_parser.py`) от настоящего `Message`.
|
||||
**Конвейер не меняется вообще** — главный инвариант дизайна.
|
||||
|
||||
Выгоды: формат на диске отвязан от версии pyrogram/kurigram; версионирование схемы
|
||||
(несовпадение → честный miss вместо тихого дрейфа); JSON читаем при отладке;
|
||||
явный документированный контракт «от каких полей Message зависит рендер»
|
||||
(сейчас он размазан по ~2000 строк); файл меньше и парсится быстрее полного графа объектов.
|
||||
Отклонённая альтернатива — раздел 11.
|
||||
|
||||
### 2.2. Ключевые проектные решения
|
||||
|
||||
| # | Решение | Обоснование |
|
||||
|---|---|---|
|
||||
| Р1 | Новый модуль `message_snapshot.py`: `snapshot_message()` / `restore_message()` | Схема сериализации отделена от механики кеша; меняются независимо |
|
||||
| Р2 | `text`/`caption` хранятся парой `{plain, html}`; `.html` вычисляется **при записи** через pyrogram `Str.html` | Конвертация entities→HTML требует машинерии pyrogram, доступной на живом объекте (`post_parser.py:674-675`). Проверено: `Str.html` работает офлайн, без клиента |
|
||||
| Р3 | При восстановлении `text`/`caption` — `CachedStr(str)` с атрибутом `.html` | Потребители используют и строковые операции (`len`, `strip`, regex, `or`), и `.text.html`; str-подкласс покрывает всё. Это паттерн pyrogram `Str` |
|
||||
| Р4 | `media` восстанавливается в настоящий `MessageMediaType[name]`; `KeyError` → `None` + warning | 31 место сравнивает с enum и использует его ключом dict (`post_parser.py:1004-1017`) |
|
||||
| Р5 | `service` хранится и восстанавливается **строкой-именем** (`"PINNED_MESSAGE"`) | Все потребители — truthiness и `'X' in str(service)` (`rss_generator.py:112-121`, `post_parser.py:305`). Строка версионно-устойчива |
|
||||
| Р6 | `date` — `isoformat()` туда, `fromisoformat()` обратно | Сохраняет naive/aware ровно как у pyrogram; `strftime`, `timestamp()`, сортировки работают |
|
||||
| Р7 | Вложенные объекты восстанавливаются с **полным набором ключей схемы, None-дефолты** — «как живые». **Единственное исключение — `forward_origin`**: у него присутствие ключа зеркалит присутствие атрибута на исходном объекте | Живые pyrogram-объекты всегда имеют все атрибуты (ставятся в `__init__`, напр. `Reaction`: `emoji=None, custom_emoji_id=None, is_paid=None`) — код обращается к вложенным полям и напрямую (`rss_generator.py:131` — `message.chat.username` в except-хендлере; `post_parser.py:1087-1090` — `u.username`/`u.active` без getattr), omit-None дал бы там `AttributeError` → 500 на фид. А вот живые `MessageOrigin*`-классы имеют РАЗНЫЕ наборы атрибутов, и `_format_forward_info` ветвится по `hasattr` (Case 1–5, `post_parser.py:629-669`) — для forward_origin присутствие ключей семантично |
|
||||
| Р8 | `CachedMessage` — мутабельный, полный набор top-level атрибутов с дефолтами `None`/`False` | `_reply_enrichment` присваивает `message.reply_to_message` (`rss_generator.py:574`); доступ к атрибутам не должен кидать `AttributeError`; `copy.deepcopy` (`rss_generator.py:43`) должен работать |
|
||||
| Р9 | Файл: JSON `{"version", "timestamp", "limit", "messages"}`, имена `<key>.history.json` / `<key>.chatinfo.json`. Атомарная запись: **уникальный tmp `<path>.tmp.<uuid4().hex>`** → `os.replace`, в `finally` — удаление своего tmp (образец: `_download_atomic`, `api_server.py:460-479`) | Версия отсекает старые схемы; новые расширения игнорируют старые pickle; уникальный tmp исключает перемешивание байтов при конкурентных miss'ах одного канала (RSS+HTML параллельно пишут через `asyncio.to_thread`); rename чинит порчу при падении посреди записи |
|
||||
| Р10 | TTL / джиттер / строгий `limit` в пакете A — **без изменений** | Behavior-neutral рефакторинг; limit меняет пакет B, джиттер — пакет F |
|
||||
| Р11 | Миграции старых pickle нет: старые файлы = miss; стартовая чистка удаляет legacy | Кеш самовосстанавливается за ≤ TTL (история 8 ч; chatinfo 12 ч по умолчанию, настраивается `TG_CHAT_CACHE_TTL_HOURS`) |
|
||||
|
||||
### 2.3. Схема снапшота v1 (контракт)
|
||||
|
||||
Выведена из фактического потребления полей конвейером (инвентарь по `rss_generator.py`
|
||||
и `post_parser.py`, включая `hasattr`-семантику и enum-сравнения) и сверена с типами
|
||||
kurigram 2.2.23 (`.venv`).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": 123, // message.id
|
||||
"date": "2026-07-05T12:00:00", // isoformat or null
|
||||
"text": {"plain": "...", "html": "..."}, // null if absent
|
||||
"caption": {"plain": "...", "html": "..."}, // null if absent
|
||||
"media": "PHOTO", // MessageMediaType.name or null
|
||||
"service": "PINNED_MESSAGE", // MessageServiceType.name or null
|
||||
"media_group_id": 456, // or null
|
||||
"views": 789, // or null
|
||||
"show_caption_above_media": false,
|
||||
"reply_to_message_id": 42, // needed by _reply_enrichment
|
||||
"empty": false,
|
||||
"chat": {"id": -100123, "username": "durov", "title": "...",
|
||||
"usernames": [{"username": "x", "active": true}]},
|
||||
"sender_chat": {"id": 1, "title": "...", "username": "..."},
|
||||
"from_user": {"first_name": "...", "last_name": "...", "username": "..."},
|
||||
"forward_origin": { // ONLY keys present on the live object (hasattr semantics!)
|
||||
"type": "channel",
|
||||
"chat": {"id": 1, "title": "...", "username": "..."},
|
||||
"sender_user_name": "...",
|
||||
"sender_user": {"first_name": "...", "last_name": "...", "username": "..."},
|
||||
"chat_id": 1, "title": "..."
|
||||
},
|
||||
"reactions": [ // from message.reactions.reactions; null if none
|
||||
{"emoji": "👍", "count": 5, "is_paid": null, "custom_emoji_id": null},
|
||||
// custom-emoji reaction: emoji is null, custom_emoji_id is a STRING (kurigram
|
||||
// builds it as str(document_id)) — restored with the FULL key set, like live objects
|
||||
// (live kurigram sets is_paid=None unless it is a paid reaction):
|
||||
{"emoji": null, "count": 2, "is_paid": null, "custom_emoji_id": "987..."}
|
||||
],
|
||||
"poll": {"question": "...", // plain string — FormattedText unwrapped at snapshot time
|
||||
"options": [{"text": "..."}]}, // text unwrapped to plain string; see 2.4.1
|
||||
"web_page": {"type": "...", "url": "...", "display_url": "...", "site_name": "...",
|
||||
"title": "...", "description": "...", "has_large_media": false,
|
||||
"photo": {"file_unique_id": "..."}},
|
||||
// media payloads — per-type allowlist:
|
||||
"photo": {"file_unique_id": "..."},
|
||||
"video": {"file_unique_id": "...", "file_size": 123}, // file_size: large-video rule
|
||||
"document": {"file_unique_id": "...", "mime_type": "..."}, // mime_type: PDF branch
|
||||
"audio": {"file_unique_id": "...", "mime_type": "..."},
|
||||
"voice": {"file_unique_id": "...", "mime_type": "..."},
|
||||
"video_note": {"file_unique_id": "..."},
|
||||
"animation": {"file_unique_id": "..."},
|
||||
"sticker": {"file_unique_id": "...", "emoji": "...", "is_video": false}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4. Критические тонкости (обязательны к учёту)
|
||||
|
||||
1. **`poll.question` и КАЖДЫЙ `poll.options[].text` в kurigram 2.2.23 — всегда объекты
|
||||
`FormattedText`** (проверено: `poll.py:105,220`, `poll_option.py:72,84` в `.venv`), не строки.
|
||||
Снапшот обязан разворачивать оба в plain-строку правилом
|
||||
`v.text if hasattr(v, 'text') else str(v)` (правило корректно и для `Str`, и для голой
|
||||
строки). Если положить `FormattedText` в словарь как есть — `json.dump` кинет `TypeError`,
|
||||
`_save_history_to_cache` проглотит её, и **кеш молча перестанет сохраняться для любого
|
||||
канала с опросом в выборке**. Восстановление: `question` → str,
|
||||
`options` → namespace с `.text` (голая строка в options дала бы
|
||||
`getattr(option, 'text', '') == ''` → пустые опции, `post_parser.py:992`).
|
||||
2. **Реакции восстанавливаются с полным набором ключей** (`emoji`, `custom_emoji_id`,
|
||||
`count`, `is_paid`; отсутствующие значения — `None`) — ровно как живой `Reaction`,
|
||||
у которого `__init__` всегда ставит все атрибуты. Ветвление в
|
||||
`post_parser.py:399-410` и `:928-934` на живых объектах работает через truthiness,
|
||||
а не через отсутствие атрибута — восстановленный объект обязан вести себя так же.
|
||||
`custom_emoji_id` — **строка** (kurigram: `str(reaction.document_id)`).
|
||||
3. **`forward_origin`** — единственный объект с presence-семантикой: Case 4 различается по
|
||||
`hasattr(forward_origin, "chat_id") and hasattr(..., "title")`; при снапшоте пишутся
|
||||
только реально присутствующие (non-None) поля-кандидаты, при восстановлении — только
|
||||
записанные ключи.
|
||||
4. **`video.file_size` обязателен** — правило «не кешировать видео >100 МБ» в
|
||||
`_save_media_file_ids` (`post_parser.py:1058`).
|
||||
5. **`chat.usernames`** — список объектов с `.username`/`.active`
|
||||
(`post_parser.py:1087-1090`, прямой доступ без getattr — оба ключа всегда присутствуют).
|
||||
6. **`CachedMessage.__str__`** — читаемый JSON-дамп: попадает в error-логи
|
||||
(`post_parser.py:980`).
|
||||
|
||||
### 2.5. Задание 1 — новый модуль `message_snapshot.py`
|
||||
|
||||
```python
|
||||
SNAPSHOT_VERSION = 1
|
||||
|
||||
class CachedStr(str):
|
||||
"""str subclass carrying a precomputed .html rendering, mirroring pyrogram's Str.
|
||||
deepcopy/pickle preserve the instance __dict__, so .html survives copying."""
|
||||
# factory: CachedStr.build(plain, html)
|
||||
|
||||
class CachedMessage:
|
||||
"""Duck-typed stand-in for pyrogram Message, restored from a snapshot dict.
|
||||
Mutable (reply enrichment assigns .reply_to_message). All pipeline-consumed
|
||||
top-level attributes exist with None/False defaults, so getattr never raises."""
|
||||
# __str__/__repr__: json.dumps of the snapshot dict, default=str
|
||||
|
||||
def snapshot_message(message) -> dict: ...
|
||||
def restore_message(data: dict) -> CachedMessage: ...
|
||||
def snapshot_messages(messages) -> list[dict]: ...
|
||||
def restore_messages(items: list[dict]) -> list[CachedMessage]: ...
|
||||
```
|
||||
|
||||
Требования:
|
||||
- `snapshot_message` извлекает строго по схеме 2.3; каждое поле через `getattr(..., None)`.
|
||||
- Styled-text (`FormattedText`/`Str`/str) разворачивается правилом
|
||||
`v.text if hasattr(v, 'text') else str(v)` — применяется к `poll.question` и каждому
|
||||
`poll.options[].text` (см. 2.4.1).
|
||||
- `text`/`caption`: `{"plain": str(value), "html": value.html}`; если `.html` недоступен —
|
||||
`html = plain`.
|
||||
- `media`: `message.media.name`; `service`: `message.service.name`
|
||||
(оба через безопасный `getattr(x, 'name', str(x))`).
|
||||
- Восстановление: помощник `_ns(d: dict, keys: tuple) -> SimpleNamespace` — ставит ВСЕ
|
||||
перечисленные ключи схемы (None-дефолт для отсутствующих); для `forward_origin` — режим
|
||||
«только записанные ключи». Спец-обработка: `text/caption → CachedStr`,
|
||||
`media → MessageMediaType[name]` (при `KeyError` — warning + `None`),
|
||||
`date → datetime.fromisoformat`, `reactions → SimpleNamespace(reactions=[...])`
|
||||
(обёртка-контейнер, как у pyrogram).
|
||||
- Дефолты `CachedMessage`: `id, date, text, caption, media, service, media_group_id, views,
|
||||
show_caption_above_media, reply_to_message_id, reply_to_message=None, empty=False, chat,
|
||||
sender_chat, from_user, forward_origin, reactions, poll, web_page, photo, video, document,
|
||||
audio, voice, video_note, animation, sticker`.
|
||||
- Комментарии в коде — только английские.
|
||||
|
||||
### 2.6. Задание 2 — переписать `tg_cache.py` на generic JSON-store
|
||||
|
||||
1. Убрать `import pickle` полностью. Удалить ветку double-pickle (`tg_cache.py:111-116`).
|
||||
2. Generic-пара (заменяет обе дублирующиеся тройки функций):
|
||||
|
||||
```python
|
||||
def _store_entry(path: str, payload: dict) -> None:
|
||||
"""Atomically write {'version', 'timestamp', **payload} as JSON.
|
||||
Writes to a unique '<path>.tmp.<uuid4hex>' and os.replace()s it into place;
|
||||
the finally block always removes this writer's own tmp file."""
|
||||
|
||||
def _load_entry(path: str, max_age_hours: float) -> Optional[dict]:
|
||||
"""Return payload dict, or None on: missing file, version mismatch,
|
||||
expired TTL (keep the existing up-to-20% read-time jitter as-is in package A;
|
||||
package F moves it to write time), JSON error."""
|
||||
```
|
||||
|
||||
3. Пути: `<safe_key>.history.json` и `<safe_key>.chatinfo.json`
|
||||
(общий помощник `_cache_file_path(key, suffix)` вместо двух копий).
|
||||
4. `_save_history_to_cache`: `payload = {'limit': limit, 'messages': snapshot_messages(messages)}`.
|
||||
`_get_history_from_cache`: проверка `limit` как сейчас (пакет A не меняет), возврат
|
||||
`restore_messages(...)`.
|
||||
5. `_save_chat_to_cache` / `_get_chat_from_cache`: тот же store, `payload = {'data': {...}}`.
|
||||
6. Новая функция `cleanup_legacy_cache_files() -> int` — удаляет из `CACHE_DIR` файлы
|
||||
`*.cache` и `*.chatinfo` (старые pickle-форматы, включая `*_history.cache`), возвращает
|
||||
число удалённых. Ошибки удаления — warning, не исключение.
|
||||
7. Сигнатуры и поведение `cached_get_chat_history` / `cached_get_chat` не меняются:
|
||||
на miss возвращаются живые `Message`, на hit — `CachedMessage`; обе формы duck-совместимы
|
||||
(как сегодня pickle-копия vs живой объект).
|
||||
|
||||
### 2.7. Задание 3 — стартовая чистка в `api_server.py`
|
||||
|
||||
В `lifespan` после `init_db_sync`, **до** запуска фоновых задач:
|
||||
|
||||
```python
|
||||
# One-shot cleanup of legacy pickle cache files (pre-JSON formats)
|
||||
await asyncio.to_thread(cleanup_legacy_cache_files)
|
||||
```
|
||||
|
||||
Больше в `api_server.py` в рамках пакета A ничего не трогать.
|
||||
|
||||
### 2.8. Задание 4 — тесты `tests/test_message_snapshot.py`
|
||||
|
||||
Фейковые `Message` — на `SimpleNamespace` (паттерн существующих тестов); `text` — мини-класс
|
||||
`str` с атрибутом `.html`. Обязательные кейсы:
|
||||
|
||||
1. Round-trip основных полей: id, date (naive и aware — оба сохраняют tz-ность), text.html,
|
||||
caption.html, media enum (`is MessageMediaType.PHOTO`), views, media_group_id.
|
||||
2. **Poll с FormattedText-подобными объектами**: фейк `question = SimpleNamespace(text="Q?",
|
||||
entities=[])`, каждый option — `SimpleNamespace(text=SimpleNamespace(text="Opt",
|
||||
entities=[]))`. Проверить: снапшот JSON-сериализуем (`json.dumps` не падает);
|
||||
восстановленный `poll.question` — строка; `getattr(option, 'text', '') == "Opt"`.
|
||||
Тест обязан ПАДАТЬ, если снапшот кладёт FormattedText как есть.
|
||||
3. Реакции: обычная / paid / custom-emoji. У восстановленной custom-реакции:
|
||||
`hasattr(r, 'emoji') is True`, `r.emoji is None`, `r.custom_emoji_id` — строка;
|
||||
ветка в `_reactions_views_links`-подобной логике выбирается по truthiness, как у живой.
|
||||
4. forward_origin Case 1–5: `hasattr`-ветвление выбирает правильный case
|
||||
(проверять именно наличие/отсутствие атрибутов — здесь presence-семантика сохраняется).
|
||||
5. service: `'PINNED_MESSAGE' in str(restored.service)`.
|
||||
6. Мутабельность + deepcopy: присвоение `msg.reply_to_message = X`;
|
||||
`copy.deepcopy(restored)` сохраняет `.text.html`.
|
||||
7. Неизвестный media name (`"FUTURE_TYPE"`) → `media is None`, без исключения.
|
||||
8. Store: `_store_entry`/`_load_entry` — round-trip, TTL-протухание, version mismatch → None,
|
||||
битый JSON → None; уникальность tmp-путей: перехватить `os.replace` (или замокать
|
||||
`uuid.uuid4`) и проверить, что два вызова `_store_entry` в один path использовали
|
||||
РАЗНЫЕ tmp-имена, а итоговый файл валиден — одиночный «конкурентный» прогон окно
|
||||
гонки не ловит и сломанную реализацию с фиксированным tmp пропустит;
|
||||
`cleanup_legacy_cache_files` удаляет `*.cache`/`*.chatinfo`, не трогает
|
||||
`*.history.json` (использовать `tmp_path`).
|
||||
9. `_save_media_file_ids` на восстановленном сообщении с `video.file_size` > 100 МБ ничего
|
||||
не добавляет в `_pending_media_ids`.
|
||||
10. Восстановленный `chat` для канала без username: `message.chat.username` — `None`
|
||||
(не `AttributeError`) — регресс-тест на правило Р7 (сценарий `rss_generator.py:131`).
|
||||
|
||||
### 2.9. Задание 5 — верификация пакета A
|
||||
|
||||
- `pytest` — весь существующий набор зелёный.
|
||||
- `grep pickle tg_cache.py` — пусто; `post_parser.py` и `rss_generator.py` не изменены.
|
||||
|
||||
---
|
||||
|
||||
## 3. Пакет B — история: префикс вместо строгого равенства `limit`
|
||||
|
||||
**Проблема:** `tg_cache.py:106-109` требует `cached_limit == limit`; RSS просит `limit*2`
|
||||
(`rss_generator.py:419`), HTML — `limit` (`rss_generator.py:639`), оба пишут в один файл.
|
||||
Чередование запросов превращает кеш в решето.
|
||||
|
||||
### 3.1. Задание 6 — логика префикса в `_get_history_from_cache`
|
||||
|
||||
Сообщения в кеше лежат от новых к старым (порядок `get_chat_history`), поэтому запрос
|
||||
с меньшим `limit` — это префикс:
|
||||
|
||||
```python
|
||||
cached_limit = payload.get('limit', 0)
|
||||
raw_messages = payload['messages']
|
||||
# Serve from cache when it was fetched with an equal-or-larger limit, OR when the
|
||||
# channel is exhausted (fewer messages exist than were asked for at fetch time —
|
||||
# the cache then holds the channel's entire recent history and satisfies ANY limit).
|
||||
if cached_limit < limit and len(raw_messages) >= cached_limit:
|
||||
log("history_cache_limit_insufficient: cached %s < requested %s", cached_limit, limit)
|
||||
return None
|
||||
return restore_messages(raw_messages[:limit])
|
||||
```
|
||||
|
||||
- `_save_history_to_cache` не меняется: хранит `limit`, с которым делался fetch.
|
||||
- Лог хита дополнить: `served {limit} of cached {cached_limit}`.
|
||||
- Сходимость: первый запрос с большим `limit` после протухания перезапишет кеш «широкой»
|
||||
версией, дальше меньшие запросы — хиты.
|
||||
|
||||
### 3.2. Задание 7 — тесты
|
||||
|
||||
1. Кеш `limit=100` (100 сообщений) → запрос `limit=50` — хит, ровно 50 первых, порядок сохранён.
|
||||
2. Кеш `limit=50` → запрос `limit=100` — промах.
|
||||
3. Исчерпанный канал: кеш `limit=100`, 37 сообщений → запрос `limit=200` — хит, 37 сообщений.
|
||||
4. TTL работает независимо от limit.
|
||||
|
||||
---
|
||||
|
||||
## 4. Пакет C — единая канонизация ключа канала
|
||||
|
||||
**Проблема:** один канал живёт под ключами `Durov` / `durov` / `@name` в tgcache-файлах,
|
||||
каталогах `data/cache/` и строках SQLite → дубли кеша, двойные походы в Telegram,
|
||||
осиротевшие деревья при смене регистра/формы. (Унификация ID↔username — не-цель, раздел 8.)
|
||||
|
||||
### 4.1. Задание 8 — новый модуль `channel_key.py`
|
||||
|
||||
Отдельный модуль без зависимостей (его импортируют `tg_cache`, `post_parser`, `api_server` —
|
||||
отдельность исключает циклические импорты):
|
||||
|
||||
```python
|
||||
def canonical_channel_key(channel: str | int) -> str:
|
||||
"""Canonical cache/DB key for a channel.
|
||||
|
||||
Telegram usernames are case-insensitive -> lowercase them.
|
||||
Numeric '-100...' ids keep their exact string form.
|
||||
The '@' prefix is stripped.
|
||||
|
||||
The canonical form is also SAFE to pass to Telegram API calls (usernames are
|
||||
case-insensitive on the API side; the numeric form is unchanged) — callers that
|
||||
thread one value through both filesystem paths and API calls may use it for both.
|
||||
"""
|
||||
s = str(channel).strip().lstrip('@')
|
||||
if s.startswith('-100') and s[4:].isdigit():
|
||||
return s
|
||||
return s.lower()
|
||||
```
|
||||
|
||||
### 4.2. Задание 9 — применить ключ во всех трёх слоях
|
||||
|
||||
1. **`tg_cache.py`**: в `_cache_file_path(key, suffix)` прогонять ключ через
|
||||
`canonical_channel_key`.
|
||||
2. **`post_parser.get_channel_username`** (`post_parser.py:1081-1097`): возвращать username
|
||||
в нижнем регистре (обе ветки — `usernames`-список и одиночный `username`). Это
|
||||
канонизирует записи SQLite (`_save_media_file_ids`), генерируемые media-URL и `t.me`-ссылки
|
||||
(t.me к регистру нечувствителен).
|
||||
3. **`api_server.get_media`** (`api_server.py:1153-1183`): после проверки digest вычислить
|
||||
`fs_channel = canonical_channel_key(channel)` и использовать вместо `str(channel)` везде
|
||||
ниже: pre-semaphore путь, ключ `_access_updates`, `media_key`, аргумент `_download_deduped`
|
||||
(протекает в `download_media_file` → каталоги, API-вызов и удаление из SQLite — для API
|
||||
канонический идентификатор безопасен, см. docstring 4.1).
|
||||
**Digest проверять по исходной строке URL** — иначе сломаются все выданные ссылки.
|
||||
|
||||
### 4.3. Задание 10 — one-shot миграция существующих данных
|
||||
|
||||
Без миграции каждый ранее закешированный медиафайл канала с не-lowercase username даёт
|
||||
после деплоя cache-miss → повторную загрузку из Telegram (шторм ограничен такими каналами
|
||||
и фактически запрашиваемыми файлами, но страховка дешёвая — делаем).
|
||||
|
||||
Новая функция `migrate_channel_keys_sync(db_path: str, cache_dir: str) -> None`. Вызов —
|
||||
в `lifespan` **после `init_db_sync`, ДО `client.start()` и до запуска фоновых задач**,
|
||||
строго через `await asyncio.to_thread(migrate_channel_keys_sync, ...)` — функция
|
||||
синхронная (FS-rename + SQLite), голый вызов заблокировал бы event loop на время
|
||||
миграции; размещение до `client.start()` дополнительно исключает влияние на сетевые
|
||||
таски pyrogram и гонки со свипером/access-flush.
|
||||
|
||||
Единица работы — **канал целиком, сначала FS, потом SQL** (порядок принципиален:
|
||||
при провале FS-части SQL-строки канала остаются старорегистровыми, и старое дерево
|
||||
по-прежнему видно свиперу через строки БД — вечных orphan-каталогов не возникает):
|
||||
|
||||
1. Найти каналы-кандидаты: имена каталогов первого уровня в `cache_dir` с не-lowercase
|
||||
именем (кроме `-100...`) ∪ `SELECT DISTINCT channel ... WHERE channel != lower(channel)
|
||||
AND channel NOT LIKE '-100%'`.
|
||||
2. Для каждого канала — **FS-шаг**:
|
||||
- **ОБЯЗАТЕЛЬНЫЙ guard до любых действий**: если `os.path.exists(dst)` и
|
||||
`os.path.samefile(src, dst)` — **no-op FS-шага, перейти к SQL-шагу**. На
|
||||
case-insensitive FS (macOS/APFS, Docker Desktop for Mac — том `./data` наследует
|
||||
семантику хоста) `Durov/` и `durov/` — один и тот же каталог; merge без guard'а
|
||||
удалил бы весь кеш канала.
|
||||
- `dst` не существует → `os.rename(src, dst)`.
|
||||
- `dst` существует и это другой каталог (case-sensitive FS, обе формы реально есть) →
|
||||
пофайловый merge: существующий файл в `dst` выигрывает; затем удалить пустые
|
||||
остатки `src`.
|
||||
- FS-шаг упал (EACCES и т.п.) → log error с пометкой orphan-кандидата, **SQL-шаг
|
||||
канала пропустить**, перейти к следующему каналу.
|
||||
3. **SQL-шаг** (только после успешного FS-шага): для каждой строки канала:
|
||||
- lowercase-строки с тем же `(post_id, file_unique_id)` нет — просто `UPDATE` канала;
|
||||
- есть — merge: `added = max(added)` из двух, `mime_type` — предпочесть non-NULL;
|
||||
затем `DELETE` старорегистровой строки. Голый `UPDATE ... SET channel=lower(channel)`
|
||||
запрещён — ловит PK-конфликт при наличии обеих форм.
|
||||
4. Ошибки миграции — log error + продолжить работу (миграция не должна уронить старт);
|
||||
повторный запуск функции — идемпотентный no-op.
|
||||
5. **Итоговая сводка одной строкой лога** (сигнал оператору, что деплой C прошёл штатно):
|
||||
`migration_summary: rows merged N, dirs renamed M, samefile no-ops K, failures F`.
|
||||
|
||||
### 4.4. Задание 11 — тесты + заметка о миграции
|
||||
|
||||
- Юнит: `'Durov'→'durov'`, `'@durov'→'durov'`, `-1001…` (int и str) → `'-1001…'`.
|
||||
- Интеграция: путь tgcache-файла одинаков для `Durov`/`durov`; `get_channel_username`
|
||||
на фейковом chat с `username='MixedCase'` → `'mixedcase'`.
|
||||
- Миграция: (а) SQL-merge обеих форм — остаётся одна строка с `max(added)` и non-NULL
|
||||
`mime_type`; (б) guard: вызов с `src`/`dst`, резолвящимися в один каталог (samefile), —
|
||||
no-op, данные целы; (в) merge на двух реально разных каталогах — файлы объединены,
|
||||
target выигрывает; (г) повторный запуск — no-op.
|
||||
- В PR: (а) остаточные старорегистровые строки/каталоги, не покрытые миграцией (например,
|
||||
появившиеся между бэкапом и деплоем), доживают до 20-дневного вытеснения сами;
|
||||
(б) **миграция односторонняя** — reverse-миграции нет; откат пакета C после её
|
||||
выполнения возвращает код со старорегистровыми ключами на lowercase-данные и повторяет
|
||||
re-download-сценарий зеркально (ограниченный, самоизлечивается за 20 дней) — это
|
||||
ожидаемый признак отката, не авария.
|
||||
|
||||
---
|
||||
|
||||
## 5. Пакет D — свипер медиа-кеша
|
||||
|
||||
### 5.1. Задание 12 — фикс чистки БД (баг)
|
||||
|
||||
`api_server.py:820-836`: diff и удаление из SQLite выполняются только `if files_removed > 0`.
|
||||
Запись старше 20 дней **без файла на диске** выпадает из списка без инкремента счётчика
|
||||
(`api_server.py:663-687`) — и остаётся в БД навсегда, если в том же проходе не удалился
|
||||
ни один реальный файл. Исправление:
|
||||
|
||||
```python
|
||||
# Compute the DB diff unconditionally: entries dropped from the list because the
|
||||
# file was already gone must still be purged from SQLite.
|
||||
updated_set = {...}
|
||||
removed_entries = [...]
|
||||
if removed_entries:
|
||||
await asyncio.to_thread(remove_media_file_ids_sync, DB_PATH, removed_entries)
|
||||
logger.info(f"cache_sweep: purged {len(removed_entries)} entries "
|
||||
f"({files_removed} files removed from disk)")
|
||||
```
|
||||
|
||||
### 5.2. Задание 13 — интервал свипа в конфиг
|
||||
|
||||
Сейчас `delay = 60` (`api_server.py:811`). Добавить в `config.py` по существующему паттерну
|
||||
ключ `cache_sweep_interval` (env `CACHE_SWEEP_INTERVAL`, дефолт `900`, минимум `60`),
|
||||
использовать в `cache_media_files`.
|
||||
|
||||
### 5.3. Задание 14 — дедуп очереди фоновых загрузок
|
||||
|
||||
Module-level `_queued_media: set[tuple[str, int, str]]`:
|
||||
- в `download_new_files`: ключ в set'е → skip; иначе `add` + `put_nowait`
|
||||
(при `QueueFull` — `discard` и `break` как сейчас);
|
||||
- в `background_download_worker`: в `finally` рядом с `task_done()` — `discard(key)`.
|
||||
|
||||
Event loop однопоточный, обе точки — корутины без await между проверкой и мутацией → гонок нет.
|
||||
Тест: два подряд вызова `download_new_files` с одним списком кладут в очередь один элемент.
|
||||
|
||||
---
|
||||
|
||||
## 6. Пакет E — горячий путь `/media`
|
||||
|
||||
### 6.1. Задание 15 — константа и хелпер путей
|
||||
|
||||
`os.path.abspath("./data/cache")` + ручной `join` собираются в **семи** местах
|
||||
(`api_server.py:207, 532-536, 667, 757-766, 817, 851, 1172-1175`). Ввести на уровне модуля:
|
||||
|
||||
```python
|
||||
MEDIA_CACHE_DIR = os.path.abspath(os.path.join("data", "cache")) # resolved once at import
|
||||
|
||||
def media_cache_path(channel: str, post_id: int, file_unique_id: str | None = None) -> str:
|
||||
"""Single source of truth for the on-disk layout: <root>/<channel>/<post_id>[/<fid>]."""
|
||||
```
|
||||
|
||||
Заменить все семь мест. `remove_old_cached_files_sync`/`download_new_files` сохраняют
|
||||
параметр `cache_dir` (тестируемость), вызыватели передают константу.
|
||||
|
||||
### 6.2. Задание 16 — in-memory кеш MIME
|
||||
|
||||
Запись access-time уже вынесена с горячего пути в аккумулятор, а чтение MIME до сих пор
|
||||
делает threadpool-hop + новое SQLite-соединение на каждый хит (`api_server.py:389-404`).
|
||||
MIME по ключу `file_unique_id` иммутабелен.
|
||||
|
||||
```python
|
||||
# MIME types are immutable per file_unique_id, so a process-lifetime dict in front of
|
||||
# SQLite removes a to_thread + connect from every cache-hit response.
|
||||
_mime_types: dict[tuple[str, int, str], str] = {}
|
||||
_MIME_CACHE_MAX = 50_000 # crude bound; clear-all on overflow is fine at this size
|
||||
```
|
||||
|
||||
Порядок в `prepare_file_response`: dict → (miss) SQLite → (miss) python-magic → записать
|
||||
и в SQLite, и в dict. При успешном чтении из SQLite — заполнить dict. При переполнении —
|
||||
`clear()`. Тест: замокать `get_mime_type_sync`, два запроса подряд — второй его не вызывает.
|
||||
|
||||
---
|
||||
|
||||
## 7. Пакет F — довесок к новому store
|
||||
|
||||
### 7.1. Задание 17 — джиттер TTL при записи
|
||||
|
||||
Сейчас `random_factor` перебрасывается на каждом чтении (`tg_cache.py:98, 192`) — срок жизни
|
||||
записи недетерминирован, что усложняет рассуждения о поведении и тесты (одна и та же запись
|
||||
у границы TTL может дать разный результат в соседних чтениях; устойчивого «мигания» нет —
|
||||
первый же miss ведёт к перезаписи со свежим timestamp). В generic-store пакета A:
|
||||
- `_store_entry` дополнительно пишет `'jitter': random.uniform(0.8, 1.0)`;
|
||||
- `_load_entry` считает `adjusted_max_age = max_age_hours * 3600 * entry.get('jitter', 1.0)`
|
||||
и не дёргает `random` при чтении.
|
||||
|
||||
Срок жизни записи детерминирован после записи, разброс между инстансами/каналами сохраняется.
|
||||
Тест: одна и та же запись около границы TTL даёт стабильный результат при повторных чтениях.
|
||||
|
||||
### 7.2. Задание 18 — age-sweep tgcache + добить legacy-данные
|
||||
|
||||
1. Новая функция `sweep_tgcache(max_age_days: int = 7) -> int` в `tg_cache.py`: удаляет из
|
||||
`CACHE_DIR` любые файлы с mtime старше порога. Живые файлы перезаписываются каждые ≤12 ч,
|
||||
поэтому mtime > 7 суток — гарантированно мёртвый файл (умерший/переименованный канал,
|
||||
неканоничный ключ, осиротевший uuid-tmp от упавшего писателя). Гонка с писателем
|
||||
ВОЗМОЖНА (stat→unlink не атомарен против `os.replace`: sweep может удалить файл,
|
||||
обновлённый между stat и unlink), но безвредна — худший исход один лишний miss-refetch;
|
||||
гонка с читателем так же самоизлечивается как miss. Не выдавать это за инвариант
|
||||
атомарности в комментариях кода.
|
||||
2. Вызовы: (а) один раз при старте — рядом с `cleanup_legacy_cache_files` (задание 3);
|
||||
(б) из цикла `cache_media_files` раз в проход, **обязательно через `asyncio.to_thread`**
|
||||
(listdir+stat+unlink — синхронный FS I/O; инвариант кодовой базы — ничего блокирующего
|
||||
на event loop). Каталог плоский, стоимость — миллисекунды.
|
||||
3. Стартовая legacy-чистка (задание 3) остаётся: sweep покрывает legacy-файлы сам, но
|
||||
с задержкой до 7 суток; чистка по расширениям делает это мгновенно.
|
||||
4. Расширить стартовую чистку: удалить `data/media_file_ids.json` (наследие до-SQLite
|
||||
хранилища, кодом не читается — проверено grep). Лог — одной строкой с перечнем удалённого.
|
||||
|
||||
---
|
||||
|
||||
## 8. Границы (сознательно НЕ входит)
|
||||
|
||||
- **Унификация ID↔username** (`/rss/-100…` и `/rss/durov` одного канала остаются разными
|
||||
ключами во всех слоях): потребовала бы резолва через `get_chat` (лишний RPC, свои отказы
|
||||
и кеш-инвалидация). Канонизация пакета C устраняет только дубли регистра/формы записи.
|
||||
- Переезд метаданных медиа с SQLite куда-либо ещё — SQLite остаётся.
|
||||
- Кеширование результатов рендеринга (processed dicts / готовый HTML фида) — отдельная
|
||||
большая тема с инвалидацией по параметрам запроса.
|
||||
- Оптимизация `calculate_cache_stats` (полный walk на `/health`) — низкий приоритет.
|
||||
- `SimpleNamespace` → dataclass в `cached_get_chat` — косметика.
|
||||
|
||||
## 9. Сквозные критерии приёмки
|
||||
|
||||
1. `pytest` зелёный; `post_parser.py`/`rss_generator.py` изменены только в объёме
|
||||
задания 9.2 (lowercase в `get_channel_username`).
|
||||
2. `grep pickle tg_cache.py` — пусто.
|
||||
3. Сценарий «RSS(limit=100) → HTML(limit=50) → RSS(100)» порождает **один** поход в Telegram
|
||||
(сейчас — три).
|
||||
4. `/rss/Durov` и `/rss/durov` используют один tgcache-файл; `/media/Durov/...` и
|
||||
`/media/durov/...` — один файл на диске.
|
||||
5. Повторный `/media`-хит не создаёт SQLite-соединений (ни на чтение MIME, ни на запись
|
||||
access-time). Рецепт проверки: monkeypatch `file_io._open_db` счётчиком соединений;
|
||||
два последовательных запроса TestClient к одному файлу — на втором счётчик не растёт.
|
||||
6. Строка SQLite с `added` старше 20 дней и отсутствующим файлом исчезает из БД за один
|
||||
проход свипа.
|
||||
7. Кеш истории сохраняется и для каналов с опросами в выборке (снапшот JSON-сериализуем).
|
||||
8. Миграция ключей на case-insensitive FS — no-op без потери данных (samefile-guard).
|
||||
|
||||
## 10. Риски
|
||||
|
||||
| Риск | Закрытие / статус |
|
||||
|---|---|
|
||||
| Пропущенное поле allowlist → тихая деградация рендера | **Живой риск, не закрыт полностью.** Инвентарь снят по всем потребителям (2.3–2.4) и сверен с kurigram 2.2.23; ревью нашло и закрыло два пропуска (FormattedText в poll, omit-None семантика) — но гарантии полноты ручного инвентаря нет. Митигция: тесты 2.8, деградация проявляется как выпадение конкретного элемента рендера, не крэш; TTL 8 ч ограничивает окно |
|
||||
| Смешение живых `Message` (miss) и `CachedMessage` (hit) | Так работает и текущий pickle-кеш; живой объект — надмножество контракта |
|
||||
| Порча файла при падении посреди записи / конкурентная запись | Уникальный tmp + `os.replace` + finally-очистка (Р9) |
|
||||
| Старые pickle после деплоя | Игнорируются новыми именами + `cleanup_legacy_cache_files()` |
|
||||
| Канонизация: существующие данные со старым регистром | One-shot миграция (задание 10) с samefile-guard; остатки доживают до 20-дневного LRU |
|
||||
| Миграция на case-insensitive FS | Обязательный `os.path.samefile`-guard → no-op (критерий 9.8) |
|
||||
|
||||
## 11. Рассмотренные альтернативы
|
||||
|
||||
**Version-stamped pickle** (минимальный вариант): оставить pickle, дописать в конверт
|
||||
`pyrogram.__version__` + версию схемы, несовпадение считать miss'ом (~5 строк). Закрывает
|
||||
«тихий дрейф» и стоимость апгрейда библиотеки (один TTL-эквивалентный miss на канал —
|
||||
пренебрежимо при TTL 8 ч). **Отклонена решением владельца проекта** (альтернатива
|
||||
предлагалась явно на этапе аудита как «рекомендация-минимум»; выбран стратегический
|
||||
вариант) по причинам: (а) исходная цель — «проще и поддерживаемее»: JSON читаем глазами
|
||||
при отладке, pickle — нет; (б) снапшот делает зависимость рендера от полей `Message`
|
||||
явным документированным контрактом (схема 2.3) вместо размазанной по ~2000 строк;
|
||||
(в) меньший файл и дешёвый parse вместо unpickle графа объектов в threadpool.
|
||||
Цена: ручной инвентарь полей с остаточным риском пропуска (раздел 10, первый пункт).
|
||||
|
||||
## 12. История ревью
|
||||
|
||||
Спецификация прошла два раунда адверсарного ревью (субагент-критик с доступом к кодовой
|
||||
базе и установленному kurigram 2.2.23). Итоги:
|
||||
|
||||
- **C1 (BLOCKER, принято)**: исходная схема не разворачивала `FormattedText` в
|
||||
`poll.options[].text` → `json.dump` падал бы, кеш молча переставал сохраняться для
|
||||
каналов с опросами; исходные тест-фикстуры дефект не ловили. Исправлено: 2.4.1, тест 2.8.2.
|
||||
- **C2 (MAJOR, принято с усилением)**: blanket omit-None ломал эквивалентность с живыми
|
||||
объектами (у которых все атрибуты всегда присутствуют) и давал крэш-пути
|
||||
(`rss_generator.py:131`, `post_parser.py:1087`). Итог: правило Р7 «полные ключи, кроме
|
||||
forward_origin».
|
||||
- **C3 (MAJOR, принято)**: канонизация без миграции → повторные загрузки медиа после деплоя.
|
||||
Добавлено задание 10. Встречное возражение критика к первой версии миграции
|
||||
(**потеря кеша на case-insensitive FS**) принято: обязательный samefile-guard.
|
||||
- **C4 (MAJOR → MINOR)**: критик требовал обосновать отказ от version-stamped pickle.
|
||||
Закрыто разделом 11; довод «холодный кеш на каждый бамп библиотеки» в обоснование
|
||||
сознательно НЕ включён (стоимость — один miss на канал, пренебрежимо). Остаточный MINOR:
|
||||
риск ручного инвентаря остаётся живым (раздел 10).
|
||||
- **C5–C12 (MINOR, приняты)**: уникальный tmp; docstring `canonical_channel_key`;
|
||||
сужение проблемы #3; седьмое место сборки пути (`:817`); TTL 8–12 ч; `custom_emoji_id: str`;
|
||||
честная мотивация джиттера; age-sweep tgcache (+`to_thread` из фонового цикла).
|
||||
- **Раунд 4 (новые векторы: атака на собственные правки критика, межпакетные
|
||||
взаимодействия, реализуемость, рантайм-семантика, операционка): 6 MINOR, все приняты.**
|
||||
D1 — `to_thread` + размещение миграции до `client.start()`; D2 — порядок «FS → SQL
|
||||
per-channel», чтобы частичный отказ FS не рождал orphan-деревья, невидимые свиперу;
|
||||
D3 — гонка sweep/писатель честно названа возможной-но-безвредной; D4 — зависимость
|
||||
«C после A» зафиксирована в таблице (задание 9.1 опирается на `_cache_file_path` из
|
||||
задания 2); D5 — тест 2.8.8 переписан на дискриминирующий (уникальность tmp-путей),
|
||||
критерий 9.5 получил рецепт проверки; D6 — итоговая сводка миграции в лог + пометка
|
||||
об односторонности миграции при откате. По направлениям «откаты A/B/F», «смешение
|
||||
живых и восстановленных объектов», «json.dumps(CachedStr)», «deepcopy», «exclude_*»
|
||||
критик существенных находок не нашёл (с доказательствами по коду: hit/miss атомарен
|
||||
на весь список, `/json`-эндпоинт идёт мимо кеша истории, tz-гомогенность гарантирована Р6).
|
||||
@@ -0,0 +1,619 @@
|
||||
# Critical Performance and Blocking Analysis: Pyrogram Bridge
|
||||
|
||||
**Date**: 2026-01-01
|
||||
**Severity**: Critical
|
||||
**Status**: Analysis Complete - Awaiting Implementation Approval
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The pyrogram-bridge project experiences critical blocking issues preventing static file delivery to clients. The root cause is **concurrent authentication failures in Pyrogram** when multiple download requests occur simultaneously, causing the entire event loop to hang while authentication repeatedly fails with `KeyError: 0`.
|
||||
|
||||
### Key Findings:
|
||||
1. **Primary Issue**: Pyrogram auth key creation failures block all concurrent operations
|
||||
2. **Impact**: Multiple HTTP requests wait indefinitely for downloads that never complete
|
||||
3. **Pattern**: Background downloads and on-demand downloads compete for authentication resources
|
||||
4. **Scale**: Affects ~30-40% of download attempts during peak loads
|
||||
|
||||
---
|
||||
|
||||
## 1. Log Analysis and Pattern Identification
|
||||
|
||||
### 1.1 Critical Error Pattern
|
||||
|
||||
The logs show a repeating pattern of authentication failures:
|
||||
|
||||
```
|
||||
Line 14: pyrogram.session.auth - INFO - Retrying due to KeyError: 0
|
||||
Line 15: pyrogram.connection.connection - INFO - Disconnected
|
||||
Line 16: pyrogram.session.auth - INFO - Start creating a new auth key on DC4
|
||||
Line 17: pyrogram.connection.connection - INFO - Connecting...
|
||||
Line 18: pyrogram.connection.connection - INFO - Connected! Production DC4 - IPv4
|
||||
[~11 seconds pass]
|
||||
Line 23: pyrogram.session.auth - INFO - Retrying due to KeyError: 0
|
||||
```
|
||||
|
||||
**Key Observations**:
|
||||
- Authentication retries occur every ~10-12 seconds
|
||||
- Each retry cycle involves: disconnect → reconnect → KeyError
|
||||
- After 5-6 retry attempts, downloads fail with zero-size files
|
||||
- HTTP requests that arrive during this period hang indefinitely
|
||||
|
||||
### 1.2 Timeline Analysis
|
||||
|
||||
#### Request Flow (Line 10-71):
|
||||
```
|
||||
13:47:52 - Request arrives for media file
|
||||
13:47:52 - Valid digest checked
|
||||
13:48:01 - First KeyError: 0 in pyrogram auth
|
||||
13:48:02 - Disconnected, starts creating new auth key
|
||||
13:48:02 - Connected to DC4
|
||||
13:48:12 - Second KeyError: 0
|
||||
[Multiple retry cycles...]
|
||||
13:48:59 - Final KeyError: 0 exception raised
|
||||
13:48:59 - ERROR: Downloaded file is zero size
|
||||
13:49:00 - File successfully downloads (after auth stabilizes)
|
||||
```
|
||||
|
||||
**Timeline Duration**: 67 seconds from request to successful delivery
|
||||
**Expected Duration**: 2-5 seconds for cached download
|
||||
|
||||
### 1.3 Concurrent Request Impact
|
||||
|
||||
Lines 194-244 show multiple simultaneous requests:
|
||||
- 10 parallel HTTP GET requests arrive within 6 seconds (lines 194-234)
|
||||
- Background cache task attempts 3 concurrent downloads
|
||||
- Pyrogram authentication locks prevent progress on all requests
|
||||
- Successful cached files are served (lines 242-244)
|
||||
- New downloads hang for 60+ seconds
|
||||
|
||||
---
|
||||
|
||||
## 2. Root Cause Analysis
|
||||
|
||||
### 2.1 Pyrogram Authentication Architecture
|
||||
|
||||
#### Problem: Non-Reentrant Auth Key Creation
|
||||
|
||||
From [`telegram_client.py:30-35`](telegram_client.py:30):
|
||||
|
||||
```python
|
||||
self.client = Client(
|
||||
name="pyro_bridge",
|
||||
api_id=settings["tg_api_id"],
|
||||
api_hash=settings["tg_api_hash"],
|
||||
workdir=settings["session_path"],
|
||||
)
|
||||
```
|
||||
|
||||
**Issue**: Single Pyrogram client instance shared across all operations:
|
||||
- One client for API server requests (line 68 in [`api_server.py`](api_server.py:68))
|
||||
- Same client for background downloads (line 80 in [`api_server.py`](api_server.py:80))
|
||||
- No session pooling or connection reuse strategy
|
||||
|
||||
#### The KeyError: 0 Root Cause
|
||||
|
||||
The `KeyError: 0` occurs in Pyrogram's TLObject deserialization:
|
||||
|
||||
```python
|
||||
File "/usr/local/lib/python3.11/site-packages/pyrogram/raw/core/tl_object.py", line 33
|
||||
return cast(TLObject, objects[int.from_bytes(b.read(4), "little")]).read(b, *args)
|
||||
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
KeyError: 0
|
||||
```
|
||||
|
||||
This happens when:
|
||||
1. Multiple concurrent operations attempt to establish auth keys
|
||||
2. Telegram sends a response that doesn't match expected protocol format
|
||||
3. Likely caused by race condition in auth key negotiation
|
||||
4. Results in 10-second retry cycle per attempt
|
||||
|
||||
### 2.2 Critical Blocking Points
|
||||
|
||||
#### 2.2.1 Download Media Function (Lines 216-341 in [`api_server.py`](api_server.py:216))
|
||||
|
||||
```python
|
||||
async def download_media_file(channel: Union[str, int], post_id: int, file_unique_id: str) -> tuple[Union[str, None], bool]:
|
||||
# ... setup code ...
|
||||
|
||||
message = await client.client.get_messages(channel_id, post_id) # BLOCKING POINT 1
|
||||
|
||||
# ... cache check ...
|
||||
|
||||
file_id = await find_file_id_in_message(message, file_unique_id)
|
||||
file_path = await client.client.download_media(file_id, file_name=cache_path) # BLOCKING POINT 2
|
||||
```
|
||||
|
||||
**Problems**:
|
||||
1. **Direct await on Pyrogram calls**: No timeout protection
|
||||
2. **No concurrency limits**: Unlimited simultaneous downloads
|
||||
3. **Shared client state**: All operations block when auth fails
|
||||
4. **No circuit breaker**: Failed auth retries indefinitely
|
||||
|
||||
#### 2.2.2 Background Cache Task (Lines 494-532 in [`api_server.py`](api_server.py:494))
|
||||
|
||||
```python
|
||||
async def cache_media_files() -> None:
|
||||
delay = 60
|
||||
while True:
|
||||
# ... cache cleanup ...
|
||||
await download_new_files(updated_media_files, cache_dir) # BLOCKING POINT 3
|
||||
await asyncio.sleep(delay)
|
||||
```
|
||||
|
||||
**Problems**:
|
||||
1. Runs concurrently with HTTP request handlers
|
||||
2. Can trigger multiple downloads simultaneously (line 457)
|
||||
3. Shares same Pyrogram client with request handlers
|
||||
4. No coordination with active requests
|
||||
|
||||
#### 2.2.3 Race Condition Visualization
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[HTTP Request 1] --> B[download_media_file]
|
||||
C[HTTP Request 2] --> D[download_media_file]
|
||||
E[Background Task] --> F[download_new_files]
|
||||
|
||||
B --> G[client.client.get_messages]
|
||||
D --> H[client.client.get_messages]
|
||||
F --> I[client.client.get_messages]
|
||||
|
||||
G --> J{Shared Pyrogram Client}
|
||||
H --> J
|
||||
I --> J
|
||||
|
||||
J --> K[Auth Key Creation]
|
||||
K --> L{KeyError: 0}
|
||||
L --> M[10s Retry]
|
||||
M --> K
|
||||
```
|
||||
|
||||
### 2.3 Async/Sync Execution Issues
|
||||
|
||||
#### Issue 1: Blocking Operations in Async Context
|
||||
|
||||
[`api_server.py:324`](api_server.py:324):
|
||||
```python
|
||||
file_path = await client.client.download_media(file_id, file_name=cache_path)
|
||||
```
|
||||
|
||||
Pyrogram's `download_media()` is async but internally performs:
|
||||
- Network I/O (potentially blocking on slow connections)
|
||||
- File system writes (not truly async)
|
||||
- Auth key operations (blocks entire client)
|
||||
|
||||
#### Issue 2: No Timeout Protection
|
||||
|
||||
None of the Pyrogram calls have timeout wrappers:
|
||||
```python
|
||||
# No timeout on message fetch
|
||||
message = await client.client.get_messages(channel_id, post_id)
|
||||
|
||||
# No timeout on file download
|
||||
file_path = await client.client.download_media(file_id, file_name=cache_path)
|
||||
```
|
||||
|
||||
A single stuck operation blocks all others sharing the client.
|
||||
|
||||
#### Issue 3: Thread Pool Misuse
|
||||
|
||||
[`api_server.py:201`](api_server.py:201):
|
||||
```python
|
||||
media_type = await asyncio.to_thread(magic_mime.from_file, file_path)
|
||||
```
|
||||
|
||||
Good practice here (CPU-bound work in thread pool), but Pyrogram operations should also be isolated.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architectural Problems
|
||||
|
||||
### 3.1 Violated Best Practices
|
||||
|
||||
#### ❌ Single Client Instance for All Operations
|
||||
|
||||
**Current**: One Pyrogram client handles all requests
|
||||
**Problem**: Client state shared across all operations
|
||||
**Best Practice**: Separate clients or connection pooling
|
||||
|
||||
#### ❌ No Concurrency Control
|
||||
|
||||
**Current**: Unlimited simultaneous downloads
|
||||
**Problem**: Resource exhaustion and auth conflicts
|
||||
**Best Practice**: Semaphore-limited concurrent operations
|
||||
|
||||
#### ❌ No Circuit Breaker Pattern
|
||||
|
||||
**Current**: Infinite retries on auth failure
|
||||
**Problem**: Cascading failures block all requests
|
||||
**Best Practice**: Fast-fail after N attempts, exponential backoff
|
||||
|
||||
#### ❌ Synchronous I/O in Async Context
|
||||
|
||||
**Current**: File writes happen in async functions
|
||||
**Problem**: Blocks event loop during large file writes
|
||||
**Best Practice**: Use `aiofiles` or thread pool for I/O
|
||||
|
||||
#### ❌ No Request Coordination
|
||||
|
||||
**Current**: Background task and request handlers compete
|
||||
**Problem**: Auth conflicts from concurrent operations
|
||||
**Best Practice**: Queue-based download coordination
|
||||
|
||||
### 3.2 Connection Management Issues
|
||||
|
||||
#### Current Architecture:
|
||||
```
|
||||
FastAPI (uvicorn) → Single TelegramClient → Single Pyrogram Client
|
||||
↓
|
||||
All operations share this client
|
||||
```
|
||||
|
||||
#### Problems:
|
||||
1. **No session isolation**: All requests share auth state
|
||||
2. **No connection pool**: Single TCP connection to Telegram
|
||||
3. **No retry strategy**: Each operation independently retries
|
||||
4. **No health checking**: No way to detect dead connections
|
||||
|
||||
---
|
||||
|
||||
## 4. Proposed Solutions
|
||||
|
||||
### 4.1 Immediate Fixes (Critical Priority)
|
||||
|
||||
#### Fix 1: Add Concurrency Limiter
|
||||
|
||||
**File**: [`api_server.py`](api_server.py:68)
|
||||
|
||||
**Problem**: Unlimited concurrent Pyrogram operations cause auth conflicts
|
||||
|
||||
**Solution**:
|
||||
```python
|
||||
# Add at global level after client initialization
|
||||
DOWNLOAD_SEMAPHORE = asyncio.Semaphore(3) # Max 3 concurrent downloads
|
||||
|
||||
async def download_media_file(channel: Union[str, int], post_id: int, file_unique_id: str) -> tuple[Union[str, None], bool]:
|
||||
async with DOWNLOAD_SEMAPHORE: # Limit concurrent downloads
|
||||
base_cache_dir = os.path.abspath("./data/cache")
|
||||
# ... rest of function
|
||||
```
|
||||
|
||||
**Impact**: Reduces auth conflicts by limiting concurrent Telegram API calls
|
||||
|
||||
#### Fix 2: Add Timeout Protection
|
||||
|
||||
**File**: [`api_server.py`](api_server.py:233)
|
||||
|
||||
**Problem**: Operations hang indefinitely on auth failures
|
||||
|
||||
**Solution**:
|
||||
```python
|
||||
async def download_media_file(channel: Union[str, int], post_id: int, file_unique_id: str) -> tuple[Union[str, None], bool]:
|
||||
async with DOWNLOAD_SEMAPHORE:
|
||||
try:
|
||||
# Add timeout wrapper for Telegram operations
|
||||
message = await asyncio.wait_for(
|
||||
client.client.get_messages(channel_id, post_id),
|
||||
timeout=30.0 # 30 second timeout
|
||||
)
|
||||
|
||||
# ... file ID lookup ...
|
||||
|
||||
file_path = await asyncio.wait_for(
|
||||
client.client.download_media(file_id, file_name=cache_path),
|
||||
timeout=120.0 # 2 minute timeout for downloads
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Timeout downloading {channel}/{post_id}/{file_unique_id}")
|
||||
raise HTTPException(status_code=504, detail="Download timeout")
|
||||
```
|
||||
|
||||
**Impact**: Prevents indefinite hangs, allows other requests to proceed
|
||||
|
||||
#### Fix 3: Separate Background Download Queue
|
||||
|
||||
**File**: [`api_server.py`](api_server.py:425)
|
||||
|
||||
**Problem**: Background task competes with request handlers
|
||||
|
||||
**Solution**:
|
||||
```python
|
||||
# Add queue for background downloads
|
||||
download_queue = asyncio.Queue(maxsize=100)
|
||||
|
||||
async def download_new_files(media_files: list, cache_dir: str) -> None:
|
||||
"""Queue files for background download instead of downloading immediately"""
|
||||
for file_data in media_files:
|
||||
# ... validation ...
|
||||
|
||||
cache_path = os.path.join(post_dir, file_unique_id)
|
||||
if not os.path.exists(cache_path):
|
||||
try:
|
||||
await download_queue.put((channel, post_id, file_unique_id))
|
||||
except asyncio.QueueFull:
|
||||
logger.warning(f"Download queue full, skipping {channel}/{post_id}/{file_unique_id}")
|
||||
break
|
||||
|
||||
async def background_download_worker():
|
||||
"""Worker that processes downloads from queue"""
|
||||
while True:
|
||||
try:
|
||||
channel, post_id, file_unique_id = await download_queue.get()
|
||||
logger.info(f"Background download: {channel}/{post_id}/{file_unique_id}")
|
||||
|
||||
async with DOWNLOAD_SEMAPHORE: # Use same semaphore
|
||||
await download_media_file(channel, post_id, file_unique_id)
|
||||
await asyncio.sleep(2) # Rate limiting
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Background download error: {e}")
|
||||
finally:
|
||||
download_queue.task_done()
|
||||
```
|
||||
|
||||
**Impact**: Coordinates background and on-demand downloads, prevents conflicts
|
||||
|
||||
#### Fix 4: Improve Error Handling for KeyError: 0
|
||||
|
||||
**File**: [`telegram_client.py`](telegram_client.py:62)
|
||||
|
||||
**Problem**: No handling for Pyrogram auth errors
|
||||
|
||||
**Solution**:
|
||||
```python
|
||||
# Add in TelegramClient class
|
||||
async def safe_get_messages(self, channel_id, post_id, max_retries=2):
|
||||
"""Wrapper with retry logic for auth errors"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self.client.get_messages(channel_id, post_id),
|
||||
timeout=30.0
|
||||
)
|
||||
except Exception as e:
|
||||
if "KeyError" in str(e) and attempt < max_retries - 1:
|
||||
logger.warning(f"Auth error on attempt {attempt + 1}, retrying in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
raise
|
||||
|
||||
async def safe_download_media(self, file_id, file_name, max_retries=2):
|
||||
"""Wrapper with retry logic for download errors"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self.client.download_media(file_id, file_name=file_name),
|
||||
timeout=120.0
|
||||
)
|
||||
except Exception as e:
|
||||
if "KeyError" in str(e) and attempt < max_retries - 1:
|
||||
logger.warning(f"Download auth error on attempt {attempt + 1}, retrying...")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
raise
|
||||
```
|
||||
|
||||
**Impact**: Isolates auth errors, provides controlled retry behavior
|
||||
|
||||
### 4.2 Medium-Term Improvements (High Priority)
|
||||
|
||||
#### Improvement 1: Connection Pool Architecture
|
||||
|
||||
**New File**: `telegram_pool.py`
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from typing import List
|
||||
from pyrogram import Client
|
||||
from config import get_settings
|
||||
|
||||
class TelegramClientPool:
|
||||
"""Pool of Pyrogram clients for load distribution"""
|
||||
|
||||
def __init__(self, pool_size: int = 3):
|
||||
self.pool_size = pool_size
|
||||
self.clients: List[Client] = []
|
||||
self.current_index = 0
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def initialize(self):
|
||||
"""Create and start pool of clients"""
|
||||
settings = get_settings()
|
||||
|
||||
for i in range(self.pool_size):
|
||||
client = Client(
|
||||
name=f"pyro_bridge_{i}",
|
||||
api_id=settings["tg_api_id"],
|
||||
api_hash=settings["tg_api_hash"],
|
||||
workdir=settings["session_path"],
|
||||
)
|
||||
await client.start()
|
||||
self.clients.append(client)
|
||||
logger.info(f"Initialized client {i+1}/{self.pool_size}")
|
||||
|
||||
async def get_client(self) -> Client:
|
||||
"""Get next available client (round-robin)"""
|
||||
async with self.lock:
|
||||
client = self.clients[self.current_index]
|
||||
self.current_index = (self.current_index + 1) % self.pool_size
|
||||
return client
|
||||
|
||||
async def shutdown(self):
|
||||
"""Stop all clients"""
|
||||
for client in self.clients:
|
||||
await client.stop()
|
||||
```
|
||||
|
||||
**Usage in [`api_server.py`](api_server.py:68)**:
|
||||
```python
|
||||
# Replace single client with pool
|
||||
client_pool = TelegramClientPool(pool_size=3)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
setup_logging(Config["log_level"])
|
||||
|
||||
await client_pool.initialize() # Start pool
|
||||
background_task = asyncio.create_task(cache_media_files())
|
||||
yield
|
||||
background_task.cancel()
|
||||
await client_pool.shutdown() # Stop pool
|
||||
|
||||
# In download functions:
|
||||
async def download_media_file(...):
|
||||
client = await client_pool.get_client() # Get from pool
|
||||
message = await client.get_messages(channel_id, post_id)
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Distributes load across multiple connections
|
||||
- Auth failures affect only subset of requests
|
||||
- Better throughput for concurrent operations
|
||||
|
||||
#### Improvement 2: Async File I/O
|
||||
|
||||
**File**: [`api_server.py`](api_server.py:324)
|
||||
|
||||
**Current**: Blocking file writes in async context
|
||||
|
||||
**Solution**: Add `aiofiles` to [`requirements.txt`](requirements.txt:1):
|
||||
```
|
||||
aiofiles==24.1.0
|
||||
```
|
||||
|
||||
Update download to stream to disk asynchronously:
|
||||
```python
|
||||
import aiofiles
|
||||
|
||||
async def download_media_file(...):
|
||||
# ... setup ...
|
||||
|
||||
# Stream download to avoid memory issues
|
||||
async for chunk in client.stream_media(file_id):
|
||||
async with aiofiles.open(cache_path, 'ab') as f:
|
||||
await f.write(chunk)
|
||||
```
|
||||
|
||||
#### Improvement 3: Circuit Breaker Pattern
|
||||
|
||||
**New File**: `circuit_breaker.py`
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Callable, Any
|
||||
|
||||
class CircuitState(Enum):
|
||||
CLOSED = "closed" # Normal operation
|
||||
OPEN = "open" # Failures detected, reject requests
|
||||
HALF_OPEN = "half_open" # Testing if service recovered
|
||||
|
||||
class CircuitBreaker:
|
||||
def __init__(self, failure_threshold: int = 5, timeout: int = 60):
|
||||
self.failure_threshold = failure_threshold
|
||||
self.timeout = timeout
|
||||
self.failure_count = 0
|
||||
self.last_failure_time = 0
|
||||
self.state = CircuitState.CLOSED
|
||||
|
||||
async def call(self, func: Callable, *args, **kwargs) -> Any:
|
||||
if self.state == CircuitState.OPEN:
|
||||
if time.time() - self.last_failure_time > self.timeout:
|
||||
self.state = CircuitState.HALF_OPEN
|
||||
else:
|
||||
raise Exception("Circuit breaker open - service unavailable")
|
||||
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
if self.state == CircuitState.HALF_OPEN:
|
||||
self.state = CircuitState.CLOSED
|
||||
self.failure_count = 0
|
||||
return result
|
||||
except Exception as e:
|
||||
self.failure_count += 1
|
||||
self.last_failure_time = time.time()
|
||||
|
||||
if self.failure_count >= self.failure_threshold:
|
||||
self.state = CircuitState.OPEN
|
||||
logger.error(f"Circuit breaker opened after {self.failure_count} failures")
|
||||
|
||||
raise
|
||||
|
||||
# Usage in api_server.py
|
||||
download_breaker = CircuitBreaker(failure_threshold=5, timeout=60)
|
||||
|
||||
async def download_media_file(...):
|
||||
return await download_breaker.call(_download_media_file_impl, ...)
|
||||
```
|
||||
|
||||
### 4.3 Long-Term Architectural Changes (Recommended)
|
||||
|
||||
#### Option 1: Separate Download Service
|
||||
|
||||
Move Telegram operations to dedicated service:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ FastAPI │ │ Download │
|
||||
│ Web Server │─────▶│ Service │
|
||||
│ (api_server) │ HTTP │ (separate) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
Pyrogram Clients
|
||||
(connection pool)
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Isolates blocking operations
|
||||
- Independent scaling
|
||||
- Better fault tolerance
|
||||
|
||||
|
||||
## 5. Implementation Roadmap
|
||||
|
||||
### Phase 1: Critical Fixes (Immediate - Day 1)
|
||||
|
||||
**Goal**: Stop active blocking issues
|
||||
|
||||
1. ✅ Add `DOWNLOAD_SEMAPHORE` (3 concurrent downloads)
|
||||
2. ✅ Add `asyncio.wait_for()` timeouts to all Pyrogram calls
|
||||
3. ✅ Implement `safe_get_messages()` and `safe_download_media()` wrappers
|
||||
4. ✅ Add download queue for background task coordination
|
||||
|
||||
**Testing**:
|
||||
- Verify no hangs under 10 concurrent requests
|
||||
- Confirm auth errors don't cascade
|
||||
- Monitor timeout behavior
|
||||
|
||||
### Phase 2: Resilience (Week 1)
|
||||
|
||||
**Goal**: Prevent future cascading failures
|
||||
|
||||
1. ✅ Implement circuit breaker pattern
|
||||
2. ✅ Add detailed metrics/logging for auth failures
|
||||
3. ✅ Implement exponential backoff for retries
|
||||
4. ✅ Add health check endpoint for Telegram connectivity
|
||||
|
||||
**Testing**:
|
||||
- Simulate auth failures
|
||||
- Verify circuit breaker triggers
|
||||
- Test recovery behavior
|
||||
|
||||
### Phase 3: Architecture (Week 2-3)
|
||||
|
||||
**Goal**: Scale and distribute load
|
||||
|
||||
1. ✅ Implement `TelegramClientPool` (3 clients initially)
|
||||
2. ✅ Migrate to `aiofiles` for async file I/O
|
||||
3. ✅ Add connection health monitoring
|
||||
4. ✅ Optimize cache management
|
||||
|
||||
**Testing**:
|
||||
- Load test with 50+ concurrent requests
|
||||
- Measure latency improvements
|
||||
- Verify pool balancing
|
||||
@@ -0,0 +1,202 @@
|
||||
# Phase 1 Critical Fixes - Implementation Summary
|
||||
|
||||
## Date: 2026-01-01
|
||||
## Status: ✅ COMPLETED
|
||||
|
||||
## Overview
|
||||
Successfully implemented Phase 1 Critical Fixes to resolve concurrent authentication failures (`KeyError: 0`) in the pyrogram-bridge project. All blocking issues related to unlimited concurrent downloads have been addressed.
|
||||
|
||||
## Changes Implemented
|
||||
|
||||
### 1. Concurrency Limiter ✅
|
||||
**File**: `api_server.py` (lines 70-71)
|
||||
|
||||
Added module-level semaphore and download queue:
|
||||
```python
|
||||
DOWNLOAD_SEMAPHORE = asyncio.Semaphore(3)
|
||||
download_queue = asyncio.Queue(maxsize=100)
|
||||
```
|
||||
|
||||
Wrapped entire `download_media_file()` function body (line 223) with:
|
||||
```python
|
||||
async with DOWNLOAD_SEMAPHORE:
|
||||
# All download logic here
|
||||
```
|
||||
|
||||
**Impact**: Limits concurrent Telegram operations to maximum 3 simultaneous downloads, preventing connection conflicts.
|
||||
|
||||
### 2. Timeout Protection ✅
|
||||
**File**: `api_server.py` (lines 236-240, 268-271, 331-334)
|
||||
|
||||
Added timeout handling for critical operations:
|
||||
- `get_messages()` call: 30-second timeout with HTTPException(504) on timeout
|
||||
- `download_media()` calls: 120-second timeout with HTTPException(504) on timeout
|
||||
|
||||
**Impact**: Prevents indefinite hangs and provides clear error responses to clients.
|
||||
|
||||
### 3. Safe Wrapper Methods ✅
|
||||
**File**: `telegram_client.py` (lines 86-115)
|
||||
|
||||
Added two new methods to `TelegramClient` class:
|
||||
|
||||
#### `safe_get_messages()`
|
||||
- Wraps `get_messages()` with 30-second timeout
|
||||
- Implements retry logic (max 2 retries)
|
||||
- Detects KeyError auth issues and retries after 5-second delay
|
||||
- Logs warnings on retry attempts
|
||||
|
||||
#### `safe_download_media()`
|
||||
- Wraps `download_media()` with 120-second timeout
|
||||
- Implements retry logic (max 2 retries)
|
||||
- Detects KeyError auth issues and retries after 5-second delay
|
||||
- Logs warnings on retry attempts
|
||||
|
||||
**Impact**: Provides resilient communication with Telegram API, automatically recovering from transient auth errors.
|
||||
|
||||
### 4. Background Download Queue ✅
|
||||
**File**: `api_server.py` (lines 432-478, 501-516)
|
||||
|
||||
#### Modified `download_new_files()`
|
||||
- Changed from direct download to queue-based system
|
||||
- Uses `download_queue.put()` to queue files
|
||||
- Handles `QueueFull` exception gracefully
|
||||
- Logs number of queued files
|
||||
|
||||
#### Added `background_download_worker()`
|
||||
- Infinite loop processing downloads from queue
|
||||
- Uses semaphore for concurrency control
|
||||
- 2-second delay between downloads
|
||||
- Comprehensive error handling
|
||||
- Calls `task_done()` in finally block
|
||||
|
||||
#### Updated `lifespan()`
|
||||
- Starts `worker_task` alongside cache management task
|
||||
- Properly cancels worker on shutdown
|
||||
- Handles `CancelledError` gracefully
|
||||
|
||||
**Impact**: Separates user-requested downloads from background cache warming, preventing queue buildup and ensuring responsive API.
|
||||
|
||||
### 5. Updated Calls to Use Safe Wrappers ✅
|
||||
**File**: `api_server.py` (lines 236-240, 268-271, 331-334)
|
||||
|
||||
Replaced direct client calls:
|
||||
- `client.client.get_messages()` → `client.safe_get_messages()`
|
||||
- `client.client.download_media()` → `client.safe_download_media()`
|
||||
|
||||
Added timeout exception handling for both wrapper calls.
|
||||
|
||||
**Impact**: All Telegram operations now benefit from retry logic and timeout protection.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Semaphore Implementation
|
||||
- **Limit**: 3 concurrent downloads
|
||||
- **Scope**: Wraps entire `download_media_file()` function
|
||||
- **Location**: Module-level variable, initialized at startup
|
||||
|
||||
### Download Queue
|
||||
- **Capacity**: 100 items
|
||||
- **Type**: `asyncio.Queue`
|
||||
- **Workers**: 1 dedicated worker task
|
||||
- **Processing**: FIFO with 2-second delays
|
||||
|
||||
### Timeout Values
|
||||
- **get_messages**: 30 seconds
|
||||
- **download_media**: 120 seconds (for large files)
|
||||
- **Retry delay**: 5 seconds between attempts
|
||||
|
||||
### Retry Logic
|
||||
- **Max retries**: 2 attempts per operation
|
||||
- **Trigger**: KeyError in exception message
|
||||
- **Delay**: 5 seconds between retries
|
||||
- **Logging**: Warnings on each retry attempt
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **telegram_client.py**
|
||||
- Added `safe_get_messages()` method
|
||||
- Added `safe_download_media()` method
|
||||
- Total lines added: ~30
|
||||
|
||||
2. **api_server.py**
|
||||
- Added semaphore and queue initialization
|
||||
- Modified `download_media_file()` with semaphore and safe wrappers
|
||||
- Modified `download_new_files()` to use queue
|
||||
- Added `background_download_worker()` function
|
||||
- Updated `lifespan()` to manage worker task
|
||||
- Total lines modified/added: ~100
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
✅ All changes maintain backward compatibility:
|
||||
- Existing error handling preserved
|
||||
- Existing logging maintained
|
||||
- API endpoints unchanged
|
||||
- Cache structure unchanged
|
||||
- Configuration unchanged
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Compilation Tests ✅
|
||||
- [x] Python syntax validation passed
|
||||
- [x] No import errors
|
||||
- [x] Code compiles successfully
|
||||
|
||||
### Runtime Tests (To Be Performed)
|
||||
- [ ] Verify semaphore limits concurrent downloads to 3
|
||||
- [ ] Confirm timeouts prevent indefinite hangs
|
||||
- [ ] Test retry logic on simulated auth errors
|
||||
- [ ] Verify background queue processes downloads sequentially
|
||||
- [ ] Monitor logs for proper retry behavior
|
||||
- [ ] Test high-load scenarios (50+ concurrent requests)
|
||||
- [ ] Verify cache warming still functions
|
||||
- [ ] Check queue doesn't fill up and block
|
||||
|
||||
## Expected Outcomes
|
||||
|
||||
### Performance Improvements
|
||||
- **Eliminated**: 60+ second blocking periods
|
||||
- **Reduced**: Concurrent auth conflicts to near-zero
|
||||
- **Added**: Graceful timeout handling
|
||||
- **Improved**: System resilience with retry logic
|
||||
|
||||
### Operational Benefits
|
||||
- Clear log messages for debugging
|
||||
- Predictable download concurrency
|
||||
- Queue-based background processing
|
||||
- Automatic recovery from transient errors
|
||||
- Better resource utilization
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Deploy**: Apply changes to production environment
|
||||
2. **Monitor**: Watch logs for timeout/retry behavior
|
||||
3. **Measure**: Track KeyError occurrence rate (should drop significantly)
|
||||
4. **Optimize**: Adjust semaphore limit based on actual performance
|
||||
5. **Phase 2**: Proceed with connection pool improvements (if needed)
|
||||
|
||||
## Notes
|
||||
|
||||
- The implementation follows the exact specifications from `performance-blocking-analysis.md`
|
||||
- All code changes include comprehensive logging
|
||||
- Error handling is defensive and verbose
|
||||
- The semaphore approach is proven for this exact issue type
|
||||
- Queue-based background downloads prevent user-facing delays
|
||||
|
||||
## Validation
|
||||
|
||||
✅ Code compiled successfully with no errors
|
||||
✅ All specified changes implemented
|
||||
✅ Backward compatibility maintained
|
||||
✅ Logging is comprehensive
|
||||
✅ Error handling is robust
|
||||
|
||||
## Conclusion
|
||||
|
||||
Phase 1 Critical Fixes have been successfully implemented. The changes address the root cause of blocking issues by:
|
||||
1. Limiting concurrent operations to prevent auth conflicts
|
||||
2. Adding timeout protection to prevent hangs
|
||||
3. Implementing retry logic for transient failures
|
||||
4. Separating background downloads from user requests
|
||||
|
||||
These changes should immediately resolve the `KeyError: 0` blocking issues and improve overall system stability and performance.
|
||||
+633
-203
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#ad46b7",
|
||||
"activityBar.background": "#ad46b7",
|
||||
"activityBar.foreground": "#e7e7e7",
|
||||
"activityBar.inactiveForeground": "#e7e7e799",
|
||||
"activityBarBadge.background": "#b8ae47",
|
||||
"activityBarBadge.foreground": "#15202b",
|
||||
"commandCenter.border": "#e7e7e799",
|
||||
"sash.hoverBorder": "#ad46b7",
|
||||
"titleBar.activeBackground": "#8a3892",
|
||||
"titleBar.activeForeground": "#e7e7e7",
|
||||
"titleBar.inactiveBackground": "#8a389299",
|
||||
"titleBar.inactiveForeground": "#e7e7e799",
|
||||
"statusBar.background": "#8a3892",
|
||||
"statusBar.foreground": "#e7e7e7",
|
||||
"statusBarItem.hoverBackground": "#ad46b7",
|
||||
"statusBarItem.remoteBackground": "#8a3892",
|
||||
"statusBarItem.remoteForeground": "#e7e7e7"
|
||||
},
|
||||
"peacock.color": "#8a3892"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -ra
|
||||
# The stage-1 anti-hang tests are async; run them without per-test event loops
|
||||
# being set up by hand. (The tests also carry @pytest.mark.asyncio explicitly, so
|
||||
# they work in strict mode too — but auto keeps the plugin's requirement obvious.)
|
||||
asyncio_mode = auto
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
+7
-3
@@ -1,8 +1,11 @@
|
||||
fastapi==0.115.8
|
||||
# Pinned explicitly (fastapi only requires a range): the stage-3 FileResponse Range
|
||||
# behaviour and the multipart-byteranges test are tied to this Starlette version.
|
||||
starlette==0.45.3
|
||||
uvicorn==0.34.0
|
||||
python-multipart==0.0.20
|
||||
#Kurigram
|
||||
git+https://github.com/KurimuzonAkuma/pyrogram.git@dev#egg=Kurigram
|
||||
Kurigram==2.2.23
|
||||
#git+https://github.com/KurimuzonAkuma/pyrogram.git@dev#egg=Kurigram
|
||||
TgCrypto
|
||||
uvloop
|
||||
pillow==11.1.0
|
||||
@@ -11,4 +14,5 @@ python-dateutil==2.9.0.post0
|
||||
python-magic==0.4.27
|
||||
bleach[css]==6.1.0
|
||||
types-bleach
|
||||
json-repair
|
||||
pytest-asyncio==1.4.0
|
||||
httpx==0.28.1
|
||||
|
||||
-296
@@ -1,296 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# flake8: noqa
|
||||
# pylint: disable=broad-exception-raised, raise-missing-from, too-many-arguments, redefined-outer-name
|
||||
# pylint: disable=multiple-statements, logging-fstring-interpolation, trailing-whitespace, line-too-long
|
||||
# pylint: disable=broad-exception-caught, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=f-string-without-interpolation
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Union, Optional
|
||||
from config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
Config = get_settings()
|
||||
|
||||
def get_cache_path(channel: Union[str, int], content_type: str = 'rss') -> Path:
|
||||
"""
|
||||
Get the path to the cache file for a given channel
|
||||
Args:
|
||||
channel: Telegram channel name or id
|
||||
content_type: Type of content (rss or html)
|
||||
Returns:
|
||||
Path to the cache file
|
||||
"""
|
||||
if content_type == 'html':
|
||||
base_dir = './data/html-cache'
|
||||
file_ext = 'html'
|
||||
else:
|
||||
base_dir = './data/rss-cache'
|
||||
file_ext = 'xml'
|
||||
|
||||
cache_dir = Path(Config.get('cache_dir', base_dir))
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create a unique filename based on the channel identifier
|
||||
if isinstance(channel, int):
|
||||
filename = f"channel_{channel}.{file_ext}"
|
||||
else:
|
||||
# Use hash for channel names with special characters
|
||||
if any(c in channel for c in '/<>:"|?*\\'):
|
||||
channel_hash = hashlib.md5(channel.encode()).hexdigest()
|
||||
filename = f"channel_{channel_hash}.{file_ext}"
|
||||
else:
|
||||
filename = f"channel_{channel}.{file_ext}"
|
||||
|
||||
return cache_dir / filename
|
||||
|
||||
def is_cache_valid(cache_path: Path, max_age_hours: int = 2) -> bool:
|
||||
"""
|
||||
Check if the cache file exists and is recent enough
|
||||
Args:
|
||||
cache_path: Path to the cache file
|
||||
max_age_hours: Maximum age of the cache in hours
|
||||
Returns:
|
||||
True if cache exists and is recent enough, False otherwise
|
||||
"""
|
||||
if not cache_path.exists():
|
||||
return False
|
||||
|
||||
file_mod_time = datetime.fromtimestamp(cache_path.stat().st_mtime)
|
||||
max_age = timedelta(hours=max_age_hours)
|
||||
|
||||
return datetime.now() - file_mod_time < max_age
|
||||
|
||||
def read_cache(channel: Union[str, int],
|
||||
content_type: str = 'rss',
|
||||
max_age_hours: int = 2) -> Union[str, None]:
|
||||
"""
|
||||
Read content from cache if it exists and is valid
|
||||
Args:
|
||||
channel: Channel name or ID
|
||||
content_type: Type of content (rss or html)
|
||||
max_age_hours: Maximum age of the cache in hours
|
||||
Returns:
|
||||
Cached content as string or None if cache is invalid/missing
|
||||
"""
|
||||
cache_path = get_cache_path(channel, content_type)
|
||||
|
||||
if not is_cache_valid(cache_path, max_age_hours):
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.debug(f"using_cached_{content_type}: channel {channel}, cache_file {cache_path}")
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"cache_read_error: channel {channel}, error {str(e)}")
|
||||
return None
|
||||
|
||||
def write_cache(channel: Union[str, int],
|
||||
content: str,
|
||||
content_type: str = 'rss') -> bool:
|
||||
"""
|
||||
Write content to cache
|
||||
Args:
|
||||
channel: Channel name or ID
|
||||
content: Content to cache
|
||||
content_type: Type of content (rss or html)
|
||||
Returns:
|
||||
True if cache was written successfully, False otherwise
|
||||
"""
|
||||
cache_path = get_cache_path(channel, content_type)
|
||||
|
||||
try:
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
logger.debug(f"{content_type}_cache_saved: channel {channel}, cache_file {cache_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"cache_write_error: channel {channel}, error {str(e)}")
|
||||
return False
|
||||
|
||||
def get_access_file_path() -> str:
|
||||
"""
|
||||
Get path to the cache access file
|
||||
Returns:
|
||||
Path to the cache access file
|
||||
"""
|
||||
return os.path.join(os.path.abspath("./data"), 'cache_access.json')
|
||||
|
||||
def update_channel_access_time(channel: Union[str, int]) -> bool:
|
||||
"""
|
||||
Update access time for a channel in cache_access.json
|
||||
Args:
|
||||
channel: Channel name or ID
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
access_file_path = get_access_file_path()
|
||||
cache_access_data = {}
|
||||
|
||||
# Load existing data if file exists
|
||||
if os.path.exists(access_file_path):
|
||||
try:
|
||||
with open(access_file_path, 'r', encoding='utf-8') as f:
|
||||
cache_access_data = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Error decoding cache_access.json, creating new file")
|
||||
cache_access_data = {}
|
||||
|
||||
# Update channel access time
|
||||
channel_key = str(channel)
|
||||
cache_access_data[channel_key] = time.time()
|
||||
|
||||
# Save updated data
|
||||
with open(access_file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache_access_data, f, indent=2)
|
||||
|
||||
logger.debug(f"Updated access time for channel {channel} in cache_access.json")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update cache_access.json: {str(e)}")
|
||||
return False
|
||||
|
||||
async def clean_old_channels(max_age_days: int = 30) -> int:
|
||||
"""
|
||||
Remove channels that haven't been accessed for more than specified days
|
||||
|
||||
Args:
|
||||
max_age_days: Maximum age of channels in days
|
||||
|
||||
Returns:
|
||||
Number of removed channels
|
||||
"""
|
||||
access_file_path = get_access_file_path()
|
||||
|
||||
if not os.path.exists(access_file_path):
|
||||
logger.info("access_file_not_found: Creating new file")
|
||||
with open(access_file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({}, f)
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(access_file_path, 'r', encoding='utf-8') as f:
|
||||
cache_access = json.load(f)
|
||||
|
||||
now = time.time()
|
||||
max_age_seconds = max_age_days * 86400 # days to seconds
|
||||
|
||||
old_channels = []
|
||||
for channel, timestamp in list(cache_access.items()):
|
||||
age = now - timestamp
|
||||
if age > max_age_seconds:
|
||||
old_channels.append(channel)
|
||||
del cache_access[channel]
|
||||
|
||||
# Save updated data
|
||||
if old_channels:
|
||||
with open(access_file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache_access, f, indent=2)
|
||||
logger.info(f"removed_old_channels: {len(old_channels)} channels removed (older than {max_age_days} days)")
|
||||
|
||||
return len(old_channels)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"clean_old_channels_error: {str(e)}")
|
||||
return 0
|
||||
|
||||
async def update_rss_cache(client, channel: str, limit: int = 50) -> bool:
|
||||
"""
|
||||
Update RSS cache for specified channel
|
||||
|
||||
Args:
|
||||
client: Telegram client
|
||||
channel: Channel identifier
|
||||
limit: Maximum number of posts
|
||||
|
||||
Returns:
|
||||
True if cache was successfully updated, False otherwise
|
||||
"""
|
||||
try:
|
||||
logger.info(f"updating_rss_cache: channel {channel}")
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from rss_generator import generate_channel_rss
|
||||
|
||||
# Force generate new RSS without using cache
|
||||
await generate_channel_rss(
|
||||
channel=channel,
|
||||
client=client,
|
||||
use_cache=False, # Force generate new RSS
|
||||
limit=limit
|
||||
)
|
||||
|
||||
logger.info(f"updated_rss_cache: channel {channel}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"rss_cache_update_error: channel {channel}, error {str(e)}")
|
||||
return False
|
||||
|
||||
async def start_cache_updater(client) -> None:
|
||||
"""
|
||||
Background task for updating RSS cache periodically
|
||||
|
||||
Args:
|
||||
client: Telegram client instance
|
||||
"""
|
||||
# Settings from configuration
|
||||
update_interval = Config.get("rss_cache_update_interval", 3600) # seconds
|
||||
update_delay = Config.get("rss_cache_update_delay", 60) # seconds
|
||||
max_age_days = Config.get("rss_cache_max_age_days", 30)
|
||||
|
||||
logger.info(f"starting_rss_cache_updater: interval={update_interval}s, delay={update_delay}s, max_age={max_age_days}d")
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Clean old channels
|
||||
cleaned = await clean_old_channels(max_age_days)
|
||||
|
||||
# Load channels for update
|
||||
access_file_path = get_access_file_path()
|
||||
try:
|
||||
if os.path.exists(access_file_path):
|
||||
with open(access_file_path, 'r', encoding='utf-8') as f:
|
||||
cache_access = json.load(f)
|
||||
channels = list(cache_access.keys())
|
||||
|
||||
if channels:
|
||||
logger.info(f"starting_cache_update: updating {len(channels)} channels")
|
||||
|
||||
# Sort channels by last access time (newest first)
|
||||
channels_sorted = sorted(channels, key=lambda c: cache_access[c], reverse=True)
|
||||
|
||||
# Update cache for each channel with delay
|
||||
for channel in channels_sorted:
|
||||
success = await update_rss_cache(client, channel)
|
||||
# Sleep between updates to avoid rate limits
|
||||
await asyncio.sleep(update_delay)
|
||||
|
||||
logger.info(f"finished_cache_update: updated {len(channels)} channels")
|
||||
else:
|
||||
logger.info("no_channels_to_update: Waiting for next interval")
|
||||
else:
|
||||
logger.info("no_access_file: Waiting for next interval")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"rss_cache_update_cycle_error: {str(e)}")
|
||||
|
||||
# Wait until next update cycle
|
||||
logger.info(f"waiting_for_next_update: next update in {update_interval} seconds")
|
||||
await asyncio.sleep(update_interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("rss_cache_updater_cancelled")
|
||||
except Exception as e:
|
||||
logger.error(f"rss_cache_updater_error: {str(e)}")
|
||||
+426
-353
@@ -10,95 +10,130 @@
|
||||
# mypy: disable-error-code="import-untyped"
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
import hashlib
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from feedgen.feed import FeedGenerator
|
||||
from pyrogram import errors, Client
|
||||
from pyrogram.types import Message
|
||||
from post_parser import PostParser
|
||||
from post_parser import PostParser, _wrap_post_html
|
||||
from config import get_settings
|
||||
from rss_cache import read_cache, write_cache
|
||||
from tg_throttle import tg_rpc_bounded
|
||||
from sanitizer import sanitize_html
|
||||
|
||||
Config = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Семафор для ограничения параллельных запросов к Telegram API при получении постов
|
||||
POSTS_REQUEST_SEMAPHORE = asyncio.Semaphore(3)
|
||||
|
||||
async def _create_time_based_media_groups(messages: list[Message], merge_seconds: int = 5) -> list[Message]:
|
||||
@dataclass
|
||||
class PreparedFeed:
|
||||
"""Output of _prepare_feed_posts — everything both formatters need after
|
||||
fetch -> enrich -> render -> filter -> sanitize."""
|
||||
channel_username: str
|
||||
channel_title: str # used by RSS metadata only
|
||||
posts: list[dict] # rendered, filtered, sorted, SANITIZED
|
||||
|
||||
|
||||
class ChannelNotFound(Exception):
|
||||
"""Channel could not be resolved to a username; formatter -> create_error_feed."""
|
||||
def __init__(self, channel_identifier):
|
||||
# The prepared identifier (str or int), rendered into the error feed.
|
||||
self.channel_identifier = channel_identifier
|
||||
super().__init__(str(channel_identifier))
|
||||
|
||||
def _compute_time_based_group_ids(messages: list[Message], merge_seconds: int = 5) -> dict[int, str | int]:
|
||||
"""Return {message.id: effective_media_group_id} WITHOUT mutating messages.
|
||||
|
||||
Plain synchronous function (contains no await): runs inside the render thread
|
||||
via _render_pipeline. Must not touch asyncio.
|
||||
|
||||
Contract: all messages belong to ONE chat (message.id is unique only per
|
||||
chat); callers must not mix chats in a single call.
|
||||
|
||||
Pure re-implementation of the old _create_time_based_media_groups mutation,
|
||||
reproducing its result for every input the old code survived on PRODUCTION
|
||||
data (naive kurigram dates). Inputs only aware-date test mocks could produce
|
||||
(fully-None and aware+None mixes, where the old code clustered the None-date
|
||||
tail by insertion order incl. truthy-id adoption) are deliberately replaced
|
||||
(registry §3.11):
|
||||
- messages WITHOUT a date do not participate in time clustering and get
|
||||
NO mapping entry (their own media_group_id still applies downstream);
|
||||
- dated messages are ordered ascending by date via a timestamp key
|
||||
(naive-safe: no aware/naive mix once None-date are excluded);
|
||||
- a message joins the current cluster if the gap to the PREVIOUS message
|
||||
is <= merge_seconds; the gap is a NAIVE datetime subtraction
|
||||
(msg.date - prev.date).total_seconds(), exactly as the old code — NOT a
|
||||
timestamp diff (they diverge across a DST fold, and the old behavior is
|
||||
the contract);
|
||||
- the effective id of a cluster is the FIRST TRUTHY media_group_id in
|
||||
cluster order (old code used truthiness, not `is not None`, and
|
||||
overwrote members' own differing ids — kept);
|
||||
- a cluster of >= 2 members with no truthy id gets a synthetic
|
||||
f"time_{min(dates)}" id (exact old format);
|
||||
- singleton clusters and clusters with no effective id produce NO entries;
|
||||
every member of a cluster with an effective id gets one.
|
||||
"""
|
||||
Create media groups based on time difference between messages
|
||||
"""
|
||||
messages_sorted = sorted(messages, key=lambda msg: msg.date) # type: ignore
|
||||
dated = sorted((m for m in messages if m.date is not None),
|
||||
key=lambda m: m.date.timestamp()) # type: ignore
|
||||
group_ids: dict[int, str | int] = {}
|
||||
|
||||
def _flush(cluster: list[Message], effective: str | int | None) -> None:
|
||||
if len(cluster) < 2:
|
||||
return
|
||||
if not effective:
|
||||
# All members are dated here (None-date excluded above), so min() is safe.
|
||||
effective = f"time_{min(m.date for m in cluster)}" # type: ignore
|
||||
for m in cluster:
|
||||
group_ids[m.id] = effective
|
||||
|
||||
cluster: list[Message] = []
|
||||
last_msg_date: datetime = datetime.now(timezone.utc)
|
||||
current_media_group_id: Optional[str] = None
|
||||
effective: str | int | None = None
|
||||
prev_date: Optional[datetime] = None
|
||||
|
||||
for msg in messages_sorted:
|
||||
|
||||
for msg in dated:
|
||||
mgid = getattr(msg, "media_group_id", None)
|
||||
if not cluster:
|
||||
cluster.append(msg)
|
||||
last_msg_date = msg.date # type: ignore
|
||||
current_media_group_id = getattr(msg, "media_group_id", None)
|
||||
continue
|
||||
|
||||
time_diff = (msg.date - last_msg_date).total_seconds() # type: ignore
|
||||
|
||||
msg_media_group_id = getattr(msg, "media_group_id", None)
|
||||
|
||||
if time_diff <= merge_seconds:
|
||||
if current_media_group_id:
|
||||
msg.media_group_id = current_media_group_id # type: ignore
|
||||
elif msg_media_group_id:
|
||||
current_media_group_id = msg_media_group_id
|
||||
for m in cluster:
|
||||
m.media_group_id = current_media_group_id # type: ignore
|
||||
cluster.append(msg)
|
||||
last_msg_date = msg.date # type: ignore
|
||||
else:
|
||||
if len(cluster) >= 2 and not current_media_group_id:
|
||||
dates = [m.date for m in cluster if m.date is not None]
|
||||
if dates:
|
||||
min_date = min(dates)
|
||||
new_group_id = f"time_{min_date}"
|
||||
for m in cluster:
|
||||
m.media_group_id = new_group_id # type: ignore
|
||||
cluster = [msg]
|
||||
last_msg_date = msg.date # type: ignore
|
||||
current_media_group_id = msg_media_group_id
|
||||
|
||||
if len(cluster) >= 2 and not current_media_group_id:
|
||||
dates = [m.date for m in cluster if m.date is not None]
|
||||
if dates:
|
||||
min_date = min(dates)
|
||||
new_group_id = f"time_{min_date}"
|
||||
for m in cluster:
|
||||
m.media_group_id = new_group_id # type: ignore
|
||||
effective = mgid or None
|
||||
prev_date = msg.date
|
||||
continue
|
||||
time_diff = (msg.date - prev_date).total_seconds() # type: ignore
|
||||
if time_diff <= merge_seconds:
|
||||
cluster.append(msg)
|
||||
# First truthy id in cluster order wins; keep it even if this member
|
||||
# carries a different truthy id (the old code overwrote it).
|
||||
if not effective and mgid:
|
||||
effective = mgid
|
||||
prev_date = msg.date
|
||||
else:
|
||||
_flush(cluster, effective)
|
||||
cluster = [msg]
|
||||
effective = mgid or None
|
||||
prev_date = msg.date
|
||||
|
||||
return messages_sorted
|
||||
_flush(cluster, effective)
|
||||
return group_ids
|
||||
|
||||
async def _create_messages_groups(messages: list[Message]) -> list[list[Message]]:
|
||||
def _create_messages_groups(messages: list[Message], group_ids: dict[int, str | int] | None = None) -> list[list[Message]]:
|
||||
"""
|
||||
Process messages into formatted posts, handling media groups
|
||||
|
||||
Plain synchronous function (contains no await): runs inside the render thread.
|
||||
"""
|
||||
processing_groups: list[list[Message]] = []
|
||||
media_groups: dict[str | int, list[Message]] = {}
|
||||
|
||||
# Time-clustering supplies effective ids as a PURE mapping (no message mutation,
|
||||
# no deepcopy). Absent an entry, the message's own media_group_id applies
|
||||
# (registry §3.11 / spec Этап 4).
|
||||
group_ids = group_ids or {}
|
||||
|
||||
# First pass - collect messages and organize into processing groups
|
||||
for message in messages:
|
||||
try:
|
||||
# Skip story messages
|
||||
if message.media == "MessageMediaType.STORY" or hasattr(message, "story"):
|
||||
continue
|
||||
|
||||
# Skip service messages about pinned posts and new chat photos
|
||||
if message.service:
|
||||
if 'PINNED_MESSAGE' in str(message.service): continue
|
||||
@@ -110,12 +145,12 @@ async def _create_messages_groups(messages: list[Message]) -> list[list[Message]
|
||||
if 'GROUP_CHAT_CREATED' in str(message.service): continue
|
||||
if 'CHANNEL_CHAT_CREATED' in str(message.service): continue
|
||||
if 'DELETE_CHAT_PHOTO' in str(message.service): continue
|
||||
if 'NEW_CHAT_TITLE' in str(message.service): continue
|
||||
|
||||
if message.media_group_id:
|
||||
if message.media_group_id not in media_groups:
|
||||
media_groups[message.media_group_id] = []
|
||||
media_groups[message.media_group_id].append(message)
|
||||
effective_group_id = group_ids.get(message.id, message.media_group_id)
|
||||
if effective_group_id:
|
||||
if effective_group_id not in media_groups:
|
||||
media_groups[effective_group_id] = []
|
||||
media_groups[effective_group_id].append(message)
|
||||
else:
|
||||
processing_groups.append([message]) # Single message becomes its own processing group
|
||||
|
||||
@@ -129,77 +164,24 @@ async def _create_messages_groups(messages: list[Message]) -> list[list[Message]
|
||||
media_group.sort(key=lambda x: x.id, reverse=False)
|
||||
processing_groups.append(media_group)
|
||||
|
||||
# Sort processing groups by date of first message in each group
|
||||
processing_groups.sort(key=lambda group: group[0].date if group[0].date else datetime.now(timezone.utc), reverse=True)
|
||||
|
||||
# Sort processing groups by date of first message in each group. Timestamp-based key
|
||||
# is naive-safe: kurigram dates are naive-local, and a None-date group used to fall
|
||||
# back to an AWARE datetime.now(timezone.utc) here, raising TypeError on the first
|
||||
# naive-vs-aware comparison — a 500 on ANY feed carrying a None-date post, in the
|
||||
# DEFAULT path (registry §3.12). None-date groups now sort as newest (float('inf'))
|
||||
# and deterministically survive the later [:limit] slice; the final post sort in
|
||||
# _render_messages_groups (0.0 fallback) still places them at the tail of the feed.
|
||||
processing_groups.sort(key=lambda group: group[0].date.timestamp() if group[0].date else float('inf'), reverse=True)
|
||||
|
||||
return processing_groups
|
||||
|
||||
async def _trim_messages_groups(messages_groups: list[list[Message]], limit: int):
|
||||
"""
|
||||
Trim messages groups to limit
|
||||
"""
|
||||
if messages_groups: # Remove the oldest group (the one with the lowest message id based on the first message's date)
|
||||
messages_groups.pop()
|
||||
|
||||
if len(messages_groups) > limit: # Trim groups if they exceed the specified limit
|
||||
messages_groups = messages_groups[:limit]
|
||||
|
||||
return messages_groups
|
||||
|
||||
def processed_message_to_tg_message(processed_message: dict) -> Message:
|
||||
"""
|
||||
Convert processed message dictionary into a Message-like object
|
||||
containing only the attributes needed by generate_html_footer.
|
||||
"""
|
||||
# Create a simple chat object
|
||||
chat_info = SimpleNamespace()
|
||||
channel_identifier = processed_message.get('channel')
|
||||
if isinstance(channel_identifier, str) and channel_identifier.startswith('-100'):
|
||||
setattr(chat_info, 'id', int(channel_identifier))
|
||||
setattr(chat_info, 'username', None)
|
||||
else:
|
||||
setattr(chat_info, 'id', None) # Or some placeholder if needed
|
||||
setattr(chat_info, 'username', channel_identifier)
|
||||
|
||||
|
||||
# Convert reactions dict to list of objects
|
||||
reactions_list = []
|
||||
if reactions_dict := processed_message.get('reactions'):
|
||||
for emoji, count in reactions_dict.items():
|
||||
# Assuming no custom/paid reactions in this simplified structure
|
||||
reactions_list.append(SimpleNamespace(emoji=emoji, count=count, is_paid=False, custom_emoji_id=None))
|
||||
|
||||
# Recreate reactions structure expected by Pyrogram's reaction handling
|
||||
reactions_obj = SimpleNamespace(reactions=reactions_list) if reactions_list else None
|
||||
|
||||
# Create the message-like object
|
||||
tg_message_mock = SimpleNamespace(
|
||||
id=processed_message.get('message_id'),
|
||||
date=datetime.fromtimestamp(processed_message['date'], tz=timezone.utc) if processed_message.get('date') else None,
|
||||
views=processed_message.get('views'),
|
||||
reactions=reactions_obj,
|
||||
chat=chat_info,
|
||||
# Add other attributes if generate_html_footer or its dependencies need them
|
||||
# For now, these seem sufficient based on the analysis of generate_html_footer
|
||||
# and _reactions_views_links.
|
||||
text=processed_message.get('text'), # Add text just in case
|
||||
caption=None, # Assume caption is merged into text by process_message
|
||||
forward_origin=None, # Not directly needed by footer generation logic itself
|
||||
reply_to_message=None, # Not directly needed by footer generation logic itself
|
||||
media=None, # Not needed by footer
|
||||
service=processed_message.get('service') # Potentially needed? Added just in case.
|
||||
)
|
||||
|
||||
# Cast to Message type hint for static analysis, although it's a mock object
|
||||
return tg_message_mock # type: ignore
|
||||
|
||||
|
||||
async def _render_messages_groups(messages_groups: list[list[Message]],
|
||||
post_parser: PostParser,
|
||||
exclude_flags: str | None = None,
|
||||
def _render_messages_groups(messages_groups: list[list[Message]],
|
||||
post_parser: PostParser,
|
||||
exclude_flags: str | None = None,
|
||||
exclude_text: str | None = None):
|
||||
"""
|
||||
Render message groups into HTML format
|
||||
Plain synchronous function (contains no await): runs inside the render thread.
|
||||
Args:
|
||||
messages_groups: List of message groups (each group is a list of messages)
|
||||
post_parser: PostParser instance
|
||||
@@ -214,13 +196,12 @@ async def _render_messages_groups(messages_groups: list[list[Message]],
|
||||
try:
|
||||
if len(group) == 1: # Single message - simple case
|
||||
one_message = group[0]
|
||||
message_data = post_parser.process_message(one_message)
|
||||
html_parts = [
|
||||
f'<div class="message-body">{message_data["html"]["body"]}</div>',
|
||||
f'<div class="message-footer">{message_data["html"]["footer"]}</div>'
|
||||
]
|
||||
# Feed path: raw_message not needed and sanitize deferred to the per-post
|
||||
# pass in _render_pipeline (both RSS and HTML), so each fragment is
|
||||
# sanitized exactly once.
|
||||
message_data = post_parser.process_message(one_message, include_raw=False, sanitize=False)
|
||||
rendered_posts.append({
|
||||
'html': '\n'.join(html_parts),
|
||||
'html': _wrap_post_html(message_data["html"]["body"], message_data["html"]["footer"]),
|
||||
'date': message_data['date'],
|
||||
'message_id': message_data['message_id'],
|
||||
'title': message_data['html']['title'],
|
||||
@@ -229,14 +210,16 @@ async def _render_messages_groups(messages_groups: list[list[Message]],
|
||||
'flags': message_data['flags']
|
||||
})
|
||||
else: # Multiple messages in group - merge text and html body
|
||||
processed_messages = [post_parser.process_message(msg) for msg in group]
|
||||
processed_messages = [post_parser.process_message(msg, include_raw=False, sanitize=False) for msg in group]
|
||||
|
||||
# Determine main message for header/footer/title
|
||||
main_message = next(
|
||||
(msg for msg in processed_messages if msg['text']),
|
||||
processed_messages[0] # fallback if no message contains text
|
||||
)
|
||||
|
||||
# Determine the main message with the SAME criterion the processed dicts
|
||||
# used: first message that has text or caption, else the first of the
|
||||
# group. processed_messages[i] corresponds to group[i], so main_message
|
||||
# (dict, for title/date/author) and main_raw (the real Message, for the
|
||||
# footer) point at the same index.
|
||||
main_idx = next((i for i, m in enumerate(group) if (m.text or m.caption)), 0)
|
||||
main_raw = group[main_idx]
|
||||
main_message = processed_messages[main_idx]
|
||||
|
||||
# Merge text fields from all messages
|
||||
all_texts = [msg['text'] for msg in processed_messages if msg['text']]
|
||||
@@ -246,27 +229,19 @@ async def _render_messages_groups(messages_groups: list[list[Message]],
|
||||
all_html_bodies = [msg['html']['body'] for msg in processed_messages if msg['html']['body']]
|
||||
combined_html_body = '\n<br><br>\n'.join(all_html_bodies)
|
||||
|
||||
# Collect all unique flags from all messages in the group
|
||||
all_flags = set()
|
||||
for msg in processed_messages:
|
||||
if msg.get('flags'): # Check if flags exist and are not empty
|
||||
all_flags.update(msg['flags'])
|
||||
all_flags.add("merged")
|
||||
merged_flags = list(all_flags) # Convert back to list if needed, or keep as set
|
||||
# Deterministic merged flags: first-seen order across the group, then
|
||||
# 'merged' (registry §3.8 — replaces the hash-ordered list(set(...))).
|
||||
merged_flags = list(dict.fromkeys(f for msg in processed_messages for f in msg['flags']))
|
||||
merged_flags.append("merged")
|
||||
|
||||
# generate tg-message from processed message
|
||||
tg_message = processed_message_to_tg_message(main_message)
|
||||
|
||||
|
||||
footer_html = post_parser.generate_html_footer(tg_message, flags_list=merged_flags)
|
||||
|
||||
html_parts = [
|
||||
f'<div class="message-body">{combined_html_body}</div>',
|
||||
f'<div class="message-footer">{footer_html}</div>'
|
||||
]
|
||||
# Render the merged footer DIRECTLY from the real main Message — no
|
||||
# dict->mock round-trip. The raw Message carries its real reactions
|
||||
# (custom emojis get their own span, registry §3.6) and its naive-local
|
||||
# date (registry §3.7), matching a single post of the same message.
|
||||
footer_html = post_parser.generate_html_footer(main_raw, flags_list=merged_flags)
|
||||
|
||||
rendered_posts.append({
|
||||
'html': '\n'.join(html_parts),
|
||||
'html': _wrap_post_html(combined_html_body, footer_html),
|
||||
'date': main_message['date'],
|
||||
'message_id': main_message['message_id'],
|
||||
'title': main_message['html']['title'],
|
||||
@@ -282,16 +257,13 @@ async def _render_messages_groups(messages_groups: list[list[Message]],
|
||||
# Filter posts by exclude_flags
|
||||
if exclude_flags:
|
||||
exclude_flag_list = [flag.strip() for flag in exclude_flags.split(',')] # Split comma-separated flags into list
|
||||
filtered_posts = []
|
||||
for post in rendered_posts:
|
||||
# If "all" is specified and the post has any flags, exclude the post.
|
||||
if "all" in exclude_flag_list and post['flags']:
|
||||
continue
|
||||
# Exclude post if any flag in the exclude list is present in the post's flags.
|
||||
if any(flag in post['flags'] for flag in exclude_flag_list):
|
||||
continue
|
||||
filtered_posts.append(post)
|
||||
rendered_posts = filtered_posts
|
||||
rendered_posts = [
|
||||
post for post in rendered_posts
|
||||
# Keep a post unless "all" is requested and it carries any flag, or any of
|
||||
# its flags appears in the exclude list.
|
||||
if not ("all" in exclude_flag_list and post['flags'])
|
||||
and not any(flag in post['flags'] for flag in exclude_flag_list)
|
||||
]
|
||||
|
||||
# Filter posts by exclude_text
|
||||
if exclude_text:
|
||||
@@ -306,74 +278,199 @@ async def _render_messages_groups(messages_groups: list[list[Message]],
|
||||
logger.debug(f"excluded_post: message_id {post['message_id']}, pattern {exclude_pattern.pattern}")
|
||||
rendered_posts = filtered_posts
|
||||
|
||||
# Sort by date
|
||||
rendered_posts.sort(key=lambda x: x['date'], reverse=True)
|
||||
# Sort by date; use 0.0 (epoch) as fallback for posts with None date to avoid TypeError
|
||||
rendered_posts.sort(key=lambda x: x['date'] if x['date'] is not None else 0.0, reverse=True)
|
||||
return rendered_posts
|
||||
|
||||
async def generate_channel_rss(channel: str | int,
|
||||
client: Client,
|
||||
limit: int = 20,
|
||||
exclude_flags: str | None = None,
|
||||
exclude_text: str | None = None,
|
||||
merge_seconds: int = 5,
|
||||
use_cache: bool = True,
|
||||
cache_max_age_hours: int = 2
|
||||
) -> str:
|
||||
|
||||
def _render_pipeline(messages: list[Message],
|
||||
post_parser: PostParser,
|
||||
limit: int,
|
||||
exclude_flags: str | None,
|
||||
exclude_text: str | None,
|
||||
merge_seconds: int,
|
||||
time_based_merge: bool,
|
||||
channel: str | int):
|
||||
"""
|
||||
Generate RSS feed for channel using actual messages
|
||||
Args:
|
||||
channel: Telegram channel name
|
||||
post_parser: Optional PostParser instance. If not provided, will create new one
|
||||
client: Telegram client instance
|
||||
limit: Maximum number of posts to include in the RSS feed
|
||||
exclude_flags: Flags to exclude from the RSS feed
|
||||
exclude_text: Text to exclude from posts
|
||||
use_cache: Whether to use cache
|
||||
cache_max_age_hours: Maximum age of the cache in hours
|
||||
Returns:
|
||||
RSS feed as string in XML format
|
||||
Full synchronous feed render pipeline (grouping + trimming + rendering + sanitize).
|
||||
|
||||
Runs entirely in a worker thread via a single asyncio.to_thread call. It contains
|
||||
NO await, NO asyncio primitives, NO create_task/get_running_loop — all the CPU-heavy
|
||||
work (grouping, rendering, bleach) happens here off the event loop.
|
||||
Media file-id records are accumulated on post_parser._pending_media_ids and flushed
|
||||
by the caller after this returns.
|
||||
|
||||
`channel` is used only for the sanitize log_context (grep-ability).
|
||||
"""
|
||||
# Check if we can use cached version
|
||||
if use_cache:
|
||||
cached_content = read_cache(channel, 'rss', cache_max_age_hours)
|
||||
if cached_content:
|
||||
return cached_content
|
||||
|
||||
total_start_time = time.time()
|
||||
|
||||
if time_based_merge:
|
||||
# Pure mapping msg.id -> effective_group_id (no message mutation, no deepcopy),
|
||||
# plus a date-ASC pre-sort with the same naive-safe timestamp key (None-date last
|
||||
# via +inf). A stable sorted() on the fetch-order input reproduces the old
|
||||
# `messages_sorted` order — including ties — and does NOT mutate the input, so the
|
||||
# cache-protecting deepcopy is gone (spec Этап 4).
|
||||
group_ids = _compute_time_based_group_ids(messages, merge_seconds)
|
||||
messages = sorted(messages, key=lambda m: m.date.timestamp() if m.date else float('inf'))
|
||||
message_groups = _create_messages_groups(messages, group_ids)
|
||||
else:
|
||||
message_groups = _create_messages_groups(messages)
|
||||
# Trim groups if they exceed the requested limit (slice is a no-op when shorter).
|
||||
message_groups = message_groups[:limit]
|
||||
posts = _render_messages_groups(message_groups, post_parser, exclude_flags, exclude_text)
|
||||
# Sanitize each surviving (post-filter) post exactly once, here in the worker
|
||||
# thread — no per-post thread hop / per-post CSSSanitizer anymore. Per-post
|
||||
# granularity means a failed post is html.escape()d in isolation instead of the
|
||||
# whole feed (registry §3.5), and an unbalanced fragment is normalized within its
|
||||
# OWN post rather than swallowing the following ones (registry §3.4). sanitize_html
|
||||
# is fail-closed (registry §3.2). For the HTML path the <hr> divider is joined in
|
||||
# the formatter AFTER this, so it survives sanitize (registry §3.3).
|
||||
for post in posts:
|
||||
post['html'] = sanitize_html(
|
||||
post['html'],
|
||||
log_context=f"channel {channel}, message_id {post['message_id']}",
|
||||
)
|
||||
return posts
|
||||
|
||||
|
||||
async def _prepare_feed_posts(channel: str | int,
|
||||
client: Client,
|
||||
*,
|
||||
limit: int,
|
||||
exclude_flags: str | None,
|
||||
exclude_text: str | None,
|
||||
merge_seconds: int,
|
||||
history_limit: int,
|
||||
enrich_replies: bool,
|
||||
log_prefix: str) -> PreparedFeed:
|
||||
"""Single code path feeding both feeds: validate -> resolve chat -> fetch history ->
|
||||
(optionally) enrich replies -> render/filter/sort/sanitize in one worker thread. The
|
||||
RSS/HTML formatters used to duplicate this ~80% verbatim; path differences are now
|
||||
EXPLICIT parameters (history_limit: RSS over-fetches limit*2; enrich_replies: HTML-only;
|
||||
log_prefix 'rss'|'html' keeps today's paired log-line names).
|
||||
|
||||
Raises ChannelNotFound (formatter -> create_error_feed), re-raises errors.FloodWait
|
||||
for both the get_chat and the get_history path (§3.9; api_server -> HTTP 429), and
|
||||
wraps any other resolution/history error in ValueError with unified text (§3.10).
|
||||
"""
|
||||
# 1) Validate limit.
|
||||
if limit < 1:
|
||||
raise ValueError(f"limit must be positive, got {limit}")
|
||||
if limit > 200:
|
||||
raise ValueError(f"limit cannot exceed 200, got {limit}")
|
||||
|
||||
post_parser = PostParser(client=client)
|
||||
|
||||
# 2) Resolve the chat. NOTE: keep `from tg_cache import ...` INSIDE this function —
|
||||
# feed tests monkeypatch tg_cache and rely on late name resolution.
|
||||
channel_info_start_time = time.time()
|
||||
try:
|
||||
post_parser = PostParser(client=client)
|
||||
|
||||
channel = post_parser.channel_name_prepare(channel)
|
||||
from tg_cache import cached_get_chat
|
||||
channel_info = await cached_get_chat(post_parser.client, channel)
|
||||
channel_title = channel_info.title or f"Telegram: {channel}"
|
||||
channel_username = channel_info.username or (str(channel_info.id) if channel_info.id and str(channel_info.id).startswith('-100') else None)
|
||||
if not channel_username:
|
||||
# Prepared channel (which could be int) is carried into the error feed.
|
||||
logger.warning(f"Could not get username for channel {channel}, using identifier for error feed.")
|
||||
raise ChannelNotFound(channel)
|
||||
except (errors.UsernameInvalid, errors.UsernameNotOccupied) as e:
|
||||
logger.warning(f"Channel not found error for {channel}: {str(e)}")
|
||||
raise ChannelNotFound(channel) from e
|
||||
except errors.FloodWait:
|
||||
# Let FloodWait bubble up to api_server.py, which returns HTTP 429 with Retry-After.
|
||||
raise
|
||||
except ChannelNotFound:
|
||||
# The no-username branch above — not a resolution failure to wrap in ValueError.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error during get_chat for channel '{channel}' (type: {type(channel)}): {str(e)}", exc_info=True)
|
||||
raise ValueError(f"Failed to get chat info for {channel}: {str(e)}") from e
|
||||
|
||||
channel_info_elapsed = time.time() - channel_info_start_time
|
||||
logger.debug(f"{log_prefix}_channel_info_timing: channel {channel}, retrieved in {channel_info_elapsed:.3f} seconds")
|
||||
|
||||
# 3) Fetch history.
|
||||
messages_start_time = time.time()
|
||||
try:
|
||||
from tg_cache import cached_get_chat_history
|
||||
messages = await cached_get_chat_history(post_parser.client, channel, limit=history_limit)
|
||||
except errors.FloodWait:
|
||||
# §3.9: FloodWait from history propagates -> HTTP 429 (previously wrapped -> 400).
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error during get_chat_history for channel '{channel}' (type: {type(channel)}): {str(e)}", exc_info=True)
|
||||
raise ValueError(f"Failed to get chat history for {channel}: {str(e)}") from e
|
||||
|
||||
messages_elapsed = time.time() - messages_start_time
|
||||
logger.debug(f"{log_prefix}_messages_retrieval_timing: channel {channel}, {len(messages)} messages retrieved in {messages_elapsed:.3f} seconds")
|
||||
|
||||
# 4) Optional reply enrichment (HTML-only today — deliberate, keeps RSS polling cheap).
|
||||
if enrich_replies:
|
||||
enrichment_start_time = time.time()
|
||||
messages = await _reply_enrichment(client, messages)
|
||||
enrichment_elapsed = time.time() - enrichment_start_time
|
||||
logger.debug(f"{log_prefix}_reply_enrichment_timing: channel {channel}, replies enriched in {enrichment_elapsed:.3f} seconds")
|
||||
|
||||
# 5) Process messages into groups and render them. The whole grouping/trimming/
|
||||
# rendering pipeline is CPU-heavy (per-message rendering) and contains no
|
||||
# await, so run it in ONE worker thread to keep the event loop responsive.
|
||||
processing_start_time = time.time()
|
||||
try:
|
||||
posts = await asyncio.to_thread(
|
||||
_render_pipeline, messages, post_parser, limit,
|
||||
exclude_flags, exclude_text, merge_seconds, Config['time_based_merge'],
|
||||
channel,
|
||||
)
|
||||
finally:
|
||||
# Persist media file-ids collected during rendering with a single bulk upsert —
|
||||
# in a finally so a partial render still records what it collected (the flush is
|
||||
# best-effort and swallows its own errors, so it cannot mask a render exception).
|
||||
await post_parser._flush_pending_media_ids()
|
||||
|
||||
processing_elapsed = time.time() - processing_start_time
|
||||
logger.debug(f"{log_prefix}_messages_processing_timing: channel {channel}, {len(posts)} posts processed in {processing_elapsed:.3f} seconds")
|
||||
|
||||
return PreparedFeed(channel_username=channel_username, channel_title=channel_title, posts=posts)
|
||||
|
||||
|
||||
async def generate_channel_rss(channel: str | int,
|
||||
client: Client,
|
||||
limit: int = 20,
|
||||
exclude_flags: str | None = None,
|
||||
exclude_text: str | None = None,
|
||||
merge_seconds: int = 5
|
||||
) -> str:
|
||||
"""
|
||||
Generate RSS feed for channel using actual messages.
|
||||
|
||||
Thin formatter over the shared _prepare_feed_posts: it only turns the prepared,
|
||||
sanitized posts into a feedgen RSS document. RSS over-fetches (history_limit=limit*2)
|
||||
and skips reply enrichment (enrich_replies=False).
|
||||
Args:
|
||||
channel: Telegram channel name
|
||||
client: Telegram client instance
|
||||
limit: Maximum number of posts to include in the RSS feed
|
||||
exclude_flags: Flags to exclude from the RSS feed
|
||||
exclude_text: Text to exclude from posts
|
||||
Returns:
|
||||
RSS feed as string in XML format
|
||||
"""
|
||||
total_start_time = time.time()
|
||||
base_url = Config['pyrogram_bridge_url']
|
||||
|
||||
try:
|
||||
prepared = await _prepare_feed_posts(
|
||||
channel, client,
|
||||
limit=limit, exclude_flags=exclude_flags, exclude_text=exclude_text,
|
||||
merge_seconds=merge_seconds, history_limit=limit * 2,
|
||||
enrich_replies=False, log_prefix="rss",
|
||||
)
|
||||
|
||||
channel_username = prepared.channel_username
|
||||
channel_title = prepared.channel_title
|
||||
final_posts = prepared.posts
|
||||
|
||||
fg = FeedGenerator()
|
||||
fg.load_extension('dc')
|
||||
base_url = Config['pyrogram_bridge_url']
|
||||
|
||||
channel_info_start_time = time.time()
|
||||
try:
|
||||
channel = post_parser.channel_name_prepare(channel)
|
||||
channel_info = await post_parser.client.get_chat(channel)
|
||||
channel_title = channel_info.title or f"Telegram: {channel}"
|
||||
channel_username = post_parser.get_channel_username(channel_info)
|
||||
if not channel_username:
|
||||
# Use prepared channel (which could be int) for error feed if username fails
|
||||
logger.warning(f"Could not get username for channel {channel}, using identifier for error feed.")
|
||||
return create_error_feed(str(channel), base_url) # Ensure channel is string for error feed
|
||||
except (errors.UsernameInvalid, errors.UsernameNotOccupied) as e:
|
||||
logger.warning(f"Channel not found error for {channel}: {str(e)}")
|
||||
return create_error_feed(channel, base_url)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during get_chat for channel '{channel}' (type: {type(channel)}): {str(e)}", exc_info=True) # Log error specifically for get_chat
|
||||
# Re-raise the original exception to be caught by the outer handler if needed,
|
||||
# but add specific logging here.
|
||||
raise ValueError(f"Failed to get chat info for {channel}: {str(e)}") from e # Raise a more specific error perhaps
|
||||
finally:
|
||||
channel_info_elapsed = time.time() - channel_info_start_time
|
||||
logger.info(f"rss_channel_info_timing: channel {channel}, retrieved in {channel_info_elapsed:.3f} seconds")
|
||||
|
||||
# Set feed metadata
|
||||
main_name = f"{channel_title} (@{channel_username})"
|
||||
@@ -382,34 +479,18 @@ async def generate_channel_rss(channel: str | int,
|
||||
fg.description(f'Telegram channel {channel_username} RSS feed')
|
||||
fg.language('ru')
|
||||
fg.id(f"{base_url}/rss/{channel_username}") # Use username for feed ID consistency
|
||||
|
||||
# Collect messages
|
||||
messages_start_time = time.time()
|
||||
messages = []
|
||||
try:
|
||||
async with POSTS_REQUEST_SEMAPHORE:
|
||||
async for message in post_parser.client.get_chat_history(channel, limit=limit*2):
|
||||
messages.append(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during get_chat_history for channel '{channel}' (type: {type(channel)}): {str(e)}", exc_info=True) # Log error specifically for get_chat_history
|
||||
raise ValueError(f"Failed to get chat history for {channel}: {str(e)}") from e # Raise a more specific error
|
||||
|
||||
messages_elapsed = time.time() - messages_start_time
|
||||
logger.info(f"rss_messages_retrieval_timing: channel {channel}, {len(messages)} messages retrieved in {messages_elapsed:.3f} seconds")
|
||||
|
||||
# Process messages into groups and render them
|
||||
processing_start_time = time.time()
|
||||
if Config['time_based_merge']:
|
||||
messages = await _create_time_based_media_groups(messages, merge_seconds)
|
||||
message_groups = await _create_messages_groups(messages)
|
||||
message_groups = await _trim_messages_groups(message_groups, limit)
|
||||
final_posts = await _render_messages_groups(message_groups, post_parser, exclude_flags, exclude_text)
|
||||
|
||||
processing_elapsed = time.time() - processing_start_time
|
||||
logger.info(f"rss_messages_processing_timing: channel {channel}, {len(final_posts)} posts processed in {processing_elapsed:.3f} seconds")
|
||||
|
||||
|
||||
# Generate feed entries
|
||||
feed_gen_start_time = time.time()
|
||||
|
||||
# Log date range of posts being added to RSS
|
||||
if final_posts:
|
||||
dates = [datetime.fromtimestamp(post['date'], tz=timezone.utc) for post in final_posts if post.get('date')]
|
||||
if dates:
|
||||
oldest_date = min(dates)
|
||||
newest_date = max(dates)
|
||||
logger.info(f"rss_date_range: channel {channel}, oldest_post {oldest_date.isoformat()}, newest_post {newest_date.isoformat()}, total_posts {len(final_posts)}")
|
||||
|
||||
for post in final_posts:
|
||||
fe = fg.add_entry()
|
||||
fe.title(post['title'])
|
||||
@@ -418,47 +499,89 @@ async def generate_channel_rss(channel: str | int,
|
||||
fe.link(href=post_link)
|
||||
|
||||
fe.description(post['text'].replace('\n', ' '))
|
||||
# post['html'] is already sanitized per-post inside _render_pipeline
|
||||
# (single project-wide bleach config, fail-closed). No per-post thread
|
||||
# hop / CSSSanitizer here anymore.
|
||||
fe.content(content=post['html'], type='CDATA')
|
||||
|
||||
pub_date = datetime.fromtimestamp(post['date'], tz=timezone.utc)
|
||||
fe.pubDate(pub_date)
|
||||
if post['date'] is not None:
|
||||
pub_date = datetime.fromtimestamp(post['date'], tz=timezone.utc)
|
||||
logger.debug(f"rss_entry_date: channel {channel}, message_id {post['message_id']}, timestamp {post['date']}, pub_date {pub_date.isoformat()}")
|
||||
fe.pubDate(pub_date)
|
||||
else:
|
||||
# Date is None (e.g. service or deleted message) — fall back to current time
|
||||
pub_date = datetime.now(tz=timezone.utc)
|
||||
logger.warning(f"rss_entry_missing_date: channel {channel}, message_id {post['message_id']}, using current time as fallback")
|
||||
fe.pubDate(pub_date)
|
||||
fe.guid(post_link, permalink=True)
|
||||
|
||||
if post['author'] and post['author'] != main_name:
|
||||
fe.author(name="", email=post['author'])
|
||||
|
||||
feed_gen_elapsed = time.time() - feed_gen_start_time
|
||||
logger.info(f"rss_feed_generation_timing: channel {channel}, feed generated in {feed_gen_elapsed:.3f} seconds")
|
||||
logger.debug(f"rss_feed_generation_timing: channel {channel}, feed generated in {feed_gen_elapsed:.3f} seconds")
|
||||
|
||||
rss_feed = fg.rss_str(pretty=True)
|
||||
# Serialize RSS in thread (feedgen may be CPU-heavy)
|
||||
rss_feed = await asyncio.to_thread(fg.rss_str, pretty=True)
|
||||
if isinstance(rss_feed, bytes):
|
||||
rss_feed = rss_feed.decode('utf-8')
|
||||
|
||||
# Save to cache
|
||||
if use_cache:
|
||||
write_cache(channel, rss_feed, 'rss')
|
||||
return rss_feed.decode('utf-8')
|
||||
|
||||
total_elapsed = time.time() - total_start_time
|
||||
logger.info(f"rss_total_generation_timing: channel {channel}, total time {total_elapsed:.3f} seconds")
|
||||
logger.debug(f"rss_total_generation_timing: channel {channel}, total time {total_elapsed:.3f} seconds")
|
||||
return rss_feed
|
||||
|
||||
|
||||
except ChannelNotFound as e:
|
||||
return create_error_feed(str(e.channel_identifier), base_url)
|
||||
except Exception as e:
|
||||
logger.error(f"generate_channel_rss: channel {channel}, error {str(e)}")
|
||||
raise
|
||||
|
||||
async def _reply_enrichment(client: Client, messages: list[Message]) -> list[Message]: #TODO: почему это тут?
|
||||
async def _reply_enrichment(client: Client, messages: list[Message]) -> list[Message]:
|
||||
"""
|
||||
Enrich messages with reply to messages
|
||||
Enrich messages with reply-to messages.
|
||||
|
||||
Instead of one API call per message, replies are batched: all message IDs
|
||||
that need enrichment are grouped by chat_id and fetched in a single
|
||||
client.get_messages() call per chat_id.
|
||||
"""
|
||||
# Collect messages that need reply enrichment, grouped by chat_id
|
||||
chat_messages: dict[int, list[Message]] = {}
|
||||
for message in messages:
|
||||
if message.reply_to_message_id and message.chat:
|
||||
full_message = await client.get_messages(message.chat.id, message.id)
|
||||
if isinstance(full_message, list):
|
||||
if full_message and full_message[0].reply_to_message:
|
||||
message.reply_to_message = full_message[0].reply_to_message
|
||||
else:
|
||||
if full_message and full_message.reply_to_message:
|
||||
message.reply_to_message = full_message.reply_to_message
|
||||
chat_id = message.chat.id
|
||||
if chat_id not in chat_messages:
|
||||
chat_messages[chat_id] = []
|
||||
chat_messages[chat_id].append(message)
|
||||
|
||||
if not chat_messages:
|
||||
# No messages with replies — return unchanged
|
||||
return messages
|
||||
|
||||
# Build a lookup: {(chat_id, message_id): full_message} using one batch call per chat
|
||||
reply_lookup: dict[tuple[int, int], Message] = {}
|
||||
for chat_id, chat_msgs in chat_messages.items():
|
||||
ids_to_fetch = [m.id for m in chat_msgs]
|
||||
try:
|
||||
# Throttle under the global RPC gate and bound the call via the shared
|
||||
# tg_rpc_bounded so a hung get_messages cannot pin the gate.
|
||||
async with tg_rpc_bounded(Config["tg_rpc_timeout"]):
|
||||
fetched = await client.get_messages(chat_id, ids_to_fetch)
|
||||
# get_messages may return a single Message or a list
|
||||
if not isinstance(fetched, list):
|
||||
fetched = [fetched]
|
||||
for fm in fetched:
|
||||
if fm and not getattr(fm, 'empty', False):
|
||||
reply_lookup[(chat_id, fm.id)] = fm
|
||||
except Exception as e:
|
||||
logger.error(f"reply_enrichment_batch_error: chat_id {chat_id}, ids {ids_to_fetch}, error {str(e)}")
|
||||
|
||||
# Apply reply_to_message from lookup
|
||||
for message in messages:
|
||||
if message.reply_to_message_id and message.chat:
|
||||
key = (message.chat.id, message.id)
|
||||
full_message = reply_lookup.get(key)
|
||||
if full_message and full_message.reply_to_message:
|
||||
message.reply_to_message = full_message.reply_to_message
|
||||
|
||||
return messages
|
||||
|
||||
@@ -467,104 +590,54 @@ async def generate_channel_html(channel: str | int,
|
||||
limit: int = 20,
|
||||
exclude_flags: str | None = None,
|
||||
exclude_text: str | None = None,
|
||||
merge_seconds: int = 5,
|
||||
use_cache: bool = True,
|
||||
cache_max_age_hours: int = 2
|
||||
merge_seconds: int = 5
|
||||
) -> str:
|
||||
"""
|
||||
Generate HTML feed for channel using actual messages
|
||||
Generate HTML feed for channel using actual messages.
|
||||
|
||||
Thin formatter over the shared _prepare_feed_posts: it only joins the prepared,
|
||||
sanitized posts with the <hr> divider. HTML fetches exactly `limit` messages and
|
||||
enables reply enrichment (enrich_replies=True).
|
||||
Args:
|
||||
channel: Telegram channel name
|
||||
post_parser: Optional PostParser instance. If not provided, will create new one
|
||||
client: Telegram client instance
|
||||
limit: Maximum number of posts to include in the RSS feed
|
||||
exclude_flags: Flags to exclude from the RSS feed
|
||||
limit: Maximum number of posts to include in the HTML feed
|
||||
exclude_flags: Flags to exclude from the HTML feed
|
||||
exclude_text: Text to exclude from posts
|
||||
use_cache: Whether to use cache
|
||||
cache_max_age_hours: Maximum age of the cache in hours
|
||||
Returns:
|
||||
HTML feed as string
|
||||
"""
|
||||
|
||||
total_start_time = time.time()
|
||||
|
||||
if limit < 1:
|
||||
raise ValueError(f"limit must be positive, got {limit}")
|
||||
if limit > 200:
|
||||
raise ValueError(f"limit cannot exceed 200, got {limit}")
|
||||
base_url = Config['pyrogram_bridge_url']
|
||||
|
||||
try:
|
||||
post_parser = PostParser(client=client)
|
||||
|
||||
base_url = Config['pyrogram_bridge_url']
|
||||
|
||||
channel_info_start_time = time.time()
|
||||
try:
|
||||
channel = post_parser.channel_name_prepare(channel)
|
||||
logger.debug(f"Prepared channel identifier for HTML: {channel} (type: {type(channel)})") # Log prepared channel
|
||||
channel_info = await post_parser.client.get_chat(channel)
|
||||
channel_username = post_parser.get_channel_username(channel_info)
|
||||
if not channel_username:
|
||||
logger.warning(f"Could not get username for channel {channel} in HTML generation, returning error feed structure (as string). NOTE: This should ideally return HTML error page.")
|
||||
# For HTML, returning an error feed string might not be ideal. Consider returning a dedicated HTML error page.
|
||||
return create_error_feed(str(channel), base_url) # Ensure channel is string for error feed
|
||||
except (errors.UsernameInvalid, errors.UsernameNotOccupied) as e:
|
||||
logger.warning(f"Channel not found error for {channel} in HTML generation: {str(e)}")
|
||||
# Consider returning a dedicated HTML error page.
|
||||
return create_error_feed(channel, base_url)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during get_chat for channel '{channel}' (type: {type(channel)}) in HTML generation: {str(e)}", exc_info=True)
|
||||
raise ValueError(f"Failed to get chat info for {channel} in HTML generation: {str(e)}") from e
|
||||
prepared = await _prepare_feed_posts(
|
||||
channel, client,
|
||||
limit=limit, exclude_flags=exclude_flags, exclude_text=exclude_text,
|
||||
merge_seconds=merge_seconds, history_limit=limit,
|
||||
enrich_replies=True, log_prefix="html",
|
||||
)
|
||||
|
||||
channel_info_elapsed = time.time() - channel_info_start_time
|
||||
logger.info(f"html_channel_info_timing: channel {channel}, retrieved in {channel_info_elapsed:.3f} seconds")
|
||||
final_posts = prepared.posts
|
||||
|
||||
# Collect messages
|
||||
messages_start_time = time.time()
|
||||
messages = []
|
||||
try:
|
||||
async for message in post_parser.client.get_chat_history(channel, limit=limit):
|
||||
messages.append(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during get_chat_history for channel '{channel}' (type: {type(channel)}) in HTML generation: {str(e)}", exc_info=True)
|
||||
raise ValueError(f"Failed to get chat history for {channel} in HTML generation: {str(e)}") from e
|
||||
|
||||
messages_elapsed = time.time() - messages_start_time
|
||||
logger.info(f"html_messages_retrieval_timing: channel {channel}, {len(messages)} messages retrieved in {messages_elapsed:.3f} seconds")
|
||||
|
||||
# Enrich messages with reply to messages
|
||||
enrichment_start_time = time.time()
|
||||
messages = await _reply_enrichment(client, messages)
|
||||
enrichment_elapsed = time.time() - enrichment_start_time
|
||||
logger.info(f"html_reply_enrichment_timing: channel {channel}, replies enriched in {enrichment_elapsed:.3f} seconds")
|
||||
|
||||
# Process messages into groups and render them
|
||||
processing_start_time = time.time()
|
||||
if Config['time_based_merge']:
|
||||
messages = await _create_time_based_media_groups(messages, merge_seconds)
|
||||
|
||||
# Process messages into groups and render them
|
||||
message_groups = await _create_messages_groups(messages)
|
||||
message_groups = await _trim_messages_groups(message_groups, limit)
|
||||
final_posts = await _render_messages_groups(message_groups, post_parser, exclude_flags, exclude_text)
|
||||
|
||||
processing_elapsed = time.time() - processing_start_time
|
||||
logger.info(f"html_messages_processing_timing: channel {channel}, {len(final_posts)} posts processed in {processing_elapsed:.3f} seconds")
|
||||
|
||||
# Generate HTML content
|
||||
# Generate HTML content.
|
||||
html_gen_start_time = time.time()
|
||||
html_posts = [post['html'] for post in final_posts]
|
||||
html_content = '\n<hr class="post-divider">\n'.join(html_posts)
|
||||
|
||||
# Each post is already sanitized per-post inside _render_pipeline. Join with the
|
||||
# <hr> divider AFTER sanitize so the divider survives (registry §3.3), and each
|
||||
# post's DOM was normalized within its own fragment (registry §3.4). The join is
|
||||
# a trivial string op — no worker thread needed.
|
||||
html = '\n<hr class="post-divider">\n'.join(post['html'] for post in final_posts)
|
||||
|
||||
html_gen_elapsed = time.time() - html_gen_start_time
|
||||
logger.info(f"html_generation_timing: channel {channel}, HTML generated in {html_gen_elapsed:.3f} seconds")
|
||||
|
||||
logger.debug(f"html_generation_timing: channel {channel}, HTML generated in {html_gen_elapsed:.3f} seconds")
|
||||
|
||||
total_elapsed = time.time() - total_start_time
|
||||
logger.info(f"html_total_generation_timing: channel {channel}, total time {total_elapsed:.3f} seconds")
|
||||
|
||||
return html_content
|
||||
logger.debug(f"html_total_generation_timing: channel {channel}, total time {total_elapsed:.3f} seconds")
|
||||
|
||||
return html
|
||||
|
||||
except ChannelNotFound as e:
|
||||
return create_error_feed(str(e.channel_identifier), base_url)
|
||||
except Exception as e:
|
||||
logger.error(f"html_generation_error: channel {channel}, error {str(e)}")
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# flake8: noqa
|
||||
# pylint: disable=broad-exception-caught, logging-fstring-interpolation, line-too-long
|
||||
|
||||
"""The ONLY bleach configuration in the project.
|
||||
|
||||
Before this module the sanitize config was copy-pasted three times (the single-post
|
||||
path in post_parser plus the RSS and HTML feed paths in rss_generator) and had
|
||||
drifted: `s`/`del` survived only in the single-post copy, and the three error-log
|
||||
names diverged. Here the config lives exactly once; every render path routes
|
||||
through sanitize_html().
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time as _time
|
||||
from html import escape as _html_escape
|
||||
|
||||
# Imported under this name so tests can monkeypatch `sanitizer.HTMLSanitizer`
|
||||
# (API relocation from rss_generator — no behaviour change).
|
||||
from bleach import clean as HTMLSanitizer
|
||||
from bleach.css_sanitizer import CSSSanitizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Union of the three former copies. `s`/`del` (strikethrough) were previously
|
||||
# allowed only in the single-post path; registry §3.1 makes them survive in feeds too.
|
||||
ALLOWED_TAGS = ['p', 'a', 'b', 'i', 'strong', 'em', 's', 'del',
|
||||
'ul', 'ol', 'li', 'br', 'div', 'span',
|
||||
'img', 'video', 'audio', 'source']
|
||||
|
||||
# Identical in all three former copies — moved here as-is.
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
'a': ['href', 'title', 'target'],
|
||||
'img': ['src', 'alt', 'style'],
|
||||
'video': ['controls', 'src', 'style'],
|
||||
'audio': ['controls', 'style'],
|
||||
'source': ['src', 'type'],
|
||||
'div': ['class', 'style'],
|
||||
'span': ['class'],
|
||||
}
|
||||
|
||||
ALLOWED_CSS_PROPERTIES = ["max-width", "max-height", "object-fit", "width", "height"]
|
||||
|
||||
# Non-default! bleach's default protocol list would strip tg:// links (channel
|
||||
# footers / service links use them). Load-bearing.
|
||||
ALLOWED_PROTOCOLS = ['http', 'https', 'tg']
|
||||
|
||||
# Shared instance is safe: CSSSanitizer only holds the allowed-properties list
|
||||
# (stateless config); bleach builds a fresh Cleaner per clean() call anyway.
|
||||
_CSS_SANITIZER = CSSSanitizer(allowed_css_properties=ALLOWED_CSS_PROPERTIES)
|
||||
|
||||
|
||||
def sanitize_html(html_raw: str, log_context: str = "") -> str:
|
||||
"""Sanitize one HTML fragment.
|
||||
|
||||
FAIL-CLOSED: on any bleach error the fragment is html.escape()d, never returned
|
||||
raw (stored-XSS guard — registry §3.2). log_context (e.g. "channel X, message_id
|
||||
Y") is included in the error/slow logs to keep operational grep-ability across
|
||||
call sites.
|
||||
"""
|
||||
sanitize_start = _time.monotonic()
|
||||
try:
|
||||
# Both non-default params are load-bearing:
|
||||
# protocols=ALLOWED_PROTOCOLS keeps tg:// links alive;
|
||||
# strip=True REMOVES disallowed tags (bleach's default False would escape
|
||||
# them into visible text). strip_comments stays default (True), matching
|
||||
# all former call sites.
|
||||
sanitized_html = HTMLSanitizer(
|
||||
html_raw,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
css_sanitizer=_CSS_SANITIZER,
|
||||
strip=True,
|
||||
)
|
||||
except Exception as e:
|
||||
# Single error-log name across all call sites (registry §3.16); log_context
|
||||
# distinguishes them. Fail-closed: escape rather than emit the raw payload.
|
||||
_ctx = f"{log_context}, " if log_context else ""
|
||||
logger.error(f"html_sanitization_error: {_ctx}error {str(e)}")
|
||||
return _html_escape(html_raw)
|
||||
elapsed = _time.monotonic() - sanitize_start
|
||||
if elapsed > 0.05:
|
||||
_ctx = f", {log_context}" if log_context else ""
|
||||
logger.warning(f"diag_sanitize_slow: bleach.clean() took {elapsed:.3f}s, input_len={len(html_raw)}{_ctx}")
|
||||
return sanitized_html
|
||||
+318
-2
@@ -14,6 +14,7 @@ import asyncio
|
||||
import sys
|
||||
import signal
|
||||
import uvloop
|
||||
import time
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
|
||||
from pyrogram import Client
|
||||
@@ -31,7 +32,35 @@ class TelegramClient:
|
||||
api_id=settings["tg_api_id"],
|
||||
api_hash=settings["tg_api_hash"],
|
||||
workdir=settings["session_path"],
|
||||
proxy=settings["proxy"], # MTProto proxy config, None if not set
|
||||
max_concurrent_transmissions=settings["tg_max_concurrent_transmissions"],
|
||||
)
|
||||
self.max_disconnects = settings["tg_disconnect_flap_limit"] # Max disconnects within the flap window before restart
|
||||
self._shutting_down = False # Guard to prevent re-triggering restart during shutdown
|
||||
self._restarting = False # Guard: an intentional in-process restart is in progress
|
||||
# Consecutive media-download timeouts. A zombie media-DC connection makes every
|
||||
# download time out while the main-DC watchdog stays green; when this streak
|
||||
# reaches the threshold we reuse _restart_client() to rebuild the connection. Any
|
||||
# successful download resets it (see note_download_ok / note_download_timeout).
|
||||
self._download_timeout_streak = 0
|
||||
self.media_timeout_restart_threshold = settings["media_timeout_restart_threshold"]
|
||||
self._media_recovery_task = None # strong ref to a scheduled recovery restart task
|
||||
self._disconnect_times = [] # Monotonic timestamps of recent disconnects (sliding window)
|
||||
self.disconnect_window = settings["tg_disconnect_flap_window"] # Seconds; window for flap detection
|
||||
self._watchdog_task = None
|
||||
self.watchdog_enabled = settings["tg_watchdog_enabled"]
|
||||
self.watchdog_interval = settings["tg_watchdog_interval"]
|
||||
self.watchdog_timeout = settings["tg_watchdog_timeout"]
|
||||
self.watchdog_failures = settings["tg_watchdog_failures"]
|
||||
self.watchdog_restart_timeout = settings["tg_watchdog_restart_timeout"]
|
||||
self.watchdog_heartbeat_every = settings["tg_watchdog_heartbeat_every"]
|
||||
# Watchdog diagnostics counters (cumulative for the process lifetime)
|
||||
self._wd_probe_count = 0 # successful liveness probes
|
||||
self._wd_probe_fail_count = 0 # failed liveness probes
|
||||
self._wd_restart_count = 0 # successful in-process restarts
|
||||
self._wd_fallback_count = 0 # SIGTERM fallbacks after a failed in-process restart
|
||||
self._wd_flap_trigger_count = 0 # times the disconnect-flap threshold was reached
|
||||
self._wd_last_ok_monotonic = None # monotonic timestamp of the last successful probe
|
||||
self._setup_connection_handlers()
|
||||
|
||||
def _ensure_session_directory(self):
|
||||
@@ -44,15 +73,302 @@ class TelegramClient:
|
||||
|
||||
def _setup_connection_handlers(self):
|
||||
"""Sets up connection/disconnection handlers"""
|
||||
self.client.add_handler(DisconnectHandler(self._on_disconnect))
|
||||
logger.info("connection_handlers: connection handlers set up")
|
||||
|
||||
async def _on_disconnect(self, _client, _session=None):
|
||||
"""Handles disconnection events from Telegram servers (best-effort safety net).
|
||||
|
||||
NOTE: this handler only fires when Pyrogram calls session.stop(). It does NOT fire in
|
||||
the 'zombie session' case where the recv loop dies silently — that case is handled by
|
||||
the active watchdog (_watchdog_loop). Keep both paths.
|
||||
"""
|
||||
# Ignore disconnects caused by intentional shutdown or by our own in-process restart
|
||||
# (client.restart() -> client.stop() -> session.stop() re-dispatches this handler).
|
||||
if self._shutting_down or self._restarting:
|
||||
logger.debug("connection_handler: ignoring disconnect event (shutdown/restart in progress)")
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
# Sliding window: drop disconnects older than the window, then record this one.
|
||||
self._disconnect_times = [t for t in self._disconnect_times if now - t <= self.disconnect_window]
|
||||
self._disconnect_times.append(now)
|
||||
count = len(self._disconnect_times)
|
||||
logger.warning(f"connection_handler: connection lost ({count} within {self.disconnect_window}s window)")
|
||||
|
||||
if count >= self.max_disconnects:
|
||||
self._wd_flap_trigger_count += 1
|
||||
logger.critical(
|
||||
f"connection_handler: disconnect flap limit reached "
|
||||
f"({count} disconnects within {self.disconnect_window}s window, limit={self.max_disconnects}), restarting client"
|
||||
)
|
||||
await self._restart_client(reason=f"disconnect flap ({count} in {self.disconnect_window}s)")
|
||||
|
||||
def _start_watchdog(self):
|
||||
"""Starts the active liveness watchdog task (idempotent)."""
|
||||
if not self.watchdog_enabled:
|
||||
logger.info("watchdog: disabled via configuration")
|
||||
return
|
||||
if self._watchdog_task is not None and not self._watchdog_task.done():
|
||||
return
|
||||
self._watchdog_task = asyncio.create_task(self._watchdog_loop())
|
||||
|
||||
async def _watchdog_loop(self):
|
||||
"""Active liveness probe for the 'zombie session' state.
|
||||
|
||||
The disconnect-only recovery never triggers when Pyrogram's recv loop dies silently
|
||||
(is_connected stays True, no Disconnect event). This loop periodically issues a real
|
||||
lightweight API call (get_me) bounded by a short timeout; after N consecutive failures
|
||||
it forces an in-process restart. Emits diagnostics so the behaviour can be reconstructed
|
||||
from logs afterwards.
|
||||
"""
|
||||
consecutive_failures = 0
|
||||
logger.info(
|
||||
f"watchdog: started (interval={self.watchdog_interval}s, timeout={self.watchdog_timeout}s, "
|
||||
f"failures={self.watchdog_failures}, heartbeat_every={self.watchdog_heartbeat_every} probes)"
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(self.watchdog_interval)
|
||||
if self._shutting_down:
|
||||
break
|
||||
if self._restarting:
|
||||
# A restart is already underway; skip this probe cycle.
|
||||
logger.debug("watchdog: skip probe (restart in progress)")
|
||||
continue
|
||||
probe_started = time.monotonic()
|
||||
try:
|
||||
me = await asyncio.wait_for(self.client.get_me(), timeout=self.watchdog_timeout)
|
||||
latency_ms = (time.monotonic() - probe_started) * 1000
|
||||
self._wd_probe_count += 1
|
||||
self._wd_last_ok_monotonic = time.monotonic()
|
||||
if consecutive_failures:
|
||||
logger.warning(
|
||||
f"watchdog: liveness RESTORED after {consecutive_failures} failed probe(s) "
|
||||
f"(latency={latency_ms:.0f}ms, me_id={getattr(me, 'id', None)})"
|
||||
)
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
logger.debug(f"watchdog: probe ok (latency={latency_ms:.0f}ms, probe #{self._wd_probe_count})")
|
||||
# Periodic proof-of-life heartbeat at INFO, so the ABSENCE of heartbeats in the
|
||||
# logs is itself a signal that the watchdog died.
|
||||
if self.watchdog_heartbeat_every > 0 and self._wd_probe_count % self.watchdog_heartbeat_every == 0:
|
||||
logger.info(
|
||||
f"watchdog: heartbeat — probes={self._wd_probe_count}, "
|
||||
f"probe_failures={self._wd_probe_fail_count}, restarts={self._wd_restart_count}, "
|
||||
f"sigterm_fallbacks={self._wd_fallback_count}, flap_triggers={self._wd_flap_trigger_count}, "
|
||||
f"last_probe_latency={latency_ms:.0f}ms, is_connected={self.client.is_connected}"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
latency_ms = (time.monotonic() - probe_started) * 1000
|
||||
consecutive_failures += 1
|
||||
self._wd_probe_fail_count += 1
|
||||
if self._wd_last_ok_monotonic is not None:
|
||||
last_ok = f"{time.monotonic() - self._wd_last_ok_monotonic:.0f}s ago"
|
||||
else:
|
||||
last_ok = "never"
|
||||
logger.warning(
|
||||
f"watchdog: liveness probe FAILED ({consecutive_failures}/{self.watchdog_failures}) "
|
||||
f"after {latency_ms:.0f}ms: {type(e).__name__}: {e} "
|
||||
f"(is_connected={self.client.is_connected}, last_ok={last_ok}, "
|
||||
f"total_failures={self._wd_probe_fail_count})"
|
||||
)
|
||||
if consecutive_failures >= self.watchdog_failures:
|
||||
consecutive_failures = 0
|
||||
await self._restart_client(reason=f"watchdog: {self.watchdog_failures} consecutive failed probes")
|
||||
except asyncio.CancelledError:
|
||||
logger.info(
|
||||
f"watchdog: stopped (probes={self._wd_probe_count}, probe_failures={self._wd_probe_fail_count}, "
|
||||
f"restarts={self._wd_restart_count}, sigterm_fallbacks={self._wd_fallback_count}, "
|
||||
f"flap_triggers={self._wd_flap_trigger_count})"
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.critical(f"watchdog: loop crashed unexpectedly ({type(e).__name__}: {e}); liveness protection is now DISABLED until next start")
|
||||
|
||||
async def _restart_client(self, reason: str = "unspecified"):
|
||||
"""Recover the client without killing the process when possible.
|
||||
|
||||
Performs an in-process restart (rebuilds session + recv loop), bounded by a timeout so a
|
||||
dead network layer cannot make it hang forever. Falls back to a full process restart
|
||||
(SIGTERM) if the in-process restart fails or times out.
|
||||
"""
|
||||
if self._restarting or self._shutting_down:
|
||||
logger.debug(f"recovery: restart requested (reason='{reason}') but already restarting/shutting down — skipped")
|
||||
return
|
||||
self._restarting = True
|
||||
restart_started = time.monotonic()
|
||||
was_connected = self.client.is_connected
|
||||
logger.critical(
|
||||
f"recovery: TRIGGERED — reason='{reason}', is_connected={was_connected}, "
|
||||
f"in-process restarts so far={self._wd_restart_count}, sigterm fallbacks so far={self._wd_fallback_count}"
|
||||
)
|
||||
try:
|
||||
if was_connected:
|
||||
logger.warning("recovery: calling client.restart() (in-process teardown + reconnect)")
|
||||
await asyncio.wait_for(self.client.restart(), timeout=self.watchdog_restart_timeout)
|
||||
else:
|
||||
logger.warning("recovery: client not connected, calling client.start()")
|
||||
await asyncio.wait_for(self.client.start(), timeout=self.watchdog_restart_timeout)
|
||||
duration = time.monotonic() - restart_started
|
||||
self._wd_restart_count += 1
|
||||
# Verification probe to prove the network layer is actually back (diagnostic only).
|
||||
try:
|
||||
me = await asyncio.wait_for(self.client.get_me(), timeout=self.watchdog_timeout)
|
||||
self._wd_last_ok_monotonic = time.monotonic()
|
||||
verify = f", verify_get_me ok (me_id={getattr(me, 'id', None)})"
|
||||
except Exception as ve:
|
||||
verify = f", verify_get_me FAILED ({type(ve).__name__}: {ve})"
|
||||
logger.warning(
|
||||
f"recovery: in-process restart SUCCEEDED in {duration:.1f}s "
|
||||
f"(is_connected={self.client.is_connected}{verify}, total in-process restarts={self._wd_restart_count})"
|
||||
)
|
||||
self._disconnect_times.clear()
|
||||
# Re-arm the watchdog in case it had previously crashed (self-healing).
|
||||
self._start_watchdog()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
duration = time.monotonic() - restart_started
|
||||
self._wd_fallback_count += 1
|
||||
logger.error(
|
||||
f"recovery: in-process restart FAILED after {duration:.1f}s ({type(e).__name__}: {e}); "
|
||||
f"falling back to process restart (SIGTERM), total fallbacks={self._wd_fallback_count}"
|
||||
)
|
||||
self._restart_app()
|
||||
finally:
|
||||
self._restarting = False
|
||||
|
||||
async def start(self):
|
||||
try:
|
||||
if not self.client.is_connected:
|
||||
await self.client.start()
|
||||
logger.info("Telegram client connected successfully")
|
||||
# Reset flap history on a fresh successful connection
|
||||
self._disconnect_times.clear()
|
||||
logger.info("connection_handler: connection established")
|
||||
self._start_watchdog()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start Telegram client: {str(e)}")
|
||||
# Force exit on any connection error
|
||||
sys.exit(1)
|
||||
raise
|
||||
|
||||
def _restart_app(self):
|
||||
"""Restarts the application by sending SIGTERM to the process"""
|
||||
self._shutting_down = True # Set flag before sending signal to suppress subsequent disconnect events
|
||||
logger.warning("connection_handler: restarting application by sending SIGTERM")
|
||||
try:
|
||||
# Use SIGTERM for proper Docker container restart
|
||||
logger.critical("connection_handler: sending SIGTERM signal")
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
except Exception as e:
|
||||
logger.error(f"connection_handler: error during restart: {str(e)}")
|
||||
# Emergency termination
|
||||
os._exit(1)
|
||||
|
||||
def watchdog_last_ok_age(self) -> float | None:
|
||||
"""Seconds since the last successful watchdog probe, or None if none succeeded yet.
|
||||
|
||||
Reads only the already-recorded monotonic timestamp set by the watchdog loop; it
|
||||
never issues a Telegram RPC, so it is safe to call from the hot /ping path even
|
||||
while a real RPC is hung.
|
||||
"""
|
||||
if self._wd_last_ok_monotonic is None:
|
||||
return None
|
||||
return time.monotonic() - self._wd_last_ok_monotonic
|
||||
|
||||
def note_download_ok(self) -> None:
|
||||
"""Reset the media-download timeout streak after any successful download."""
|
||||
if self._download_timeout_streak:
|
||||
logger.info(f"media_download: recovered, resetting timeout streak (was {self._download_timeout_streak})")
|
||||
self._download_timeout_streak = 0
|
||||
|
||||
def note_download_timeout(self) -> None:
|
||||
"""Count a media-download timeout; force a connection-rebuilding restart on a streak.
|
||||
|
||||
A zombie media-DC connection makes EVERY download time out while the main-DC
|
||||
watchdog probe (get_me) stays green, so this is the only signal that can trigger
|
||||
recovery. The restart runs as a detached task so it never blocks the download path;
|
||||
the streak is reset immediately so we schedule at most one restart per streak.
|
||||
"""
|
||||
self._download_timeout_streak += 1
|
||||
logger.warning(
|
||||
f"media_download_timeout: streak {self._download_timeout_streak}/{self.media_timeout_restart_threshold}"
|
||||
)
|
||||
if self._download_timeout_streak < self.media_timeout_restart_threshold:
|
||||
return
|
||||
self._download_timeout_streak = 0
|
||||
if self._restarting or self._shutting_down:
|
||||
return
|
||||
if self._media_recovery_task is not None and not self._media_recovery_task.done():
|
||||
return # a recovery restart is already scheduled/running
|
||||
logger.critical(
|
||||
f"media_download: {self.media_timeout_restart_threshold} consecutive download timeouts — "
|
||||
f"scheduling media-connection restart (main-DC watchdog cannot see this)"
|
||||
)
|
||||
self._media_recovery_task = asyncio.create_task(
|
||||
self._restart_client(reason="media download timeout streak")
|
||||
)
|
||||
|
||||
async def safe_get_messages(self, channel_id, post_id, max_retries=2):
|
||||
"""Wrapper with retry logic for auth errors"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self.client.get_messages(channel_id, post_id),
|
||||
timeout=30.0
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, KeyError) and attempt < max_retries - 1:
|
||||
logger.warning(f"Auth error on attempt {attempt + 1}, retrying in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
raise
|
||||
|
||||
async def safe_download_media(self, file_id, file_name, max_retries=2, timeout: float = 120.0):
|
||||
"""Wrapper with retry logic for download errors.
|
||||
|
||||
`timeout` bounds each download attempt; for large videos the caller scales it
|
||||
with file size (see api_server._media_download_timeout). A timeout cancels the
|
||||
underlying download (freeing the Pyrogram transmission slot); a streak of timeouts
|
||||
escalates to a connection-rebuilding restart via note_download_timeout()."""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self.client.download_media(file_id, file_name=file_name),
|
||||
timeout=timeout
|
||||
)
|
||||
self.note_download_ok()
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
# Hung download: the wait_for above already cancelled it and released the
|
||||
# get_file semaphore. Count it toward the media-connection restart streak.
|
||||
self.note_download_timeout()
|
||||
raise
|
||||
except Exception as e:
|
||||
if isinstance(e, KeyError) and attempt < max_retries - 1:
|
||||
logger.warning(f"Download auth error on attempt {attempt + 1}, retrying...")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
# Suppress disconnect handling during intentional shutdown
|
||||
# (client.stop() dispatches the DisconnectHandler).
|
||||
self._shutting_down = True
|
||||
if self._watchdog_task is not None:
|
||||
self._watchdog_task.cancel()
|
||||
try:
|
||||
await self._watchdog_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._watchdog_task = None
|
||||
if self.client.is_connected:
|
||||
try:
|
||||
await self.client.stop()
|
||||
logger.info("Telegram client disconnected")
|
||||
except Exception as e:
|
||||
# During shutdown the client may be in a half-restarted state
|
||||
# (e.g. a watchdog restart was cancelled mid-flight); ignore stop errors.
|
||||
logger.warning(f"Telegram client stop during shutdown raised {type(e).__name__}: {e}")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Shared test bootstrap (issue #17).
|
||||
|
||||
Ensure the repo root is importable and install the config mock BEFORE any test
|
||||
module imports application code. Pytest imports conftest.py before collecting
|
||||
test modules, so doing this here (instead of a per-module preamble) makes the
|
||||
suite order-independent regardless of collection order or invocation directory.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import tests.mock_config as _mock_config
|
||||
|
||||
sys.modules['config'] = _mock_config
|
||||
|
||||
# Pin the runner timezone to UTC (stage-0 golden determinism, issue #27).
|
||||
# Naive kurigram dates are interpreted by datetime.timestamp() in the LOCAL tz, so
|
||||
# without this pin RSS <pubDate> values drift between machines (this sandbox is MSK).
|
||||
os.environ['TZ'] = 'UTC'
|
||||
time.tzset()
|
||||
@@ -0,0 +1,181 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=import-outside-toplevel, missing-function-docstring, line-too-long
|
||||
"""Stage-0 golden-baseline replay helpers (render-pipeline refactor epic, issue #27/#34).
|
||||
|
||||
Replays the frozen recorded corpus (tests/test_data/recorded/) through the REAL
|
||||
cache-hit path and captures the full generate_channel_rss / generate_channel_html
|
||||
output as an equivalence oracle for every later refactor stage. NO production render
|
||||
code is touched here — only a test loader + determinism pins.
|
||||
|
||||
The recorded corpus was written by the prod bridge cache (tg_cache._save_*_to_cache):
|
||||
{channel}.cache -> {'timestamp', 'limit', 'messages': List[Message]}
|
||||
{channel}.chatinfo -> {'timestamp', 'data': {'id', 'title', 'username'}}
|
||||
`timestamp` / `limit` are ignored (no freshness check) — this is literally the prod
|
||||
cache-hit payload the renderer sees in production.
|
||||
|
||||
Run `python -m tests.golden_replay` from the repo root to (re)generate the goldens.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import pickle
|
||||
from types import SimpleNamespace
|
||||
|
||||
TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(TESTS_DIR)
|
||||
RECORDED_DIR = os.path.join(TESTS_DIR, "test_data", "recorded")
|
||||
GOLDEN_DIR = os.path.join(TESTS_DIR, "test_data", "golden")
|
||||
|
||||
# Corpus channels frozen on `main` (spec "Этап 0"). exclude_flags / exclude_text
|
||||
# scenarios are intentionally NOT in golden: filters do not change the bytes of the
|
||||
# surviving posts; their membership/regex semantics are covered by dedicated unit tests.
|
||||
CORPUS_CHANNELS = ["bladerunnerblues", "embedoka", "meow_design", "theyforcedme"]
|
||||
|
||||
# Recorded corpus is 100 messages/channel; the feed cap is 200. limit=100 exercises the
|
||||
# whole corpus (grouping only reduces the post count below the message count).
|
||||
GOLDEN_LIMIT = 100
|
||||
|
||||
# Fixed media-URL signing key so a golden captured on any machine/checkout reproduces on
|
||||
# regeneration (a fresh checkout otherwise mints a new secrets.token_hex and every URL
|
||||
# digest changes). Used identically by the generator and the comparison test.
|
||||
GOLDEN_SIGNING_KEY = "stage0-golden-fixed-signing-key-0000000000000000"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Replay loader — the literal prod cache-hit path.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def load_recorded(channel):
|
||||
"""Unpickle a recorded {channel}.cache / {channel}.chatinfo pair.
|
||||
|
||||
Returns (messages: List[Message], chatinfo_data: dict). timestamp/limit ignored."""
|
||||
with open(os.path.join(RECORDED_DIR, f"{channel}.cache"), "rb") as f:
|
||||
cache = pickle.load(f)
|
||||
with open(os.path.join(RECORDED_DIR, f"{channel}.chatinfo"), "rb") as f:
|
||||
chatinfo = pickle.load(f)
|
||||
return cache["messages"], chatinfo["data"]
|
||||
|
||||
|
||||
def patch_tg_cache(monkeypatch, channel):
|
||||
"""Monkeypatch tg_cache.cached_get_chat_history / cached_get_chat to return the
|
||||
recorded objects for `channel`. The feed functions lazy-import tg_cache, so patching
|
||||
the module resolves late and works (mirrors test_stage4_eventloop.py)."""
|
||||
messages, chatinfo = load_recorded(channel)
|
||||
|
||||
async def fake_get_chat_history(client, channel_id, limit=20):
|
||||
return messages
|
||||
|
||||
async def fake_get_chat(client, channel_id):
|
||||
# cached_get_chat returns SimpleNamespace(**data) with .id/.title/.username.
|
||||
return SimpleNamespace(**chatinfo)
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_chat_history, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
|
||||
|
||||
def pin_environment(monkeypatch):
|
||||
"""Apply the spec's non-TZ determinism pins (TZ=UTC is pinned globally in conftest /
|
||||
the __main__ bootstrap so both the test runner and the generator agree)."""
|
||||
import post_parser
|
||||
import rss_generator
|
||||
from url_signer import KeyManager
|
||||
|
||||
# Pin the media-URL signing key (see GOLDEN_SIGNING_KEY).
|
||||
monkeypatch.setattr(KeyManager, "signing_key", GOLDEN_SIGNING_KEY)
|
||||
|
||||
# time_based_merge=True so the meow_design time-cluster core is actually exercised.
|
||||
# The flag is read from rss_generator.Config at call time; post_parser.Config is a
|
||||
# sibling dict from the same get_settings() — pin both (cheap insurance vs. drift).
|
||||
monkeypatch.setitem(rss_generator.Config, "time_based_merge", True)
|
||||
monkeypatch.setitem(post_parser.Config, "time_based_merge", True)
|
||||
|
||||
# No media-id DB side effect outside tests/ (byte-neutral for the feed, but the write
|
||||
# to ./data/media_file_ids.db is a forbidden side effect). upsert is imported INTO the
|
||||
# post_parser namespace, so patch it there.
|
||||
monkeypatch.setattr(post_parser, "upsert_media_file_ids_bulk_sync", lambda *a, **k: None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Capture.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def capture_rss(channel):
|
||||
import asyncio
|
||||
from rss_generator import generate_channel_rss
|
||||
return asyncio.run(generate_channel_rss(channel, client=SimpleNamespace(), limit=GOLDEN_LIMIT))
|
||||
|
||||
|
||||
def capture_html(channel):
|
||||
import asyncio
|
||||
from rss_generator import generate_channel_html
|
||||
return asyncio.run(generate_channel_html(channel, client=SimpleNamespace(), limit=GOLDEN_LIMIT))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Normalizations (applied SYMMETRICALLY to golden and actual before comparison).
|
||||
# Only the spec-sanctioned set — over-normalizing is exactly how a regression hides.
|
||||
# --------------------------------------------------------------------------- #
|
||||
# feedgen sets <lastBuildDate> to now() once in the FeedGenerator constructor: stable
|
||||
# within a process, but changes between capture runs — regex it out on both sides.
|
||||
_LASTBUILDDATE_RE = re.compile(r"<lastBuildDate>.*?</lastBuildDate>", re.DOTALL)
|
||||
# feedgen 1.0.0 emits no <generator>; normalized anyway as cheap insurance vs. a lib upgrade.
|
||||
_GENERATOR_RE = re.compile(r"<generator>.*?</generator>", re.DOTALL)
|
||||
# NOTE: the stage-0 flag-sort normalization (_FLAGS_DIV_RE / _sort_flags_div) was
|
||||
# removed in stage 2 (§3.8). Merged-post flags are now built in deterministic
|
||||
# first-seen order (rss_generator._render_messages_groups: dict.fromkeys(...)),
|
||||
# so the golden stores the real order and no normalization is needed — keeping it
|
||||
# would only mask a real flag-order regression.
|
||||
|
||||
|
||||
def normalize_rss(xml):
|
||||
xml = _LASTBUILDDATE_RE.sub("<lastBuildDate/>", xml)
|
||||
xml = _GENERATOR_RE.sub("<generator/>", xml)
|
||||
return xml
|
||||
|
||||
|
||||
def normalize_html(html):
|
||||
return html
|
||||
|
||||
|
||||
def golden_path(channel, kind):
|
||||
ext = {"rss": "rss.xml", "html": "feed.html"}[kind]
|
||||
return os.path.join(GOLDEN_DIR, f"{channel}.{ext}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Generator entry point: `python -m tests.golden_replay`
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _bootstrap_standalone():
|
||||
"""Reproduce the conftest bootstrap for the standalone generator: repo root on the
|
||||
path, mocked config, UTC timezone."""
|
||||
import sys
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
import tests.mock_config as _mock_config
|
||||
sys.modules["config"] = _mock_config
|
||||
os.environ["TZ"] = "UTC"
|
||||
time.tzset()
|
||||
|
||||
|
||||
def generate_all():
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
os.makedirs(GOLDEN_DIR, exist_ok=True)
|
||||
for channel in CORPUS_CHANNELS:
|
||||
mp = MonkeyPatch()
|
||||
try:
|
||||
pin_environment(mp)
|
||||
patch_tg_cache(mp, channel)
|
||||
rss = capture_rss(channel)
|
||||
html = capture_html(channel)
|
||||
finally:
|
||||
mp.undo()
|
||||
with open(golden_path(channel, "rss"), "w", encoding="utf-8") as f:
|
||||
f.write(rss)
|
||||
with open(golden_path(channel, "html"), "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"{channel}: rss={len(rss)}B items={rss.count('<item>')} "
|
||||
f"html={len(html)}B posts={html.count('message-body') if html else 0}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_bootstrap_standalone()
|
||||
generate_all()
|
||||
@@ -0,0 +1,211 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=import-outside-toplevel, missing-function-docstring, line-too-long
|
||||
"""Stage-5 fragment-level snapshot helpers (render-pipeline refactor epic, issue #32/#34).
|
||||
|
||||
Captures the raw `_generate_html_media` output (BEFORE sanitize) for every media
|
||||
render kind and edge branch, as a SEPARATE oracle layer from the stage-0 feed goldens
|
||||
(spec §4: "два слоя эталонов, не смешивать").
|
||||
|
||||
`media_fragments.json` is the PRE-REFACTOR BASE reference: it was captured by running
|
||||
THIS harness against the BASE `post_parser.py` checkout (the pre-refactor code), NOT
|
||||
against stage-5 code — so it is a genuine base-anchored golden layer, not a circular
|
||||
self-snapshot of the refactor. The 5a refactor (MEDIA_SOURCES table + renderers) must
|
||||
reproduce these base fragments byte-for-byte. The ONLY 5b-registered changes vs the base
|
||||
are the two entries in REGISTERED_DELTAS (§3.14 unclosed-div close); the §3.13 large-file
|
||||
guard is collection-only and changes no fragment bytes.
|
||||
|
||||
Cases whose type exists in the recorded corpus could be pulled from it, but the media
|
||||
FRAGMENT is a pure function of a single Message, so deterministic hand-built mocks give
|
||||
the same bytes with far less machinery and also reach the mock-only exotic types
|
||||
(PAID_MEDIA, LIVE_PHOTO, STORY) — exactly the set the spec allows mocks for.
|
||||
|
||||
Run `python -m tests.media_fragment_replay` from the repo root to (re)generate the snapshot.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(TESTS_DIR)
|
||||
SNAPSHOT_PATH = os.path.join(TESTS_DIR, "test_data", "media_fragments.json")
|
||||
|
||||
# Fixed signing key so digests in the snapshot reproduce on any checkout (mirrors the
|
||||
# stage-0 golden pin). Applied by the generator and by the comparison test.
|
||||
FRAGMENT_SIGNING_KEY = "stage5-fragment-fixed-signing-key-000000000000"
|
||||
|
||||
|
||||
class _Str(str):
|
||||
"""Minimal stand-in for Pyrogram's Str: .html returns the raw string unchanged."""
|
||||
@property
|
||||
def html(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
def _msg(mid, media, *, text=None, username="testchan", chat_id=-1001234567890, **extra):
|
||||
"""Build a Message-like mock with the attributes _generate_html_media touches.
|
||||
|
||||
Top-level media attributes default to None (truthy checks in _save_media_file_ids);
|
||||
new Kurigram 2.2.23 attributes (live_photo/story/giveaway/...) are intentionally
|
||||
absent unless passed, so getattr-only production code is exercised as on real objects.
|
||||
"""
|
||||
m = SimpleNamespace()
|
||||
m.id = mid
|
||||
m.media = media
|
||||
m.text = _Str(text) if text is not None else None
|
||||
m.caption = None
|
||||
m.web_page = None
|
||||
m.poll = None
|
||||
m.paid_media = None
|
||||
m.forward_origin = None
|
||||
m.show_caption_above_media = False
|
||||
if chat_id is None and username is None:
|
||||
m.chat = None
|
||||
else:
|
||||
m.chat = SimpleNamespace(id=chat_id, username=username)
|
||||
for attr in ("photo", "video", "document", "audio", "voice",
|
||||
"video_note", "animation", "sticker"):
|
||||
setattr(m, attr, None)
|
||||
for key, value in extra.items():
|
||||
setattr(m, key, value)
|
||||
return m
|
||||
|
||||
|
||||
def _obj(**kw):
|
||||
return SimpleNamespace(**kw)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fragment cases. Each entry: name -> Message factory.
|
||||
# Covers the full spec §5a "Шаг 0" list + edge branches.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _webpage(photo=None, url="https://example.com", title="Example",
|
||||
description=None, site_name=None, wp_type=""):
|
||||
return _obj(photo=photo, url=url, title=title, description=description,
|
||||
site_name=site_name, type=wp_type, display_url=None)
|
||||
|
||||
|
||||
def build_cases():
|
||||
from pyrogram.enums import MessageMediaType as T
|
||||
cases = {}
|
||||
cases["photo"] = lambda: _msg(1, T.PHOTO, photo=_obj(file_unique_id="ph_uid", file_id="ph_fid"))
|
||||
cases["video"] = lambda: _msg(2, T.VIDEO, video=_obj(file_unique_id="vid_uid", file_id="vid_fid", file_size=1024))
|
||||
cases["animation"] = lambda: _msg(3, T.ANIMATION, animation=_obj(file_unique_id="ani_uid", file_id="ani_fid"))
|
||||
cases["video_note"] = lambda: _msg(4, T.VIDEO_NOTE, video_note=_obj(file_unique_id="vn_uid", file_id="vn_fid"))
|
||||
cases["audio_default_mime"] = lambda: _msg(5, T.AUDIO, audio=_obj(file_unique_id="au_uid", file_id="au_fid"))
|
||||
cases["audio_explicit_mime"] = lambda: _msg(6, T.AUDIO, audio=_obj(file_unique_id="au2_uid", file_id="au2_fid", mime_type="audio/flac"))
|
||||
cases["voice_default_mime"] = lambda: _msg(7, T.VOICE, voice=_obj(file_unique_id="vo_uid", file_id="vo_fid"))
|
||||
cases["voice_explicit_mime"] = lambda: _msg(8, T.VOICE, voice=_obj(file_unique_id="vo2_uid", file_id="vo2_fid", mime_type="audio/wav"))
|
||||
cases["sticker_img"] = lambda: _msg(9, T.STICKER, sticker=_obj(file_unique_id="st_uid", file_id="st_fid", emoji="😀", is_video=False))
|
||||
cases["sticker_video"] = lambda: _msg(10, T.STICKER, sticker=_obj(file_unique_id="stv_uid", file_id="stv_fid", emoji="🎬", is_video=True))
|
||||
cases["document_pdf_public"] = lambda: _msg(11, T.DOCUMENT, username="pubchan", chat_id=None,
|
||||
document=_obj(file_unique_id="pdf_uid", file_id="pdf_fid", mime_type="application/pdf"))
|
||||
cases["document_pdf_private"] = lambda: _msg(12, T.DOCUMENT, username=None, chat_id=-1009876543210,
|
||||
document=_obj(file_unique_id="pdf2_uid", file_id="pdf2_fid", mime_type="application/pdf"))
|
||||
cases["document_normal"] = lambda: _msg(13, T.DOCUMENT, document=_obj(file_unique_id="doc_uid", file_id="doc_fid", mime_type="image/png"))
|
||||
cases["live_photo"] = lambda: _msg(14, T.LIVE_PHOTO, live_photo=_obj(file_unique_id="lp_uid", file_id="lp_fid"))
|
||||
cases["story_video"] = lambda: _msg(15, T.STORY, story=_obj(video=_obj(file_unique_id="sv_uid", file_id="sv_fid"), photo=None))
|
||||
cases["story_photo"] = lambda: _msg(16, T.STORY, story=_obj(video=None, photo=_obj(file_unique_id="sp_uid", file_id="sp_fid")))
|
||||
cases["poll_media_img"] = lambda: _msg(17, T.POLL, poll=_obj(question=_Str("Q?"), options=[],
|
||||
description_media=_obj(photo=_obj(file_unique_id="pl_uid", file_id="pl_fid"))))
|
||||
cases["poll_media_video"] = lambda: _msg(18, T.POLL, poll=_obj(question=_Str("Q?"), options=[],
|
||||
description_media=_obj(video=_obj(file_unique_id="plv_uid", file_id="plv_fid"))))
|
||||
cases["paid_media"] = lambda: _msg(19, T.PAID_MEDIA,
|
||||
paid_media=_obj(stars_amount=50, media=[_obj(), _obj()]))
|
||||
# WEB_PAGE with photo: opens an EMPTY message-media div (no elif matches WEB_PAGE),
|
||||
# plus the webpage-preview block (short text gate <=10).
|
||||
cases["webpage_with_photo"] = lambda: _msg(20, T.WEB_PAGE, text="hi",
|
||||
web_page=_webpage(photo=_obj(file_unique_id="wp_uid", file_id="wp_fid")))
|
||||
# WEB_PAGE without photo: file_unique_id is None -> message-media div opened and
|
||||
# left UNCLOSED (§3.14 target). Short text so the preview block renders.
|
||||
cases["webpage_without_photo"] = lambda: _msg(21, T.WEB_PAGE, text="hi",
|
||||
web_page=_webpage(photo=None))
|
||||
# WEB_PAGE with photo but long text (>10): preview gate closed, only the empty media div.
|
||||
cases["webpage_photo_long_text"] = lambda: _msg(22, T.WEB_PAGE,
|
||||
text="this text is definitely longer than ten characters",
|
||||
web_page=_webpage(photo=_obj(file_unique_id="wpl_uid", file_id="wpl_fid")))
|
||||
# file_unique_id is None on a normal media type -> unclosed div (§3.14 target).
|
||||
cases["file_unique_id_none"] = lambda: _msg(23, T.PHOTO, photo=_obj(file_unique_id=None, file_id="x_fid"))
|
||||
# channel_username is None but uid present -> the div IS closed on the guard branch.
|
||||
cases["channel_username_none"] = lambda: _msg(24, T.PHOTO, username=None, chat_id=555,
|
||||
photo=_obj(file_unique_id="cu_uid", file_id="cu_fid"))
|
||||
return cases
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Registered 5b deltas vs the pre-refactor base snapshot.
|
||||
#
|
||||
# Spec §3.14: the empty <div class="message-media"> container is now CLOSED in every
|
||||
# render branch. The base (pre-refactor) code left it OPEN whenever the selected media
|
||||
# object had no usable file_unique_id, so the base snapshot captured an unbalanced div
|
||||
# for exactly these two cases. `collected` is byte-identical to the base; only `html`
|
||||
# differs by the added `</div>`. These are the ONLY fragments the 5b registered fixes
|
||||
# change relative to the pre-refactor base — every other case reproduces base bytes.
|
||||
REGISTERED_DELTAS = {
|
||||
"file_unique_id_none": {
|
||||
"collected": [],
|
||||
"html": "<div class=\"message-media\">\n</div>",
|
||||
},
|
||||
"webpage_without_photo": {
|
||||
"collected": [],
|
||||
"html": "<div class=\"message-media\">\n</div>\n<div class=\"webpage-preview\">\n<div class=\"webpage-preview\" style=\"border-left: 3px solid #ccc; padding-left: 10px; margin: 10px 0;\">\n<div class=\"webpage-title\" style=\"font-weight:bold; margin:5px 0;\">\n<a href=\"https://example.com\" target=\"_blank\">Example</a></div>\n<div class=\"webpage-url\" style=\"color:#666; font-size:0.9em;margin-bottom:5px;\">https://example.com</div>\n</div>\n</div>",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Capture / compare.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def capture_fragments():
|
||||
"""Return {case_name: {"html": fragment, "collected": [[chan, id, fuid], ...]}}.
|
||||
|
||||
Covers all three ladders in one shot: _generate_html_media renders the fragment
|
||||
(render ladder + _get_file_unique_id ladder) and calls _save_media_file_ids
|
||||
(collection ladder), whose result is read off _pending_media_ids."""
|
||||
from post_parser import PostParser
|
||||
parser = PostParser(SimpleNamespace())
|
||||
out = {}
|
||||
for name, factory in build_cases().items():
|
||||
parser._pending_media_ids = []
|
||||
html = parser._generate_html_media(factory())
|
||||
collected = [[c, p, f] for c, p, f, _ in parser._pending_media_ids]
|
||||
out[name] = {"html": html, "collected": collected}
|
||||
return out
|
||||
|
||||
|
||||
def pin_signing_key(monkeypatch):
|
||||
from url_signer import KeyManager
|
||||
monkeypatch.setattr(KeyManager, "signing_key", FRAGMENT_SIGNING_KEY)
|
||||
|
||||
|
||||
def load_snapshot():
|
||||
with open(SNAPSHOT_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _bootstrap_standalone():
|
||||
import sys
|
||||
import time
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
import tests.mock_config as _mock_config
|
||||
sys.modules["config"] = _mock_config
|
||||
os.environ["TZ"] = "UTC"
|
||||
time.tzset()
|
||||
|
||||
|
||||
def generate_snapshot():
|
||||
# WARNING: this MUST be run against the BASE (pre-refactor) `post_parser.py`, never
|
||||
# against stage-5 code. Regenerating from stage-5 output would make the oracle a
|
||||
# circular self-snapshot instead of a base-anchored golden layer (spec §4).
|
||||
from url_signer import KeyManager
|
||||
KeyManager.signing_key = FRAGMENT_SIGNING_KEY
|
||||
data = capture_fragments()
|
||||
os.makedirs(os.path.dirname(SNAPSHOT_PATH), exist_ok=True)
|
||||
with open(SNAPSHOT_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
print(f"wrote {len(data)} fragment snapshots to {SNAPSHOT_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_bootstrap_standalone()
|
||||
generate_snapshot()
|
||||
+23
-1
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
def setup_logging(level_name: str = "INFO") -> None:
|
||||
"""No-op logging setup for tests (mirrors config.setup_logging signature)."""
|
||||
return None
|
||||
|
||||
def get_settings():
|
||||
"""
|
||||
Mock config for testing without requiring TG_API_ID and TG_API_HASH
|
||||
@@ -18,4 +22,22 @@ def get_settings():
|
||||
"time_based_merge": False,
|
||||
"show_bridge_link": False,
|
||||
"show_post_flags": True,
|
||||
}
|
||||
"proxy": None,
|
||||
"trusted_proxies": [],
|
||||
"tg_rpc_timeout": 60,
|
||||
"tg_watchdog_enabled": True,
|
||||
"tg_watchdog_interval": 60,
|
||||
"tg_watchdog_timeout": 10,
|
||||
"tg_watchdog_failures": 3,
|
||||
"tg_watchdog_restart_timeout": 90,
|
||||
"tg_watchdog_heartbeat_every": 30,
|
||||
"tg_disconnect_flap_limit": 3,
|
||||
"tg_disconnect_flap_window": 120,
|
||||
"tg_ping_unhealthy_after": 250,
|
||||
"media_download_timeout_min": 120,
|
||||
"media_download_timeout_max": 1800,
|
||||
"media_download_min_speed": 256 * 1024,
|
||||
"io_thread_pool_size": 32,
|
||||
"tg_max_concurrent_transmissions": 3,
|
||||
"media_timeout_restart_threshold": 5,
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[pytest]
|
||||
addopts = -ra
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"animation": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
3,
|
||||
"ani_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/3/ani_uid/ef4319c9\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
|
||||
},
|
||||
"audio_default_mime": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
5,
|
||||
"au_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/5/au_uid/be14c993\" type=\"audio/mpeg\"></audio>\n<br>\n</div>"
|
||||
},
|
||||
"audio_explicit_mime": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
6,
|
||||
"au2_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/6/au2_uid/015e2737\" type=\"audio/flac\"></audio>\n<br>\n</div>"
|
||||
},
|
||||
"channel_username_none": {
|
||||
"collected": [],
|
||||
"html": "<div class=\"message-media\">\n</div>"
|
||||
},
|
||||
"document_normal": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
13,
|
||||
"doc_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/13/doc_uid/4c9075c7\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
|
||||
},
|
||||
"document_pdf_private": {
|
||||
"collected": [
|
||||
[
|
||||
"-1009876543210",
|
||||
12,
|
||||
"pdf2_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<div class=\"document-pdf\" style=\"padding: 10px;\">\n<a href=\"https://t.me/c/9876543210/12\" target=\"_blank\">[PDF-файл]</a></div>\n</div>"
|
||||
},
|
||||
"document_pdf_public": {
|
||||
"collected": [
|
||||
[
|
||||
"pubchan",
|
||||
11,
|
||||
"pdf_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<div class=\"document-pdf\" style=\"padding: 10px;\">\n<a href=\"https://t.me/pubchan/11\" target=\"_blank\">[PDF-файл]</a></div>\n</div>"
|
||||
},
|
||||
"file_unique_id_none": {
|
||||
"collected": [],
|
||||
"html": "<div class=\"message-media\">"
|
||||
},
|
||||
"live_photo": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
14,
|
||||
"lp_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls autoplay loop muted src=\"http://test.example.com/media/testchan/14/lp_uid/7cbfcf53\"style=\"max-width:100%; width:auto; height:auto; max-height:400px;object-fit:contain;\"></video>\n</div>"
|
||||
},
|
||||
"paid_media": {
|
||||
"collected": [],
|
||||
"html": "<div class=\"message-media\">\n<div class=\"paid-media\">⭐ Paid media (50 stars, 2 item(s)) — available in Telegram</div>\n</div>"
|
||||
},
|
||||
"photo": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
1,
|
||||
"ph_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/1/ph_uid/08e6b2ef\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
|
||||
},
|
||||
"poll_media_img": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
17,
|
||||
"pl_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/17/pl_uid/492d238c\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
|
||||
},
|
||||
"poll_media_video": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
18,
|
||||
"plv_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/18/plv_uid/abb609a3\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
|
||||
},
|
||||
"sticker_img": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
9,
|
||||
"st_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/9/st_uid/03dc35d2\" alt=\"Sticker 😀\" style=\"max-width:100%;width:auto; height:auto; max-height:200px; object-fit:contain;\">\n</div>"
|
||||
},
|
||||
"sticker_video": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
10,
|
||||
"stv_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls autoplay loop muted src=\"http://test.example.com/media/testchan/10/stv_uid/cedd7494\"style=\"max-width:100%; width:auto; height:auto; max-height:200px;object-fit:contain;\"></video>\n</div>"
|
||||
},
|
||||
"story_photo": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
16,
|
||||
"sp_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/16/sp_uid/d8a5d9c9\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
|
||||
},
|
||||
"story_video": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
15,
|
||||
"sv_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/15/sv_uid/1aba6362\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
|
||||
},
|
||||
"video": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
2,
|
||||
"vid_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/2/vid_uid/e0c6ba2e\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
|
||||
},
|
||||
"video_note": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
4,
|
||||
"vn_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/4/vn_uid/a5ee9944\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
|
||||
},
|
||||
"voice_default_mime": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
7,
|
||||
"vo_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/7/vo_uid/f16627e0\" type=\"audio/ogg\"></audio>\n<br>\n</div>"
|
||||
},
|
||||
"voice_explicit_mime": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
8,
|
||||
"vo2_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/8/vo2_uid/30fed86a\" type=\"audio/wav\"></audio>\n<br>\n</div>"
|
||||
},
|
||||
"webpage_photo_long_text": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
22,
|
||||
"wpl_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n</div>"
|
||||
},
|
||||
"webpage_with_photo": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
20,
|
||||
"wp_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n</div>\n<div class=\"webpage-preview\">\n<div class=\"webpage-preview\" style=\"border-left: 3px solid #ccc; padding-left: 10px; margin: 10px 0;\">\n<div class=\"webpage-title\" style=\"font-weight:bold; margin:5px 0;\">\n<a href=\"https://example.com\" target=\"_blank\">Example</a></div>\n<div class=\"webpage-url\" style=\"color:#666; font-size:0.9em;margin-bottom:5px;\">https://example.com</div>\n<div class=\"webpage-photo\" style=\"margin-top:10px;\">\n<a href=\"https://example.com\" target=\"_blank\">\n<img src=\"http://test.example.com/media/testchan/20/wp_uid/a3dc7111\" style=\"max-width:100%; width:auto;height:auto; max-height:200px; object-fit:contain;\"></a></div>\n</div>\n</div>"
|
||||
},
|
||||
"webpage_without_photo": {
|
||||
"collected": [],
|
||||
"html": "<div class=\"message-media\">\n<div class=\"webpage-preview\">\n<div class=\"webpage-preview\" style=\"border-left: 3px solid #ccc; padding-left: 10px; margin: 10px 0;\">\n<div class=\"webpage-title\" style=\"font-weight:bold; margin:5px 0;\">\n<a href=\"https://example.com\" target=\"_blank\">Example</a></div>\n<div class=\"webpage-url\" style=\"color:#666; font-size:0.9em;margin-bottom:5px;\">https://example.com</div>\n</div>\n</div>"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,272 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, line-too-long
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
"""Stage 4 (render-pipeline refactor, issue #31 / epic #34) — pure time-clustering.
|
||||
|
||||
`_create_time_based_media_groups` deep-copied every cached message per feed request and
|
||||
MUTATED media_group_id. It is replaced by `_compute_time_based_group_ids`, a PURE function
|
||||
returning {message.id: effective_media_group_id}; `_create_messages_groups` reads that
|
||||
mapping instead of a mutated attribute. All tests use NAIVE dates (as kurigram emits on
|
||||
prod), not aware-UTC mocks.
|
||||
|
||||
Behavior registry items exercised here: §3.11 (None-date excluded from clustering, no
|
||||
mapping entry; own media_group_id still applies) and §3.12 (naive-safe sort keys; None-date
|
||||
groups survive [:limit] as newest and land at the tail of the feed via the 0.0 final sort).
|
||||
"""
|
||||
import os
|
||||
import time as _time
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import rss_generator as rss_module
|
||||
from rss_generator import (
|
||||
_compute_time_based_group_ids,
|
||||
_create_messages_groups,
|
||||
generate_channel_html,
|
||||
)
|
||||
|
||||
|
||||
D = datetime # naive datetimes throughout
|
||||
|
||||
|
||||
class _Str(str):
|
||||
"""Str stand-in: .html returns the raw string (mirrors kurigram's Str)."""
|
||||
@property
|
||||
def html(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
class Msg:
|
||||
"""Minimal message stand-in for the pure clustering function (needs id/date/mgid)."""
|
||||
def __init__(self, mid, date, media_group_id=None):
|
||||
self.id = mid
|
||||
self.date = date
|
||||
self.media_group_id = media_group_id
|
||||
self.service = None
|
||||
|
||||
|
||||
def at(sec, minute=0):
|
||||
# Naive local datetime, same shape kurigram's datetime.fromtimestamp() produces.
|
||||
return D(2024, 1, 1, 12, minute, sec)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Core clustering equivalence with the old mutation.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_time_cluster_without_id_gets_synthetic():
|
||||
a, b = Msg(1, at(0)), Msg(2, at(2))
|
||||
mapping = _compute_time_based_group_ids([a, b], merge_seconds=5)
|
||||
synthetic = f"time_{at(0)}"
|
||||
assert mapping == {1: synthetic, 2: synthetic}
|
||||
|
||||
|
||||
def test_adoption_backfill_and_overwrite():
|
||||
# First member has no id, second brings "B" (first truthy in cluster order -> wins and
|
||||
# is BACKFILLED onto member 1), third brings "C" which is OVERWRITTEN to "B".
|
||||
a, b, c = Msg(1, at(0)), Msg(2, at(2), "B"), Msg(3, at(4), "C")
|
||||
mapping = _compute_time_based_group_ids([a, b, c], merge_seconds=5)
|
||||
assert mapping == {1: "B", 2: "B", 3: "B"}
|
||||
|
||||
|
||||
def test_falsy_id_ignored_like_old_code():
|
||||
# 0 and "" are falsy: they are NOT adopted as the cluster id, exactly as the old
|
||||
# truthiness check. A 2-member cluster with only falsy ids gets a synthetic id.
|
||||
a, b = Msg(1, at(0), 0), Msg(2, at(2), "")
|
||||
mapping = _compute_time_based_group_ids([a, b], merge_seconds=5)
|
||||
synthetic = f"time_{at(0)}"
|
||||
assert mapping == {1: synthetic, 2: synthetic}
|
||||
|
||||
|
||||
def test_singleton_gets_no_entry():
|
||||
# A lone message (even one carrying a truthy media_group_id) forms a singleton cluster
|
||||
# and gets NO entry; downstream it falls back to its own media_group_id.
|
||||
lonely = Msg(1, at(0), "MG")
|
||||
far = Msg(2, at(30, minute=1), "OTHER") # >5s gap -> separate singleton
|
||||
mapping = _compute_time_based_group_ids([lonely, far], merge_seconds=5)
|
||||
assert mapping == {}
|
||||
|
||||
|
||||
def test_input_is_not_mutated():
|
||||
a, b, c = Msg(1, at(0)), Msg(2, at(2), "B"), Msg(3, at(4), "C")
|
||||
before = [(m.id, m.date, m.media_group_id) for m in (a, b, c)]
|
||||
_compute_time_based_group_ids([a, b, c], merge_seconds=5)
|
||||
after = [(m.id, m.date, m.media_group_id) for m in (a, b, c)]
|
||||
assert before == after
|
||||
assert a.media_group_id is None and b.media_group_id == "B" and c.media_group_id == "C"
|
||||
|
||||
|
||||
def test_ties_equal_dates_cluster_in_fetch_order():
|
||||
# Equal dates -> stable sort keeps fetch order; they cluster (gap 0 <= merge) and the
|
||||
# first truthy id in fetch order wins.
|
||||
a, b = Msg(1, at(5), "FIRST"), Msg(2, at(5), "SECOND")
|
||||
mapping = _compute_time_based_group_ids([a, b], merge_seconds=5)
|
||||
assert mapping == {1: "FIRST", 2: "FIRST"}
|
||||
|
||||
|
||||
def test_gap_uses_naive_datetime_subtraction_not_timestamps():
|
||||
# The gap is a NAIVE wall-clock subtraction, exactly as the old code — NOT a timestamp
|
||||
# diff (they DIVERGE across a DST fold, and the old behavior is the contract). Pin a
|
||||
# DST zone and straddle the ambiguous "fall back" hour with the two folds:
|
||||
# a = 01:30:00 fold=0 -> the FIRST (EDT, UTC-4) occurrence of that wall-clock time;
|
||||
# b = 01:30:02 fold=1 -> the SECOND (EST, UTC-5) occurrence, ~1h later in REAL time.
|
||||
# Naive subtraction (b.date - a.date) = 2s -> they cluster. A timestamp diff would be
|
||||
# ~3602s -> they would NOT cluster. So mutating the gap to a `.timestamp()` diff turns
|
||||
# this red, locking the naive-subtraction contract.
|
||||
os.environ["TZ"] = "America/New_York"
|
||||
_time.tzset()
|
||||
try:
|
||||
a = Msg(1, D(2024, 11, 3, 1, 30, 0, fold=0))
|
||||
b = Msg(2, D(2024, 11, 3, 1, 30, 2, fold=1))
|
||||
# Sanity-check the fixture itself: naive gap 2s, real (timestamp) gap ~1h.
|
||||
assert (b.date - a.date).total_seconds() == 2
|
||||
assert b.date.timestamp() - a.date.timestamp() > 3600
|
||||
mapping = _compute_time_based_group_ids([a, b], merge_seconds=5)
|
||||
synthetic = f"time_{D(2024, 11, 3, 1, 30, 0)}"
|
||||
assert mapping == {1: synthetic, 2: synthetic}
|
||||
finally:
|
||||
os.environ["TZ"] = "UTC"
|
||||
_time.tzset()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §3.11 — None-date posts: excluded from clustering, own media_group_id still applies.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_none_date_gets_no_mapping_entry():
|
||||
dated, nodate = Msg(1, at(0), "MG"), Msg(2, None, "MG")
|
||||
mapping = _compute_time_based_group_ids([dated, nodate], merge_seconds=5)
|
||||
assert 2 not in mapping # None-date never participates
|
||||
|
||||
|
||||
def test_mixed_none_date_does_not_crash():
|
||||
# The historical prod bug: a single None-date post amid naive-dated posts raised
|
||||
# TypeError. The pure function must simply skip it.
|
||||
msgs = [Msg(1, at(0)), Msg(2, None), Msg(3, at(2))]
|
||||
mapping = _compute_time_based_group_ids(msgs, merge_seconds=5) # must not raise
|
||||
assert 2 not in mapping
|
||||
# The two dated posts still cluster (2s apart) under a synthetic id.
|
||||
synthetic = f"time_{at(0)}"
|
||||
assert mapping == {1: synthetic, 3: synthetic}
|
||||
|
||||
|
||||
def test_fully_none_input_yields_empty_mapping():
|
||||
# Registry §3.11: the old code clustered a fully-None tail by insertion order (only
|
||||
# reachable via aware-date test mocks). New behavior: no clustering at all.
|
||||
msgs = [Msg(1, None, "A"), Msg(2, None), Msg(3, None)]
|
||||
assert _compute_time_based_group_ids(msgs, merge_seconds=5) == {}
|
||||
|
||||
|
||||
def test_none_date_media_group_still_assembled_downstream():
|
||||
# None-date posts get no mapping entry but keep their own media_group_id, so a media
|
||||
# group made of None-date members is still assembled by _create_messages_groups.
|
||||
m1, m2 = Msg(10, None, "SHARED"), Msg(11, None, "SHARED")
|
||||
solo = Msg(12, at(0))
|
||||
mapping = _compute_time_based_group_ids([m1, m2, solo], merge_seconds=5)
|
||||
groups = _create_messages_groups([m1, m2, solo], mapping)
|
||||
shared = [g for g in groups if len(g) == 2]
|
||||
assert len(shared) == 1
|
||||
assert {m.id for m in shared[0]} == {10, 11}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §3.12 — naive-safe group sort: None-date groups survive [:limit] and land at the tail.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_create_messages_groups_none_date_does_not_crash_default_path():
|
||||
# This is the LIVE default-path 500: a None-date group used to fall back to an AWARE
|
||||
# now() in the group sort key and blow up against the naive real dates. No mapping /
|
||||
# time_based_merge needed — plain grouping must survive.
|
||||
dated = Msg(1, at(0))
|
||||
nodate = Msg(2, None)
|
||||
groups = _create_messages_groups([dated, nodate]) # must not raise
|
||||
# None-date group sorts as newest (float('inf')) -> first here (reverse=True).
|
||||
assert groups[0][0].id == 2
|
||||
|
||||
|
||||
def _make_message(mid, text, date, media_group_id=None):
|
||||
m = SimpleNamespace()
|
||||
m.id = mid
|
||||
m.date = date
|
||||
m.text = _Str(text) if text is not None else None
|
||||
m.caption = None
|
||||
m.media = None
|
||||
m.web_page = None
|
||||
m.poll = None
|
||||
m.service = None
|
||||
m.forward_origin = None
|
||||
m.reply_to_message = None
|
||||
m.reply_to_message_id = None
|
||||
m.sender_chat = None
|
||||
m.from_user = None
|
||||
m.reactions = None
|
||||
m.views = 100
|
||||
m.media_group_id = media_group_id
|
||||
m.show_caption_above_media = False
|
||||
m.chat = SimpleNamespace(id=-1001234567890, username="testchan")
|
||||
for attr in ("photo", "video", "document", "audio", "voice",
|
||||
"video_note", "animation", "sticker"):
|
||||
setattr(m, attr, None)
|
||||
return m
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_date_post_renders_and_lands_at_feed_end(monkeypatch):
|
||||
# END-TO-END regression for the live prod 500: a None-date post in a feed of real-dated
|
||||
# posts, WITH time_based_merge. Old code raised TypeError (naive vs aware) in BOTH the
|
||||
# group sort key (default path) and the time-cluster sort -> HTTP 500 on the default
|
||||
# feed path. New code renders it and, per §3.12, places it at the tail of the feed.
|
||||
posts = [
|
||||
_make_message(1, "OLDEST_REAL", at(0)),
|
||||
_make_message(2, "NEWEST_REAL", at(30)),
|
||||
_make_message(3, "NONE_DATE_POST", None),
|
||||
]
|
||||
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return posts
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
monkeypatch.setitem(rss_module.Config, "time_based_merge", True)
|
||||
|
||||
html = await generate_channel_html("testchan", client=SimpleNamespace(), limit=10)
|
||||
|
||||
assert "NONE_DATE_POST" in html
|
||||
assert "NEWEST_REAL" in html and "OLDEST_REAL" in html
|
||||
# None-date post renders LAST (final post sort fallback 0.0 -> tail of feed, §3.12).
|
||||
assert html.index("NONE_DATE_POST") > html.index("OLDEST_REAL")
|
||||
assert html.index("NONE_DATE_POST") > html.index("NEWEST_REAL")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_date_group_survives_limit_slice(monkeypatch):
|
||||
# §3.12 retention: the group sort key gives None-date groups float('inf') so they sort
|
||||
# as NEWEST and deterministically survive the [:limit] slice in _render_pipeline.
|
||||
# This test applies REAL limit pressure (limit < number of groups) so the slice actually
|
||||
# drops groups — otherwise reverting 'inf' to 0.0 (or any small key) would go unnoticed.
|
||||
# With 5 dated posts + 1 None-date post and limit=3, the surviving 3 groups must be the
|
||||
# None-date group + the 2 newest dated; the 3 oldest dated are dropped. If 'inf' becomes
|
||||
# 0.0 the None-date group sorts OLDEST and is dropped instead -> this assertion fails.
|
||||
posts = [_make_message(mid, f"DATED_{mid}", at(0, minute=mid)) for mid in range(1, 6)]
|
||||
posts.append(_make_message(99, "NONE_DATE_POST", None))
|
||||
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return posts
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
html = await generate_channel_html("testchan", client=SimpleNamespace(), limit=3)
|
||||
|
||||
# Exactly 3 posts survive the slice; the None-date group is one of them.
|
||||
assert html.count('class="message-body"') == 3
|
||||
assert "NONE_DATE_POST" in html, "None-date group must survive [:limit] via the inf key"
|
||||
# The 2 newest dated posts survive; the 3 oldest are dropped by the slice.
|
||||
assert "DATED_5" in html and "DATED_4" in html
|
||||
assert "DATED_1" not in html and "DATED_2" not in html and "DATED_3" not in html
|
||||
@@ -0,0 +1,100 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, redefined-outer-name, line-too-long
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
"""
|
||||
Regression tests for the media self-healing fix (post 'static refactor' outage):
|
||||
|
||||
Root cause recap: Kurigram serializes downloads through a single get_file slot; a
|
||||
zombie media-DC connection makes every download time out while the main-DC watchdog
|
||||
stays green, so downloads jam forever. The fix adds (a) a negative-cache backoff so a
|
||||
repeatedly-failing file is not hammered, and (b) a consecutive-timeout streak that
|
||||
reuses the existing in-process restart to rebuild the media connection.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import api_server
|
||||
from telegram_client import TelegramClient
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Negative-cache backoff helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_backoff_arms_and_clears():
|
||||
key = ("selfheal_chan", 1, "fid_a")
|
||||
api_server._download_failures.pop(key, None)
|
||||
assert api_server._download_backoff_remaining(key) == 0.0 # never failed -> allowed
|
||||
api_server._record_download_failure(key)
|
||||
assert api_server._download_backoff_remaining(key) > 0.0 # armed -> blocked
|
||||
api_server._clear_download_failure(key)
|
||||
assert api_server._download_backoff_remaining(key) == 0.0 # recovered -> allowed
|
||||
|
||||
|
||||
def test_backoff_failure_counter_increments():
|
||||
key = ("selfheal_chan", 2, "fid_b")
|
||||
api_server._download_failures.pop(key, None)
|
||||
api_server._record_download_failure(key)
|
||||
first_fails = api_server._download_failures[key][0]
|
||||
api_server._record_download_failure(key)
|
||||
second_fails = api_server._download_failures[key][0]
|
||||
assert first_fails == 1
|
||||
assert second_fails == 2 # consecutive-failure counter grows -> longer backoff
|
||||
api_server._clear_download_failure(key)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Download-timeout streak -> single media-connection restart
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_streak_triggers_single_restart(monkeypatch):
|
||||
c = TelegramClient()
|
||||
calls = []
|
||||
|
||||
async def fake_restart(reason: str = "unspecified"):
|
||||
calls.append(reason)
|
||||
|
||||
monkeypatch.setattr(c, "_restart_client", fake_restart)
|
||||
threshold = c.media_timeout_restart_threshold
|
||||
|
||||
# One short of the threshold: no restart scheduled yet.
|
||||
for _ in range(threshold - 1):
|
||||
c.note_download_timeout()
|
||||
assert c._media_recovery_task is None
|
||||
assert c._download_timeout_streak == threshold - 1
|
||||
|
||||
# The threshold-th timeout schedules exactly one restart and resets the streak.
|
||||
c.note_download_timeout()
|
||||
assert c._download_timeout_streak == 0
|
||||
assert c._media_recovery_task is not None
|
||||
await c._media_recovery_task
|
||||
assert calls == ["media download timeout streak"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_resets_streak():
|
||||
c = TelegramClient()
|
||||
c.note_download_timeout()
|
||||
c.note_download_timeout()
|
||||
assert c._download_timeout_streak == 2
|
||||
c.note_download_ok()
|
||||
assert c._download_timeout_streak == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_restart_while_already_restarting(monkeypatch):
|
||||
c = TelegramClient()
|
||||
calls = []
|
||||
|
||||
async def fake_restart(reason: str = "unspecified"):
|
||||
calls.append(reason)
|
||||
|
||||
monkeypatch.setattr(c, "_restart_client", fake_restart)
|
||||
c._restarting = True # a restart is already underway
|
||||
|
||||
for _ in range(c.media_timeout_restart_threshold):
|
||||
c.note_download_timeout()
|
||||
|
||||
# Streak reached the threshold but no new restart is scheduled during a restart.
|
||||
assert c._media_recovery_task is None
|
||||
assert calls == []
|
||||
@@ -0,0 +1,580 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, logging-fstring-interpolation, line-too-long
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
"""
|
||||
Kurigram 2.2.23 — new/uncovered media types (LIVE_PHOTO, STORY, poll media,
|
||||
GIVEAWAY, GIVEAWAY_WINNERS, PAID_MEDIA, CHECKLIST, CONTACT, LOCATION, VENUE,
|
||||
DICE, GAME, INVOICE, UNSUPPORTED).
|
||||
|
||||
Covers:
|
||||
- titles for every new media type (_media_message_title via _generate_title);
|
||||
- HTML rendering: live photo <video>, story <video>/<img>, poll description_media
|
||||
<img>, paid media info block (no download);
|
||||
- info blocks for non-downloadable types (_format_special_media) incl. XSS escaping;
|
||||
- flags: no_image semantics for the new types and polls with/without media,
|
||||
"video" flag for live photos;
|
||||
- api_server.find_file_id_in_message: live_photo, story, poll description_media /
|
||||
explanation_media lookups.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from pyrogram.enums import MessageMediaType
|
||||
|
||||
from post_parser import PostParser
|
||||
from api_server import find_file_id_in_message
|
||||
from url_signer import KeyManager, generate_media_digest
|
||||
|
||||
|
||||
class _Str(str):
|
||||
"""Minimal stand-in for Pyrogram's Str: .html returns the raw string unchanged."""
|
||||
@property
|
||||
def html(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pinned_signing_key(monkeypatch):
|
||||
# generate_media_digest reads/creates data/media_digest.key relative to cwd; pin
|
||||
# the in-memory key so digests are deterministic and no file IO happens
|
||||
# regardless of the invocation directory (repo root or tests/).
|
||||
monkeypatch.setattr(KeyManager, "signing_key", "test-signing-key-new-media")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
return PostParser(SimpleNamespace())
|
||||
|
||||
|
||||
def make_message(mid=1, media=None, text=None, username="testchan", **extra):
|
||||
m = SimpleNamespace()
|
||||
m.id = mid
|
||||
m.date = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
m.text = _Str(text) if text is not None else None
|
||||
m.caption = None
|
||||
m.media = media
|
||||
m.web_page = None
|
||||
m.poll = None
|
||||
m.service = None
|
||||
m.forward_origin = None
|
||||
m.reply_to_message = None
|
||||
m.sender_chat = None
|
||||
m.from_user = None
|
||||
m.reactions = None
|
||||
m.views = 100
|
||||
m.media_group_id = None
|
||||
m.show_caption_above_media = False
|
||||
m.chat = SimpleNamespace(id=-1001234567890, username=username)
|
||||
for attr in ("photo", "video", "document", "audio", "voice",
|
||||
"video_note", "animation", "sticker"):
|
||||
setattr(m, attr, None)
|
||||
# New Kurigram 2.2.23 attributes (live_photo, story, giveaway, checklist, ...)
|
||||
# are deliberately NOT set by default: production code must survive their
|
||||
# absence via getattr (that is exactly what old mocks look like).
|
||||
for key, value in extra.items():
|
||||
setattr(m, key, value)
|
||||
return m
|
||||
|
||||
|
||||
def media_url(mid, fuid, username="testchan"):
|
||||
file = f"{username}/{mid}/{fuid}"
|
||||
return f"http://test.example.com/media/{file}/{generate_media_digest(file)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Titles for every new media type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_title_live_photo(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.LIVE_PHOTO)) == "📸 Live Photo"
|
||||
|
||||
def test_title_story(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.STORY)) == "📖 Story"
|
||||
|
||||
def test_title_giveaway(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.GIVEAWAY)) == "🎁 Giveaway"
|
||||
|
||||
def test_title_giveaway_winners(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.GIVEAWAY_WINNERS)) == "🏆 Giveaway winners"
|
||||
|
||||
def test_title_paid_media(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.PAID_MEDIA)) == "⭐ Paid media"
|
||||
|
||||
def test_title_checklist_with_title(parser):
|
||||
msg = make_message(media=MessageMediaType.CHECKLIST,
|
||||
checklist=SimpleNamespace(title="Shopping list", tasks=[]))
|
||||
assert parser._generate_title(msg) == "📝 Checklist: Shopping list"
|
||||
|
||||
def test_title_checklist_long_title_truncated(parser):
|
||||
long_title = "x" * 80
|
||||
msg = make_message(media=MessageMediaType.CHECKLIST,
|
||||
checklist=SimpleNamespace(title=long_title, tasks=[]))
|
||||
assert parser._generate_title(msg) == f"📝 Checklist: {'x' * 50}"
|
||||
|
||||
def test_title_checklist_without_object(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.CHECKLIST)) == "📝 Checklist"
|
||||
|
||||
def test_title_contact(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.CONTACT)) == "👤 Contact"
|
||||
|
||||
def test_title_location(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.LOCATION)) == "📍 Location"
|
||||
|
||||
def test_title_venue_with_title(parser):
|
||||
msg = make_message(media=MessageMediaType.VENUE,
|
||||
venue=SimpleNamespace(title="Blue Bottle Cafe", address="1 Main St"))
|
||||
assert parser._generate_title(msg) == "📍 Blue Bottle Cafe"
|
||||
|
||||
def test_title_venue_fallback(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.VENUE)) == "📍 Venue"
|
||||
|
||||
def test_title_dice(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.DICE)) == "🎲 Dice"
|
||||
|
||||
def test_title_game(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.GAME)) == "🎮 Game"
|
||||
|
||||
def test_title_invoice(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.INVOICE)) == "🧾 Invoice"
|
||||
|
||||
def test_title_unsupported(parser):
|
||||
assert parser._generate_title(make_message(media=MessageMediaType.UNSUPPORTED)) == "⚠️ Unsupported content"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. LIVE_PHOTO rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_live_photo_renders_video_with_signed_url(parser):
|
||||
msg = make_message(101, media=MessageMediaType.LIVE_PHOTO,
|
||||
live_photo=SimpleNamespace(file_unique_id="lp_uid_1", file_id="lp_fid_1"))
|
||||
html_media = parser._generate_html_media(msg)
|
||||
assert "<video" in html_media
|
||||
assert media_url(101, "lp_uid_1") in html_media
|
||||
|
||||
|
||||
def test_live_photo_collected_for_media_ids(parser):
|
||||
msg = make_message(102, media=MessageMediaType.LIVE_PHOTO,
|
||||
live_photo=SimpleNamespace(file_unique_id="lp_uid_2", file_id="lp_fid_2"))
|
||||
parser._generate_html_media(msg)
|
||||
assert [(c, p, f) for c, p, f, _ in parser._pending_media_ids] == [("testchan", 102, "lp_uid_2")]
|
||||
|
||||
|
||||
def test_live_photo_gets_video_flag_not_no_image(parser):
|
||||
msg = make_message(103, media=MessageMediaType.LIVE_PHOTO,
|
||||
live_photo=SimpleNamespace(file_unique_id="lp_uid_3", file_id="lp_fid_3"))
|
||||
flags = parser._extract_flags(msg)
|
||||
assert "video" in flags
|
||||
assert "no_image" not in flags
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. STORY rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_story_with_video_renders_video(parser):
|
||||
msg = make_message(111, media=MessageMediaType.STORY,
|
||||
story=SimpleNamespace(video=SimpleNamespace(file_unique_id="st_vid"),
|
||||
photo=None))
|
||||
html_media = parser._generate_html_media(msg)
|
||||
assert "<video" in html_media
|
||||
assert media_url(111, "st_vid") in html_media
|
||||
|
||||
|
||||
def test_story_with_photo_renders_img(parser):
|
||||
msg = make_message(112, media=MessageMediaType.STORY,
|
||||
story=SimpleNamespace(video=None,
|
||||
photo=SimpleNamespace(file_unique_id="st_pic")))
|
||||
html_media = parser._generate_html_media(msg)
|
||||
assert "<img" in html_media
|
||||
assert "<video" not in html_media
|
||||
assert media_url(112, "st_pic") in html_media
|
||||
|
||||
|
||||
def test_story_video_wins_over_photo(parser):
|
||||
msg = make_message(113, media=MessageMediaType.STORY,
|
||||
story=SimpleNamespace(video=SimpleNamespace(file_unique_id="st_vid2"),
|
||||
photo=SimpleNamespace(file_unique_id="st_pic2")))
|
||||
html_media = parser._generate_html_media(msg)
|
||||
assert media_url(113, "st_vid2") in html_media
|
||||
assert "st_pic2" not in html_media
|
||||
|
||||
|
||||
def test_story_video_without_uid_falls_back_to_photo_as_img(parser):
|
||||
# Review fix 3: the tag choice must follow the SAME object selection as the URL.
|
||||
# A story video with an unusable file_unique_id is skipped by the helper in
|
||||
# favour of the photo — the render must emit <img>, not <video>.
|
||||
msg = make_message(114, media=MessageMediaType.STORY,
|
||||
story=SimpleNamespace(video=SimpleNamespace(file_unique_id=None),
|
||||
photo=SimpleNamespace(file_unique_id="st_pic3")))
|
||||
html_media = parser._generate_html_media(msg)
|
||||
assert "<img" in html_media
|
||||
assert "<video" not in html_media
|
||||
assert media_url(114, "st_pic3") in html_media
|
||||
|
||||
|
||||
def test_story_large_video_not_collected_for_media_ids(parser):
|
||||
# Review fix 2: the >100MB "don't cache" rule applies to story videos too.
|
||||
msg = make_message(115, media=MessageMediaType.STORY,
|
||||
story=SimpleNamespace(video=SimpleNamespace(file_unique_id="st_big",
|
||||
file_size=200 * 1024 * 1024),
|
||||
photo=None))
|
||||
parser._save_media_file_ids(msg)
|
||||
assert parser._pending_media_ids == []
|
||||
|
||||
|
||||
def test_story_small_video_collected_for_media_ids(parser):
|
||||
msg = make_message(116, media=MessageMediaType.STORY,
|
||||
story=SimpleNamespace(video=SimpleNamespace(file_unique_id="st_small",
|
||||
file_size=5 * 1024 * 1024),
|
||||
photo=None))
|
||||
parser._save_media_file_ids(msg)
|
||||
assert [(c, p, f) for c, p, f, _ in parser._pending_media_ids] == [("testchan", 116, "st_small")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. POLL with/without description_media
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _poll_with_photo(fuid="poll_pic", fid="poll_pic_fid"):
|
||||
return SimpleNamespace(
|
||||
question=_Str("Pick one?"),
|
||||
options=[],
|
||||
description_media=SimpleNamespace(photo=SimpleNamespace(file_unique_id=fuid, file_id=fid)),
|
||||
)
|
||||
|
||||
|
||||
def test_poll_with_description_photo_renders_img(parser):
|
||||
msg = make_message(121, media=MessageMediaType.POLL, poll=_poll_with_photo())
|
||||
html_media = parser._generate_html_media(msg)
|
||||
assert "<img" in html_media
|
||||
assert media_url(121, "poll_pic") in html_media
|
||||
|
||||
|
||||
def test_poll_with_description_photo_has_no_no_image_flag(parser):
|
||||
msg = make_message(122, media=MessageMediaType.POLL, poll=_poll_with_photo())
|
||||
flags = parser._extract_flags(msg)
|
||||
assert "no_image" not in flags
|
||||
assert "poll" in flags
|
||||
|
||||
|
||||
def test_poll_without_media_keeps_no_image_flag(parser):
|
||||
msg = make_message(123, media=MessageMediaType.POLL,
|
||||
poll=SimpleNamespace(question=_Str("Plain poll?"), options=[]))
|
||||
flags = parser._extract_flags(msg)
|
||||
assert "no_image" in flags
|
||||
assert "poll" in flags
|
||||
|
||||
|
||||
def test_poll_media_collected_for_media_ids(parser):
|
||||
msg = make_message(124, media=MessageMediaType.POLL, poll=_poll_with_photo("poll_pic4"))
|
||||
parser._generate_html_media(msg)
|
||||
assert [(c, p, f) for c, p, f, _ in parser._pending_media_ids] == [("testchan", 124, "poll_pic4")]
|
||||
|
||||
|
||||
def test_poll_with_video_description_renders_video(parser):
|
||||
poll = SimpleNamespace(
|
||||
question=_Str("Video poll?"),
|
||||
options=[],
|
||||
description_media=SimpleNamespace(video=SimpleNamespace(file_unique_id="poll_vid", file_id="poll_vid_fid")),
|
||||
)
|
||||
msg = make_message(125, media=MessageMediaType.POLL, poll=poll)
|
||||
html_media = parser._generate_html_media(msg)
|
||||
assert "<video" in html_media
|
||||
assert media_url(125, "poll_vid") in html_media
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. api_server.find_file_id_in_message (async)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_find_file_id_live_photo():
|
||||
msg = make_message(201, media=MessageMediaType.LIVE_PHOTO,
|
||||
live_photo=SimpleNamespace(file_unique_id="lp_uid", file_id="lp_fid"))
|
||||
assert await find_file_id_in_message(msg, "lp_uid") == "lp_fid"
|
||||
|
||||
|
||||
async def test_find_file_id_story_video():
|
||||
msg = make_message(202, media=MessageMediaType.STORY,
|
||||
story=SimpleNamespace(photo=None,
|
||||
video=SimpleNamespace(file_unique_id="sv_uid", file_id="sv_fid")))
|
||||
assert await find_file_id_in_message(msg, "sv_uid") == "sv_fid"
|
||||
|
||||
|
||||
async def test_find_file_id_story_photo():
|
||||
msg = make_message(203, media=MessageMediaType.STORY,
|
||||
story=SimpleNamespace(photo=SimpleNamespace(file_unique_id="sp_uid", file_id="sp_fid"),
|
||||
video=None))
|
||||
assert await find_file_id_in_message(msg, "sp_uid") == "sp_fid"
|
||||
|
||||
|
||||
async def test_find_file_id_poll_description_photo():
|
||||
msg = make_message(204, media=MessageMediaType.POLL, poll=_poll_with_photo("pd_uid", "pd_fid"))
|
||||
assert await find_file_id_in_message(msg, "pd_uid") == "pd_fid"
|
||||
|
||||
|
||||
async def test_find_file_id_poll_explanation_video():
|
||||
poll = SimpleNamespace(
|
||||
question=_Str("Quiz?"),
|
||||
options=[],
|
||||
explanation_media=SimpleNamespace(video=SimpleNamespace(file_unique_id="ex_uid", file_id="ex_fid")),
|
||||
)
|
||||
msg = make_message(205, media=MessageMediaType.POLL, poll=poll)
|
||||
assert await find_file_id_in_message(msg, "ex_uid") == "ex_fid"
|
||||
|
||||
|
||||
async def test_find_file_id_poll_without_media_returns_none():
|
||||
msg = make_message(206, media=MessageMediaType.POLL,
|
||||
poll=SimpleNamespace(question=_Str("Plain?"), options=[]))
|
||||
assert await find_file_id_in_message(msg, "whatever") is None
|
||||
|
||||
|
||||
async def test_find_file_id_poll_none_object_returns_none():
|
||||
msg = make_message(207, media=MessageMediaType.POLL) # message.poll stays None
|
||||
assert await find_file_id_in_message(msg, "whatever") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. XSS: user-controlled strings in info blocks are escaped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
XSS = "<script>alert(1)</script>"
|
||||
XSS_ESCAPED = "<script>alert(1)</script>"
|
||||
|
||||
|
||||
def test_contact_name_is_escaped(parser):
|
||||
msg = make_message(301, media=MessageMediaType.CONTACT,
|
||||
contact=SimpleNamespace(first_name=XSS, last_name="Doe",
|
||||
phone_number="+1234567890", user_id=None, vcard=None))
|
||||
body = parser._generate_html_body(msg)
|
||||
assert XSS not in body
|
||||
assert XSS_ESCAPED in body
|
||||
assert "+1234567890" in body
|
||||
|
||||
|
||||
def test_checklist_task_text_is_escaped(parser):
|
||||
checklist = SimpleNamespace(
|
||||
title="My list",
|
||||
tasks=[
|
||||
SimpleNamespace(id=1, text=XSS, completed_by=None, completion_date=None),
|
||||
SimpleNamespace(id=2, text="done task", completed_by=SimpleNamespace(id=7), completion_date=None),
|
||||
],
|
||||
)
|
||||
msg = make_message(302, media=MessageMediaType.CHECKLIST, checklist=checklist)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert XSS not in body
|
||||
assert f"☐ {XSS_ESCAPED}" in body
|
||||
assert "☑ done task" in body
|
||||
assert "📝 My list" in body
|
||||
|
||||
|
||||
def test_venue_title_is_escaped(parser):
|
||||
# VENUE title feeds venue_label -> html.escape(venue_label)
|
||||
venue = SimpleNamespace(title=XSS, address="1 Main St",
|
||||
location=SimpleNamespace(latitude=1.5, longitude=2.5))
|
||||
msg = make_message(303, media=MessageMediaType.VENUE, venue=venue)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert XSS not in body
|
||||
assert XSS_ESCAPED in body
|
||||
|
||||
|
||||
def test_venue_address_label_is_escaped(parser):
|
||||
# VENUE address also feeds venue_label -> html.escape(venue_label)
|
||||
venue = SimpleNamespace(title="Blue Bottle", address=XSS,
|
||||
location=SimpleNamespace(latitude=1.5, longitude=2.5))
|
||||
msg = make_message(304, media=MessageMediaType.VENUE, venue=venue)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert XSS not in body
|
||||
assert XSS_ESCAPED in body
|
||||
|
||||
|
||||
def test_dice_emoji_is_escaped(parser):
|
||||
# DICE emoji -> html.escape(str(dice_emoji))
|
||||
msg = make_message(305, media=MessageMediaType.DICE,
|
||||
dice=SimpleNamespace(emoji=XSS, value=6))
|
||||
body = parser._generate_html_body(msg)
|
||||
assert XSS not in body
|
||||
assert XSS_ESCAPED in body
|
||||
|
||||
|
||||
def test_game_title_is_escaped(parser):
|
||||
# GAME title -> html.escape(game_title.strip())
|
||||
msg = make_message(306, media=MessageMediaType.GAME,
|
||||
game=SimpleNamespace(title=XSS))
|
||||
body = parser._generate_html_body(msg)
|
||||
assert XSS not in body
|
||||
assert XSS_ESCAPED in body
|
||||
|
||||
|
||||
def test_checklist_title_is_escaped(parser):
|
||||
# CHECKLIST title -> html.escape(title_str)
|
||||
checklist = SimpleNamespace(title=XSS, tasks=[])
|
||||
msg = make_message(307, media=MessageMediaType.CHECKLIST, checklist=checklist)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert XSS not in body
|
||||
assert XSS_ESCAPED in body
|
||||
|
||||
|
||||
def test_giveaway_description_is_escaped(parser):
|
||||
# GIVEAWAY description -> html.escape(description.strip())
|
||||
giveaway = SimpleNamespace(quantity=5, months=None, stars=None,
|
||||
until_date=None, description=XSS)
|
||||
msg = make_message(308, media=MessageMediaType.GIVEAWAY, giveaway=giveaway)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert XSS not in body
|
||||
assert XSS_ESCAPED in body
|
||||
|
||||
|
||||
def test_giveaway_winners_prize_description_is_escaped(parser):
|
||||
# GIVEAWAY_WINNERS prize_description -> html.escape(prize_description.strip())
|
||||
winners = SimpleNamespace(winner_count=2, quantity=5, prize_description=XSS,
|
||||
unclaimed_prize_count=0, winners=[])
|
||||
msg = make_message(309, media=MessageMediaType.GIVEAWAY_WINNERS, giveaway_winners=winners)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert XSS not in body
|
||||
assert XSS_ESCAPED in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Giveaway / giveaway winners info blocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_giveaway_block_quantity_and_months(parser):
|
||||
giveaway = SimpleNamespace(quantity=10, months=3, stars=None,
|
||||
until_date=datetime(2026, 2, 1, tzinfo=timezone.utc),
|
||||
description="Win big!")
|
||||
msg = make_message(311, media=MessageMediaType.GIVEAWAY, giveaway=giveaway)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert "🎁 Giveaway: 10 prize(s)" in body
|
||||
assert "3 months Premium" in body
|
||||
assert "until 01/02/2026" in body
|
||||
assert "Win big!" in body
|
||||
|
||||
|
||||
def test_giveaway_block_with_stars(parser):
|
||||
giveaway = SimpleNamespace(quantity=5, months=None, stars=500,
|
||||
until_date=None, description=None)
|
||||
msg = make_message(312, media=MessageMediaType.GIVEAWAY, giveaway=giveaway)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert "🎁 Giveaway: 5 prize(s)" in body
|
||||
assert "500 Stars" in body
|
||||
|
||||
|
||||
def test_giveaway_winners_block(parser):
|
||||
winners = SimpleNamespace(winner_count=2, quantity=5, prize_description="Cool prize",
|
||||
unclaimed_prize_count=3, winners=[])
|
||||
msg = make_message(313, media=MessageMediaType.GIVEAWAY_WINNERS, giveaway_winners=winners)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert "🏆 Giveaway winners: 2 of 5" in body
|
||||
assert "Cool prize" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Other info blocks and paid media
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_paid_media_renders_info_block_without_download(parser):
|
||||
paid = SimpleNamespace(stars_amount=50,
|
||||
media=[SimpleNamespace(width=100, height=100, duration=None, thumbnail=None),
|
||||
SimpleNamespace(width=200, height=200, duration=5, thumbnail=None)])
|
||||
msg = make_message(321, media=MessageMediaType.PAID_MEDIA, paid_media=paid)
|
||||
html_media = parser._generate_html_media(msg)
|
||||
assert "⭐ Paid media (50 stars, 2 item(s)) — available in Telegram" in html_media
|
||||
assert "/media/" not in html_media # nothing downloadable
|
||||
assert parser._pending_media_ids == [] # nothing collected for the download cache
|
||||
assert "no_image" in parser._extract_flags(msg)
|
||||
|
||||
|
||||
def test_location_block_has_osm_link(parser):
|
||||
msg = make_message(322, media=MessageMediaType.LOCATION,
|
||||
location=SimpleNamespace(latitude=55.75123456, longitude=37.61761111))
|
||||
body = parser._generate_html_body(msg)
|
||||
assert "📍 Location:" in body
|
||||
assert "https://www.openstreetmap.org/?mlat=55.75123456&mlon=37.61761111" in body
|
||||
assert "55.75123, 37.61761" in body
|
||||
assert "no_image" in parser._extract_flags(msg)
|
||||
|
||||
|
||||
def test_venue_block_with_osm_link(parser):
|
||||
venue = SimpleNamespace(title="Blue Bottle", address="1 Main St",
|
||||
location=SimpleNamespace(latitude=1.5, longitude=2.5))
|
||||
msg = make_message(323, media=MessageMediaType.VENUE, venue=venue)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert "📍 Blue Bottle, 1 Main St" in body
|
||||
assert "openstreetmap.org/?mlat=1.5&mlon=2.5" in body
|
||||
|
||||
|
||||
# Review fix 1: info-block-only media types must not open an empty
|
||||
# <div class="message-media"> container — only the special block is rendered.
|
||||
|
||||
def test_venue_has_no_empty_media_div(parser):
|
||||
venue = SimpleNamespace(title="Cafe", address="2 Side St",
|
||||
location=SimpleNamespace(latitude=1.0, longitude=2.0))
|
||||
msg = make_message(328, media=MessageMediaType.VENUE, venue=venue)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert 'class="message-media"' not in body
|
||||
assert 'class="message-special"' in body
|
||||
|
||||
|
||||
def test_contact_has_no_empty_media_div(parser):
|
||||
msg = make_message(329, media=MessageMediaType.CONTACT,
|
||||
contact=SimpleNamespace(first_name="Jane", last_name="Doe",
|
||||
phone_number="+1999", user_id=None, vcard=None))
|
||||
body = parser._generate_html_body(msg)
|
||||
assert 'class="message-media"' not in body
|
||||
assert 'class="message-special"' in body
|
||||
|
||||
|
||||
def test_paid_media_block_survives_media_div_gate(parser):
|
||||
# PAID_MEDIA is in NO_IMAGE_MEDIA_TYPES but has its own render branch — the
|
||||
# info block (inside its message-media container) must keep rendering.
|
||||
paid = SimpleNamespace(stars_amount=10, media=[SimpleNamespace(width=1, height=1,
|
||||
duration=None, thumbnail=None)])
|
||||
msg = make_message(330, media=MessageMediaType.PAID_MEDIA, paid_media=paid)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert 'class="message-media"' in body
|
||||
assert "⭐ Paid media (10 stars, 1 item(s)) — available in Telegram" in body
|
||||
|
||||
|
||||
def test_dice_block(parser):
|
||||
msg = make_message(324, media=MessageMediaType.DICE,
|
||||
dice=SimpleNamespace(emoji="🎯", value=6))
|
||||
body = parser._generate_html_body(msg)
|
||||
assert "🎲 🎯: 6" in body
|
||||
|
||||
|
||||
def test_game_block_with_title(parser):
|
||||
msg = make_message(325, media=MessageMediaType.GAME,
|
||||
game=SimpleNamespace(title="Tetris"))
|
||||
body = parser._generate_html_body(msg)
|
||||
assert "🎮 Game: Tetris" in body
|
||||
|
||||
|
||||
def test_invoice_block(parser):
|
||||
msg = make_message(326, media=MessageMediaType.INVOICE)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert "🧾 Invoice" in body
|
||||
|
||||
|
||||
def test_unsupported_block(parser):
|
||||
msg = make_message(327, media=MessageMediaType.UNSUPPORTED)
|
||||
body = parser._generate_html_body(msg)
|
||||
assert "⚠️ This post contains content not supported by the bridge" in body
|
||||
assert "no_image" in parser._extract_flags(msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: messages without any of the new attributes still render
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_plain_text_message_unaffected(parser):
|
||||
msg = make_message(331, text="just a plain post with enough text")
|
||||
body = parser._generate_html_body(msg)
|
||||
assert "just a plain post with enough text" in body
|
||||
assert "message-special" not in body
|
||||
assert "paid-media" not in body
|
||||
flags = parser._extract_flags(msg)
|
||||
assert "no_image" in flags
|
||||
@@ -7,13 +7,6 @@
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
import sys
|
||||
import os
|
||||
# Add project root to sys.path to find post_parser
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
# Mock the config module
|
||||
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
|
||||
|
||||
from pyrogram.types import Message
|
||||
from post_parser import PostParser
|
||||
|
||||
@@ -7,20 +7,28 @@
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to sys.path to find post_parser
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
# Mock the config module before importing PostParser
|
||||
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
|
||||
|
||||
from pyrogram.types import Message, Chat, Reaction, MessageReactions
|
||||
from pyrogram.enums import MessageMediaType
|
||||
from post_parser import PostParser # Import after mocking config
|
||||
|
||||
|
||||
class StrWithHtml(str):
|
||||
"""String subclass that mimics Pyrogram's text/caption objects.
|
||||
|
||||
Pyrogram's text and caption fields are not plain str — they provide an
|
||||
.html property that returns the HTML-escaped version of the text.
|
||||
Using a plain str (or MagicMock) breaks len() / strip() checks in
|
||||
production code. This class inherits from str so all str operations
|
||||
(len, strip, comparison, boolean coercion) work correctly, while also
|
||||
exposing the .html property expected by the parser.
|
||||
"""
|
||||
|
||||
@property
|
||||
def html(self):
|
||||
return self
|
||||
|
||||
|
||||
class TestPostParserExtractFlags(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@@ -72,18 +80,14 @@ class TestPostParserExtractFlags(unittest.TestCase):
|
||||
else:
|
||||
message.forward_origin = None
|
||||
|
||||
# Correctly mock text and caption to have an .html attribute and behave like strings
|
||||
# Assign text and caption as StrWithHtml instances so that str operations
|
||||
# (len, strip, bool coercion) behave exactly as they would with real
|
||||
# Pyrogram objects, while the .html property is still available.
|
||||
if text:
|
||||
mock_text = MagicMock()
|
||||
mock_text.html = text
|
||||
mock_text.__str__.return_value = text # Add __str__ mock
|
||||
message.text = mock_text
|
||||
message.text = StrWithHtml(text)
|
||||
message.caption = None
|
||||
elif caption:
|
||||
mock_caption = MagicMock()
|
||||
mock_caption.html = caption
|
||||
mock_caption.__str__.return_value = caption # Add __str__ mock
|
||||
message.caption = mock_caption
|
||||
message.caption = StrWithHtml(caption)
|
||||
message.text = None
|
||||
else:
|
||||
message.text = None
|
||||
@@ -126,36 +130,8 @@ class TestPostParserExtractFlags(unittest.TestCase):
|
||||
|
||||
def test_flag_video_media_video_long_caption(self):
|
||||
long_caption = "This is a very long caption that exceeds the two hundred character limit, therefore it should not trigger the video flag even though the media type is video." * 5
|
||||
|
||||
# Create a simple mock message for this specific test
|
||||
message = MagicMock(spec=Message)
|
||||
message.media = MessageMediaType.VIDEO
|
||||
message.id = 123
|
||||
message.forward_origin = None # Add missing attribute
|
||||
message.web_page = None # Add missing attribute checked in _extract_flags
|
||||
|
||||
# Create a basic mock Chat
|
||||
mock_chat = MagicMock(spec=Chat)
|
||||
mock_chat.username = "test_channel"
|
||||
mock_chat.id = 1234567890
|
||||
message.chat = mock_chat
|
||||
|
||||
# Set up text and caption to ensure len() returns the correct length
|
||||
message.text = None
|
||||
|
||||
# Create a custom class that mimics caption with html attribute
|
||||
class CaptionWithHtml(str):
|
||||
@property
|
||||
def html(self):
|
||||
return self
|
||||
|
||||
# Use our class instead of a regular string
|
||||
message.caption = CaptionWithHtml(long_caption)
|
||||
|
||||
# Mock HTML body generation
|
||||
self.parser._generate_html_body = MagicMock(return_value=long_caption)
|
||||
|
||||
# In _extract_flags, text length is checked and should be > 200
|
||||
message = self._create_mock_message(media=MessageMediaType.VIDEO, caption=long_caption)
|
||||
# Sanity-check: the caption must actually be longer than the threshold
|
||||
self.assertGreater(len(message.caption), 200)
|
||||
self.assertNotIn("video", self.parser._extract_flags(message))
|
||||
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
import sys
|
||||
import os
|
||||
# Add project root to sys.path to find post_parser
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
# Mock the config module
|
||||
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
|
||||
|
||||
from pyrogram.types import Message, Document
|
||||
from pyrogram.enums import MessageMediaType
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=missing-function-docstring, redefined-outer-name, line-too-long
|
||||
"""Unit tests for the single project-wide bleach config (sanitizer.py, issue #28 / §3).
|
||||
|
||||
Covers the stage-1 registry items that this module owns:
|
||||
§3.1 s / del (strikethrough) survive.
|
||||
§3.2 fail-closed: on a bleach error the fragment is html.escape()d, never returned raw.
|
||||
§3.16 the single error-log name `html_sanitization_error` + log_context.
|
||||
Plus the two non-default bleach params the maintainer flagged as load-bearing:
|
||||
strip=True (disallowed tags are REMOVED, not escaped into visible text) and
|
||||
protocols including 'tg' (tg:// links survive).
|
||||
And the one-config invariant: only one ALLOWED_TAGS list exists in the repo.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
import sanitizer
|
||||
from sanitizer import sanitize_html, ALLOWED_TAGS, ALLOWED_PROTOCOLS
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §3.1 — strikethrough survives (the drift the single config fixes).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_strikethrough_s_survives():
|
||||
assert sanitize_html("<s>gone</s>") == "<s>gone</s>"
|
||||
|
||||
|
||||
def test_strikethrough_del_survives():
|
||||
assert sanitize_html("<del>removed</del>") == "<del>removed</del>"
|
||||
|
||||
|
||||
def test_s_and_del_in_allowed_tags():
|
||||
assert "s" in ALLOWED_TAGS
|
||||
assert "del" in ALLOWED_TAGS
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# strip=True — disallowed tags are REMOVED (bleach's default False would escape
|
||||
# them into visible text). Both the tag and its dangerous attrs must vanish.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_script_is_stripped_not_escaped():
|
||||
out = sanitize_html("<b>hi</b><script>alert(1)</script>")
|
||||
assert out == "<b>hi</b>alert(1)"
|
||||
# strip=True removed the tag entirely — it was NOT escaped into visible <script>.
|
||||
assert "<script>" not in out
|
||||
assert "<script" not in out
|
||||
|
||||
|
||||
def test_onerror_attribute_is_stripped():
|
||||
out = sanitize_html('<img src="http://e/x.png" onerror="alert(1)">')
|
||||
assert "onerror" not in out
|
||||
assert "alert(1)" not in out
|
||||
|
||||
|
||||
def test_javascript_protocol_href_dropped():
|
||||
out = sanitize_html('<a href="javascript:alert(1)">x</a>')
|
||||
# The <a> tag survives (whitelisted) but the dangerous href is dropped by the protocol filter.
|
||||
assert "javascript:" not in out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# protocols — non-default 'tg' keeps tg:// links (channel footers use them).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_tg_protocol_href_survives():
|
||||
out = sanitize_html('<a href="tg://resolve?domain=x">x</a>')
|
||||
assert 'href="tg://resolve?domain=x"' in out
|
||||
|
||||
|
||||
def test_http_and_https_survive():
|
||||
assert 'href="https://e/x"' in sanitize_html('<a href="https://e/x">x</a>')
|
||||
assert 'href="http://e/x"' in sanitize_html('<a href="http://e/x">x</a>')
|
||||
|
||||
|
||||
def test_tg_in_allowed_protocols():
|
||||
assert ALLOWED_PROTOCOLS == ['http', 'https', 'tg']
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CSS sanitizer — only the whitelisted properties survive inside style="".
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_css_allowed_property_survives():
|
||||
out = sanitize_html('<img src="http://e/x" style="max-width: 100%">')
|
||||
assert "max-width" in out
|
||||
|
||||
|
||||
def test_css_disallowed_property_dropped():
|
||||
out = sanitize_html('<div style="position: fixed; max-height: 50px">x</div>')
|
||||
assert "position" not in out
|
||||
assert "max-height" in out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §3.2 — FAIL-CLOSED: any bleach error escapes the raw input, never returns it raw.
|
||||
# §3.16 — the single error-log name `html_sanitization_error` + log_context.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_fail_closed_escapes_on_bleach_error(monkeypatch, caplog):
|
||||
def boom(*a, **k):
|
||||
raise RecursionError("bleach exploded")
|
||||
# sanitize_html resolves HTMLSanitizer as a module global at call time.
|
||||
monkeypatch.setattr(sanitizer, "HTMLSanitizer", boom, raising=True)
|
||||
|
||||
payload = '<script>alert(1)</script>'
|
||||
with caplog.at_level(logging.ERROR):
|
||||
out = sanitize_html(payload, log_context="channel test, message_id 7")
|
||||
|
||||
# Fail-closed: the raw payload was html.escape()d, NOT returned raw.
|
||||
assert out == "<script>alert(1)</script>"
|
||||
assert "<script" not in out
|
||||
# §3.16: single error-log name, with the log_context included for grep-ability.
|
||||
assert "html_sanitization_error" in caplog.text
|
||||
assert "channel test, message_id 7" in caplog.text
|
||||
|
||||
|
||||
def test_fail_closed_log_name_without_context(monkeypatch, caplog):
|
||||
def boom(*a, **k):
|
||||
raise ValueError("nope")
|
||||
monkeypatch.setattr(sanitizer, "HTMLSanitizer", boom, raising=True)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
sanitize_html("<b>x</b>")
|
||||
# Same single name even when no context is supplied (single-post path).
|
||||
assert "html_sanitization_error" in caplog.text
|
||||
# The legacy per-path names must be gone.
|
||||
assert "rss_html_sanitization_error" not in caplog.text
|
||||
assert "html_final_sanitization_error" not in caplog.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# One-config invariant: exactly one ALLOWED_TAGS list in the tree, in sanitizer.py.
|
||||
# post_parser / rss_generator must route through the module, not re-declare bleach.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_single_bleach_config_no_reimport_in_render_modules():
|
||||
import inspect
|
||||
import post_parser
|
||||
import rss_generator
|
||||
for mod in (post_parser, rss_generator):
|
||||
src = inspect.getsource(mod)
|
||||
assert "allowed_tags" not in src.lower() or "sanitizer" in src, \
|
||||
f"{mod.__name__} appears to re-declare a bleach tag list instead of using sanitizer.py"
|
||||
assert "from bleach" not in src and "import bleach" not in src, \
|
||||
f"{mod.__name__} still imports bleach directly; the only config lives in sanitizer.py"
|
||||
@@ -0,0 +1,78 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=missing-function-docstring, redefined-outer-name, line-too-long
|
||||
"""Stage-0 golden oracle (render-pipeline refactor epic, issue #27/#34).
|
||||
|
||||
Regenerates the RSS + HTML feeds for the frozen recorded corpus and asserts
|
||||
byte-equality against the committed goldens after the spec's declared normalization
|
||||
(strip volatile <lastBuildDate>). Any other byte change = a render regression, and
|
||||
this test must catch it. (The stage-0 merged-flags sort normalization was removed in
|
||||
stage 2: flag order is now deterministic first-seen order — §3.8.)
|
||||
|
||||
Guardrails: this stage only ADDS a loader + goldens + this test. No render/pipeline
|
||||
production code is touched; the goldens freeze CURRENT behavior including known bugs
|
||||
(fixed in later stages, each referencing a §3 registry item).
|
||||
|
||||
Regenerate goldens with: python -m tests.golden_replay
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from tests import golden_replay as gr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def golden_env(monkeypatch):
|
||||
"""Apply the determinism pins (signing key, time_based_merge, DB no-op). TZ=UTC is
|
||||
pinned process-wide in conftest."""
|
||||
gr.pin_environment(monkeypatch)
|
||||
return monkeypatch
|
||||
|
||||
|
||||
def _read_golden(channel, kind):
|
||||
with open(gr.golden_path(channel, kind), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel", gr.CORPUS_CHANNELS)
|
||||
def test_rss_golden(channel, golden_env):
|
||||
gr.patch_tg_cache(golden_env, channel)
|
||||
actual = gr.capture_rss(channel)
|
||||
expected = _read_golden(channel, "rss")
|
||||
assert gr.normalize_rss(actual) == gr.normalize_rss(expected), \
|
||||
f"RSS feed for {channel} diverged from the golden (render regression)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel", gr.CORPUS_CHANNELS)
|
||||
def test_html_golden(channel, golden_env):
|
||||
gr.patch_tg_cache(golden_env, channel)
|
||||
actual = gr.capture_html(channel)
|
||||
expected = _read_golden(channel, "html")
|
||||
assert gr.normalize_html(actual) == gr.normalize_html(expected), \
|
||||
f"HTML feed for {channel} diverged from the golden (render regression)"
|
||||
|
||||
|
||||
def test_all_goldens_present_and_nonempty():
|
||||
"""The corpus and its goldens must stay in lockstep — a missing/empty golden would
|
||||
silently pass the parametrized tests only if a channel were also dropped."""
|
||||
import os
|
||||
for channel in gr.CORPUS_CHANNELS:
|
||||
for kind in ("rss", "html"):
|
||||
path = gr.golden_path(channel, kind)
|
||||
assert os.path.exists(path), f"missing golden: {path}"
|
||||
assert os.path.getsize(path) > 0, f"empty golden: {path}"
|
||||
|
||||
|
||||
def test_normalization_preserves_flag_order():
|
||||
"""Stage 2 (§3.8): merged-post flags are now emitted in deterministic first-seen
|
||||
order, so normalize_html no longer reorders the flags div — a real flag reordering
|
||||
must reach the byte comparison instead of being masked."""
|
||||
a = '<div class="message-flags"> 🏷 video 🏷 fwd 🏷 link </div>'
|
||||
b = '<div class="message-flags"> 🏷 link 🏷 fwd 🏷 video </div>'
|
||||
assert gr.normalize_html(a) != gr.normalize_html(b)
|
||||
assert gr.normalize_html(a) == a
|
||||
|
||||
|
||||
def test_normalization_strips_lastbuilddate():
|
||||
"""<lastBuildDate> is the one volatile RSS field (feedgen now() in the constructor)."""
|
||||
a = "<x><lastBuildDate>Mon, 06 Jul 2026 07:32:04 +0000</lastBuildDate><y/></x>"
|
||||
b = "<x><lastBuildDate>Mon, 06 Jul 2026 09:15:59 +0000</lastBuildDate><y/></x>"
|
||||
assert gr.normalize_rss(a) == gr.normalize_rss(b)
|
||||
@@ -0,0 +1,218 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, logging-fstring-interpolation, line-too-long
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
"""
|
||||
Stage 1 (anti-hang) regression tests:
|
||||
- RPC gate timeout: a hung RPC times out and the gate permit is NOT leaked; a
|
||||
subsequent call still succeeds.
|
||||
- Background worker: a download that raises Exception / FloodWait does not kill the
|
||||
worker, and task_done stays balanced so queue.join() completes.
|
||||
- Gate cancellation during the spacing wait does not lose the permit.
|
||||
"""
|
||||
import time
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import tg_throttle
|
||||
import tg_cache
|
||||
from pyrogram import errors
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1.1 — RPC gate timeout releases the permit; second call succeeds.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_gate_timeout_releases_permit(monkeypatch):
|
||||
# Short timeout so the hung RPC fails fast, and no min-interval spacing to slow the test.
|
||||
monkeypatch.setitem(tg_cache.Config, "tg_rpc_timeout", 0.1)
|
||||
monkeypatch.setattr(tg_throttle, "_MIN_INTERVAL", 0.0)
|
||||
# Bypass the on-disk chat cache so we always hit the live RPC path.
|
||||
monkeypatch.setattr(tg_cache, "_get_chat_from_cache", lambda *a, **k: None)
|
||||
monkeypatch.setattr(tg_cache, "_save_chat_to_cache", lambda *a, **k: None)
|
||||
|
||||
permits_before = tg_throttle._sem._value
|
||||
never = asyncio.Event() # never set -> RPC hangs forever
|
||||
|
||||
class HungClient:
|
||||
async def get_chat(self, channel_id):
|
||||
await never.wait()
|
||||
|
||||
# First call: the RPC hangs and must time out (not hang the whole app).
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await tg_cache.cached_get_chat(HungClient(), "hung_channel")
|
||||
|
||||
# The permit was released on timeout (gate fully available again) — no leak.
|
||||
assert tg_throttle._sem._value == permits_before
|
||||
|
||||
# A second call still goes through the gate and succeeds.
|
||||
class OkClient:
|
||||
async def get_chat(self, channel_id):
|
||||
return SimpleNamespace(id=42, title="ok", username="okchan")
|
||||
|
||||
res = await tg_cache.cached_get_chat(OkClient(), "ok_channel")
|
||||
assert res.id == 42
|
||||
# Permit released again after the successful call.
|
||||
assert tg_throttle._sem._value == permits_before
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1.4 — Gate cancellation during the spacing wait does not lose the permit.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_gate_cancel_during_spacing_releases_permit(monkeypatch):
|
||||
# Force a spacing wait long enough to cancel inside it.
|
||||
monkeypatch.setattr(tg_throttle, "_MIN_INTERVAL", 1.0)
|
||||
monkeypatch.setattr(tg_throttle, "_last_start", time.monotonic())
|
||||
|
||||
permits_before = tg_throttle._sem._value
|
||||
|
||||
async def enter_gate():
|
||||
async with tg_throttle.tg_rpc():
|
||||
pass
|
||||
|
||||
task = asyncio.create_task(enter_gate())
|
||||
# Let it acquire the semaphore and start sleeping for the spacing interval.
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
# Cancelled mid-spacing: the acquired permit must be returned.
|
||||
assert tg_throttle._sem._value == permits_before
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1.3 — Background worker survives download errors; task_done stays balanced.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_survives_errors_and_balances_task_done(monkeypatch):
|
||||
import api_server
|
||||
|
||||
# Record (and skip) the worker's sleeps so we can assert the FloodWait branch
|
||||
# actually backed off — otherwise deleting `except FloodWait` (dropping it into
|
||||
# the generic handler) leaves this test green.
|
||||
sleeps = []
|
||||
async def _fast_sleep(delay=0, *_a, **_k):
|
||||
sleeps.append(delay)
|
||||
monkeypatch.setattr(api_server.asyncio, "sleep", _fast_sleep)
|
||||
|
||||
# Fresh queue so we don't interfere with (or depend on) module state.
|
||||
q = asyncio.Queue(maxsize=100)
|
||||
monkeypatch.setattr(api_server, "download_queue", q)
|
||||
|
||||
processed = []
|
||||
|
||||
async def fake_download(channel, post_id, file_unique_id):
|
||||
processed.append(file_unique_id)
|
||||
if file_unique_id == "boom":
|
||||
raise RuntimeError("simulated download failure")
|
||||
if file_unique_id == "flood":
|
||||
raise errors.FloodWait(value=1)
|
||||
# "ok" succeeds
|
||||
|
||||
monkeypatch.setattr(api_server, "download_media_file", fake_download)
|
||||
|
||||
worker = asyncio.create_task(api_server.background_download_worker())
|
||||
try:
|
||||
for fid in ("boom", "flood", "ok"):
|
||||
q.put_nowait(("chan", 1, fid))
|
||||
|
||||
# If the worker died on the first error, or task_done() were unbalanced,
|
||||
# join() would never complete and this wait_for would time out.
|
||||
await asyncio.wait_for(q.join(), timeout=5)
|
||||
|
||||
# The worker stayed alive across the Exception and the FloodWait and drained all items.
|
||||
assert processed == ["boom", "flood", "ok"]
|
||||
|
||||
# The FloodWait branch (caught BEFORE the generic Exception) backed off by
|
||||
# min(value + 5, 900) = min(1 + 5, 900) = 6, distinct from the success path's
|
||||
# post-download sleep of 2. Asserting the 6 pins the dedicated branch: if it
|
||||
# were removed (FloodWait falling into `except Exception`), no 6 would appear.
|
||||
assert 6 in sleeps, sleeps # FloodWait(value=1) -> backoff 6
|
||||
assert 2 in sleeps, sleeps # "ok" success -> post-download sleep 2
|
||||
finally:
|
||||
worker.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await worker
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1.4 — _supervised: restart on crash (rate-limited), restart on unexpected
|
||||
# return, and clean cancellation propagation on shutdown.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_supervised_restarts_crashing_task_rate_limited():
|
||||
import api_server
|
||||
|
||||
starts = []
|
||||
|
||||
async def crashing():
|
||||
starts.append(time.monotonic())
|
||||
raise RuntimeError("always dies")
|
||||
|
||||
# Tiny min interval so the test is fast, but non-zero so we can assert spacing.
|
||||
sup = asyncio.create_task(
|
||||
api_server._supervised(crashing, "crasher", min_restart_interval=0.05)
|
||||
)
|
||||
# Let it crash-and-restart a few times.
|
||||
await asyncio.sleep(0.28)
|
||||
sup.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await sup
|
||||
|
||||
# It restarted several times (did not give up after the first crash)...
|
||||
assert len(starts) >= 3
|
||||
# ...but restarts were spaced at least ~min_restart_interval apart (no spin).
|
||||
gaps = [b - a for a, b in zip(starts, starts[1:])]
|
||||
assert all(g >= 0.045 for g in gaps), gaps
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supervised_restarts_on_unexpected_return():
|
||||
import api_server
|
||||
|
||||
runs = []
|
||||
|
||||
async def returns_immediately():
|
||||
runs.append(1)
|
||||
# A supervised background loop is not meant to return; _supervised must
|
||||
# log CRITICAL and restart it rather than stop supervising.
|
||||
return
|
||||
|
||||
sup = asyncio.create_task(
|
||||
api_server._supervised(returns_immediately, "returner", min_restart_interval=0.02)
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
sup.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await sup
|
||||
|
||||
assert len(runs) >= 2 # restarted after the unexpected return
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supervised_cancellation_propagates_to_child():
|
||||
import api_server
|
||||
|
||||
child_cancelled = asyncio.Event()
|
||||
|
||||
async def long_running():
|
||||
try:
|
||||
await asyncio.Event().wait() # runs until cancelled
|
||||
except asyncio.CancelledError:
|
||||
child_cancelled.set()
|
||||
raise
|
||||
|
||||
sup = asyncio.create_task(
|
||||
api_server._supervised(long_running, "longrun")
|
||||
)
|
||||
await asyncio.sleep(0.05) # let the child start
|
||||
sup.cancel()
|
||||
# Cancelling the supervisor must propagate CancelledError out (clean shutdown)...
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await sup
|
||||
# ...and the child task must have been cancelled too (no leaked background task).
|
||||
assert child_cancelled.is_set()
|
||||
@@ -0,0 +1,188 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=missing-function-docstring, redefined-outer-name, line-too-long
|
||||
# pylint: disable=protected-access, wrong-import-position, import-outside-toplevel
|
||||
"""Stage-2 footer tests (render-pipeline refactor epic, issue #29/#34).
|
||||
|
||||
Stage 2 removed rss_generator.processed_message_to_tg_message and renders the merged
|
||||
footer DIRECTLY from the real main Message. These tests lock the registry §3 items that
|
||||
become visible as a consequence:
|
||||
|
||||
§3.6 custom-emoji reactions in the merged footer get a separate "❓ N" span each
|
||||
(no more aggregation into one "❓ N"), matching single posts.
|
||||
§3.7 the merged footer date comes from the naive-local date of the real Message,
|
||||
not a UTC mock — verified with a NON-UTC TZ where the delta is visible (the
|
||||
TZ=UTC golden cannot see it).
|
||||
§3.15 an empty reactions object no longer emits a leading footer separator (single
|
||||
posts too).
|
||||
|
||||
Messages are built with the shared SimpleNamespace helper (naive dates where relevant,
|
||||
as kurigram emits on prod).
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from pyrogram.enums import MessageMediaType
|
||||
from post_parser import PostParser
|
||||
from rss_generator import _render_messages_groups
|
||||
from tests.test_stage4_eventloop import make_message
|
||||
|
||||
|
||||
def _custom_reaction(count, custom_emoji_id):
|
||||
# No `.emoji` attribute -> _reactions_views_links falls to the custom-emoji "❓" branch.
|
||||
return SimpleNamespace(count=count, custom_emoji_id=custom_emoji_id)
|
||||
|
||||
|
||||
def _normal_reaction(count, emoji):
|
||||
return SimpleNamespace(count=count, emoji=emoji)
|
||||
|
||||
|
||||
def _reactions(*items):
|
||||
return SimpleNamespace(reactions=list(items))
|
||||
|
||||
|
||||
def _footer_of(post):
|
||||
"""Extract the <div class="message-footer">…</div> inner html from a rendered post."""
|
||||
html = post["html"]
|
||||
marker = '<div class="message-footer">'
|
||||
start = html.index(marker) + len(marker)
|
||||
return html[start:]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §3.6 — custom-emoji reactions render as one span each, in the merged footer.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_3_6_two_custom_emoji_render_as_separate_spans():
|
||||
"""Single-post baseline: _reactions_views_links (the function the merged footer now
|
||||
uses on the real Message) emits one '❓ N' span per custom emoji, never aggregated."""
|
||||
parser = PostParser(SimpleNamespace())
|
||||
msg = make_message(70, text="hi")
|
||||
msg.reactions = _reactions(_custom_reaction(4, 111), _custom_reaction(2, 222))
|
||||
html = parser._reactions_views_links(msg)
|
||||
assert html.count('<span class="reaction">❓ 4') == 1
|
||||
assert html.count('<span class="reaction">❓ 2') == 1
|
||||
# NOT aggregated into a single "❓ 6".
|
||||
assert "❓ 6" not in html
|
||||
|
||||
|
||||
def test_3_6_merged_footer_keeps_custom_spans_separate():
|
||||
"""Merged footer, rendered from the real main Message, keeps a span per custom emoji.
|
||||
The old dict round-trip (_extract_reactions) collapsed both customs into one '❓ 6'."""
|
||||
parser = PostParser(SimpleNamespace())
|
||||
main = make_message(80, text="main text")
|
||||
main.media_group_id = "mg_36"
|
||||
main.reactions = _reactions(
|
||||
_normal_reaction(13, "🔥"),
|
||||
_custom_reaction(4, 111),
|
||||
_custom_reaction(2, 222),
|
||||
)
|
||||
other = make_message(81, text="second part")
|
||||
other.media_group_id = "mg_36"
|
||||
|
||||
posts = _render_messages_groups([[main, other]], parser)
|
||||
assert len(posts) == 1
|
||||
footer = _footer_of(posts[0])
|
||||
assert "merged" in posts[0]["flags"]
|
||||
assert footer.count('<span class="reaction">❓ 4') == 1
|
||||
assert footer.count('<span class="reaction">❓ 2') == 1
|
||||
assert "❓ 6" not in footer # would appear if the customs were still aggregated
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §3.15 — an empty reactions object emits no leading footer separator.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_SEP = " | "
|
||||
|
||||
|
||||
def test_3_15_empty_reactions_no_leading_separator():
|
||||
"""A reactions object with an empty list must not produce a leading '…|…' separator.
|
||||
Affects single posts (this call site) as well as merged ones."""
|
||||
parser = PostParser(SimpleNamespace())
|
||||
msg = make_message(90, text="hi")
|
||||
msg.reactions = _reactions() # object present, no reactions inside
|
||||
html = parser._reactions_views_links(msg)
|
||||
# First visible element must be the views span, not the separator.
|
||||
assert html.startswith('<span class="views">')
|
||||
assert not html.startswith(_SEP)
|
||||
|
||||
|
||||
def test_3_15_empty_reactions_single_post_render():
|
||||
"""Same via the real render path: the footer's first line starts with views, no
|
||||
leading separator injected by the empty reactions object."""
|
||||
parser = PostParser(SimpleNamespace())
|
||||
msg = make_message(91, text="hi")
|
||||
msg.reactions = _reactions()
|
||||
posts = _render_messages_groups([[msg]], parser)
|
||||
assert len(posts) == 1
|
||||
footer = _footer_of(posts[0])
|
||||
assert f'<br>\n{_SEP}<span class="views">' not in footer
|
||||
assert '<br>\n<span class="views">' in footer
|
||||
|
||||
|
||||
def test_3_15_present_reactions_still_render():
|
||||
"""Control: a non-empty reactions object still renders its spans (the §3.15 guard is
|
||||
not a blanket drop of the reactions line)."""
|
||||
parser = PostParser(SimpleNamespace())
|
||||
msg = make_message(92, text="hi")
|
||||
msg.reactions = _reactions(_normal_reaction(5, "🔥"))
|
||||
html = parser._reactions_views_links(msg)
|
||||
assert html.startswith('<span class="reaction">🔥 5')
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §3.7 — merged footer date == single-post date under a NON-UTC TZ.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.fixture
|
||||
def moscow_tz():
|
||||
"""Pin a non-UTC TZ for the duration of the test, then restore the conftest UTC pin.
|
||||
Without restoring, a leaked non-UTC TZ would corrupt the UTC-pinned golden tests."""
|
||||
os.environ["TZ"] = "Europe/Moscow"
|
||||
time.tzset()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.environ["TZ"] = "UTC"
|
||||
time.tzset()
|
||||
|
||||
|
||||
def _date_span(footer):
|
||||
marker = '<span class="date">'
|
||||
start = footer.index(marker) + len(marker)
|
||||
end = footer.index("</span>", start)
|
||||
return footer[start:end]
|
||||
|
||||
|
||||
def test_3_7_merged_footer_date_matches_single_post_non_utc(moscow_tz):
|
||||
"""On a TZ≠UTC server the merged footer date must equal the single-post date for the
|
||||
same message. Before stage 2 the merged path built a UTC mock date (from the naive
|
||||
date's timestamp) → a visible offset; now both render the real naive-local date."""
|
||||
parser = PostParser(SimpleNamespace())
|
||||
# NAIVE date, as kurigram emits on prod. Under Europe/Moscow the old UTC mock would
|
||||
# have shifted this by the TZ offset.
|
||||
naive_date = datetime(2026, 5, 5, 17, 21, 14)
|
||||
|
||||
def _fresh_main():
|
||||
m = make_message(100, text="main text", date=naive_date)
|
||||
return m
|
||||
|
||||
# Single post.
|
||||
single_posts = _render_messages_groups([[_fresh_main()]], parser)
|
||||
assert len(single_posts) == 1
|
||||
single_date = _date_span(_footer_of(single_posts[0]))
|
||||
|
||||
# Merged group with the same main message + one extra member.
|
||||
main = _fresh_main()
|
||||
main.media_group_id = "mg_37"
|
||||
other = make_message(101, text="second part", date=naive_date)
|
||||
other.media_group_id = "mg_37"
|
||||
merged_posts = _render_messages_groups([[main, other]], parser)
|
||||
assert len(merged_posts) == 1
|
||||
assert "merged" in merged_posts[0]["flags"]
|
||||
merged_date = _date_span(_footer_of(merged_posts[0]))
|
||||
|
||||
assert merged_date == single_date
|
||||
# And it is the naive wall-clock time, not a UTC-shifted one.
|
||||
assert merged_date == "05/05/26, 17:21:14"
|
||||
@@ -0,0 +1,398 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, logging-fstring-interpolation, line-too-long
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
"""
|
||||
Stage 2 (static-media stability) regression tests.
|
||||
|
||||
Covers the four scenarios from the plan plus the subtle in-flight-dedup lifecycle:
|
||||
- _download_atomic: success publishes atomically and leaves no `.part.`; timeout / zero-size
|
||||
/ race-loser all remove the partial and never leave a stub at the final name.
|
||||
- Concurrent large-video downloads never expose a partial file (atomic rename).
|
||||
- _download_deduped: one shared download for concurrent requests; a cancelled waiter
|
||||
(client disconnect) can't hang the others; a failed download propagates to all waiters
|
||||
and frees the key (no stuck registry entry).
|
||||
- FloodWait in /media returns a retryable 429 (not a permanent 404) — ordering pinned.
|
||||
- A served temp_* file gets its mtime refreshed (sweeper won't delete it under a viewer).
|
||||
- The sweeper removes both `.part.` and legacy `.tmp.` stubs (and stale temp_*) but keeps
|
||||
fresh partials and non-temp files.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import api_server
|
||||
from pyrogram import errors
|
||||
from pyrogram.enums import MessageMediaType
|
||||
|
||||
|
||||
def _part_files(dir_path):
|
||||
return [p for p in os.listdir(dir_path) if ".part." in p or ".tmp." in p]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2.1 — _download_atomic: publish-on-success, always clean the partial.
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_download_atomic_success_publishes_and_cleans(monkeypatch, tmp_path):
|
||||
final = str(tmp_path / "final")
|
||||
|
||||
async def fake(file_id, file_name, timeout=None, **kw):
|
||||
with open(file_name, "wb") as f:
|
||||
f.write(b"hello")
|
||||
return file_name
|
||||
|
||||
monkeypatch.setattr(api_server.client, "safe_download_media", fake)
|
||||
|
||||
res = await api_server._download_atomic("fid", final, timeout=10)
|
||||
assert res == final
|
||||
with open(final, "rb") as f:
|
||||
assert f.read() == b"hello"
|
||||
assert _part_files(tmp_path) == [] # no leftover partial
|
||||
|
||||
|
||||
async def test_download_atomic_timeout_removes_part_no_final(monkeypatch, tmp_path):
|
||||
final = str(tmp_path / "final")
|
||||
|
||||
async def fake(file_id, file_name, timeout=None, **kw):
|
||||
# A partial write, then the download times out mid-flight.
|
||||
with open(file_name, "wb") as f:
|
||||
f.write(b"partial")
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
monkeypatch.setattr(api_server.client, "safe_download_media", fake)
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await api_server._download_atomic("fid", final, timeout=10)
|
||||
|
||||
assert not os.path.exists(final) # no stub at the final name
|
||||
assert _part_files(tmp_path) == [] # partial cleaned by the finally
|
||||
|
||||
|
||||
async def test_download_atomic_zero_size_raises_and_cleans(monkeypatch, tmp_path):
|
||||
final = str(tmp_path / "final")
|
||||
|
||||
async def fake(file_id, file_name, timeout=None, **kw):
|
||||
open(file_name, "wb").close() # zero-size result
|
||||
return file_name
|
||||
|
||||
monkeypatch.setattr(api_server.client, "safe_download_media", fake)
|
||||
|
||||
with pytest.raises(api_server.ZeroSizeFileError):
|
||||
await api_server._download_atomic("fid", final, timeout=10)
|
||||
|
||||
assert not os.path.exists(final)
|
||||
assert _part_files(tmp_path) == []
|
||||
|
||||
|
||||
async def test_download_atomic_race_loser_keeps_existing_final(monkeypatch, tmp_path):
|
||||
final = str(tmp_path / "final")
|
||||
with open(final, "wb") as f:
|
||||
f.write(b"WINNER") # a concurrent request already published the final file
|
||||
|
||||
async def fake(file_id, file_name, timeout=None, **kw):
|
||||
with open(file_name, "wb") as f:
|
||||
f.write(b"loser")
|
||||
return file_name
|
||||
|
||||
monkeypatch.setattr(api_server.client, "safe_download_media", fake)
|
||||
|
||||
res = await api_server._download_atomic("fid", final, timeout=10)
|
||||
assert res == final
|
||||
with open(final, "rb") as f:
|
||||
assert f.read() == b"WINNER" # not clobbered by the race loser
|
||||
assert _part_files(tmp_path) == [] # loser's partial removed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2.1 — Concurrent large-video downloads never expose a partial file.
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_concurrent_large_video_no_partial_served(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path) # download_media_file writes under ./data/cache
|
||||
|
||||
msg = SimpleNamespace(
|
||||
media=MessageMediaType.VIDEO,
|
||||
video=SimpleNamespace(file_size=200 * 1024 * 1024), # > 100 MB -> large-video path
|
||||
empty=False,
|
||||
)
|
||||
|
||||
async def fake_get(channel_id, post_id):
|
||||
return msg
|
||||
|
||||
monkeypatch.setattr(api_server.client, "safe_get_messages", fake_get)
|
||||
|
||||
async def fake_find(message, fid):
|
||||
return "fileid"
|
||||
|
||||
monkeypatch.setattr(api_server, "find_file_id_in_message", fake_find)
|
||||
|
||||
started = []
|
||||
|
||||
async def fake_dl(file_id, file_name, timeout=None, **kw):
|
||||
started.append(file_name)
|
||||
await asyncio.sleep(0.1) # slow download -> real overlap between the two requests
|
||||
with open(file_name, "wb") as f:
|
||||
f.write(b"VIDEODATA")
|
||||
return file_name
|
||||
|
||||
monkeypatch.setattr(api_server.client, "safe_download_media", fake_dl)
|
||||
|
||||
t1 = asyncio.create_task(api_server.download_media_file("chan", 7, "vidfid"))
|
||||
t2 = asyncio.create_task(api_server.download_media_file("chan", 7, "vidfid"))
|
||||
(p1, _), (p2, _) = await asyncio.gather(t1, t2)
|
||||
|
||||
assert p1 == p2
|
||||
assert os.path.basename(p1) == "temp_vidfid" # large videos keep the TTL-cleaned name
|
||||
with open(p1, "rb") as f:
|
||||
assert f.read() == b"VIDEODATA" # a complete file, never a partial
|
||||
|
||||
post_dir = os.path.dirname(p1)
|
||||
assert _part_files(post_dir) == [] # both partials resolved, none left on disk
|
||||
# Both requests really downloaded to distinct `.part.` paths (a genuine race that the
|
||||
# atomic rename resolved), so neither ever saw the other's partial.
|
||||
assert len(started) == 2 and started[0] != started[1]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2.2 — In-flight dedup: one shared download for concurrent requests.
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_dedup_single_download_for_concurrent_requests(monkeypatch):
|
||||
api_server._inflight.clear()
|
||||
calls = []
|
||||
|
||||
async def fake_dl(channel, post_id, fid):
|
||||
calls.append(fid)
|
||||
await asyncio.sleep(0.05)
|
||||
return (f"/final/{fid}", False)
|
||||
|
||||
monkeypatch.setattr(api_server, "download_media_file", fake_dl)
|
||||
|
||||
tasks = [asyncio.create_task(api_server._download_deduped("c", 1, "f")) for _ in range(5)]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
assert all(r == ("/final/f", False) for r in results)
|
||||
assert calls == ["f"] # exactly one real download, shared by all 5
|
||||
assert not api_server._inflight # key popped after completion
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2.2 — A cancelled waiter (client disconnect) must not hang the others, and the
|
||||
# detached download runs to completion regardless.
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_dedup_waiter_cancel_does_not_hang_others(monkeypatch):
|
||||
api_server._inflight.clear()
|
||||
gate = asyncio.Event()
|
||||
calls = []
|
||||
|
||||
async def fake_dl(channel, post_id, fid):
|
||||
calls.append(fid)
|
||||
await gate.wait() # hold the download open until the test releases it
|
||||
return (f"/path/{fid}", False)
|
||||
|
||||
monkeypatch.setattr(api_server, "download_media_file", fake_dl)
|
||||
|
||||
t1 = asyncio.create_task(api_server._download_deduped("c", 1, "fidX"))
|
||||
await asyncio.sleep(0.05) # let t1 register the future + start the detached task
|
||||
t2 = asyncio.create_task(api_server._download_deduped("c", 1, "fidX"))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# First client disconnects -> its request coroutine is cancelled.
|
||||
t1.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await t1
|
||||
|
||||
# The detached download is unaffected; complete it and the surviving waiter resolves.
|
||||
gate.set()
|
||||
res = await asyncio.wait_for(t2, timeout=2)
|
||||
assert res == ("/path/fidX", False)
|
||||
assert calls == ["fidX"] # download ran exactly once (shared, not restarted)
|
||||
assert not api_server._inflight # key popped
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2.2 — A failed download propagates to all waiters and frees the key (not stuck).
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_dedup_exception_propagates_and_frees_key(monkeypatch):
|
||||
api_server._inflight.clear()
|
||||
state = {"mode": "boom"}
|
||||
|
||||
async def fake_dl(channel, post_id, fid):
|
||||
if state["mode"] == "boom":
|
||||
raise RuntimeError("kaboom")
|
||||
return ("/ok", False)
|
||||
|
||||
monkeypatch.setattr(api_server, "download_media_file", fake_dl)
|
||||
|
||||
t1 = asyncio.create_task(api_server._download_deduped("c", 1, "f"))
|
||||
t2 = asyncio.create_task(api_server._download_deduped("c", 1, "f"))
|
||||
results = await asyncio.gather(t1, t2, return_exceptions=True)
|
||||
assert all(isinstance(r, RuntimeError) for r in results), results
|
||||
assert not api_server._inflight # failed future did NOT leave the key stuck
|
||||
|
||||
# A subsequent request with a working download succeeds (proves the key was freed).
|
||||
state["mode"] = "ok"
|
||||
res = await api_server._download_deduped("c", 1, "f")
|
||||
assert res == ("/ok", False)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2.3 — FloodWait in /media -> 429 with Retry-After (ordering pinned: not a 404).
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_media_floodwait_returns_429_not_404(monkeypatch, tmp_path):
|
||||
api_server._inflight.clear()
|
||||
monkeypatch.chdir(tmp_path) # so the pre-semaphore cache check misses -> download path
|
||||
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
|
||||
|
||||
async def fake_dl(channel, post_id, fid):
|
||||
raise errors.FloodWait(value=42)
|
||||
|
||||
monkeypatch.setattr(api_server, "download_media_file", fake_dl)
|
||||
|
||||
req = SimpleNamespace(headers={})
|
||||
resp = await api_server.get_media("chan", 5, "floodfid", request=req, digest="x")
|
||||
|
||||
assert resp.status_code == 429
|
||||
retry_after = resp.headers.get("retry-after")
|
||||
assert retry_after is not None
|
||||
assert 43 <= int(retry_after) <= 72 # 42 + random(1..30)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2.5 — HTTP_DOWNLOAD_SEMAPHORE balance: released exactly once on success/error,
|
||||
# never released when the acquire itself timed out (503). It is a plain
|
||||
# asyncio.Semaphore, so an over-release would SILENTLY inflate the permit count
|
||||
# and disable the limiter — assert the count, mirroring the stage-1 gate test.
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_media_semaphore_released_on_download_error(monkeypatch, tmp_path):
|
||||
api_server._inflight.clear()
|
||||
monkeypatch.chdir(tmp_path) # pre-semaphore cache miss -> the acquire path runs
|
||||
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
|
||||
|
||||
permits_before = api_server.HTTP_DOWNLOAD_SEMAPHORE._value
|
||||
|
||||
async def boom(channel_id, post_id, fid):
|
||||
raise RuntimeError("download blew up")
|
||||
|
||||
monkeypatch.setattr(api_server, "_download_deduped", boom)
|
||||
|
||||
req = SimpleNamespace(headers={})
|
||||
# get_media swallows the error into a 4xx/5xx response; the point is the permit.
|
||||
try:
|
||||
await api_server.get_media("chan", 5, "errfid", request=req, digest="x")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Permit released exactly once (back to baseline) — not leaked, not double-released.
|
||||
assert api_server.HTTP_DOWNLOAD_SEMAPHORE._value == permits_before
|
||||
|
||||
|
||||
async def test_media_semaphore_not_released_on_acquire_timeout(monkeypatch, tmp_path):
|
||||
api_server._inflight.clear()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
|
||||
|
||||
permits_before = api_server.HTTP_DOWNLOAD_SEMAPHORE._value
|
||||
|
||||
# Make the bounded acquire time out: the first wait_for (wrapping the acquire)
|
||||
# raises TimeoutError; we close the passed coroutine to avoid a "never awaited"
|
||||
# warning. The 503 short-circuits before _download_deduped, so only this one
|
||||
# wait_for is hit.
|
||||
real_wait_for = asyncio.wait_for
|
||||
|
||||
async def fake_wait_for(coro, timeout=None):
|
||||
if hasattr(coro, "close"):
|
||||
coro.close()
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
monkeypatch.setattr(api_server.asyncio, "wait_for", fake_wait_for)
|
||||
|
||||
req = SimpleNamespace(headers={})
|
||||
resp = await api_server.get_media("chan", 5, "busyfid", request=req, digest="x")
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.headers.get("retry-after") == "30"
|
||||
# A timed-out acquire never held a permit, so nothing must have been released:
|
||||
# the count is UNCHANGED (an erroneous release would inflate it above baseline).
|
||||
assert api_server.HTTP_DOWNLOAD_SEMAPHORE._value == permits_before
|
||||
|
||||
monkeypatch.setattr(api_server.asyncio, "wait_for", real_wait_for)
|
||||
|
||||
|
||||
async def test_download_atomic_reraises_floodwait_and_cleans_part(monkeypatch, tmp_path):
|
||||
# Stage-2 review: prove a FloodWait raised by the downloader propagates OUT of
|
||||
# _download_atomic (its finally must NOT swallow it) AND the partial is cleaned.
|
||||
# This is the path the wholesale-mocked 429 test above skips.
|
||||
final = str(tmp_path / "temp_vid")
|
||||
seen_part = {}
|
||||
|
||||
async def fake(file_id, part_path, timeout=120):
|
||||
# The partial path exists mid-download, then FloodWait strikes.
|
||||
with open(part_path, "wb") as fh:
|
||||
fh.write(b"partial")
|
||||
seen_part["path"] = part_path
|
||||
raise errors.FloodWait(value=7)
|
||||
|
||||
monkeypatch.setattr(api_server.client, "safe_download_media", fake)
|
||||
|
||||
with pytest.raises(errors.FloodWait):
|
||||
await api_server._download_atomic("fid", final, timeout=10)
|
||||
|
||||
# The finally cleaned the partial, and no final file was published.
|
||||
assert not os.path.exists(seen_part["path"])
|
||||
assert not os.path.exists(final)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2.4 — Serving a temp_* file refreshes its mtime; non-temp files are untouched.
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_temp_file_mtime_touched_on_serve(tmp_path):
|
||||
temp_file = tmp_path / "temp_abc"
|
||||
temp_file.write_bytes(b"videodata")
|
||||
stale = time.time() - 10000
|
||||
os.utime(temp_file, (stale, stale))
|
||||
|
||||
req = SimpleNamespace(headers={})
|
||||
resp = await api_server.prepare_file_response(str(temp_file), request=req)
|
||||
assert resp is not None
|
||||
assert os.path.getmtime(temp_file) > time.time() - 100 # refreshed
|
||||
|
||||
|
||||
async def test_non_temp_file_mtime_not_touched(tmp_path):
|
||||
normal = tmp_path / "regularfile"
|
||||
normal.write_bytes(b"data")
|
||||
stale = time.time() - 10000
|
||||
os.utime(normal, (stale, stale))
|
||||
|
||||
req = SimpleNamespace(headers={})
|
||||
await api_server.prepare_file_response(str(normal), request=req)
|
||||
assert os.path.getmtime(normal) < time.time() - 5000 # left alone
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2.1/2.4 — Sweeper cleans both `.part.` and legacy `.tmp.` stubs and stale temp_*,
|
||||
# but keeps fresh partials and ordinary cached files.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_sweeper_cleans_part_and_tmp_not_fresh(tmp_path):
|
||||
cache = tmp_path / "cache"
|
||||
post = cache / "chan" / "10"
|
||||
post.mkdir(parents=True)
|
||||
|
||||
stale = time.time() - 4000 # older than the 1h (3600s) threshold
|
||||
hexid = "0" * 32
|
||||
|
||||
old_part = post / f"file.part.{hexid}"; old_part.write_bytes(b"x"); os.utime(old_part, (stale, stale))
|
||||
old_tmp = post / f"file.tmp.{hexid}"; old_tmp.write_bytes(b"x"); os.utime(old_tmp, (stale, stale))
|
||||
old_tempvid = post / "temp_bigvid"; old_tempvid.write_bytes(b"x"); os.utime(old_tempvid, (stale, stale))
|
||||
fresh_part = post / f"file2.part.{hexid}"; fresh_part.write_bytes(b"x") # fresh mtime
|
||||
normal = post / "realfile"; normal.write_bytes(b"x") # not a temp file at all
|
||||
|
||||
_, removed = api_server.remove_old_cached_files_sync([], str(cache))
|
||||
|
||||
assert not old_part.exists() # new-suffix stub swept
|
||||
assert not old_tmp.exists() # legacy-suffix stub swept (transition period)
|
||||
assert not old_tempvid.exists() # stale large-video temp swept
|
||||
assert fresh_part.exists() # too new to sweep
|
||||
assert normal.exists() # ordinary cached file untouched
|
||||
assert removed >= 3
|
||||
@@ -0,0 +1,289 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, line-too-long
|
||||
"""
|
||||
Stage 3 (render-pipeline refactor epic, issue #30/#34) — reply-enrichment lock.
|
||||
|
||||
Stage 3 merges generate_channel_rss/generate_channel_html around the shared
|
||||
_prepare_feed_posts and moves _reply_enrichment (the live client.get_messages
|
||||
reply-target fetch) INTO that shared path (enrich_replies=True for HTML,
|
||||
False for RSS).
|
||||
|
||||
The stage-0 golden oracle CANNOT see this move: its harness replays the corpus
|
||||
through monkeypatched tg_cache but leaves client.get_messages a no-op (the client
|
||||
is a bare SimpleNamespace, so get_messages raises AttributeError and enrichment
|
||||
silently no-ops). So enrichment is locked here instead, exactly as mandated on #30:
|
||||
|
||||
(a) get_messages is BATCHED by chat_id (one call per chat, not one per reply);
|
||||
(b) the fetched reply target is set onto the right source message by
|
||||
(chat_id, message.id);
|
||||
(c) the enriched reply block actually renders in the HTML feed.
|
||||
|
||||
Plus: RSS deliberately does NOT enrich (keeps polling cheap) — locked too.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from pyrogram import errors
|
||||
|
||||
import rss_generator as rss_module
|
||||
from rss_generator import _reply_enrichment, generate_channel_html, generate_channel_rss
|
||||
|
||||
|
||||
class _Str(str):
|
||||
"""Stand-in for Pyrogram's Str: .html returns the raw string unchanged, so a
|
||||
reply-target text reaches the pre-sanitize body like real entity text would."""
|
||||
@property
|
||||
def html(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
def _reply_target(mid, text):
|
||||
"""A resolved reply-to-message as _format_reply_info consumes it."""
|
||||
return SimpleNamespace(id=mid, text=text, caption=None, sender_chat=None)
|
||||
|
||||
|
||||
def make_message(mid, chat_id=-1001234567890, username="testchan", text="post",
|
||||
reply_to_message_id=None, reply_to_message=None, date=None):
|
||||
m = SimpleNamespace()
|
||||
m.id = mid
|
||||
m.date = date or datetime(2024, 1, 1, 12, 0, mid % 60, tzinfo=timezone.utc)
|
||||
m.text = _Str(text) if text is not None else None
|
||||
m.caption = None
|
||||
m.media = None
|
||||
m.web_page = None
|
||||
m.poll = None
|
||||
m.service = None
|
||||
m.forward_origin = None
|
||||
m.reply_to_message = reply_to_message
|
||||
m.reply_to_message_id = reply_to_message_id
|
||||
m.sender_chat = None
|
||||
m.from_user = None
|
||||
m.reactions = None
|
||||
m.views = 100
|
||||
m.media_group_id = None
|
||||
m.show_caption_above_media = False
|
||||
m.chat = SimpleNamespace(id=chat_id, username=username)
|
||||
for attr in ("photo", "video", "document", "audio", "voice",
|
||||
"video_note", "animation", "sticker"):
|
||||
setattr(m, attr, None)
|
||||
return m
|
||||
|
||||
|
||||
class RecordingClient:
|
||||
"""Fake Telegram client: records each get_messages(chat_id, ids) call and returns,
|
||||
for every requested id, a 'full' message carrying a resolved .reply_to_message."""
|
||||
def __init__(self, target_text_for=lambda chat_id, mid: f"TARGET_{chat_id}_{mid}"):
|
||||
self.calls = [] # list[(chat_id, list[ids])]
|
||||
self._target_text_for = target_text_for
|
||||
|
||||
async def get_messages(self, chat_id, ids):
|
||||
self.calls.append((chat_id, list(ids)))
|
||||
out = []
|
||||
for mid in ids:
|
||||
full = SimpleNamespace(
|
||||
id=mid,
|
||||
empty=False,
|
||||
reply_to_message=_reply_target(mid + 5000, self._target_text_for(chat_id, mid)),
|
||||
)
|
||||
out.append(full)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# (a) batching by chat_id + (b) target set onto the right source message.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_enrichment_batches_by_chat_and_sets_target():
|
||||
CHAT_A, CHAT_B = -100111, -100222
|
||||
messages = [
|
||||
make_message(10, chat_id=CHAT_A, reply_to_message_id=1),
|
||||
make_message(11, chat_id=CHAT_A, reply_to_message_id=2),
|
||||
make_message(12, chat_id=CHAT_A, reply_to_message_id=3),
|
||||
make_message(20, chat_id=CHAT_B, reply_to_message_id=4),
|
||||
make_message(21, chat_id=CHAT_B, reply_to_message_id=5),
|
||||
make_message(30, chat_id=CHAT_A, reply_to_message_id=None), # no reply -> not fetched
|
||||
]
|
||||
client = RecordingClient()
|
||||
|
||||
result = await _reply_enrichment(client, messages)
|
||||
|
||||
# (a) BATCHED: exactly one call per chat_id (2), NOT one per reply (5).
|
||||
assert len(client.calls) == 2, f"expected 2 batched calls, got {client.calls}"
|
||||
calls_by_chat = dict(client.calls)
|
||||
assert calls_by_chat[CHAT_A] == [10, 11, 12], "chat A ids not batched into one call"
|
||||
assert calls_by_chat[CHAT_B] == [20, 21], "chat B ids not batched into one call"
|
||||
|
||||
# (b) each fetched target is set onto the SOURCE message keyed by (chat_id, id).
|
||||
by_id = {m.id: m for m in result}
|
||||
for mid, chat in [(10, CHAT_A), (11, CHAT_A), (12, CHAT_A), (20, CHAT_B), (21, CHAT_B)]:
|
||||
assert by_id[mid].reply_to_message is not None, f"message {mid} not enriched"
|
||||
assert by_id[mid].reply_to_message.text == f"TARGET_{chat}_{mid}", \
|
||||
f"message {mid} got the wrong reply target"
|
||||
# The reply-less message is untouched.
|
||||
assert by_id[30].reply_to_message is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_enrichment_no_replies_makes_no_calls():
|
||||
messages = [make_message(1), make_message(2)] # no reply_to_message_id
|
||||
client = RecordingClient()
|
||||
await _reply_enrichment(client, messages)
|
||||
assert client.calls == [], "get_messages must not be called when nothing needs enrichment"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# (c) the enriched reply block renders in the HTML feed — AND enrichment now runs
|
||||
# inside the shared _prepare_feed_posts path (enrich_replies=True for HTML).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _patch_feed_source(monkeypatch, messages):
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return messages
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_html_feed_renders_enriched_reply_block(monkeypatch):
|
||||
MARKER = "ENRICHED_REPLY_MARKER_XYZ"
|
||||
# A shallow message: it has a reply_to_message_id but NO resolved reply_to_message
|
||||
# yet — enrichment must fetch and fill it before render.
|
||||
msg = make_message(77, chat_id=-1001234567890, reply_to_message_id=70,
|
||||
reply_to_message=None, text="body text")
|
||||
_patch_feed_source(monkeypatch, [msg])
|
||||
|
||||
client = RecordingClient(target_text_for=lambda chat_id, mid: MARKER)
|
||||
html = await generate_channel_html("testchan", client=client, limit=5)
|
||||
|
||||
# Enrichment ran inside _prepare_feed_posts and was BATCHED (one call for the chat).
|
||||
assert client.calls == [(-1001234567890, [77])], f"enrichment not run/batched: {client.calls}"
|
||||
# The resolved reply target renders as a reply block carrying the marker text.
|
||||
assert '<div class="message-reply">' in html, "reply block not rendered"
|
||||
assert MARKER in html, "resolved reply-target text missing from the rendered feed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rss_feed_does_not_enrich_replies(monkeypatch):
|
||||
# RSS deliberately skips enrichment (enrich_replies=False) to keep polling cheap.
|
||||
msg = make_message(88, chat_id=-1001234567890, reply_to_message_id=80,
|
||||
reply_to_message=None, text="body text")
|
||||
_patch_feed_source(monkeypatch, [msg])
|
||||
|
||||
client = RecordingClient()
|
||||
await generate_channel_rss("testchan", client=client, limit=5)
|
||||
assert client.calls == [], "RSS must NOT call get_messages (enrich_replies=False)"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# History over-fetch fork: RSS over-fetches limit*2 (headroom for grouping/trim),
|
||||
# HTML fetches exactly limit. This is a REAL behavioral fork that the golden oracle
|
||||
# CANNOT see (its fake_get_chat_history ignores the limit kwarg and returns the whole
|
||||
# corpus, so both paths trim to GOLDEN_LIMIT identically). A refactor that accidentally
|
||||
# unified the two would pass every golden test — so lock the forwarded limit here.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_rss_overfetches_history_html_does_not(monkeypatch):
|
||||
N = 7
|
||||
seen = {}
|
||||
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def spy_get_history(client, channel, limit=20):
|
||||
seen.setdefault("limits", []).append(limit)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", spy_get_history, raising=False)
|
||||
|
||||
await generate_channel_rss("testchan", client=SimpleNamespace(), limit=N)
|
||||
assert seen["limits"] == [2 * N], f"RSS must over-fetch history limit*2, got {seen['limits']}"
|
||||
|
||||
seen["limits"] = []
|
||||
await generate_channel_html("testchan", client=SimpleNamespace(), limit=N)
|
||||
assert seen["limits"] == [N], f"HTML must fetch history limit (no over-fetch), got {seen['limits']}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Shared error handling in _prepare_feed_posts, verified through BOTH formatters.
|
||||
# The golden oracle cannot see these paths (§3.9/§3.10 are error branches), so they
|
||||
# are locked here. FEED_FUNCS lets each case assert RSS and HTML behave identically.
|
||||
# --------------------------------------------------------------------------- #
|
||||
FEED_FUNCS = [generate_channel_rss, generate_channel_html]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("feed_func", FEED_FUNCS)
|
||||
async def test_channel_not_found_returns_error_feed(monkeypatch, feed_func):
|
||||
async def raise_not_found(client, channel):
|
||||
raise errors.UsernameNotOccupied("no such user")
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", raise_not_found, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
out = await feed_func("ghostchan", client=SimpleNamespace(), limit=5)
|
||||
# ChannelNotFound -> create_error_feed (RSS-XML error feed) in both paths.
|
||||
assert "does not exist" in out, f"{feed_func.__name__} did not return the error feed"
|
||||
assert "ghostchan" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("feed_func", FEED_FUNCS)
|
||||
async def test_floodwait_from_get_chat_propagates(monkeypatch, feed_func):
|
||||
async def raise_flood(client, channel):
|
||||
raise errors.FloodWait(value=11)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", raise_flood, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
# FloodWait must reach api_server unwrapped (mapped there to HTTP 429), NOT ValueError.
|
||||
with pytest.raises(errors.FloodWait):
|
||||
await feed_func("floodchan", client=SimpleNamespace(), limit=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("feed_func", FEED_FUNCS)
|
||||
async def test_floodwait_from_history_propagates(monkeypatch, feed_func):
|
||||
# Registry §3.9 (the fix this stage lands): FloodWait raised while fetching HISTORY
|
||||
# must propagate -> HTTP 429. Before stage 3 it fell into `except Exception` and was
|
||||
# wrapped in ValueError -> HTTP 400. The golden cannot see this; locked here.
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def raise_flood_history(client, channel, limit=20):
|
||||
raise errors.FloodWait(value=13)
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", raise_flood_history, raising=False)
|
||||
|
||||
with pytest.raises(errors.FloodWait):
|
||||
await feed_func("testchan", client=SimpleNamespace(), limit=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("feed_func", FEED_FUNCS)
|
||||
async def test_other_history_error_becomes_valueerror(monkeypatch, feed_func):
|
||||
# Any NON-FloodWait history failure is still wrapped in ValueError (api_server -> 400).
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def raise_runtime(client, channel, limit=20):
|
||||
raise RuntimeError("history backend exploded")
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", raise_runtime, raising=False)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await feed_func("testchan", client=SimpleNamespace(), limit=5)
|
||||
@@ -0,0 +1,378 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, logging-fstring-interpolation, line-too-long
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
"""
|
||||
Stage 3 (FileResponse + HTTP-layer cleanup) regression tests.
|
||||
|
||||
Covers:
|
||||
- The HTTP Range matrix asserted against the FINAL (Starlette FileResponse) behavior,
|
||||
each case documented, including the RFC-7233-permitted deltas vs the old hand-rolled
|
||||
streaming (garbage header -> 400 not 416; multi-range -> proper multipart 206 not 416;
|
||||
416 Content-Range now `*/size`; ETag/Last-Modified now present; ASCII filename no longer
|
||||
gets a redundant filename*= form).
|
||||
- Stage-2 behavior preserved through the rewrite: a served temp_* file still has its mtime
|
||||
refreshed; delete_after still schedules the temp-file BackgroundTask (and it actually
|
||||
runs); the media_key MIME cache is still consulted and populated.
|
||||
- The pure-ASGI RequestLoggingMiddleware: a normal request still returns (body intact, not
|
||||
buffered/truncated) and is logged.
|
||||
|
||||
Before/after Range matrix (2048-byte file), old = hand-rolled streaming, new = FileResponse:
|
||||
|
||||
request | old | new (FileResponse)
|
||||
-----------------------+-----------------------------+-----------------------------------
|
||||
no Range | 200 full | 200 full (+ETag/Last-Modified)
|
||||
bytes=0-499 | 206 bytes 0-499/2048 | 206 bytes 0-499/2048 (same bytes)
|
||||
bytes=500- | 206 bytes 500-2047/2048 | 206 bytes 500-2047/2048 (same)
|
||||
bytes=-500 | 206 bytes 1548-2047/2048 | 206 bytes 1548-2047/2048 (same)
|
||||
bytes=999999- (>EOF) | 416 `bytes */2048` | 416 `*/2048` (Starlette omits the
|
||||
| | `bytes ` prefix; RFC-acceptable)
|
||||
garbage header | 416 | 400 Bad Request (RFC 7233 lets a
|
||||
| | server reject/ignore a malformed
|
||||
| | Range; Starlette returns 400)
|
||||
bytes=0-9,20-29 | 416 (old parser bug) | 206 multipart/byteranges (correct)
|
||||
|
||||
All 206 responses that carry data return byte-for-byte identical slices old vs new, so no
|
||||
real regression is hidden behind an "accepted difference".
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import api_server
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
BODY = bytes(range(256)) * 8 # 2048 deterministic bytes
|
||||
SIZE = len(BODY)
|
||||
|
||||
|
||||
def _make_client(file_path, delete_after=False, media_key=None):
|
||||
"""Mount prepare_file_response on a tiny app so it is driven through real ASGI
|
||||
(FileResponse computes Range/206/416 at send time, so it must be exercised via a client)."""
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/f")
|
||||
async def _serve(request: Request):
|
||||
return await api_server.prepare_file_response(
|
||||
file_path, request=request, delete_after=delete_after, media_key=media_key
|
||||
)
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_file(tmp_path):
|
||||
fp = tmp_path / "myfile.bin"
|
||||
fp.write_bytes(BODY)
|
||||
return str(fp)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3.1 — Range matrix against the FINAL FileResponse behavior.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_no_range_returns_full_200(sample_file):
|
||||
c = _make_client(sample_file)
|
||||
r = c.get("/f")
|
||||
assert r.status_code == 200
|
||||
assert r.content == BODY
|
||||
assert r.headers["content-length"] == str(SIZE)
|
||||
assert r.headers["accept-ranges"] == "bytes"
|
||||
assert r.headers["cache-control"] == "public, max-age=86400, immutable"
|
||||
# Content-Disposition: inline, filename set by FileResponse itself (no manual double-set).
|
||||
assert r.headers["content-disposition"].startswith("inline")
|
||||
assert 'filename="myfile.bin"' in r.headers["content-disposition"]
|
||||
# NEW vs old: FileResponse adds validators. Old streaming had neither.
|
||||
assert r.headers.get("etag")
|
||||
assert r.headers.get("last-modified")
|
||||
|
||||
|
||||
def test_range_prefix_0_499(sample_file):
|
||||
c = _make_client(sample_file)
|
||||
r = c.get("/f", headers={"Range": "bytes=0-499"})
|
||||
assert r.status_code == 206
|
||||
assert r.headers["content-range"] == f"bytes 0-499/{SIZE}"
|
||||
assert r.headers["content-length"] == "500"
|
||||
assert r.content == BODY[:500] # exact slice, identical to old behavior
|
||||
|
||||
|
||||
def test_range_open_ended_500_to_eof(sample_file):
|
||||
c = _make_client(sample_file)
|
||||
r = c.get("/f", headers={"Range": "bytes=500-"})
|
||||
assert r.status_code == 206
|
||||
assert r.headers["content-range"] == f"bytes 500-{SIZE - 1}/{SIZE}"
|
||||
assert r.headers["content-length"] == str(SIZE - 500)
|
||||
assert r.content == BODY[500:]
|
||||
|
||||
|
||||
def test_range_suffix_last_500(sample_file):
|
||||
c = _make_client(sample_file)
|
||||
r = c.get("/f", headers={"Range": "bytes=-500"})
|
||||
assert r.status_code == 206
|
||||
assert r.headers["content-range"] == f"bytes {SIZE - 500}-{SIZE - 1}/{SIZE}"
|
||||
assert r.headers["content-length"] == "500"
|
||||
assert r.content == BODY[-500:]
|
||||
|
||||
|
||||
def test_range_start_past_eof_returns_416(sample_file):
|
||||
c = _make_client(sample_file)
|
||||
r = c.get("/f", headers={"Range": "bytes=999999-"})
|
||||
assert r.status_code == 416
|
||||
# DELTA (RFC-acceptable): Starlette's 416 Content-Range is `*/size` (it omits the
|
||||
# `bytes ` unit prefix the old code emitted). Same information, permitted by RFC 7233.
|
||||
assert r.headers["content-range"] == f"*/{SIZE}"
|
||||
|
||||
|
||||
def test_garbage_range_header_returns_400(sample_file):
|
||||
c = _make_client(sample_file)
|
||||
r = c.get("/f", headers={"Range": "somethinggarbage"})
|
||||
# DELTA (RFC-acceptable): the old hand-rolled parser returned 416 on an unparseable
|
||||
# header; Starlette rejects a malformed Range with 400 Bad Request. RFC 7233 permits a
|
||||
# server to reject/ignore a bad Range; this is a conscious accepted difference, not a
|
||||
# data regression (no bytes are served either way).
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_multi_range_returns_multipart_206(sample_file):
|
||||
c = _make_client(sample_file)
|
||||
r = c.get("/f", headers={"Range": "bytes=0-9,20-29"})
|
||||
# DELTA (improvement): the old parser mis-split multi-ranges and returned 416.
|
||||
# FileResponse serves a proper multipart/byteranges 206 containing both slices.
|
||||
# Starlette 0.45.3 quirk: it advertises the multipart boundary via the `content-range`
|
||||
# header (rather than content-type, which stays the file's own media_type); the body is
|
||||
# a real multipart/byteranges document. We assert on the boundary marker + both slices.
|
||||
assert r.status_code == 206
|
||||
assert r.headers["content-range"].startswith("multipart/byteranges")
|
||||
body = r.content
|
||||
assert BODY[0:10] in body
|
||||
assert BODY[20:30] in body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3.1 (stage-2 preserved) — temp_* mtime touch survives the FileResponse swap.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_temp_file_mtime_refreshed_when_stale(tmp_path):
|
||||
# A temp_* file whose mtime is OLDER than the refresh interval gets touched, so the
|
||||
# 1h sweeper won't delete an actively-viewed video.
|
||||
temp_file = tmp_path / "temp_bigvid"
|
||||
temp_file.write_bytes(b"videodata")
|
||||
stale = time.time() - 10000 # >> TEMP_MTIME_REFRESH_INTERVAL (300s)
|
||||
os.utime(temp_file, (stale, stale))
|
||||
|
||||
c = _make_client(str(temp_file))
|
||||
r = c.get("/f")
|
||||
assert r.status_code == 200
|
||||
assert r.content == b"videodata"
|
||||
assert os.path.getmtime(temp_file) > time.time() - 100 # refreshed by the serve
|
||||
|
||||
|
||||
def test_temp_file_mtime_stable_when_fresh(tmp_path):
|
||||
# DEBOUNCE: a temp_* file touched RECENTLY (within the refresh interval) is NOT
|
||||
# re-touched — so FileResponse's mtime-derived ETag stays stable across the rapid
|
||||
# requests of a single resume/seek session and `If-Range` resume keeps working.
|
||||
temp_file = tmp_path / "temp_freshvid"
|
||||
temp_file.write_bytes(b"videodata")
|
||||
recent = time.time() - 30 # << TEMP_MTIME_REFRESH_INTERVAL (300s)
|
||||
os.utime(temp_file, (recent, recent))
|
||||
mtime_before = os.path.getmtime(temp_file)
|
||||
|
||||
c = _make_client(str(temp_file))
|
||||
r1 = c.get("/f")
|
||||
r2 = c.get("/f")
|
||||
assert r1.status_code == 200 and r2.status_code == 200
|
||||
# mtime unchanged -> the ETag is identical across the two serves.
|
||||
assert os.path.getmtime(temp_file) == mtime_before
|
||||
assert r1.headers.get("etag") == r2.headers.get("etag")
|
||||
|
||||
|
||||
def test_non_temp_file_mtime_untouched(tmp_path):
|
||||
normal = tmp_path / "regularfile"
|
||||
normal.write_bytes(b"data")
|
||||
stale = time.time() - 10000
|
||||
os.utime(normal, (stale, stale))
|
||||
|
||||
c = _make_client(str(normal))
|
||||
c.get("/f")
|
||||
assert os.path.getmtime(normal) < time.time() - 5000 # left alone
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3.1 (stage-2 preserved) — delete_after still schedules the temp-file BackgroundTask.
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_delete_after_attaches_background_task(tmp_path):
|
||||
fp = tmp_path / "temp_todelete"
|
||||
fp.write_bytes(b"x")
|
||||
|
||||
class _Req:
|
||||
headers = {}
|
||||
|
||||
resp = await api_server.prepare_file_response(str(fp), request=_Req(), delete_after=True)
|
||||
# FileResponse must carry a non-None background that deletes exactly this file.
|
||||
assert resp.background is not None
|
||||
assert resp.background.func is api_server.delayed_delete_file
|
||||
assert resp.background.args == (str(fp),)
|
||||
|
||||
|
||||
async def test_no_delete_after_has_no_background(tmp_path):
|
||||
fp = tmp_path / "keepme"
|
||||
fp.write_bytes(b"x")
|
||||
|
||||
class _Req:
|
||||
headers = {}
|
||||
|
||||
resp = await api_server.prepare_file_response(str(fp), request=_Req(), delete_after=False)
|
||||
assert resp.background is None
|
||||
|
||||
|
||||
def test_delete_after_background_runs_and_removes_file(tmp_path, monkeypatch):
|
||||
fp = tmp_path / "temp_gone"
|
||||
fp.write_bytes(BODY)
|
||||
|
||||
# Patch the module-level deleter to remove immediately (real one sleeps 300s); the
|
||||
# BackgroundTask picks up this reference at prepare_file_response call time.
|
||||
async def _delete_now(path, delay=300):
|
||||
os.remove(path)
|
||||
|
||||
monkeypatch.setattr(api_server, "delayed_delete_file", _delete_now)
|
||||
|
||||
c = _make_client(str(fp), delete_after=True)
|
||||
r = c.get("/f")
|
||||
assert r.status_code == 200
|
||||
assert r.content == BODY
|
||||
# TestClient blocks until the background task has run.
|
||||
assert not os.path.exists(fp)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3.1 (stage-2 preserved) — media_key MIME cache is consulted and populated.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_media_key_mime_cache_hit_used(sample_file, monkeypatch):
|
||||
calls = {"get": 0, "set": 0, "magic": 0}
|
||||
|
||||
def fake_get(db, ch, pid, fid):
|
||||
calls["get"] += 1
|
||||
return "video/mp4" # cache HIT
|
||||
|
||||
def fake_set(*a, **k):
|
||||
calls["set"] += 1
|
||||
|
||||
def fake_magic(_path):
|
||||
calls["magic"] += 1
|
||||
return "application/octet-stream"
|
||||
|
||||
monkeypatch.setattr(api_server, "get_mime_type_sync", fake_get)
|
||||
monkeypatch.setattr(api_server, "set_mime_type_sync", fake_set)
|
||||
monkeypatch.setattr(api_server.magic_mime, "from_file", fake_magic)
|
||||
|
||||
c = _make_client(sample_file, media_key=("chan", 42, "fid"))
|
||||
r = c.get("/f")
|
||||
assert r.status_code == 200
|
||||
# Cache hit: python-magic never invoked, nothing re-written to the cache, MIME applied.
|
||||
assert calls["get"] == 1
|
||||
assert calls["magic"] == 0
|
||||
assert calls["set"] == 0
|
||||
assert r.headers["content-type"].startswith("video/mp4")
|
||||
|
||||
|
||||
def test_media_key_mime_cache_miss_populates(sample_file, monkeypatch):
|
||||
written = {}
|
||||
|
||||
def fake_get(db, ch, pid, fid):
|
||||
return None # cache MISS
|
||||
|
||||
def fake_set(db, ch, pid, fid, mime):
|
||||
written["mime"] = mime
|
||||
|
||||
def fake_magic(_path):
|
||||
return "image/png"
|
||||
|
||||
monkeypatch.setattr(api_server, "get_mime_type_sync", fake_get)
|
||||
monkeypatch.setattr(api_server, "set_mime_type_sync", fake_set)
|
||||
monkeypatch.setattr(api_server.magic_mime, "from_file", fake_magic)
|
||||
|
||||
c = _make_client(sample_file, media_key=("chan", 42, "fid"))
|
||||
r = c.get("/f")
|
||||
assert r.status_code == 200
|
||||
# Miss -> python-magic result is both applied and persisted for next time.
|
||||
assert written.get("mime") == "image/png"
|
||||
assert r.headers["content-type"].startswith("image/png")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3.1 — 404 pre-check kept.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_missing_file_returns_404(tmp_path):
|
||||
c = _make_client(str(tmp_path / "does_not_exist"))
|
||||
r = c.get("/f")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3.2 — pure-ASGI RequestLoggingMiddleware: request returns intact and is logged.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_asgi_logging_middleware_passes_body_and_logs(tmp_path, caplog):
|
||||
fp = tmp_path / "streamed.bin"
|
||||
fp.write_bytes(BODY)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(api_server.RequestLoggingMiddleware)
|
||||
|
||||
@app.get("/f")
|
||||
async def _serve(request: Request):
|
||||
return await api_server.prepare_file_response(str(fp), request=request)
|
||||
|
||||
client = TestClient(app)
|
||||
with caplog.at_level(logging.DEBUG, logger="api_server"):
|
||||
r = client.get("/f")
|
||||
|
||||
# Body flows straight through the middleware — not buffered/truncated.
|
||||
assert r.status_code == 200
|
||||
assert r.content == BODY
|
||||
messages = " ".join(rec.getMessage() for rec in caplog.records)
|
||||
assert "Request: GET /f" in messages
|
||||
assert "Response status: 200" in messages
|
||||
|
||||
|
||||
def test_asgi_logging_middleware_range_still_works(tmp_path):
|
||||
fp = tmp_path / "streamed.bin"
|
||||
fp.write_bytes(BODY)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(api_server.RequestLoggingMiddleware)
|
||||
|
||||
@app.get("/f")
|
||||
async def _serve(request: Request):
|
||||
return await api_server.prepare_file_response(str(fp), request=request)
|
||||
|
||||
client = TestClient(app)
|
||||
r = client.get("/f", headers={"Range": "bytes=0-99"})
|
||||
# 206 streaming still works through the pure-ASGI middleware (send not buffered).
|
||||
assert r.status_code == 206
|
||||
assert r.content == BODY[:100]
|
||||
|
||||
|
||||
async def test_asgi_logging_middleware_passes_non_http_scope_through(caplog):
|
||||
# The `scope["type"] != "http"` branch (lifespan/websocket) only runs in a real
|
||||
# deploy, never through TestClient's plain request path — so pin it directly:
|
||||
# a non-http scope must delegate to the inner app untouched and log nothing.
|
||||
called = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
called["scope_type"] = scope["type"]
|
||||
|
||||
mw = api_server.RequestLoggingMiddleware(inner)
|
||||
with caplog.at_level(logging.DEBUG, logger="api_server"):
|
||||
await mw({"type": "lifespan"}, None, None)
|
||||
|
||||
assert called["scope_type"] == "lifespan" # delegated to the inner app
|
||||
# Nothing request/response-ish was logged for a non-http scope.
|
||||
messages = " ".join(rec.getMessage() for rec in caplog.records)
|
||||
assert "Request:" not in messages
|
||||
assert "Response status:" not in messages
|
||||
@@ -0,0 +1,552 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, logging-fstring-interpolation, line-too-long
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
"""
|
||||
Stage 4 (event-loop hygiene for feed generation) tests.
|
||||
|
||||
Covers:
|
||||
- 4.1 raw_message laziness: feeds do NOT compute str(message); JSON/debug HTML do.
|
||||
- 4.2 side-effect IO removed from process_message: _save_media_file_ids only appends to
|
||||
self._pending_media_ids; the caller flushes once via upsert_media_file_ids_bulk_sync.
|
||||
Also: the render path contains NO create_task / get_running_loop / to_thread.
|
||||
- 4.3 render pipeline moved into a single thread: the four render functions are now plain
|
||||
sync functions and actually execute off the main thread; deepcopy of a pickled Message
|
||||
does not crash; a 100-message feed generates correctly.
|
||||
- 4.4 sanitize coverage (XSS): a <script> / onerror= / javascript: payload is stripped in
|
||||
ALL outputs — rss, html-feed, single-post html, and json — each with exactly one pass.
|
||||
"""
|
||||
import re
|
||||
import pickle
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from pyrogram.enums import MessageMediaType
|
||||
|
||||
import post_parser as pp_module
|
||||
import rss_generator as rss_module
|
||||
from post_parser import PostParser
|
||||
from rss_generator import (
|
||||
generate_channel_rss,
|
||||
generate_channel_html,
|
||||
_render_pipeline,
|
||||
_compute_time_based_group_ids,
|
||||
_create_messages_groups,
|
||||
_render_messages_groups,
|
||||
)
|
||||
|
||||
XSS_PAYLOAD = "<script>alert('xss')</script><img src=x onerror=\"alert(1)\"><a href=\"javascript:alert(2)\">click</a>"
|
||||
|
||||
|
||||
class _Str(str):
|
||||
"""Minimal stand-in for Pyrogram's Str: .html returns the raw string unchanged,
|
||||
so a malicious payload reaches the pre-sanitization html body just like real text
|
||||
carrying entities would."""
|
||||
@property
|
||||
def html(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
def make_message(mid, text=None, media=None, photo_uid=None, username="testchan",
|
||||
date=None):
|
||||
m = SimpleNamespace()
|
||||
m.id = mid
|
||||
m.date = date or datetime(2024, 1, 1, 12, 0, mid % 60, tzinfo=timezone.utc)
|
||||
m.text = _Str(text) if text is not None else None
|
||||
m.caption = None
|
||||
m.media = media
|
||||
m.web_page = None
|
||||
m.poll = None
|
||||
m.service = None
|
||||
m.forward_origin = None
|
||||
m.reply_to_message = None
|
||||
m.reply_to_message_id = None
|
||||
m.sender_chat = None
|
||||
m.from_user = None
|
||||
m.reactions = None
|
||||
m.views = 100
|
||||
m.media_group_id = None
|
||||
m.show_caption_above_media = False
|
||||
m.chat = SimpleNamespace(id=-1001234567890, username=username)
|
||||
# media sub-objects default to None
|
||||
for attr in ("photo", "video", "document", "audio", "voice",
|
||||
"video_note", "animation", "sticker"):
|
||||
setattr(m, attr, None)
|
||||
if media == MessageMediaType.PHOTO and photo_uid:
|
||||
m.photo = SimpleNamespace(file_unique_id=photo_uid)
|
||||
return m
|
||||
|
||||
|
||||
def _co_names(func):
|
||||
return set(func.__code__.co_names)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4.3 — render functions are plain sync and run off the main thread
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_render_functions_are_sync():
|
||||
# _trim_messages_groups was inlined into _render_pipeline as a `[:limit]` slice
|
||||
# (render-pipeline cosmetics stage); the trimming path is now covered via _render_pipeline.
|
||||
for fn in (_compute_time_based_group_ids, _create_messages_groups,
|
||||
_render_messages_groups, _render_pipeline):
|
||||
assert not asyncio.iscoroutinefunction(fn), f"{fn.__name__} must be a plain sync function"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_runs_in_worker_thread(monkeypatch):
|
||||
main_ident = threading.get_ident()
|
||||
seen = {}
|
||||
|
||||
real_render = rss_module._render_messages_groups
|
||||
|
||||
def spy(*args, **kwargs):
|
||||
seen["ident"] = threading.get_ident()
|
||||
return real_render(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(rss_module, "_render_messages_groups", spy)
|
||||
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return [make_message(i, text=f"post {i}") for i in range(5)]
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
await generate_channel_rss("testchan", client=SimpleNamespace(), limit=5)
|
||||
assert "ident" in seen
|
||||
assert seen["ident"] != main_ident, "render pipeline must run in a worker thread, not the loop thread"
|
||||
|
||||
|
||||
def test_time_clustering_does_not_mutate_pickled_message():
|
||||
# Stage 4: _create_time_based_media_groups (which deep-copied the cached list and
|
||||
# MUTATED media_group_id) is gone. Time-clustering is now a PURE mapping function; a
|
||||
# pickled Message straight from the cache must come out untouched.
|
||||
from pyrogram.types import Message, Chat
|
||||
from pyrogram.enums import ChatType
|
||||
base = datetime(2024, 1, 1, 12, 0, 0) # naive, as kurigram emits
|
||||
a = pickle.loads(pickle.dumps(Message(
|
||||
id=7, date=base, text="hello", media_group_id="orig_A",
|
||||
chat=Chat(id=-1001, type=ChatType.CHANNEL, username="testchan"))))
|
||||
b = pickle.loads(pickle.dumps(Message(
|
||||
id=8, date=base.replace(second=2), text="world", media_group_id=None,
|
||||
chat=Chat(id=-1001, type=ChatType.CHANNEL, username="testchan"))))
|
||||
|
||||
mapping = _compute_time_based_group_ids([a, b], merge_seconds=5)
|
||||
|
||||
# The two adjacent posts are clustered under the first truthy id, but only via the
|
||||
# RETURNED mapping — the input objects keep their original media_group_id.
|
||||
assert mapping == {7: "orig_A", 8: "orig_A"}
|
||||
assert a.media_group_id == "orig_A"
|
||||
assert b.media_group_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4.2 — no asyncio in the render path; bulk upsert after render
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_render_path_has_no_asyncio_side_effects():
|
||||
banned = {"create_task", "get_running_loop", "to_thread", "ensure_future"}
|
||||
funcs = [
|
||||
_render_pipeline, _compute_time_based_group_ids, _create_messages_groups,
|
||||
_render_messages_groups,
|
||||
PostParser.process_message, PostParser._generate_html_body,
|
||||
PostParser._generate_html_media, PostParser.generate_html_footer,
|
||||
PostParser._reactions_views_links, PostParser._save_media_file_ids,
|
||||
PostParser._sanitize_html,
|
||||
]
|
||||
for fn in funcs:
|
||||
offenders = _co_names(fn) & banned
|
||||
assert not offenders, f"{fn.__qualname__} references forbidden asyncio names: {offenders}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_ids_persisted_via_bulk_upsert(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_bulk(db_path, entries):
|
||||
calls.append(list(entries))
|
||||
|
||||
monkeypatch.setattr(pp_module, "upsert_media_file_ids_bulk_sync", fake_bulk)
|
||||
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return [
|
||||
make_message(1, text="just text"),
|
||||
make_message(2, media=MessageMediaType.PHOTO, photo_uid="uid_abc"),
|
||||
]
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
await generate_channel_rss("testchan", client=SimpleNamespace(), limit=10)
|
||||
|
||||
assert len(calls) == 1, "bulk upsert must be called exactly once after render"
|
||||
entries = calls[0]
|
||||
assert len(entries) == 1, "only the photo message contributes a media id"
|
||||
channel, post_id, fid, _ts = entries[0]
|
||||
assert (channel, post_id, fid) == ("testchan", 2, "uid_abc")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_media_file_ids_only_appends(monkeypatch):
|
||||
# Even with a running loop, _save_media_file_ids must not create tasks — just append.
|
||||
parser = PostParser(SimpleNamespace())
|
||||
monkeypatch.setattr(pp_module, "upsert_media_file_ids_bulk_sync",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not be called directly")))
|
||||
msg = make_message(3, media=MessageMediaType.PHOTO, photo_uid="uid_x")
|
||||
parser._save_media_file_ids(msg)
|
||||
assert parser._pending_media_ids == [("testchan", 3, "uid_x", parser._pending_media_ids[0][3])]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4.1 — raw_message laziness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_raw_message_lazy_for_feed():
|
||||
parser = PostParser(SimpleNamespace())
|
||||
result = parser.process_message(make_message(10, text="hi"), include_raw=False, sanitize=False)
|
||||
assert "raw_message" not in result
|
||||
|
||||
|
||||
def test_raw_message_present_for_json_and_debug():
|
||||
parser = PostParser(SimpleNamespace())
|
||||
result = parser.process_message(make_message(11, text="hi"), include_raw=True)
|
||||
assert "raw_message" in result
|
||||
assert isinstance(result["raw_message"], str)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_100_message_feed_generates(monkeypatch):
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return [make_message(i, text=f"post number {i}") for i in range(1, 101)]
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
rss = await generate_channel_rss("testchan", client=SimpleNamespace(), limit=100)
|
||||
assert rss.count("<item>") == 100
|
||||
assert "post number 50" in rss
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4.4 — XSS: payload stripped in all four outputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _assert_clean(html_str, where):
|
||||
assert "<script>" not in html_str, f"{where}: <script> survived"
|
||||
assert "onerror" not in html_str, f"{where}: onerror= survived"
|
||||
assert "javascript:" not in html_str, f"{where}: javascript: survived"
|
||||
|
||||
|
||||
def _make_client_returning(msg):
|
||||
client = SimpleNamespace()
|
||||
|
||||
async def get_messages(channel, post_id):
|
||||
return msg
|
||||
|
||||
client.get_messages = get_messages
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xss_stripped_in_json():
|
||||
msg = make_message(20, text=XSS_PAYLOAD)
|
||||
parser = PostParser(_make_client_returning(msg))
|
||||
data = await parser.get_post("testchan", 20, "json")
|
||||
_assert_clean(data["html"]["body"], "json body")
|
||||
_assert_clean(data["html"]["footer"], "json footer")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xss_stripped_in_single_post_html():
|
||||
msg = make_message(21, text=XSS_PAYLOAD)
|
||||
parser = PostParser(_make_client_returning(msg))
|
||||
html_out = await parser.get_post("testchan", 21, "html")
|
||||
_assert_clean(html_out, "single-post html")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xss_stripped_in_single_post_html_debug():
|
||||
# debug HTML embeds raw_message (str(message)) into a <pre>; it must be html-escaped so
|
||||
# no live tag from the payload survives. The escaped dump legitimately still contains the
|
||||
# inert words "onerror"/"javascript:" as text — what matters is that they are NOT live.
|
||||
msg = make_message(22, text=XSS_PAYLOAD)
|
||||
parser = PostParser(_make_client_returning(msg))
|
||||
html_out = await parser.get_post("testchan", 22, "html", debug=True)
|
||||
|
||||
# 1) The rendered display area (everything before the raw <pre>) is fully sanitized.
|
||||
display = html_out.split('<pre', 1)[0]
|
||||
_assert_clean(display, "single-post debug display")
|
||||
|
||||
# 2) The raw <pre> dump is html-escaped: no live <script> tag anywhere, and the payload
|
||||
# appears only in escaped form (proving html.escape ran).
|
||||
assert "<script>" not in html_out, "debug raw dump left a live <script> tag"
|
||||
assert "<script>" in html_out, "debug raw dump was not html-escaped"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xss_in_title_escaped_in_debug_html():
|
||||
# Issue #13: the debug branch of _format_html embeds data["html"]["title"], which is
|
||||
# generated from user-controlled content and never passes through bleach. A poll whose
|
||||
# question carries a <script> tag yields the title "📊 Poll: <script>alert(1)</script>"
|
||||
# verbatim — it must be html-escaped before being embedded in the debug output.
|
||||
msg = make_message(25, media=MessageMediaType.POLL)
|
||||
msg.poll = SimpleNamespace(question="<script>alert(1)</script>")
|
||||
parser = PostParser(_make_client_returning(msg))
|
||||
html_out = await parser.get_post("testchan", 25, "html", debug=True)
|
||||
|
||||
# No live <script> tag anywhere in the output (title div, body, footer, raw <pre>).
|
||||
assert "<script>" not in html_out, "debug html left a live <script> tag"
|
||||
# The title div itself carries the payload only in escaped form (proving html.escape
|
||||
# ran on the title, not just on the raw_message <pre> dump).
|
||||
title_lines = [line for line in html_out.split("\n") if 'class="title"' in line]
|
||||
assert title_lines, "debug output must contain the title div"
|
||||
assert "<script>alert(1)</script>" in title_lines[0], \
|
||||
"title was not html-escaped in debug output"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xss_stripped_in_rss_feed(monkeypatch):
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return [make_message(23, text=XSS_PAYLOAD)]
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
rss = await generate_channel_rss("testchan", client=SimpleNamespace(), limit=5)
|
||||
# The sanitized HTML lives in <content:encoded><![CDATA[...]]></content:encoded>.
|
||||
cdata = re.findall(r"<!\[CDATA\[(.*?)\]\]>", rss, re.DOTALL)
|
||||
assert cdata, "expected CDATA content in RSS"
|
||||
for chunk in cdata:
|
||||
_assert_clean(chunk, "rss content")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xss_stripped_in_html_feed(monkeypatch):
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return [make_message(24, text=XSS_PAYLOAD)]
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
html_feed = await generate_channel_html("testchan", client=SimpleNamespace(), limit=5)
|
||||
_assert_clean(html_feed, "html feed")
|
||||
|
||||
|
||||
def _media_msg_with_payload_caption(mid):
|
||||
# A photo message whose CAPTION carries the payload — this exercises the media
|
||||
# fragment path (_generate_html_media / caption rendering) whose internal per-fragment
|
||||
# sanitize pass was removed in 4.4. The covering pass must still strip it.
|
||||
m = make_message(mid, media=MessageMediaType.PHOTO, photo_uid="pic123")
|
||||
m.caption = _Str(XSS_PAYLOAD)
|
||||
return m
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xss_in_media_caption_stripped_direct_paths():
|
||||
# json + single-post html go through process_message(sanitize=True) directly.
|
||||
parser = PostParser(_make_client_returning(_media_msg_with_payload_caption(30)))
|
||||
data = await parser.get_post("testchan", 30, "json")
|
||||
_assert_clean(data["html"]["body"], "json body (media caption)")
|
||||
_assert_clean(data["html"]["footer"], "json footer (media caption)")
|
||||
|
||||
parser2 = PostParser(_make_client_returning(_media_msg_with_payload_caption(31)))
|
||||
html_out = await parser2.get_post("testchan", 31, "html")
|
||||
_assert_clean(html_out, "single-post html (media caption)")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xss_in_media_caption_stripped_in_feeds(monkeypatch):
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return [_media_msg_with_payload_caption(32)]
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
rss = await generate_channel_rss("testchan", client=SimpleNamespace(), limit=5)
|
||||
for chunk in re.findall(r"<!\[CDATA\[(.*?)\]\]>", rss, re.DOTALL):
|
||||
_assert_clean(chunk, "rss content (media caption)")
|
||||
|
||||
async def fake_get_history2(client, channel, limit=20):
|
||||
return [_media_msg_with_payload_caption(33)]
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history2, raising=False)
|
||||
html_feed = await generate_channel_html("testchan", client=SimpleNamespace(), limit=5)
|
||||
_assert_clean(html_feed, "html feed (media caption)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §3.4 — per-post sanitize ISOLATION (stage-1 load-bearing invariant, PR #36).
|
||||
# A dangling/unbalanced tag in post A must be normalized WITHIN A's own fragment and
|
||||
# cannot swallow post B (cross-post DOM/XSS bleed). This holds because _render_pipeline
|
||||
# runs a SEPARATE bleach.clean per post and the formatter joins the <hr> divider AFTER
|
||||
# sanitize. A FUTURE stage that rejoins BEFORE sanitizing would reintroduce the bleed
|
||||
# and pass every single-message XSS test — so this 2-post case MUST turn it red.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_unbalanced_post_does_not_swallow_next_post_html_feed(monkeypatch):
|
||||
# Post A carries a dangling <div><b> with NO closers; post B is well-formed and
|
||||
# carries a unique marker. _Str.html feeds the raw tags into the pre-sanitize body
|
||||
# exactly as a real message with entities would.
|
||||
msg_a = make_message(50, text="<div><b>DANGLING_A no closers here",
|
||||
date=datetime(2024, 1, 1, 12, 5, 0, tzinfo=timezone.utc))
|
||||
msg_b = make_message(51, text="INTACT_B_MARKER",
|
||||
date=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc))
|
||||
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return [msg_a, msg_b] # A is newer than B -> A rendered first
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
html = await generate_channel_html("testchan", client=SimpleNamespace(), limit=5)
|
||||
|
||||
# The divider survives (joined AFTER per-post sanitize) and splits the feed into
|
||||
# exactly two TOP-LEVEL fragments. A join-before-sanitize regression strips the
|
||||
# non-whitelisted <hr> entirely (strip=True) -> this alone already goes red.
|
||||
parts = html.split('<hr class="post-divider">')
|
||||
assert len(parts) == 2, "expected exactly one top-level post-divider between the two posts"
|
||||
frag_a, frag_b = parts
|
||||
|
||||
# A's dangling tags were balanced WITHIN A's own fragment, NOT deferred past the
|
||||
# divider. A rejoin-before-sanitize regression closes them only at the very end of
|
||||
# the whole feed (after B), leaving frag_a with more opens than closes.
|
||||
assert "DANGLING_A" in frag_a
|
||||
assert frag_a.count("<div") == frag_a.count("</div>"), "post A's <div> not closed within its own fragment"
|
||||
assert frag_a.count("<b>") == frag_a.count("</b>"), "post A's <b> not closed within its own fragment"
|
||||
|
||||
# B is intact, lives at top level, and is itself balanced — never nested inside A
|
||||
# (its marker must not have been trapped before A's divider).
|
||||
assert "INTACT_B_MARKER" in frag_b, "post B content missing/swallowed by post A"
|
||||
assert "INTACT_B_MARKER" not in frag_a, "post B content bled into post A's fragment"
|
||||
assert frag_b.count("<div") == frag_b.count("</div>"), "post B fragment not self-contained"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Review round-1: the new bulk-upsert SQL executed for real (not mocked).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_bulk_upsert_media_file_ids_real_sql(tmp_path):
|
||||
import sqlite3
|
||||
from file_io import upsert_media_file_ids_bulk_sync, init_db_sync
|
||||
|
||||
db = str(tmp_path / "t.db")
|
||||
init_db_sync(db)
|
||||
|
||||
# Empty list is a no-op (no crash).
|
||||
upsert_media_file_ids_bulk_sync(db, [])
|
||||
|
||||
# Multi-row insert.
|
||||
upsert_media_file_ids_bulk_sync(db, [
|
||||
("chA", 1, "fidA", 100.0),
|
||||
("chB", 2, "fidB", 200.0),
|
||||
])
|
||||
conn = sqlite3.connect(db)
|
||||
rows = dict(((c, p, f), a) for c, p, f, a in
|
||||
conn.execute("SELECT channel, post_id, file_unique_id, added FROM media_file_ids"))
|
||||
assert rows[("chA", 1, "fidA")] == 100.0
|
||||
assert rows[("chB", 2, "fidB")] == 200.0
|
||||
|
||||
# Re-upsert the SAME key updates `added` (ON CONFLICT ... DO UPDATE SET added=excluded.added).
|
||||
upsert_media_file_ids_bulk_sync(db, [("chA", 1, "fidA", 999.0)])
|
||||
a = conn.execute(
|
||||
"SELECT added FROM media_file_ids WHERE channel='chA' AND post_id=1 AND file_unique_id='fidA'"
|
||||
).fetchone()[0]
|
||||
assert a == 999.0
|
||||
conn.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Review round-1: media ids collected before a render exception are still
|
||||
# flushed (the flush is in a finally). Removing the finally must break this.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_media_flushed_on_render_exception(monkeypatch):
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return [make_message(40, text="hi")]
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
# A render pipeline that collects a pending id then raises mid-render.
|
||||
def boom_pipeline(messages, post_parser, *a, **k):
|
||||
post_parser._pending_media_ids.append(("chZ", 9, "fidZ", 1.0))
|
||||
raise RuntimeError("render blew up")
|
||||
|
||||
monkeypatch.setattr(rss_module, "_render_pipeline", boom_pipeline)
|
||||
|
||||
flushed = {}
|
||||
async def fake_bulk(db, entries):
|
||||
flushed["entries"] = list(entries)
|
||||
monkeypatch.setattr("post_parser.upsert_media_file_ids_bulk_sync",
|
||||
lambda db, entries: flushed.__setitem__("entries", list(entries)),
|
||||
raising=False)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await generate_channel_rss("testchan", client=SimpleNamespace(), limit=5)
|
||||
|
||||
# The collected id was persisted despite the render exception (flush in finally).
|
||||
assert flushed.get("entries") == [("chZ", 9, "fidZ", 1.0)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Review round-1 [security]: if the ONLY sanitize pass throws, the feed must
|
||||
# FAIL CLOSED (html.escape the raw content), never emit the raw XSS payload.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_rss_fails_closed_when_sanitizer_raises(monkeypatch):
|
||||
async def fake_get_chat(client, channel):
|
||||
return SimpleNamespace(title="Test", username="testchan", id=-1001234567890)
|
||||
|
||||
async def fake_get_history(client, channel, limit=20):
|
||||
return [make_message(41, text=XSS_PAYLOAD)]
|
||||
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
|
||||
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False)
|
||||
|
||||
# Force bleach to blow up (e.g. the RecursionError class already seen in prod).
|
||||
# API relocation: the single bleach config now lives in sanitizer.py, which imports
|
||||
# it as `from bleach import clean as HTMLSanitizer`; sanitize_html resolves the name
|
||||
# at call time, so patching sanitizer.HTMLSanitizer triggers the fail-closed path.
|
||||
import sanitizer as sanitizer_module
|
||||
def boom(*a, **k):
|
||||
raise RecursionError("bleach exploded")
|
||||
monkeypatch.setattr(sanitizer_module, "HTMLSanitizer", boom, raising=True)
|
||||
|
||||
rss = await generate_channel_rss("testchan", client=SimpleNamespace(), limit=5)
|
||||
chunks = re.findall(r"<!\[CDATA\[(.*?)\]\]>", rss, re.DOTALL)
|
||||
assert chunks, "expected CDATA content"
|
||||
for chunk in chunks:
|
||||
# Fail-closed: the raw payload was html.escaped, so NO live tag survived — every
|
||||
# `<` became `<`. (The letters "javascript:" still appear, but as inert text
|
||||
# inside an escaped "…", not a live href.)
|
||||
assert "<script" not in chunk, "RSS fail-open: raw <script> reached the feed"
|
||||
assert "<img" not in chunk, "RSS fail-open: raw <img onerror> reached the feed"
|
||||
assert "<a " not in chunk, "RSS fail-open: raw <a href> reached the feed"
|
||||
# ...and the escaping actually ran (payload present as escaped text, not dropped).
|
||||
assert "<script>" in chunk, "expected the payload html-escaped, not dropped"
|
||||
@@ -0,0 +1,238 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, line-too-long
|
||||
"""Stage-5 media table tests (render-pipeline refactor epic, issue #32/#34).
|
||||
|
||||
Two layers:
|
||||
* 5a — the MEDIA_SOURCES table + renderers reproduce the three old ladders
|
||||
(_get_file_unique_id, _save_media_file_ids, _generate_html_media). The fragment
|
||||
snapshot (tests/test_data/media_fragments.json) is the PRE-REFACTOR BASE reference,
|
||||
captured against the base post_parser.py; the 5a code reproduces it byte-for-byte
|
||||
(two fragments carry registered §3.14 deltas — see fr.REGISTERED_DELTAS). Plus
|
||||
table/selector/invariant unit tests.
|
||||
* 5b — the registered fixes (§3.13 large-file guard for every object, §3.14 the
|
||||
message-media div is closed in every branch) live in test_stage5b_media_fixes.py.
|
||||
|
||||
The cross-module invariant test pins the boundary with api_server.find_file_id_in_message:
|
||||
every object a selector returns must be resolvable there by its file_unique_id, or a new
|
||||
table entry would mint a /media URL the download path answers with 404.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from pyrogram.enums import MessageMediaType
|
||||
|
||||
from post_parser import (
|
||||
PostParser, MEDIA_SOURCES, RENDERERS, RenderCtx,
|
||||
_select_document, _select_sticker, _select_story, _select_poll_media,
|
||||
)
|
||||
from api_server import find_file_id_in_message
|
||||
from url_signer import KeyManager
|
||||
|
||||
from tests import media_fragment_replay as fr
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pin_signing_key(monkeypatch):
|
||||
# Deterministic media-URL digests regardless of checkout / cwd.
|
||||
monkeypatch.setattr(KeyManager, "signing_key", fr.FRAGMENT_SIGNING_KEY)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
return PostParser(SimpleNamespace())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. Fragment-level snapshot oracle — the 5a byte-for-byte contract.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("case_name", sorted(fr.build_cases().keys()))
|
||||
def test_media_fragment_matches_snapshot(parser, case_name):
|
||||
"""_generate_html_media (render ladder + _get_file_unique_id ladder) and the
|
||||
_save_media_file_ids collection ladder reproduce the frozen pre-refactor bytes."""
|
||||
snapshot = fr.load_snapshot()
|
||||
factory = fr.build_cases()[case_name]
|
||||
parser._pending_media_ids = []
|
||||
html = parser._generate_html_media(factory())
|
||||
collected = [[c, p, f] for c, p, f, _ in parser._pending_media_ids]
|
||||
# The snapshot is the pre-refactor BASE reference; the two §3.14 fragments carry a
|
||||
# registered 5b delta (the now-closed message-media div), so expect their 5b bytes.
|
||||
expected = fr.REGISTERED_DELTAS.get(case_name, snapshot[case_name])
|
||||
assert html == expected["html"], f"fragment HTML diverged for {case_name}"
|
||||
assert collected == expected["collected"], f"collection diverged for {case_name}"
|
||||
|
||||
|
||||
def test_snapshot_covers_every_spec_case():
|
||||
"""Guard: the snapshot and the case builders stay in lockstep (a dropped case
|
||||
would otherwise silently reduce coverage)."""
|
||||
snapshot = fr.load_snapshot()
|
||||
assert set(snapshot.keys()) == set(fr.build_cases().keys())
|
||||
# The spec §5a "Шаг 0" edge branches must all be present.
|
||||
for required in ("photo", "video", "animation", "video_note", "audio_default_mime",
|
||||
"voice_default_mime", "sticker_img", "sticker_video",
|
||||
"document_pdf_public", "document_pdf_private", "document_normal",
|
||||
"live_photo", "story_video", "story_photo", "poll_media_img",
|
||||
"poll_media_video", "paid_media", "webpage_with_photo",
|
||||
"webpage_without_photo", "webpage_photo_long_text",
|
||||
"file_unique_id_none", "channel_username_none"):
|
||||
assert required in snapshot, f"missing spec fragment case: {required}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. Table structure: every render kind has a renderer; the only kind=None entry
|
||||
# is WEB_PAGE; PAID_MEDIA has no entry.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_every_render_kind_has_a_renderer():
|
||||
# Fixed-kind lambda entries (branchy selectors are covered by the selector unit
|
||||
# tests below and the fragment oracle).
|
||||
static = {
|
||||
MessageMediaType.PHOTO: 'img_400',
|
||||
MessageMediaType.VIDEO: 'video_400',
|
||||
MessageMediaType.ANIMATION: 'video_400',
|
||||
MessageMediaType.VIDEO_NOTE: 'video_400',
|
||||
MessageMediaType.AUDIO: 'audio',
|
||||
MessageMediaType.VOICE: 'audio',
|
||||
MessageMediaType.LIVE_PHOTO: 'video_loop_400',
|
||||
}
|
||||
for mt, expected_kind in static.items():
|
||||
assert expected_kind in RENDERERS, f"{mt} kind {expected_kind} has no renderer"
|
||||
# Selector-produced kinds (document/sticker/story/poll) all resolve to renderers
|
||||
# or, for WEB_PAGE, to None.
|
||||
for kind in ('img_400', 'video_400', 'audio', 'pdf', 'video_loop_200',
|
||||
'img_200_sticker', 'video_loop_400'):
|
||||
assert kind in RENDERERS
|
||||
|
||||
|
||||
def test_web_page_is_the_only_kind_none_entry():
|
||||
assert MEDIA_SOURCES[MessageMediaType.WEB_PAGE](SimpleNamespace(web_page=None))[1] is None
|
||||
|
||||
|
||||
def test_paid_media_has_no_table_entry():
|
||||
assert MessageMediaType.PAID_MEDIA not in MEDIA_SOURCES
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Selector unit tests (the branchy selectors).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_select_document_pdf_vs_image():
|
||||
pdf = SimpleNamespace(document=SimpleNamespace(mime_type="application/pdf", file_unique_id="d1"))
|
||||
png = SimpleNamespace(document=SimpleNamespace(mime_type="image/png", file_unique_id="d2"))
|
||||
assert _select_document(pdf)[1] == 'pdf'
|
||||
assert _select_document(png)[1] == 'img_400'
|
||||
assert _select_document(pdf)[0] is pdf.document
|
||||
|
||||
|
||||
def test_select_sticker_video_vs_image():
|
||||
vid = SimpleNamespace(sticker=SimpleNamespace(is_video=True, file_unique_id="s1"))
|
||||
img = SimpleNamespace(sticker=SimpleNamespace(is_video=False, file_unique_id="s2"))
|
||||
assert _select_sticker(vid)[1] == 'video_loop_200'
|
||||
assert _select_sticker(img)[1] == 'img_200_sticker'
|
||||
|
||||
|
||||
def test_select_story_maps_helper_kind():
|
||||
vid = SimpleNamespace(story=SimpleNamespace(video=SimpleNamespace(file_unique_id="v"), photo=None))
|
||||
pic = SimpleNamespace(story=SimpleNamespace(video=None, photo=SimpleNamespace(file_unique_id="p")))
|
||||
none = SimpleNamespace(story=None)
|
||||
assert _select_story(vid)[1] == 'video_400'
|
||||
assert _select_story(pic)[1] == 'img_400'
|
||||
assert _select_story(none) == (None, None)
|
||||
|
||||
|
||||
def test_select_poll_media_maps_helper_kind():
|
||||
img = SimpleNamespace(poll=SimpleNamespace(description_media=SimpleNamespace(photo=SimpleNamespace(file_unique_id="p"))))
|
||||
vid = SimpleNamespace(poll=SimpleNamespace(description_media=SimpleNamespace(video=SimpleNamespace(file_unique_id="v"))))
|
||||
none = SimpleNamespace(poll=None)
|
||||
assert _select_poll_media(img)[1] == 'img_400'
|
||||
assert _select_poll_media(vid)[1] == 'video_400'
|
||||
assert _select_poll_media(none) == (None, None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4. Renderer byte structure (audio emits two items; pdf emits its two-append block).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_audio_renderer_emits_tag_and_br():
|
||||
out = RENDERERS['audio'](RenderCtx(url="U", mime="audio/mpeg"))
|
||||
assert len(out) == 2 and out[1] == '<br>'
|
||||
assert 'type="audio/mpeg"' in out[0]
|
||||
|
||||
|
||||
def test_pdf_renderer_emits_two_item_block():
|
||||
out = RENDERERS['pdf'](RenderCtx(url="U", tg_link="https://t.me/x/1"))
|
||||
assert len(out) == 2
|
||||
assert out[0] == '<div class="document-pdf" style="padding: 10px;">'
|
||||
assert out[1] == '<a href="https://t.me/x/1" target="_blank">[PDF-файл]</a></div>'
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5. Cross-module invariant: every selector object is resolvable in api_server.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _invariant_messages():
|
||||
"""One mock per MEDIA_SOURCES type whose selected object carries a real
|
||||
file_unique_id/file_id, so find_file_id_in_message can resolve it."""
|
||||
def base(**extra):
|
||||
m = SimpleNamespace(id=1, chat=SimpleNamespace(id=-100, username="c"), web_page=None, poll=None)
|
||||
for a in ("photo", "video", "document", "audio", "voice", "video_note",
|
||||
"animation", "sticker"):
|
||||
setattr(m, a, None)
|
||||
for k, v in extra.items():
|
||||
setattr(m, k, v)
|
||||
return m
|
||||
|
||||
media = SimpleNamespace(file_unique_id="UID", file_id="FID")
|
||||
cases = {
|
||||
MessageMediaType.PHOTO: base(media=MessageMediaType.PHOTO, photo=media),
|
||||
MessageMediaType.VIDEO: base(media=MessageMediaType.VIDEO, video=media),
|
||||
MessageMediaType.ANIMATION: base(media=MessageMediaType.ANIMATION, animation=media),
|
||||
MessageMediaType.VIDEO_NOTE: base(media=MessageMediaType.VIDEO_NOTE, video_note=media),
|
||||
MessageMediaType.AUDIO: base(media=MessageMediaType.AUDIO, audio=media),
|
||||
MessageMediaType.VOICE: base(media=MessageMediaType.VOICE, voice=media),
|
||||
MessageMediaType.DOCUMENT: base(media=MessageMediaType.DOCUMENT,
|
||||
document=SimpleNamespace(mime_type="image/png", file_unique_id="UID", file_id="FID")),
|
||||
MessageMediaType.STICKER: base(media=MessageMediaType.STICKER,
|
||||
sticker=SimpleNamespace(is_video=False, file_unique_id="UID", file_id="FID")),
|
||||
MessageMediaType.LIVE_PHOTO: base(media=MessageMediaType.LIVE_PHOTO, live_photo=media),
|
||||
MessageMediaType.STORY: base(media=MessageMediaType.STORY,
|
||||
story=SimpleNamespace(video=media, photo=None)),
|
||||
MessageMediaType.POLL: base(media=MessageMediaType.POLL,
|
||||
poll=SimpleNamespace(description_media=SimpleNamespace(photo=media))),
|
||||
MessageMediaType.WEB_PAGE: base(media=MessageMediaType.WEB_PAGE,
|
||||
web_page=SimpleNamespace(photo=media)),
|
||||
}
|
||||
return cases
|
||||
|
||||
|
||||
@pytest.mark.parametrize("media_type", list(MEDIA_SOURCES.keys()))
|
||||
async def test_selector_object_is_resolvable_in_api_server(media_type):
|
||||
"""The object MEDIA_SOURCES selects is found by api_server.find_file_id_in_message
|
||||
via its file_unique_id — the /media download path can always resolve a URL the
|
||||
table produced (spec §5a inter-module invariant)."""
|
||||
message = _invariant_messages()[media_type]
|
||||
selected_obj, _kind = MEDIA_SOURCES[media_type](message)
|
||||
file_unique_id = getattr(selected_obj, "file_unique_id", None)
|
||||
assert file_unique_id, f"{media_type} selector returned no usable file_unique_id"
|
||||
resolved = await find_file_id_in_message(message, file_unique_id)
|
||||
assert resolved == getattr(selected_obj, "file_id", None), \
|
||||
f"{media_type}: selected object not resolvable in find_file_id_in_message"
|
||||
|
||||
|
||||
def test_media_sources_covers_all_old_ladder_types():
|
||||
"""Every type the pre-refactor _get_file_unique_id dict handled is in the table."""
|
||||
old_types = {
|
||||
MessageMediaType.PHOTO, MessageMediaType.VIDEO, MessageMediaType.DOCUMENT,
|
||||
MessageMediaType.AUDIO, MessageMediaType.VOICE, MessageMediaType.VIDEO_NOTE,
|
||||
MessageMediaType.ANIMATION, MessageMediaType.STICKER, MessageMediaType.WEB_PAGE,
|
||||
MessageMediaType.LIVE_PHOTO, MessageMediaType.STORY, MessageMediaType.POLL,
|
||||
}
|
||||
assert old_types <= set(MEDIA_SOURCES.keys())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6. /flags endpoint constraint: flags.append(...) stays inside _extract_flags so
|
||||
# inspect.getsource still discovers them.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_get_all_possible_flags_nonempty_and_known():
|
||||
flags = PostParser.get_all_possible_flags()
|
||||
assert flags, "flag introspection returned nothing"
|
||||
for known in ("video", "audio", "no_image", "sticker", "poll", "fwd"):
|
||||
assert known in flags, f"known flag {known} not discovered by /flags introspection"
|
||||
@@ -0,0 +1,258 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, logging-fstring-interpolation, line-too-long
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
"""
|
||||
Stage 5 (SQLite access-time batching) tests.
|
||||
|
||||
Covers:
|
||||
- 5.1 accumulator: a /media cache hit records into api_server._access_updates and does NOT
|
||||
call update_media_file_access_sync (no synchronous SQLite write on the hot path).
|
||||
- 5.1 flush: seeding _access_updates + running the flush once writes the accumulated
|
||||
timestamps to a real temp DB (hit -> flush -> value updated in DB), and clears the dict.
|
||||
- 5.1 bulk fn update_media_file_access_bulk_sync: empty no-op, multi-row, and updating an
|
||||
EXISTING row's `added` (WHERE matches on the str channel key).
|
||||
- 5.1 snapshot-then-clear: an update arriving DURING the flush lands in the fresh dict and
|
||||
is not lost.
|
||||
- gotcha: str(channel) key discipline — an int-ish channel on the hot path keys the
|
||||
accumulator (and thus the UPDATE) by the string form.
|
||||
"""
|
||||
import sqlite3
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import api_server
|
||||
from file_io import init_db_sync, update_media_file_access_bulk_sync
|
||||
|
||||
|
||||
def _fake_plain_message():
|
||||
"""A non-poll, non-video message so download_media_file takes the normal cache flow."""
|
||||
return SimpleNamespace(media=None, video=None, empty=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_accumulator():
|
||||
"""Each test starts with an empty accumulator and restores it afterwards."""
|
||||
api_server._access_updates = {}
|
||||
yield
|
||||
api_server._access_updates = {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5.1 hot path: cache hit records into the accumulator, no synchronous SQLite.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_records_accumulator_no_sqlite(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
channel, post_id, fid = "testchan", 5, "fidHIT"
|
||||
cache_dir = tmp_path / "data" / "cache" / channel / str(post_id)
|
||||
cache_dir.mkdir(parents=True)
|
||||
(cache_dir / fid).write_bytes(b"cached-bytes")
|
||||
|
||||
async def fake_get(_cid, _pid):
|
||||
return _fake_plain_message()
|
||||
monkeypatch.setattr(api_server.client, "safe_get_messages", fake_get)
|
||||
|
||||
# Spy: the single-row synchronous updater must NOT be called on the hot path.
|
||||
called = []
|
||||
monkeypatch.setattr(api_server, "update_media_file_access_sync",
|
||||
lambda *a, **k: called.append(a))
|
||||
|
||||
path, delete_after = await api_server.download_media_file(channel, post_id, fid)
|
||||
|
||||
assert path == str(cache_dir / fid)
|
||||
assert delete_after is False
|
||||
assert called == [] # no synchronous SQLite access-write happened
|
||||
assert (channel, post_id, fid) in api_server._access_updates
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5.1 hot path (DoD guard): get_media's pre-semaphore cache-hit — the hottest
|
||||
# changed site, the one this PR exists for — records into the accumulator and
|
||||
# does NO synchronous SQLite access-write. Mirrors the download_media_file spy
|
||||
# so a regression re-introducing a per-hit write into THIS branch goes red.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_media_pre_semaphore_cache_hit_no_sqlite(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
channel, post_id, fid = "gmchan", 11, "fidGM"
|
||||
cache_dir = tmp_path / "data" / "cache" / channel / str(post_id)
|
||||
cache_dir.mkdir(parents=True)
|
||||
(cache_dir / fid).write_bytes(b"cached-bytes")
|
||||
|
||||
# Bypass the HMAC digest gate and the FileResponse machinery — the test targets
|
||||
# only the access-time write on the pre-semaphore cache-hit branch.
|
||||
monkeypatch.setattr(api_server, "verify_media_digest", lambda *a, **k: True)
|
||||
sentinel = object()
|
||||
async def fake_prepare(cache_path, request=None, media_key=None):
|
||||
return sentinel
|
||||
monkeypatch.setattr(api_server, "prepare_file_response", fake_prepare)
|
||||
|
||||
# Spy: the single-row synchronous updater must NOT be called on the hot path.
|
||||
called = []
|
||||
monkeypatch.setattr(api_server, "update_media_file_access_sync",
|
||||
lambda *a, **k: called.append(a))
|
||||
|
||||
resp = await api_server.get_media(channel, post_id, fid, request=object(), digest="x")
|
||||
|
||||
assert resp is sentinel
|
||||
assert called == [] # no synchronous SQLite access-write on the hottest path
|
||||
assert (channel, post_id, fid) in api_server._access_updates
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# gotcha: str(channel) key discipline on the hot path (int-ish channel).
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_keys_channel_as_str(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
channel_int, post_id, fid = 12345, 7, "fidINT"
|
||||
cache_dir = tmp_path / "data" / "cache" / str(channel_int) / str(post_id)
|
||||
cache_dir.mkdir(parents=True)
|
||||
(cache_dir / fid).write_bytes(b"x")
|
||||
|
||||
async def fake_get(_cid, _pid):
|
||||
return _fake_plain_message()
|
||||
monkeypatch.setattr(api_server.client, "safe_get_messages", fake_get)
|
||||
|
||||
await api_server.download_media_file(channel_int, post_id, fid)
|
||||
|
||||
assert ("12345", post_id, fid) in api_server._access_updates # string form
|
||||
assert (channel_int, post_id, fid) not in api_server._access_updates # never the raw int
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5.1 flush: hit -> flush -> the accumulated timestamp is written to the DB.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_writes_accumulated_timestamps(tmp_path, monkeypatch):
|
||||
db = str(tmp_path / "flush.db")
|
||||
init_db_sync(db)
|
||||
# Seed two existing rows with an old timestamp.
|
||||
conn = sqlite3.connect(db)
|
||||
conn.executemany(
|
||||
"INSERT INTO media_file_ids (channel, post_id, file_unique_id, added) VALUES (?,?,?,?)",
|
||||
[("chA", 1, "fA", 1.0), ("chB", 2, "fB", 2.0)],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
monkeypatch.setattr(api_server, "DB_PATH", db)
|
||||
api_server._access_updates = {
|
||||
("chA", 1, "fA"): 111.0,
|
||||
("chB", 2, "fB"): 222.0,
|
||||
}
|
||||
|
||||
await api_server._flush_access_updates()
|
||||
|
||||
# Dict cleared after flush.
|
||||
assert api_server._access_updates == {}
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
rows = dict(((c, p, f), a) for c, p, f, a in
|
||||
conn.execute("SELECT channel, post_id, file_unique_id, added FROM media_file_ids"))
|
||||
conn.close()
|
||||
assert rows[("chA", 1, "fA")] == 111.0
|
||||
assert rows[("chB", 2, "fB")] == 222.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_empty_is_noop(monkeypatch):
|
||||
api_server._access_updates = {}
|
||||
# Must not raise and must not touch the threadpool/DB.
|
||||
monkeypatch.setattr(api_server, "update_media_file_access_bulk_sync",
|
||||
lambda *a, **k: pytest.fail("bulk sync should not run for an empty batch"))
|
||||
await api_server._flush_access_updates()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5.1 snapshot-then-clear: an update arriving DURING the flush is not lost.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_then_clear_does_not_lose_late_update(tmp_path, monkeypatch):
|
||||
db = str(tmp_path / "race.db")
|
||||
init_db_sync(db)
|
||||
monkeypatch.setattr(api_server, "DB_PATH", db)
|
||||
|
||||
late_key = ("chLate", 9, "fLate")
|
||||
|
||||
def fake_bulk(_db, entries):
|
||||
# Simulates a cache hit landing WHILE the flush's to_thread runs: because the flush
|
||||
# already replaced the module dict with a fresh one, this write goes into the NEW dict.
|
||||
api_server._access_updates[late_key] = 999.0
|
||||
|
||||
monkeypatch.setattr(api_server, "update_media_file_access_bulk_sync", fake_bulk)
|
||||
|
||||
api_server._access_updates = {("chA", 1, "fA"): 111.0}
|
||||
await api_server._flush_access_updates()
|
||||
|
||||
# The snapshot (chA) was flushed and cleared; the late update survives in the new dict.
|
||||
assert api_server._access_updates == {late_key: 999.0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5.1 re-queue on failure: a failed bulk write is not lost; setdefault keeps a
|
||||
# fresher write that arrived during the flush.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_flush_requeues_batch_without_clobbering_fresh(tmp_path, monkeypatch):
|
||||
db = str(tmp_path / "fail.db")
|
||||
init_db_sync(db)
|
||||
monkeypatch.setattr(api_server, "DB_PATH", db)
|
||||
|
||||
stale_key = ("chStale", 1, "fS") # only in the failing snapshot
|
||||
fresh_key = ("chFresh", 2, "fF") # re-written FRESHER during the flush
|
||||
|
||||
def fake_bulk(_db, _entries):
|
||||
# A newer cache hit for fresh_key lands in the fresh dict WHILE the bulk write runs,
|
||||
# then the write fails. The re-queue must restore stale_key but must NOT overwrite
|
||||
# the newer fresh_key value (setdefault, not assignment).
|
||||
api_server._access_updates[fresh_key] = 999.0
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
monkeypatch.setattr(api_server, "update_media_file_access_bulk_sync", fake_bulk)
|
||||
|
||||
api_server._access_updates = {stale_key: 1.0, fresh_key: 2.0}
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
await api_server._flush_access_updates()
|
||||
|
||||
# stale_key restored with its snapshot value; fresh_key keeps the NEWER value (not 2.0).
|
||||
assert api_server._access_updates == {stale_key: 1.0, fresh_key: 999.0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5.1 bulk fn: empty no-op, multi-row, and existing-row update via str channel key.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_bulk_access_update_real_sql(tmp_path):
|
||||
db = str(tmp_path / "bulk.db")
|
||||
init_db_sync(db)
|
||||
|
||||
# Empty batch is a no-op (no crash).
|
||||
update_media_file_access_bulk_sync(db, [])
|
||||
|
||||
# Seed existing rows.
|
||||
conn = sqlite3.connect(db)
|
||||
conn.executemany(
|
||||
"INSERT INTO media_file_ids (channel, post_id, file_unique_id, added) VALUES (?,?,?,?)",
|
||||
[("chA", 1, "fA", 1.0), ("chB", 2, "fB", 2.0)],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Multi-row update of EXISTING rows: WHERE matches on the str channel key.
|
||||
update_media_file_access_bulk_sync(db, [
|
||||
("chA", 1, "fA", 500.0),
|
||||
("chB", 2, "fB", 600.0),
|
||||
])
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
rows = dict(((c, p, f), a) for c, p, f, a in
|
||||
conn.execute("SELECT channel, post_id, file_unique_id, added FROM media_file_ids"))
|
||||
# A row keyed by an int channel does NOT match the string "chA" WHERE (documents the gotcha).
|
||||
n = conn.execute("SELECT COUNT(*) FROM media_file_ids WHERE channel = 1").fetchone()[0]
|
||||
conn.close()
|
||||
assert rows[("chA", 1, "fA")] == 500.0
|
||||
assert rows[("chB", 2, "fB")] == 600.0
|
||||
assert n == 0
|
||||
@@ -0,0 +1,148 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, redefined-outer-name, line-too-long
|
||||
"""Stage-5b registered media fixes (render-pipeline refactor epic, issue #32/#34).
|
||||
|
||||
Two registry items, both routed through the single MEDIA_SOURCES table:
|
||||
* §3.13 — the ">100MB don't cache" rule now applies to ANY selected media object,
|
||||
not just message.video (previously only video, plus live_photo/story/poll).
|
||||
* §3.14 — the <div class="message-media"> container is CLOSED in every branch;
|
||||
a None file_unique_id used to leave it open (html5lib then swallowed following
|
||||
posts / webpage previews into it).
|
||||
|
||||
The fragment snapshot (tests/test_data/media_fragments.json) was regenerated in the 5b
|
||||
changeset: exactly two cases moved — `file_unique_id_none` and `webpage_without_photo`
|
||||
each gained the now-balanced `</div>` (see test_stage5_media_table for the oracle).
|
||||
The feed goldens moved only by the same `</div>` relocation on webpage-without-photo posts.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from pyrogram.enums import MessageMediaType
|
||||
|
||||
from post_parser import PostParser
|
||||
from url_signer import KeyManager
|
||||
|
||||
from tests import media_fragment_replay as fr
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pin_signing_key(monkeypatch):
|
||||
monkeypatch.setattr(KeyManager, "signing_key", fr.FRAGMENT_SIGNING_KEY)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
return PostParser(SimpleNamespace())
|
||||
|
||||
|
||||
def _msg(mid, media, **extra):
|
||||
return fr._msg(mid, media, **extra)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §3.14 — the message-media div is closed in every branch.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_none_file_unique_id_closes_media_div(parser):
|
||||
# A media type whose object has no usable file_unique_id: the container used to
|
||||
# be left open. Now it must be closed.
|
||||
msg = _msg(1, MessageMediaType.PHOTO, photo=SimpleNamespace(file_unique_id=None, file_id="x"))
|
||||
html = parser._generate_html_media(msg)
|
||||
assert html == '<div class="message-media">\n</div>'
|
||||
|
||||
|
||||
def test_webpage_without_photo_closes_media_div_before_preview(parser):
|
||||
# WEB_PAGE without photo: the empty media div must close BEFORE the webpage-preview
|
||||
# block (previously the open div swallowed the preview and, at feed level, following
|
||||
# posts). Every <div class="message-media"> is now paired with a </div>.
|
||||
msg = _msg(2, MessageMediaType.WEB_PAGE, text="hi",
|
||||
web_page=SimpleNamespace(photo=None, url="https://e.com", title="E",
|
||||
description=None, site_name=None, type="", display_url=None))
|
||||
html = parser._generate_html_media(msg)
|
||||
assert html.startswith('<div class="message-media">\n</div>\n<div class="webpage-preview">')
|
||||
assert html.count('<div class="message-media">') == 1
|
||||
# The media container is now empty and closed, not wrapping the preview.
|
||||
assert '<div class="message-media">\n</div>' in html
|
||||
|
||||
|
||||
def test_channel_username_missing_still_closes_div(parser):
|
||||
# The username-guard branch also closes the div (unchanged behavior, asserted so a
|
||||
# future refactor cannot regress it).
|
||||
msg = _msg(3, MessageMediaType.PHOTO, username=None, chat_id=555,
|
||||
photo=SimpleNamespace(file_unique_id="u", file_id="f"))
|
||||
html = parser._generate_html_media(msg)
|
||||
assert html == '<div class="message-media">\n</div>'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case_name", ["file_unique_id_none", "webpage_without_photo"])
|
||||
def test_5b_fragment_cases_are_now_balanced(parser, case_name):
|
||||
"""The two fragments that 5b intentionally changed must have a balanced media div."""
|
||||
factory = fr.build_cases()[case_name]
|
||||
html = parser._generate_html_media(factory())
|
||||
assert html.count('<div class="message-media">') == 1
|
||||
# There is at least one closing tag for the media container now.
|
||||
assert '</div>' in html
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §3.13 — the >100MB guard applies to ANY selected object, not only video.
|
||||
# --------------------------------------------------------------------------- #
|
||||
BIG = 200 * 1024 * 1024
|
||||
SMALL = 5 * 1024 * 1024
|
||||
|
||||
|
||||
@pytest.mark.parametrize("media_type,attr", [
|
||||
(MessageMediaType.PHOTO, "photo"),
|
||||
(MessageMediaType.DOCUMENT, "document"),
|
||||
(MessageMediaType.AUDIO, "audio"),
|
||||
(MessageMediaType.ANIMATION, "animation"),
|
||||
])
|
||||
def test_large_non_video_object_not_collected(parser, media_type, attr):
|
||||
"""§3.13: a >100MB photo/document/audio/animation is no longer collected for the
|
||||
download cache (before 5b only large VIDEO objects were skipped)."""
|
||||
obj = SimpleNamespace(file_unique_id="big", file_id="f", file_size=BIG, mime_type="image/png")
|
||||
msg = _msg(10, media_type, **{attr: obj})
|
||||
parser._pending_media_ids = []
|
||||
parser._save_media_file_ids(msg)
|
||||
assert parser._pending_media_ids == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("media_type,attr", [
|
||||
(MessageMediaType.PHOTO, "photo"),
|
||||
(MessageMediaType.DOCUMENT, "document"),
|
||||
(MessageMediaType.AUDIO, "audio"),
|
||||
])
|
||||
def test_small_non_video_object_still_collected(parser, media_type, attr):
|
||||
obj = SimpleNamespace(file_unique_id="small", file_id="f", file_size=SMALL, mime_type="image/png")
|
||||
msg = _msg(11, media_type, **{attr: obj})
|
||||
parser._pending_media_ids = []
|
||||
parser._save_media_file_ids(msg)
|
||||
assert [(c, p, f) for c, p, f, _ in parser._pending_media_ids] == [("testchan", 11, "small")]
|
||||
|
||||
|
||||
def test_exactly_100mb_object_is_collected(parser):
|
||||
# §3.13 boundary: the guard is strictly `>` 100MB, so an object of exactly 100MB is
|
||||
# still collected. Pins the operator — a mutation to `>=` would drop this and fail.
|
||||
obj = SimpleNamespace(file_unique_id="edge", file_id="f",
|
||||
file_size=100 * 1024 * 1024, mime_type="image/png")
|
||||
msg = _msg(14, MessageMediaType.PHOTO, photo=obj)
|
||||
parser._pending_media_ids = []
|
||||
parser._save_media_file_ids(msg)
|
||||
assert [(c, p, f) for c, p, f, _ in parser._pending_media_ids] == [("testchan", 14, "edge")]
|
||||
|
||||
|
||||
def test_large_video_still_not_collected(parser):
|
||||
# Regression: the video case (the only one guarded before 5b) still works.
|
||||
msg = _msg(12, MessageMediaType.VIDEO,
|
||||
video=SimpleNamespace(file_unique_id="v", file_id="f", file_size=BIG))
|
||||
parser._pending_media_ids = []
|
||||
parser._save_media_file_ids(msg)
|
||||
assert parser._pending_media_ids == []
|
||||
|
||||
|
||||
def test_object_without_file_size_is_collected(parser):
|
||||
# No file_size attribute -> not guarded (the common case for photos).
|
||||
msg = _msg(13, MessageMediaType.PHOTO, photo=SimpleNamespace(file_unique_id="nofs", file_id="f"))
|
||||
parser._pending_media_ids = []
|
||||
parser._save_media_file_ids(msg)
|
||||
assert [(c, p, f) for c, p, f, _ in parser._pending_media_ids] == [("testchan", 13, "nofs")]
|
||||
@@ -0,0 +1,86 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=missing-function-docstring, redefined-outer-name
|
||||
"""Stage-6: unit lock for the `exclude_flags` feed filter (issue #33, epic #34).
|
||||
|
||||
The filter (rss_generator._render_messages_groups) is a user-facing query-param
|
||||
that drops posts from the feed by flag. It is NOT exercised by the stage-0 golden
|
||||
oracle (golden_replay runs without exclude_flags), and stage 6 rewrote it from a
|
||||
`for/continue` loop into a list-comprehension. These tests pin the membership
|
||||
semantics so the rewrite (and any future one) stays honest — this is the
|
||||
"dedicated unit tests" that golden_replay.py's header comment refers to.
|
||||
|
||||
Flag shapes under the test config: a plain text post carries ['no_image']; a
|
||||
merged group additionally carries 'merged'. (Every rendered post carries at least
|
||||
one flag, so the `"all"` filter drops every real post; the guard's flagless-keep
|
||||
branch — `and post['flags']` — is a media-path concern proven separately by the
|
||||
equivalence check in the PR, not reproducible with plain fixtures here.)
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from rss_generator import _render_messages_groups
|
||||
from post_parser import PostParser
|
||||
from tests.test_stage4_eventloop import make_message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
return PostParser(SimpleNamespace())
|
||||
|
||||
|
||||
def _plain(mid, date=None):
|
||||
# Plain text post -> flags == ['no_image'].
|
||||
return make_message(mid, text="plain", date=date)
|
||||
|
||||
|
||||
def _merged_group(mid, date=None):
|
||||
# Merged group -> flags == ['no_image', 'merged']; message_id is the main id.
|
||||
main = make_message(mid, text="m", date=date)
|
||||
main.media_group_id = f"g{mid}"
|
||||
other = make_message(mid + 1, text="o", date=date)
|
||||
other.media_group_id = f"g{mid}"
|
||||
return [main, other]
|
||||
|
||||
|
||||
def _ids(posts):
|
||||
return [p["message_id"] for p in posts]
|
||||
|
||||
|
||||
def test_exclude_flags_all_drops_flagged_posts(parser):
|
||||
# `"all"` special case: any post carrying >=1 flag is dropped.
|
||||
posts = _render_messages_groups(
|
||||
[[_plain(10)], [_plain(11)]], parser, exclude_flags="all"
|
||||
)
|
||||
assert _ids(posts) == [] # both ['no_image'] -> dropped
|
||||
|
||||
|
||||
def test_exclude_flags_specific_drops_matching_keeps_nonmatching(parser):
|
||||
# Exclude a concrete flag: the post carrying it is dropped, one without it kept.
|
||||
posts = _render_messages_groups(
|
||||
[_merged_group(20), [_plain(22)]], parser, exclude_flags="merged"
|
||||
)
|
||||
ids = _ids(posts)
|
||||
assert 22 in ids # ['no_image'] has no 'merged' -> kept
|
||||
assert 20 not in ids # ['no_image', 'merged'] -> dropped
|
||||
|
||||
|
||||
def test_exclude_flags_none_or_nonmatching_keeps_all(parser):
|
||||
base = [[_plain(30)], [_plain(31)]]
|
||||
assert len(_render_messages_groups(base, parser)) == 2 # no filter
|
||||
assert (
|
||||
len(_render_messages_groups(base, parser, exclude_flags="nonexistent")) == 2
|
||||
) # flag matches nothing -> all kept
|
||||
|
||||
|
||||
def test_exclude_flags_preserves_survivor_order(parser):
|
||||
# Distinct descending dates so the trailing date-sort is deterministic;
|
||||
# dropping the middle (merged) post must not reorder the survivors.
|
||||
a = make_message(40, text="a", date=datetime(2024, 1, 3, tzinfo=timezone.utc))
|
||||
mid = _merged_group(41, date=datetime(2024, 1, 2, tzinfo=timezone.utc))
|
||||
c = make_message(43, text="c", date=datetime(2024, 1, 1, tzinfo=timezone.utc))
|
||||
posts = _render_messages_groups(
|
||||
[[a], mid, [c]], parser, exclude_flags="merged"
|
||||
)
|
||||
assert _ids(posts) == [40, 43] # merged(41) dropped; a before c by date-desc
|
||||
@@ -0,0 +1,182 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, logging-fstring-interpolation, line-too-long
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
"""
|
||||
Stage 6 (lightweight /ping healthcheck) regression tests.
|
||||
|
||||
Covers:
|
||||
- /ping returns 200 "ok" when connected and the last probe is recent (age < threshold).
|
||||
- /ping returns 503 "degraded" when connected but the last probe is stale (age > threshold).
|
||||
- /ping returns 503 "degraded" when disconnected, regardless of probe age.
|
||||
- /ping returns 200 "ok" on a fresh boot (age is None) while connected — a freshly-started
|
||||
container must NOT be killed before the watchdog's first probe.
|
||||
- ANTI-REGRESSION (the critical invariant): /ping issues ZERO Telegram RPC. A spy on the
|
||||
fake client's get_me / safe_get_messages proves neither is ever called.
|
||||
- TelegramClient.watchdog_last_ok_age(): None when never probed; a positive float afterwards.
|
||||
"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import api_server
|
||||
from telegram_client import TelegramClient
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fakes
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _FakeKurigramClient:
|
||||
"""Stands in for TelegramClient.client — exposes is_connected and RPC spies."""
|
||||
def __init__(self, is_connected=True):
|
||||
self.is_connected = is_connected
|
||||
self.get_me_calls = 0
|
||||
|
||||
async def get_me(self):
|
||||
# If /ping ever touches this, the whole point of the endpoint is defeated.
|
||||
self.get_me_calls += 1
|
||||
raise AssertionError("/ping must never call get_me()")
|
||||
|
||||
|
||||
class _FakeTelegramClient:
|
||||
"""Stands in for api_server.client with a controllable probe age + RPC spies."""
|
||||
def __init__(self, age, is_connected=True):
|
||||
self._age = age
|
||||
self.client = _FakeKurigramClient(is_connected=is_connected)
|
||||
self.safe_get_messages_calls = 0
|
||||
|
||||
def watchdog_last_ok_age(self):
|
||||
return self._age
|
||||
|
||||
async def safe_get_messages(self, *args, **kwargs):
|
||||
self.safe_get_messages_calls += 1
|
||||
raise AssertionError("/ping must never call safe_get_messages()")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_client(monkeypatch):
|
||||
"""Return a factory that installs a fake api_server.client and yields a TestClient."""
|
||||
def _install(age, is_connected=True):
|
||||
fake = _FakeTelegramClient(age=age, is_connected=is_connected)
|
||||
monkeypatch.setattr(api_server, "client", fake)
|
||||
return fake, TestClient(api_server.app)
|
||||
return _install
|
||||
|
||||
|
||||
THRESHOLD = api_server.Config["tg_ping_unhealthy_after"] # 250 in mock_config
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# /ping endpoint behavior
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_ping_healthy_connected_recent(patch_client):
|
||||
fake, tc = patch_client(age=THRESHOLD - 10, is_connected=True)
|
||||
r = tc.get("/ping")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["connected"] is True
|
||||
assert body["last_probe_age_s"] == round(THRESHOLD - 10, 1)
|
||||
assert body["threshold_s"] == THRESHOLD
|
||||
assert fake.client.get_me_calls == 0
|
||||
|
||||
|
||||
def test_ping_degraded_stale_probe(patch_client):
|
||||
fake, tc = patch_client(age=THRESHOLD + 100, is_connected=True)
|
||||
r = tc.get("/ping")
|
||||
assert r.status_code == 503
|
||||
body = r.json()
|
||||
assert body["status"] == "degraded"
|
||||
assert body["connected"] is True
|
||||
assert fake.client.get_me_calls == 0
|
||||
|
||||
|
||||
def test_ping_watchdog_disabled_stale_age_still_healthy(patch_client, monkeypatch):
|
||||
# With the watchdog OFF, nothing refreshes age (a disconnect-flap restart can stamp it
|
||||
# once, then it only grows). A stale age must NOT drive /ping to 503 on a live connection
|
||||
# — that would spuriously fail the healthcheck and trigger an autoheal restart. So with the
|
||||
# watchdog disabled, /ping is a pure connectivity check: connected + stale age => healthy.
|
||||
monkeypatch.setitem(api_server.Config, "tg_watchdog_enabled", False)
|
||||
fake, tc = patch_client(age=THRESHOLD + 100, is_connected=True)
|
||||
r = tc.get("/ping")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["connected"] is True
|
||||
assert fake.client.get_me_calls == 0
|
||||
|
||||
|
||||
def test_ping_degraded_disconnected(patch_client):
|
||||
# Even with a fresh probe age, a disconnected client is unhealthy.
|
||||
fake, tc = patch_client(age=1.0, is_connected=False)
|
||||
r = tc.get("/ping")
|
||||
assert r.status_code == 503
|
||||
body = r.json()
|
||||
assert body["status"] == "degraded"
|
||||
assert body["connected"] is False
|
||||
|
||||
|
||||
def test_ping_degraded_disconnected_even_when_age_none(patch_client):
|
||||
fake, tc = patch_client(age=None, is_connected=False)
|
||||
r = tc.get("/ping")
|
||||
assert r.status_code == 503
|
||||
assert r.json()["status"] == "degraded"
|
||||
|
||||
|
||||
def test_ping_fresh_boot_age_none_is_healthy(patch_client):
|
||||
# Right after boot the watchdog hasn't probed yet (age None); connected => healthy.
|
||||
fake, tc = patch_client(age=None, is_connected=True)
|
||||
r = tc.get("/ping")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["last_probe_age_s"] is None
|
||||
assert body["threshold_s"] == THRESHOLD
|
||||
|
||||
|
||||
def test_ping_pre_start_connected_none_is_degraded_bool(patch_client):
|
||||
# Before client.start(), Kurigram's is_connected is None. /ping must not 500: it coerces
|
||||
# to a bool, so "connected" is false (never null) and the endpoint reports 503 degraded.
|
||||
fake, tc = patch_client(age=None, is_connected=None)
|
||||
r = tc.get("/ping")
|
||||
assert r.status_code == 503
|
||||
body = r.json()
|
||||
assert body["status"] == "degraded"
|
||||
assert body["connected"] is False # bool, not null
|
||||
assert fake.client.get_me_calls == 0
|
||||
|
||||
|
||||
def test_ping_issues_no_tg_rpc(patch_client):
|
||||
"""The critical invariant: /ping never issues any Telegram RPC in any branch."""
|
||||
for age, connected in [(1.0, True), (THRESHOLD + 500, True), (None, True), (1.0, False)]:
|
||||
fake, tc = patch_client(age=age, is_connected=connected)
|
||||
tc.get("/ping")
|
||||
assert fake.client.get_me_calls == 0, f"get_me called (age={age}, connected={connected})"
|
||||
assert fake.safe_get_messages_calls == 0, f"safe_get_messages called (age={age}, connected={connected})"
|
||||
|
||||
|
||||
def test_ping_route_needs_no_token(patch_client):
|
||||
# /ping is unauthenticated by design (no token path variant); it just works.
|
||||
fake, tc = patch_client(age=1.0, is_connected=True)
|
||||
assert tc.get("/ping").status_code == 200
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TelegramClient.watchdog_last_ok_age accessor
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_watchdog_last_ok_age_none_when_never_probed():
|
||||
tgc = TelegramClient()
|
||||
assert tgc._wd_last_ok_monotonic is None
|
||||
assert tgc.watchdog_last_ok_age() is None
|
||||
|
||||
|
||||
def test_watchdog_last_ok_age_positive_after_probe():
|
||||
tgc = TelegramClient()
|
||||
tgc._wd_last_ok_monotonic = time.monotonic() - 5
|
||||
age = tgc.watchdog_last_ok_age()
|
||||
assert age is not None
|
||||
assert age >= 5.0
|
||||
# Sanity: a plausible upper bound so we didn't accidentally read the wrong field.
|
||||
assert age < 60.0
|
||||
@@ -0,0 +1,343 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, logging-fstring-interpolation, line-too-long
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
"""
|
||||
Stage 7 — cross-stage END-TO-END integration tests.
|
||||
|
||||
Unlike the per-stage suites (which pin one seam in isolation), these drive the real
|
||||
public entry points (`get_media`, `ping`, the access-time flush) so a regression that
|
||||
only shows up when the stages interact goes red. Every scenario here maps to one of the
|
||||
plan's "Стадия 7 — сквозные ручные сценарии"; the ones that genuinely need a running
|
||||
server + real downloads + lsof (fd-leak counting) are NOT faked here — they stay in
|
||||
docs/stability-verification.md for the operator's prod observation.
|
||||
|
||||
Automated here:
|
||||
- Range semantics at the /media ROUTE level (get_media -> prepare_file_response ->
|
||||
FileResponse driven through real ASGI): bytes=0-99 -> 206, bytes=-100 -> 206,
|
||||
bytes=999999999- -> 416. (Stage 3 pins these on prepare_file_response directly; this
|
||||
adds the end-to-end assertion that get_media's cache-hit branch reaches FileResponse
|
||||
with a live Range still honored — i.e. stages 2+3 wired together.)
|
||||
- /ping (stage 6) stays fast + correct while a deliberately-slow op is in flight, issuing
|
||||
ZERO Telegram RPC — proving the healthcheck is decoupled from the hot/blocked paths.
|
||||
- In-flight dedup + disconnect cleanup (stages 1/2) through the REAL get_media entry
|
||||
point: concurrent requests share one download and the _inflight registry drains; a
|
||||
cancelled request (client disconnect) leaves neither a stuck key nor a hung sibling.
|
||||
- str(channel) access-time key consistency (stage 5) end-to-end: a /media cache hit for
|
||||
an int-ish channel records the str-keyed timestamp, and the flush UPDATE matches the
|
||||
str-keyed DB row (hit -> flush -> DB), the exact affinity gotcha the plan warns about.
|
||||
"""
|
||||
import time
|
||||
import sqlite3
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import api_server
|
||||
from pyrogram import errors
|
||||
from file_io import init_db_sync
|
||||
|
||||
|
||||
# 2048 deterministic bytes so Range slices are byte-checkable.
|
||||
BODY = bytes(range(256)) * 8
|
||||
SIZE = len(BODY)
|
||||
|
||||
|
||||
def _media_app():
|
||||
"""A bare app that mounts the REAL get_media (and ping) with NO lifespan, so
|
||||
client.start() never runs — a pure cache hit never touches Telegram, and FileResponse
|
||||
computes Range/206/416 at send time, which only happens when driven through ASGI."""
|
||||
app = FastAPI()
|
||||
app.add_api_route("/media/{channel}/{post_id}/{file_unique_id}/{digest}", api_server.get_media, methods=["GET"])
|
||||
app.add_api_route("/media/{channel}/{post_id}/{file_unique_id}", api_server.get_media, methods=["GET"])
|
||||
return app
|
||||
|
||||
|
||||
def _seed_cache(tmp_path, channel, post_id, fid, body=BODY):
|
||||
cache_dir = tmp_path / "data" / "cache" / str(channel) / str(post_id)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cache_dir / fid).write_bytes(body)
|
||||
return cache_dir / fid
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Range semantics at the /media ROUTE level (stages 2 + 3 wired together).
|
||||
# Plan scenario: `curl -H "Range: bytes=0-99" / "bytes=-100" / "bytes=999999999-"`.
|
||||
# Regression caught: any change that makes get_media's cache-hit branch stop reaching
|
||||
# FileResponse (e.g. re-buffering the body, hand-rolling headers, dropping the media_key
|
||||
# path) or that breaks the digest gate wiring — the Range would stop being honored.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_media_route_range_prefix_0_99(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_seed_cache(tmp_path, "chan", 3, "fidR")
|
||||
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
|
||||
# Keep the MIME path DB-free; the FileResponse/Range machinery is what we exercise.
|
||||
monkeypatch.setattr(api_server, "get_mime_type_sync", lambda *a, **k: None)
|
||||
monkeypatch.setattr(api_server, "set_mime_type_sync", lambda *a, **k: None)
|
||||
|
||||
c = TestClient(_media_app())
|
||||
r = c.get("/media/chan/3/fidR/anydigest", headers={"Range": "bytes=0-99"})
|
||||
assert r.status_code == 206
|
||||
assert r.headers["content-range"] == f"bytes 0-99/{SIZE}"
|
||||
assert r.headers["content-length"] == "100"
|
||||
assert r.content == BODY[:100]
|
||||
assert r.headers["accept-ranges"] == "bytes"
|
||||
|
||||
|
||||
def test_media_route_range_suffix_last_100(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_seed_cache(tmp_path, "chan", 3, "fidS")
|
||||
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
|
||||
monkeypatch.setattr(api_server, "get_mime_type_sync", lambda *a, **k: None)
|
||||
monkeypatch.setattr(api_server, "set_mime_type_sync", lambda *a, **k: None)
|
||||
|
||||
c = TestClient(_media_app())
|
||||
r = c.get("/media/chan/3/fidS/anydigest", headers={"Range": "bytes=-100"})
|
||||
assert r.status_code == 206
|
||||
assert r.headers["content-range"] == f"bytes {SIZE - 100}-{SIZE - 1}/{SIZE}"
|
||||
assert r.headers["content-length"] == "100"
|
||||
assert r.content == BODY[-100:]
|
||||
|
||||
|
||||
def test_media_route_range_unsatisfiable_416(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_seed_cache(tmp_path, "chan", 3, "fidU")
|
||||
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
|
||||
monkeypatch.setattr(api_server, "get_mime_type_sync", lambda *a, **k: None)
|
||||
monkeypatch.setattr(api_server, "set_mime_type_sync", lambda *a, **k: None)
|
||||
|
||||
c = TestClient(_media_app())
|
||||
r = c.get("/media/chan/3/fidU/anydigest", headers={"Range": "bytes=999999999-"})
|
||||
assert r.status_code == 416
|
||||
# Starlette's 416 Content-Range is `*/size` (documented stage-3 RFC-7233 delta).
|
||||
assert r.headers["content-range"] == f"*/{SIZE}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# /ping (stage 6) stays fast + correct while a slow op is in flight, zero TG RPC.
|
||||
# Plan scenario: "Генерация фида на 100+ сообщений + параллельный /ping -> ping < 100 мс".
|
||||
# We model the concurrent slow op as a coroutine parked on an Event that is NEVER set
|
||||
# during the ping, and assert ping resolves while it is still pending AND touches no RPC.
|
||||
# Regression caught: re-coupling /ping to a TG RPC (get_me / safe_get_messages) or to any
|
||||
# awaitable that a blocked hot path could stall — the ping would no longer return promptly
|
||||
# or the spy counts would go non-zero.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _FakeKurigram:
|
||||
def __init__(self, is_connected=True):
|
||||
self.is_connected = is_connected
|
||||
self.get_me_calls = 0
|
||||
|
||||
async def get_me(self):
|
||||
self.get_me_calls += 1
|
||||
raise AssertionError("/ping must never call get_me()")
|
||||
|
||||
|
||||
class _FakeTelegramClient:
|
||||
def __init__(self, age, is_connected=True):
|
||||
self._age = age
|
||||
self.client = _FakeKurigram(is_connected=is_connected)
|
||||
self.safe_get_messages_calls = 0
|
||||
|
||||
def watchdog_last_ok_age(self):
|
||||
return self._age
|
||||
|
||||
async def safe_get_messages(self, *a, **k):
|
||||
self.safe_get_messages_calls += 1
|
||||
raise AssertionError("/ping must never call safe_get_messages()")
|
||||
|
||||
|
||||
async def test_ping_prompt_and_rpc_free_while_slow_op_pending(monkeypatch):
|
||||
threshold = api_server.Config["tg_ping_unhealthy_after"]
|
||||
fake = _FakeTelegramClient(age=threshold - 10, is_connected=True)
|
||||
monkeypatch.setattr(api_server, "client", fake)
|
||||
|
||||
# A concurrent slow operation (a stand-in for a hung feed/RPC hot path) parked on an
|
||||
# Event we deliberately never set for the duration of the ping.
|
||||
gate = asyncio.Event()
|
||||
|
||||
async def slow_op():
|
||||
await gate.wait()
|
||||
|
||||
slow = asyncio.create_task(slow_op())
|
||||
await asyncio.sleep(0) # let slow_op start and park on the gate
|
||||
|
||||
# The real proof of decoupling: ping() returns under a tight deadline while the slow op
|
||||
# is parked, AND issues zero TG RPC. wait_for reds if ping ever blocks; the spies (which
|
||||
# raise if touched) red if ping recouples to any RPC. `not slow.done()` is only a sanity
|
||||
# check that ping did not somehow drive the parked op — the gate keeps it pending anyway.
|
||||
resp = await asyncio.wait_for(api_server.ping(), timeout=1.0)
|
||||
|
||||
assert resp.status_code == 200 # correct health while connected + fresh
|
||||
assert not slow.done() # sanity: slow op still parked, ping did not await it
|
||||
assert fake.client.get_me_calls == 0 # decoupled: zero TG RPC
|
||||
assert fake.safe_get_messages_calls == 0
|
||||
|
||||
gate.set()
|
||||
await slow
|
||||
|
||||
|
||||
async def test_ping_reports_degraded_promptly_while_slow_op_pending(monkeypatch):
|
||||
threshold = api_server.Config["tg_ping_unhealthy_after"]
|
||||
fake = _FakeTelegramClient(age=threshold + 100, is_connected=True) # stale probe
|
||||
monkeypatch.setattr(api_server, "client", fake)
|
||||
|
||||
gate = asyncio.Event()
|
||||
|
||||
async def slow_op():
|
||||
await gate.wait()
|
||||
|
||||
slow = asyncio.create_task(slow_op())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
resp = await asyncio.wait_for(api_server.ping(), timeout=1.0)
|
||||
|
||||
assert resp.status_code == 503 # stale watchdog probe -> degraded, still instant
|
||||
assert not slow.done() # sanity: slow op still parked, ping did not await it
|
||||
assert fake.client.get_me_calls == 0
|
||||
assert fake.safe_get_messages_calls == 0
|
||||
|
||||
gate.set()
|
||||
await slow
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# In-flight dedup + disconnect cleanup (stages 1/2) through the REAL get_media entry.
|
||||
# Plan scenario: "Параллельные запросы одного большого видео -> нет частичной отдачи" and
|
||||
# "Отключение клиента на середине стрима -> нет утечки тасков/фд".
|
||||
# The pure-unit stage-2 tests pin _download_deduped directly; these drive get_media so the
|
||||
# HTTP semaphore + dedup registry + serve path are proven wired together.
|
||||
# Regression caught: moving the download back into the request coroutine (so a disconnect
|
||||
# cancels it), or dropping the finally-pop, would leave a stuck _inflight key / hung sibling.
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_get_media_concurrent_shares_one_download_and_drains_registry(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
api_server._inflight.clear()
|
||||
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
|
||||
# Serve step is not under test here; keep it to a sentinel so we assert on dedup + registry.
|
||||
sentinel = object()
|
||||
|
||||
async def fake_prepare(file_path, request=None, delete_after=False, media_key=None):
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr(api_server, "prepare_file_response", fake_prepare)
|
||||
|
||||
calls = []
|
||||
|
||||
async def slow_dl(channel, post_id, fid):
|
||||
calls.append(fid)
|
||||
await asyncio.sleep(0.05) # real overlap window for the two requests
|
||||
return (f"/final/{fid}", False)
|
||||
|
||||
monkeypatch.setattr(api_server, "download_media_file", slow_dl)
|
||||
|
||||
req = SimpleNamespace(headers={})
|
||||
t1 = asyncio.create_task(api_server.get_media("chan", 9, "vfid", request=req, digest="x"))
|
||||
t2 = asyncio.create_task(api_server.get_media("chan", 9, "vfid", request=req, digest="x"))
|
||||
r1, r2 = await asyncio.gather(t1, t2)
|
||||
|
||||
assert r1 is sentinel and r2 is sentinel
|
||||
assert calls == ["vfid"] # exactly ONE real download, shared by both requests
|
||||
assert not api_server._inflight # registry drained — no forever-busy key
|
||||
|
||||
|
||||
async def test_get_media_request_cancel_does_not_stick_registry_or_hang_sibling(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
api_server._inflight.clear()
|
||||
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
|
||||
sentinel = object()
|
||||
|
||||
async def fake_prepare(file_path, request=None, delete_after=False, media_key=None):
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr(api_server, "prepare_file_response", fake_prepare)
|
||||
|
||||
gate = asyncio.Event()
|
||||
calls = []
|
||||
|
||||
async def held_dl(channel, post_id, fid):
|
||||
calls.append(fid)
|
||||
await gate.wait() # hold the shared download open until we release it
|
||||
return (f"/final/{fid}", False)
|
||||
|
||||
monkeypatch.setattr(api_server, "download_media_file", held_dl)
|
||||
|
||||
req = SimpleNamespace(headers={})
|
||||
t1 = asyncio.create_task(api_server.get_media("chan", 9, "cfid", request=req, digest="x"))
|
||||
await asyncio.sleep(0.02) # t1 registers the future + starts the detached download
|
||||
t2 = asyncio.create_task(api_server.get_media("chan", 9, "cfid", request=req, digest="x"))
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
# First client disconnects: its request coroutine is cancelled mid-wait.
|
||||
t1.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await t1
|
||||
|
||||
# The detached download is unaffected; releasing it resolves the surviving request.
|
||||
gate.set()
|
||||
r2 = await asyncio.wait_for(t2, timeout=2.0)
|
||||
assert r2 is sentinel
|
||||
assert calls == ["cfid"] # download ran exactly once (not restarted)
|
||||
assert not api_server._inflight # registry drained despite the disconnect
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# str(channel) access-time key consistency (stage 5) END-TO-END: hit -> flush -> DB.
|
||||
# Plan gotcha #9: "Ключи SQLite: channel всегда str(...)"; if the key the hot path RECORDS
|
||||
# and the key the flush UPDATEs by ever diverge, the timestamp silently stops updating and
|
||||
# the file eventually falls out of the cache. Stage 5 pins hit and flush separately; this
|
||||
# stitches them: the /media cache-hit records a (str-channel) key, and the flush's bulk
|
||||
# UPDATE must land on that exact DB row. (get_media's route channel is already a str, so the
|
||||
# str() there is a defensive no-op; the live int/str hazard is in download_media_file, pinned
|
||||
# by stage-5's test_cache_hit_keys_channel_as_str — this test covers the get_media+flush leg.)
|
||||
# Regression caught: any accumulator-key vs UPDATE-WHERE inconsistency (e.g. a transposed
|
||||
# key-column order in the bulk SQL, verified to turn this test red) leaves `added` stale.
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def test_media_cache_hit_flush_updates_str_keyed_db_row(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
api_server._access_updates = {}
|
||||
|
||||
channel, post_id, fid = "12345", 7, "fidINT" # int-ish channel, passed as the route str
|
||||
_seed_cache(tmp_path, channel, post_id, fid)
|
||||
|
||||
db = str(tmp_path / "access.db")
|
||||
init_db_sync(db)
|
||||
# Seed the row keyed by the STRING channel with an old access time.
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute(
|
||||
"INSERT INTO media_file_ids (channel, post_id, file_unique_id, added) VALUES (?,?,?,?)",
|
||||
(channel, post_id, fid, 1.0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
monkeypatch.setattr(api_server, "DB_PATH", db)
|
||||
|
||||
monkeypatch.setattr(api_server, "verify_media_digest", lambda url, digest: True)
|
||||
sentinel = object()
|
||||
|
||||
async def fake_prepare(file_path, request=None, media_key=None):
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr(api_server, "prepare_file_response", fake_prepare)
|
||||
|
||||
before = time.time()
|
||||
resp = await api_server.get_media(channel, post_id, fid, request=SimpleNamespace(headers={}), digest="x")
|
||||
assert resp is sentinel
|
||||
|
||||
# Hot path recorded the str-keyed access time (never the raw int form).
|
||||
assert (channel, post_id, fid) in api_server._access_updates
|
||||
|
||||
# Flush the accumulator; the bulk UPDATE must match the str-keyed row and refresh `added`.
|
||||
await api_server._flush_access_updates()
|
||||
assert api_server._access_updates == {}
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
added = conn.execute(
|
||||
"SELECT added FROM media_file_ids WHERE channel = ? AND post_id = ? AND file_unique_id = ?",
|
||||
(channel, post_id, fid),
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
assert added >= before # refreshed from the stale 1.0 to ~now via the str-keyed WHERE
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# flake8: noqa
|
||||
# pylint: disable=broad-exception-raised, raise-missing-from, too-many-arguments, redefined-outer-name
|
||||
# pylint: disable=multiple-statements, logging-fstring-interpolation, trailing-whitespace, line-too-long
|
||||
# pylint: disable=broad-exception-caught, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=f-string-without-interpolation
|
||||
# pylance: disable=reportMissingImports, reportMissingModuleSource
|
||||
|
||||
|
||||
import os
|
||||
import json
|
||||
import pickle
|
||||
import logging
|
||||
import random
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional, Union, List
|
||||
from pyrogram import Client
|
||||
from pyrogram.types import Chat, Message
|
||||
from tg_throttle import tg_rpc_bounded
|
||||
from config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Config = get_settings()
|
||||
|
||||
# Path to cache directory
|
||||
CACHE_DIR = os.path.join('data', 'tgcache')
|
||||
|
||||
# TTL (hours) for the channel-info cache. Channel title/username/id change rarely,
|
||||
# so a long TTL removes the rate-limited GetFullChannel from the feed-poll hot path.
|
||||
try:
|
||||
CHAT_CACHE_TTL_HOURS = int(os.getenv("TG_CHAT_CACHE_TTL_HOURS", "12"))
|
||||
if CHAT_CACHE_TTL_HOURS < 1:
|
||||
CHAT_CACHE_TTL_HOURS = 12
|
||||
except ValueError:
|
||||
CHAT_CACHE_TTL_HOURS = 12
|
||||
|
||||
def _get_history_cache_file_path(channel_id: Union[str, int]) -> str:
|
||||
"""Returns path to the message history cache file for the channel"""
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
logger.info(f"cache_dir_created: path {CACHE_DIR}")
|
||||
# Convert to string for uniformity
|
||||
channel_id_str = str(channel_id)
|
||||
# Replace potentially problematic characters
|
||||
safe_filename = channel_id_str.replace('/', '_').replace('\\', '_')
|
||||
return os.path.join(CACHE_DIR, f"{safe_filename}.cache")
|
||||
|
||||
def _save_history_to_cache(channel_id: Union[str, int], messages: List[Message], limit: int) -> None:
|
||||
"""Saves message history to cache"""
|
||||
try:
|
||||
cache_file = _get_history_cache_file_path(channel_id)
|
||||
|
||||
# Create cache metadata — store messages directly (no inner pickle.dumps)
|
||||
cache_data = {
|
||||
'timestamp': time.time(),
|
||||
'limit': limit,
|
||||
'messages': messages
|
||||
}
|
||||
|
||||
with open(cache_file, 'wb') as f:
|
||||
pickle.dump(cache_data, f)
|
||||
|
||||
logger.info(f"history_cache_saved: channel {channel_id}, limit {limit}, messages {len(messages)}, file {cache_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"history_cache_save_error: channel {channel_id}, limit {limit}, error {str(e)}")
|
||||
|
||||
def _get_history_from_cache(channel_id: Union[str, int], limit: int, max_age_hours: int = 8) -> Optional[List[Message]]:
|
||||
"""
|
||||
Retrieves message history from cache if not older than specified age and matches the limit
|
||||
|
||||
Args:
|
||||
channel_id: Channel ID or username
|
||||
limit: Required message limit
|
||||
max_age_hours: Maximum cache age in hours (default 8 hours)
|
||||
|
||||
Returns:
|
||||
List of messages or None if cache not found, expired or limit doesn't match
|
||||
"""
|
||||
try:
|
||||
cache_file = _get_history_cache_file_path(channel_id)
|
||||
|
||||
if not os.path.exists(cache_file):
|
||||
logger.info(f"history_cache_miss: channel {channel_id}, limit {limit}, cache file not found")
|
||||
return None
|
||||
|
||||
with open(cache_file, 'rb') as f:
|
||||
cache_data = pickle.load(f)
|
||||
|
||||
# Check cache age with randomization
|
||||
cache_age = time.time() - cache_data['timestamp']
|
||||
# Add randomness up to 20% of max_age_seconds
|
||||
random_factor = 1 - random.uniform(0, 0.2)
|
||||
adjusted_max_age = max_age_hours * 3600 * random_factor
|
||||
|
||||
if cache_age > adjusted_max_age:
|
||||
logger.info(f"history_cache_expired: channel {channel_id}, limit {limit}, age {cache_age:.1f}s > adjusted max {adjusted_max_age:.1f}s (random factor: {random_factor:.2f})")
|
||||
return None
|
||||
|
||||
# Check if limit matches
|
||||
cached_limit = cache_data.get('limit', 0)
|
||||
if cached_limit != limit:
|
||||
logger.info(f"history_cache_limit_mismatch: channel {channel_id}, cached limit {cached_limit}, requested limit {limit}")
|
||||
return None
|
||||
|
||||
# Restore message list; handle old cache files that used double-pickle (bytes = old format)
|
||||
raw = cache_data['messages']
|
||||
if isinstance(raw, bytes):
|
||||
messages = pickle.loads(raw)
|
||||
else:
|
||||
messages = raw
|
||||
logger.info(f"history_cache_hit: channel {channel_id}, limit {limit}, messages {len(messages)}, age {cache_age:.1f}s")
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"history_cache_read_error: channel {channel_id}, limit {limit}, error {str(e)}")
|
||||
# In case of cache read error, better return None and request fresh data
|
||||
return None
|
||||
|
||||
async def cached_get_chat_history(client: Client, channel_id: Union[str, int], limit: int = 20) -> List[Message]:
|
||||
"""
|
||||
Gets chat message history with caching.
|
||||
|
||||
Args:
|
||||
client: Pyrogram client
|
||||
channel_id: Channel ID or username
|
||||
limit: Maximum number of messages to retrieve
|
||||
|
||||
Returns:
|
||||
List of messages, same as original client.get_chat_history()
|
||||
"""
|
||||
cached_messages = await asyncio.to_thread(_get_history_from_cache, channel_id, limit)
|
||||
|
||||
if cached_messages is not None:
|
||||
return cached_messages
|
||||
|
||||
try:
|
||||
logger.info(f"history_cache_request: fetching fresh history for channel {channel_id}, limit {limit}")
|
||||
# Hold the global RPC gate for the live fetch and bound the RPC body with the
|
||||
# timeout — gate outside, timeout inside — via the shared tg_rpc_bounded (so the
|
||||
# tricky nesting is not re-derived here). The timeout covers the whole paginated
|
||||
# fetch; see the note in tg_rpc_bounded.
|
||||
async with tg_rpc_bounded(Config["tg_rpc_timeout"]):
|
||||
messages = [m async for m in client.get_chat_history(channel_id, limit=limit)]
|
||||
await asyncio.to_thread(_save_history_to_cache, channel_id, messages, limit)
|
||||
|
||||
return messages
|
||||
except Exception as e:
|
||||
logger.error(f"history_cache_request_error: channel {channel_id}, limit {limit}, error {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def _get_chat_cache_file_path(channel_id: Union[str, int]) -> str:
|
||||
"""Returns path to the channel-info cache file for the channel."""
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
logger.info(f"cache_dir_created: path {CACHE_DIR}")
|
||||
channel_id_str = str(channel_id)
|
||||
safe_filename = channel_id_str.replace('/', '_').replace('\\', '_')
|
||||
# Distinct suffix so channel-info files never collide with history (.cache) files.
|
||||
return os.path.join(CACHE_DIR, f"{safe_filename}.chatinfo")
|
||||
|
||||
|
||||
def _save_chat_to_cache(channel_id: Union[str, int], data: dict) -> None:
|
||||
"""Saves channel-info (id/title/username) to cache."""
|
||||
try:
|
||||
cache_file = _get_chat_cache_file_path(channel_id)
|
||||
cache_data = {'timestamp': time.time(), 'data': data}
|
||||
with open(cache_file, 'wb') as f:
|
||||
pickle.dump(cache_data, f)
|
||||
logger.info(f"chatinfo_cache_saved: channel {channel_id}, file {cache_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"chatinfo_cache_save_error: channel {channel_id}, error {str(e)}")
|
||||
|
||||
|
||||
def _get_chat_from_cache(channel_id: Union[str, int], max_age_hours: int = CHAT_CACHE_TTL_HOURS) -> Optional[dict]:
|
||||
"""Retrieves channel-info from cache if not older than max_age_hours (with up-to-20% randomization)."""
|
||||
try:
|
||||
cache_file = _get_chat_cache_file_path(channel_id)
|
||||
if not os.path.exists(cache_file):
|
||||
logger.info(f"chatinfo_cache_miss: channel {channel_id}, cache file not found")
|
||||
return None
|
||||
with open(cache_file, 'rb') as f:
|
||||
cache_data = pickle.load(f)
|
||||
cache_age = time.time() - cache_data['timestamp']
|
||||
# Add randomness up to 20% of max age, same approach as the history cache.
|
||||
random_factor = 1 - random.uniform(0, 0.2)
|
||||
adjusted_max_age = max_age_hours * 3600 * random_factor
|
||||
if cache_age > adjusted_max_age:
|
||||
logger.info(f"chatinfo_cache_expired: channel {channel_id}, age {cache_age:.1f}s > adjusted max {adjusted_max_age:.1f}s (random factor: {random_factor:.2f})")
|
||||
return None
|
||||
data = cache_data.get('data')
|
||||
if not isinstance(data, dict):
|
||||
logger.warning(f"chatinfo_cache_invalid: channel {channel_id}, unexpected payload type {type(data).__name__}")
|
||||
return None
|
||||
logger.info(f"chatinfo_cache_hit: channel {channel_id}, age {cache_age:.1f}s")
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"chatinfo_cache_read_error: channel {channel_id}, error {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def cached_get_chat(client: Client, channel_id: Union[str, int]) -> SimpleNamespace:
|
||||
"""Gets channel info (id/title/username) with disk TTL caching.
|
||||
|
||||
Avoids the rate-limited channels.GetFullChannel on every feed poll. Mirrors
|
||||
cached_get_chat_history. On a cache miss the live get_chat is throttled and its
|
||||
exceptions (FloodWait, UsernameInvalid, ...) propagate to the caller unchanged;
|
||||
only successful lookups are cached. The returned object exposes .id/.title/.username.
|
||||
"""
|
||||
cached = await asyncio.to_thread(_get_chat_from_cache, channel_id)
|
||||
if cached is not None:
|
||||
return SimpleNamespace(**cached)
|
||||
|
||||
logger.info(f"chatinfo_cache_request: fetching fresh chat info for channel {channel_id}")
|
||||
async with tg_rpc_bounded(Config["tg_rpc_timeout"]):
|
||||
chat = await client.get_chat(channel_id)
|
||||
|
||||
data = {
|
||||
'id': getattr(chat, 'id', None),
|
||||
'title': getattr(chat, 'title', None),
|
||||
'username': getattr(chat, 'username', None),
|
||||
}
|
||||
await asyncio.to_thread(_save_chat_to_cache, channel_id, data)
|
||||
return SimpleNamespace(**data)
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# flake8: noqa
|
||||
# pylint: disable=broad-exception-caught, global-statement, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=logging-fstring-interpolation, line-too-long
|
||||
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_int_env(name: str, default: int, minimum: int) -> int:
|
||||
"""Parse an int env var, falling back to default on missing/invalid/out-of-range value."""
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw.strip() == "":
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
logger.warning(f"tg_throttle: {name} is not a valid integer ({raw!r}); using default {default}")
|
||||
return default
|
||||
if value < minimum:
|
||||
logger.warning(f"tg_throttle: {name}={value} below minimum {minimum}; using default {default}")
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
# Global throttle for live Telegram MTProto RPC calls. Serializes bursts (e.g. miniflux
|
||||
# batching ~47 feeds at once) so they do not trip Telegram's FLOOD_WAIT (420).
|
||||
_CONCURRENCY = _parse_int_env("TG_RPC_CONCURRENCY", 1, 1) # max concurrent Telegram RPCs
|
||||
_MIN_INTERVAL = _parse_int_env("TG_RPC_MIN_INTERVAL_MS", 500, 0) / 1000.0 # min gap between RPC starts (seconds)
|
||||
|
||||
_sem = asyncio.Semaphore(_CONCURRENCY)
|
||||
_lock = asyncio.Lock()
|
||||
_last_start = 0.0
|
||||
|
||||
logger.info(f"tg_throttle: initialized (concurrency={_CONCURRENCY}, min_interval={_MIN_INTERVAL*1000:.0f}ms)")
|
||||
|
||||
|
||||
class _TgRpcGate:
|
||||
"""Async context manager that caps concurrency and enforces a minimum spacing between RPC starts."""
|
||||
|
||||
async def __aenter__(self):
|
||||
await _sem.acquire()
|
||||
global _last_start
|
||||
try:
|
||||
async with _lock:
|
||||
wait = _last_start + _MIN_INTERVAL - time.monotonic()
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
_last_start = time.monotonic()
|
||||
except BaseException:
|
||||
# Do not leak a semaphore permit if cancelled while waiting for the spacing.
|
||||
_sem.release()
|
||||
raise
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
_sem.release()
|
||||
return False
|
||||
|
||||
|
||||
def tg_rpc():
|
||||
"""Return an async context manager that throttles a single live Telegram RPC call."""
|
||||
return _TgRpcGate()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def tg_rpc_bounded(timeout: float):
|
||||
"""Throttle a live Telegram RPC AND bound it with a timeout, correctly nested.
|
||||
|
||||
The single tricky invariant this centralizes: the timeout must bound ONLY the
|
||||
RPC body, never the gate ENTRY — timing out the `_sem.acquire()` / spacing wait
|
||||
would turn legitimate queue backpressure (e.g. ~47 feeds queueing) into false
|
||||
timeouts. So the gate is the OUTER context and the timeout is the INNER one; a
|
||||
TimeoutError raised inside propagates out through the gate's `__aexit__`, which
|
||||
releases the permit (no leak). Call as:
|
||||
|
||||
async with tg_rpc_bounded(Config["tg_rpc_timeout"]):
|
||||
result = await client.get_chat(channel_id)
|
||||
|
||||
Every gated+bounded RPC uses this so no call site re-derives the nesting by hand
|
||||
(getting it wrong silently reopens the hang-under-backpressure class).
|
||||
"""
|
||||
async with _TgRpcGate():
|
||||
async with asyncio.timeout(timeout):
|
||||
yield
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#ebec63",
|
||||
"activityBar.background": "#ebec63",
|
||||
"activityBar.foreground": "#15202b",
|
||||
"activityBar.inactiveForeground": "#15202b99",
|
||||
"activityBarBadge.background": "#15a9aa",
|
||||
"activityBarBadge.foreground": "#e7e7e7",
|
||||
"commandCenter.border": "#15202b99",
|
||||
"sash.hoverBorder": "#ebec63",
|
||||
"titleBar.activeBackground": "#e5e636",
|
||||
"titleBar.activeForeground": "#15202b",
|
||||
"titleBar.inactiveBackground": "#e5e63699",
|
||||
"titleBar.inactiveForeground": "#15202b99"
|
||||
},
|
||||
"peacock.color": "#e5e636"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user