80 Commits

Author SHA1 Message Date
agent_coder 6388f2f4da test(media): lock XSS escaping on new-media-type user-string sinks (#21 review F1)
Docker Image CI / build (pull_request) Has been cancelled
The new Telegram media types (Kurigram 2.2.23) escape every user string in
_format_special_media, but only CONTACT first_name + CHECKLIST task text were
regression-tested — the reviewer mutation-proved the other sinks were uncovered
(removing html.escape from VENUE title / GIVEAWAY description / CHECKLIST title kept
the suite green). Given this project's XSS history (#12 fail-open, #13 debug-title),
lock them.

Add 7 regression tests (mirroring test_contact_name_is_escaped): a <script> payload in
VENUE title, VENUE address label, DICE emoji, GAME title, CHECKLIST title, GIVEAWAY
description, and GIVEAWAY_WINNERS prize_description; each asserts the raw payload is
absent and the escaped form present. Mutation-verified: removing the escape on each sink
reds its test. 321 passed (314 + 7). Test-only — no production code touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:52:03 +03:00
vvzvlad f9550d8330 feat: upgrade to Kurigram 2.2.23 and render new Telegram media types
Docker Image CI / build (pull_request) Has been cancelled
Upgrade Kurigram 2.2.22 -> 2.2.23 (no breaking API changes; whole suite
green on the new version unchanged).

New in 2.2.23 and now rendered by the bridge:
- LIVE_PHOTO: rendered as a looping video via the /media pipeline
- Poll media (description_media): photo/video/animation/sticker attached
  to a poll is rendered above the poll block; find_file_id_in_message
  searches poll description_media AND explanation_media instead of
  early-returning None for polls

Previously silent media types now rendered:
- STORY: reposted story photo/video via the /media pipeline (render and
  URL derive from the same helper-selected object)
- GIVEAWAY / GIVEAWAY_WINNERS: info blocks with quantity/prize/date
- PAID_MEDIA: info block with star amount (content is not downloadable)
- CHECKLIST: title + tasks with done marks
- CONTACT, LOCATION, VENUE (with OSM links), DICE, GAME, INVOICE,
  UNSUPPORTED: info blocks, all user-controlled strings html-escaped

Supporting changes:
- titles for all new types in _media_message_title
- no_image flag covers info-block types; poll with media is not no_image;
  LIVE_PHOTO counts as video for the video flag
- _save_media_file_ids collects live_photo/story/poll media for the
  background cache warmer, honoring the >100MB video guard; paid media
  is never collected
- info-block types no longer emit an empty message-media div
- 53 new tests (media URLs with digest, download symmetry, XSS escaping,
  flags, size guard); 314 total

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:58:34 +03:00
agent_vscode 4fc8ebbd03 Merge pull request 'fix: XSS в debug-title (#13) + order-independent тест-сьют (#17)' (#20) from fix/issues-13-17 into main
Docker Image CI / build (push) Has been cancelled
2026-07-05 17:21:59 +03:00
vvzvlad f13d1507ad fix: escape debug title (XSS) and make test suite order-independent
Docker Image CI / build (pull_request) Has been cancelled
Issue #13: data["html"]["title"] was embedded into the debug HTML of
/post/html without escaping; title never passes through bleach, so a post
whose generated title carries markup (e.g. a poll question) was a reflected
XSS under ?debug=true. Escape it with html.escape, same as raw_message.
Add a regression test with a <script> payload in a poll question.

Issue #17: a bare 'pytest' from the repo root failed 22 tests because the
config in tests/pytest.ini was not picked up (asyncio_mode lost, .venv
collected) and every test module polluted sys.modules['config'] at import
time. Move the config to a root pytest.ini with testpaths=tests, and
centralize the sys.path bootstrap + config mock in tests/conftest.py, which
pytest imports before any test module. Drop the per-module preambles.

Closes #13, closes #17

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:20:01 +03:00
vvzvlad 39d5001bd6 Merge pull request 'Стабилизация: статика без флаки + устранение зависаний (стадии 1–7)' (#19) from fix/stability into main
Docker Image CI / build (push) Has been cancelled
Reviewed-on: #19
2026-07-05 17:00:49 +03:00
agent_vscode 0cf27a981a Merge pull request 'fix(stability): стадия 1 — таймауты на все TG RPC + устойчивый воркер загрузок + супервизия' (#8) from fix/stage-1-hangs into fix/stability
Docker Image CI / build (pull_request) Has been cancelled
2026-07-05 16:58:51 +03:00
agent_vscode 9f893969ca Merge pull request 'fix(stability): стадия 2 — атомарные скачивания медиа, in-flight дедуп, FloodWait->429, ограниченный семафор' (#10) from fix/stage-2-static into fix/stage-1-hangs 2026-07-05 16:58:44 +03:00
agent_vscode c5e50354ba Merge pull request 'fix(stability): стадия 3 — раздача через FileResponse, чистый ASGI-логгер, больший executor' (#11) from fix/stage-3-fileresponse into fix/stage-2-static 2026-07-05 16:58:37 +03:00
agent_vscode c2574acaba Merge pull request 'fix(stability): стадия 4 — гигиена event loop (рендер фида в поток, ленивый raw_message, один sanitize на выход)' (#12) from fix/stage-4-eventloop into fix/stage-3-fileresponse 2026-07-05 16:58:31 +03:00
agent_vscode b2b0a13557 Merge pull request 'fix(stability): стадия 5 — батчинг записи access-time в SQLite' (#15) from fix/stage-5-sqlite into fix/stage-4-eventloop 2026-07-05 16:58:24 +03:00
agent_vscode 19c72ad2bf Merge pull request 'feat(stability): стадия 6 — лёгкий /ping healthcheck без TG RPC' (#16) from fix/stage-6-healthcheck into fix/stage-5-sqlite 2026-07-05 16:58:17 +03:00
agent_vscode fe815db348 Merge pull request 'test(stability): стадия 7 — сквозная верификация (интеграционные тесты + отчёт)' (#18) from fix/stage-7-verification into fix/stage-6-healthcheck 2026-07-05 16:58:09 +03:00
agent_coder 3a133c349d docs(stability): stage 7 review round 1 — correct rollback granularity to per-stage
Review finding (low, doc-only): the rollback plan said each stage is one
independently-revertable commit, but each stage is actually two commits on its
branch (feature + review-round fix, the latter often fixing a real bug), so
reverting a single commit would orphan the review-round fix. Reword the unit of
rollback to the whole STAGE (branch/PR) and spell out the revert command per merge
strategy: squash-merge -> revert the one squash commit; merge-commit -> revert -m 1
the merge; linear history -> revert the full range of the stage's commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:17:49 +03:00
agent_coder cff6e61d2f test(stability): stage 7 — end-to-end verification (integration tests + report) (#7)
Caps the stacked stability work (stages 1-6). Verification only — ZERO production
code change (git diff against fix/stage-6-healthcheck touches only these two files).

- tests/test_stage7_integration.py (8 cross-stage integration tests via TestClient +
  a mocked TelegramClient, no network): Range at the /media route level (206/206/416
  through get_media -> prepare_file_response -> FileResponse); /ping stays prompt and
  issues zero TG RPC while a slow op is parked; in-flight dedup shares one download and
  drains _inflight after completion AND after request cancellation (no stuck key / hung
  waiter); str(channel) access-time hit -> flush -> the bulk UPDATE lands on the seeded
  row (mutation-verified: transposing the key columns reds it).
- docs/stability-verification.md: per-stage DoD -> evidence mapping (test or "operator
  observation" for prod-only items), the exact manual curl/lsof scenarios for the
  operator to run post-deploy, the diag-log signals to watch, and the per-stage
  independent-commit rollback plan.

Full integrated suite: 260 passed (252 baseline + 8). The prod deploy + live diag-log
observation (plan items 3-4) are the operator's call — this stage does not deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:06:09 +03:00
agent_coder 1cc592bef6 fix(stability): stage 6 review round 1 — /ping degrades to pure connectivity when watchdog disabled
Review finding (low, real): the comment claimed /ping degenerates to a pure
connectivity check when TG_WATCHDOG_ENABLED=false, but _wd_last_ok_monotonic is
also stamped by _restart_client (on the disconnect-flap path, which runs before
the watchdog-enabled gate), so with the watchdog off one flap sets age and nothing
ever refreshes it — age grows unbounded past the threshold and /ping returns 503 on
a live connection, spuriously failing the container healthcheck and triggering an
autoheal restart after every flap.

Fix: gate the staleness branch on the watchdog being enabled —
  healthy = connected and (not Config["tg_watchdog_enabled"] or age is None or age < threshold)
so with the watchdog disabled /ping is a pure connectivity check (matching the
intent), and correct the comment to note a flap-restart can stamp age even when the
watchdog is off. New test test_ping_watchdog_disabled_stale_age_still_healthy:
watchdog off + connected + stale age => 200 ok. Adversarially validated — reverting
the gate reds the new test (503) while the watchdog-ON stale-probe test stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:47:29 +03:00
agent_coder 7d6ee0271d feat(stability): stage 6 — lightweight /ping healthcheck that never touches Telegram (#6)
The container healthcheck hit /rss/...?limit=1 (5s timeout): on a cold cache RSS
generation exceeds 5s, or a hung TG RPC makes it hang, so docker/autoheal restarts
the container mid-download and corrupts temp files. Replace it with /ping, which
reflects process/loop liveness (answers instantly, always) plus TG liveness read
from the watchdog's last-probe data — issuing ZERO Telegram RPC.

- telegram_client: public watchdog_last_ok_age() — seconds since the last successful
  watchdog probe (None if never). Pure read of the Stage-1 _wd_last_ok_monotonic
  field; no RPC.
- api_server: /ping route (no token, no TG RPC, no SQLite, no fs scan). healthy =
  connected and (age is None or age < threshold). age is None right after boot =>
  healthy (don't kill before the first probe). connected coerced to bool so the JSON
  "connected" field is always a bool (pre-start reports false, never null).
- config: TG_PING_UNHEALTHY_AFTER knob, default interval*(failures+1)+timeout = 250s
  (how long until the watchdog itself gives up), env-overridable.
- dockercompose.yml: healthcheck -> curl -sf http://127.0.0.1:80/ping, interval 5m,
  timeout 5s, retries 3, start_period 30s. Old /rss check removed, not left behind.
- tests: 10 (healthy/stale/disconnected/fresh-boot/pre-start-null/no-token +
  the anti-regression zero-TG-RPC spy across all branches + the accessor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:34:06 +03:00
agent_coder d0801ef0ff fix(stability): stage 5 review round 1 — DoD guard on get_media hot path + correct affinity comment
Review findings (both low, no bugs; accumulator design adversarially confirmed):
- test-coverage: the DoD's hottest changed site — get_media's pre-semaphore
  cache-hit — had no direct zero-SQLite guard (the spy test only exercised
  download_media_file), so a regression re-introducing a per-hit write into
  the get_media branch would pass green. Add a mirror spy test through
  get_media asserting the accumulator is written and update_media_file_access_sync
  is NOT called. Verified site-specific: neutering only the get_media write
  reds the new test while the download_media_file test stays green.
- documentation: the _access_updates comment claimed a str/int key mix "would
  make the WHERE silently never match" — empirically false: channel is a TEXT
  column, so a bound int is affinity-coerced and DOES match. Reword to say we
  key str(channel) to stay consistent with the stored form rather than lean on
  SQLite's implicit coercion (the code was already correct on both sites).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:18:36 +03:00
agent_coder a04588740b perf(stability): batch SQLite access-time writes out of the /media hot path (#5)
Stage 5. A /media cache hit no longer touches SQLite: it records the access
timestamp into a module-level accumulator (a dict write on the event loop,
cheap and atomic), and a supervised 60s background task flushes the whole
batch in one executemany UPDATE. This removes both per-hit write sites — the
awaited to_thread in download_media_file and the fire-and-forget create_task
in the pre-semaphore fast path — so under active RSS polling the threadpool is
no longer starved by per-request access-time UPDATEs.

- file_io: add update_media_file_access_bulk_sync (one connection, executemany;
  empty batch is a no-op).
- api_server: _access_updates accumulator + _flush_access_updates (snapshot-
  then-clear atomically before the await so writes during the flush land in the
  fresh dict; re-queue the batch with setdefault on write failure so a fresher
  concurrent write is never clobbered and no access-time is lost) +
  _access_flush_loop under _supervised + a final flush on shutdown, ordered
  after the loop task is cancelled and before the io threadpool is shut down.
- Keys use str(channel) to match the TEXT channel column (a str/int mix would
  make the UPDATE WHERE silently never match, evicting still-used files).
- tests/test_stage5_sqlite.py: 7 tests (no-sync-write hot path, str-key
  discipline, hit->flush->DB, empty no-op, snapshot-then-clear race, re-queue-
  without-clobbering-fresh, bulk SQL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:04:47 +03:00
claude code agent 8d21390294 fix(stability): fail CLOSED on feed-sanitize exception + cover bulk-SQL/flush (review round 1)
#1 [security] The final feed-sanitize try/except was FAIL-OPEN: since 4.4 removed
the per-fragment passes, post['html'] / the concatenated feed html is now raw
channel-controlled HTML, and this is its ONLY sanitize. If bleach itself throws
(RecursionError/OOM on pathological nested HTML — a class already seen in this
project), the except branch returned the RAW payload = stored XSS. Both branches
(RSS per-post, HTML whole-feed) now fail CLOSED via html.escape, so the content
survives as inert text and no live tag ever reaches the client. Added a test that
forces bleach to raise and asserts no live <script>/<img>/<a> reaches the feed
(adversarially validated: reverting to fail-open makes it fail).

#2 [test] Direct test of the new upsert_media_file_ids_bulk_sync on a temp DB:
empty no-op, multi-row insert, and re-upsert-updates-added (the real executemany +
ON CONFLICT, previously only mocked).

#3 [test] Test that media ids collected before a render exception are still
flushed (the flush is in a finally) — removing the finally now turns a test red.

#4 [cleanup] Removed the dead  in rss_generator (the
flush is delegated to post_parser._flush_pending_media_ids).

#5 [doc] Corrected the sanitize-map comments: RSS sanitizes per-post, only HTML is
the whole-feed pass.

233 passed (214 baseline + 19).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:41:09 +03:00
claude code agent 9f9091f6fb fix(stability): stage 4 — event-loop hygiene for feed generation
Feed generation blocked the loop (deepcopy of hundreds of Messages + per-message
bleach x4 + str(message) per post). This moves the CPU work off the loop and cuts
it down.

4.1 raw_message is lazy: process_message(include_raw=False) omits str(message)
    entirely; only /json and debug-HTML compute it.
4.2 (done BEFORE 4.3) _save_media_file_ids no longer touches asyncio/DB — it
    appends to the per-request PostParser._pending_media_ids, and the caller
    flushes once via to_thread(upsert_media_file_ids_bulk_sync) (a new executemany
    fn in file_io.py). Removed _persist_pending_count + the create_task machinery.
    This had to precede 4.3 or get_running_loop() inside the render thread would
    raise and silently kill media-id persistence.
4.3 The render pipeline (_create_time_based_media_groups, _create_messages_groups,
    _trim_messages_groups, _render_messages_groups) is now plain-sync and runs in
    ONE asyncio.to_thread(_render_pipeline) from both feed generators; deepcopy
    moved into the thread. The render path is verified free of asyncio primitives.
4.4 Sanitize is now ONE pass per output boundary (removed the 4 internal
    per-fragment bleach passes): RSS/HTML feeds sanitize once at the whole-feed
    pass; /html and /json sanitize body+footer once in process_message; the debug
    <pre> raw dump is html.escape'd. Media embeds in body, reactions in footer, so
    both are covered by the boundary pass. XSS tests (<script>/onerror=/javascript:)
    confirm all four outputs are clean.

Review round-1: documented that flags are now extracted from the pre-sanitize body
(a 4.4 consequence — non-security, legitimate links unaffected); added
media-caption XSS tests for the exact fragments whose internal passes were removed
(adversarially validated: neutering the sanitizer makes them fail); the media-id
flush is now in a finally so a partial render still persists what it collected.

230 passed (214 baseline + 16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:16:14 +03:00
claude code agent 1e24fd5354 fix(stability): pass FileResponse a stat_result (404 not 500 on race) + doc/annotation fixes (review round 1)
#1 [medium] FileResponse with stat_result=None re-stats at send-time and raises a
RuntimeError (-> 500 + stacktrace, escaping get_media's try/except) if the file was
swept between the early exists() check and the send. The old streaming code caught
FileNotFoundError on getsize -> 404. Now prepare_file_response takes ONE
authoritative os.stat (in try/except FileNotFoundError -> 404) after the temp_*
touch and passes it as FileResponse(stat_result=...): restores the 404 semantics,
collapses the double stat into one, and makes the ETag reflect the observed mtime.
The remaining narrow deleted-between-our-stat-and-open window pre-existed.

#2/#3 [doc] Corrected the Content-Disposition comment (FileResponse emits
filename="x" for ASCII, filename*=UTF-8''x only for non-ASCII, via setdefault — a
manual header would override, not double) and the ETag-stability comment (stable
within a 300s window; a longer view costs at most one safe 200 If-Range restart
per interval).

#4 [simplification] Removed the now-dead StreamingResponse import and corrected
get_media's return annotation to -> Response (nothing returns StreamingResponse
after the FileResponse migration).

#5 [test] Added a test for the middleware's non-http (lifespan/websocket) branch —
it only runs in a real deploy, never through TestClient — asserting it delegates
to the inner app and logs nothing.

214 passed (195 baseline + 19).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 08:42:54 +03:00
claude code agent 870e0a40d8 fix(stability): stage 3 — serve via FileResponse, pure-ASGI logging, bigger executor
Replaces the hand-rolled media streaming with Starlette FileResponse, drops the
BaseHTTPMiddleware, and enlarges the default threadpool.

3.1 prepare_file_response now returns FileResponse (handles Range/If-Range/206/
    416/multipart, sets Accept-Ranges/ETag/Last-Modified, reads efficiently — no
    per-64KB to_thread hop that starved the pool). Kept: the early 404 pre-check,
    the MIME logic (python-magic + SQLite cache), and every stage-2 behavior — the
    temp_* mtime touch (now DEBOUNCED, see below), delete_after -> BackgroundTask
    (passed as FileResponse background=), the media_key MIME cache. Removed the
    manual Range parsing, file_chunk_generator, and hand-built headers;
    Content-Disposition is formed by FileResponse from filename= (no double-set).
    206 slices are byte-identical to the old code; accepted RFC-7233 deltas
    documented in the tests.
3.2 RequestLoggingMiddleware rewritten as a pure-ASGI class (wraps only send to
    observe the status line, never buffers the body, passes non-http scopes
    through) — the streaming body flows untouched.
3.3 lifespan sets a larger default executor (ThreadPoolExecutor, IO_THREAD_POOL_SIZE
    default 32) and shuts it down on exit.

Review round-1 fixes folded in: the temp_* mtime touch is DEBOUNCED
(TEMP_MTIME_REFRESH_INTERVAL=300s) so FileResponse's mtime-derived ETag stays
stable across a resume/seek session (an every-serve touch broke If-Range resume);
starlette pinned to 0.45.3; the io executor is shut down on lifespan exit; the
ASGI logger includes the query string.

Tests (tests/test_stage3_fileresponse.py, 18): the Range matrix vs FileResponse
with every delta documented; temp_* mtime refreshed when stale AND stable when
fresh (ETag identical); delete_after background runs and removes the file;
media_key MIME cache hit/miss; the ASGI middleware passes the body and logs.
213 passed (195 baseline + 18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 08:26:35 +03:00
claude code agent 41d143458f fix(stability): test the HTTP semaphore balance + correct the dedup docstring (review round 1)
F1 [WARNING] The HTTP_DOWNLOAD_SEMAPHORE admission-control balance was untested,
and it is a plain asyncio.Semaphore — so an over-release would SILENTLY inflate
the permit count and disable the limiter (no ValueError), a green suite hiding the
exact bug stage 2 fixes. Added two tests (mirroring the stage-1 tg_throttle
balance test): the permit is released exactly once when the download errors (count
back to baseline), and a timed-out acquire (503) releases NOTHING (count
unchanged). Adversarially validated: neutralizing the finally release makes the
first test fail, so it genuinely catches a leak.

F2 [low] Corrected the _download_deduped docstring: the detached task sets the
Future in try/except, and its finally ALWAYS pops the key — the previous wording
attributed the Future-set to the finally, which an auditor reading the finally
block would find contradicted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 07:56:23 +03:00
claude code agent 444bd3e42f fix(stability): stage 2 — atomic media downloads, in-flight dedup, FloodWait->429, bounded semaphore
Fixes flaky static media serving: partial files served as 'ready', truncated
stubs living for an hour, and app-wide hangs under a saturated download path.

2.1 _download_atomic(file_id, final_path, timeout): downloads to a unique
    {final}.part.{hex}, validates size>0, and publishes solely via os.rename
    (atomic on POSIX); a finally ALWAYS removes the partial (timeout/cancel/
    zero-size/race-loser). Every downloader call routes through it, so a file at a
    FINAL name ({fid} / temp_{fid}) is GUARANTEED complete (grep-proven: the only
    safe_download_media caller passes a .part. path). Big-video timeout scales with
    size (min/max/min-speed knobs, documented in dockercompose.yml). The sweeper
    regex now matches both .part. and legacy .tmp. stubs.
2.2 In-flight dedup registry: the first request for a key runs the download in a
    DETACHED task sharing a Future; the task's finally sets the Future AND pops the
    key. Waiters await asyncio.shield(fut) so a client disconnect / waiter timeout
    cancels only the waiter, never the download — no hung waiters, no stuck key, a
    failed download frees the key for retry.
2.3 FloodWait->429: handler before except RPCError (FloodWait subclasses it),
    Retry-After = min(value + rand(1,30), 300); propagates from the detached task
    through the Future.
2.4 Serving a temp_* file touches its mtime so the 1h sweeper can't delete a
    video out from under a viewer.
2.5 The HTTP download semaphore acquire is bounded (wait_for 30 -> 503 +
    Retry-After); the permit is released only if the acquire succeeded. The
    request-scoped-permit trade-off (a disconnect can transiently exceed the
    download count) is documented inline for stage-7 prod observation.

Tests (tests/test_stage2_static.py, 13): atomic publish/clean on every exit incl.
FloodWait-through-the-finally; concurrent big-video serves no partial; dedup runs
one download, a cancelled waiter doesn't hang others, a failed download frees the
key; FloodWait -> 429; mtime touch; sweeper cleans .part./.tmp./stale temp_ but
keeps fresh files. 193 passed (180 baseline + 13).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 07:40:47 +03:00
claude code agent 03d1de2954 fix(stability): declare pytest-asyncio, bound /raw_json RPC, assert FloodWait backoff, dedupe gate+timeout (review round 1)
F1 [WARNING] pytest-asyncio was not declared, so the 6 new async stage-1 tests
ERROR on a clean checkout (async def not natively supported) — zero regression
protection, masked by a globally-installed plugin. Added pytest-asyncio to
requirements.txt + asyncio_mode=auto to tests/pytest.ini. Verified on a fresh venv
from requirements alone: the tests collect and run (180 passed).

F2 [WARNING] The /raw_json endpoint's get_messages was the one remaining live RPC
without a timeout, violating the stage-1 DoD ('every Telegram RPC is bounded').
Wrapped it in asyncio.wait_for(..., 30) mirroring PostParser.get_post. (It is not
under the tg_rpc gate, so its blast radius was one request, not the app.)

F3 [WARNING] The worker test globally no-op'd asyncio.sleep, so the dedicated
except FloodWait branch was indistinguishable from the generic handler — deleting
it kept the test green. The sleep stub now records delays and the test asserts the
FloodWait backoff of 6 (=min(1+5,900)), distinct from the success path's 2.

F5 [low] The tricky 'gate outside, timeout inside' nesting was open-coded at 3
sites (each re-deriving the invariant). Extracted tg_rpc_bounded(timeout) into
tg_throttle (using asyncio.timeout()); the 3 sites now use it, so a future call
site cannot silently wrap the gate entry and reopen the hang-under-backpressure.

F4 [low] Documented TG_RPC_TIMEOUT in the dockercompose.yml environment block
next to the other TG_RPC_* knobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 07:12:10 +03:00
claude code agent 4daf611f05 fix(stability): stage 1 — timeouts on all TG RPC + resilient download worker + supervision
Eliminates the app-wide hangs where one stuck Telegram RPC holds the single
tg_rpc gate permit forever (freezing every RSS/HTML request) and where the
background download worker dies silently.

1.1 Every RPC under the global gate is now bounded by asyncio.wait_for
    (config tg_rpc_timeout, env TG_RPC_TIMEOUT, default 60). The wait_for wraps
    ONLY the RPC body, never the gate acquire, so queue backpressure stays
    legitimate; the paginated get_chat_history is collected in an inner coroutine
    so the async-for can be bounded. A timeout propagates out of the gate so its
    permit is released (no leak).
1.2 The other live RPC paths get the same treatment: _reply_enrichment's
    get_messages now runs under the gate + timeout; PostParser.get_post is bounded
    at 30s (and a stray print removed); /health's get_me at 10s.
1.3 background_download_worker: queue.get() moved out of the try so task_done()
    in finally balances exactly one get (no ValueError that killed the worker);
    FloodWait is caught BEFORE the generic Exception (sleeps, does not flood);
    download_new_files uses put_nowait so a full queue no longer blocks the
    sweeper forever.
1.4 Both background tasks run under a _supervised() wrapper: a crash or an
    unexpected return is logged CRITICAL and restarted (restarts rate-limited to
    once per 60s so a hard-failing task can't spin), while CancelledError is
    propagated to the child for a clean shutdown.

Tests (tests/test_stage1_hangs.py): gate timeout releases the permit + a second
call succeeds; gate cancel mid-spacing releases the permit; the worker survives
Exception and FloodWait with balanced task_done; and _supervised restarts a
crashing task (rate-limited) and an unexpected return, and propagates cancellation
to its child. 180 passed (174 baseline + 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:51:08 +03:00
vvzvlad e3b458d774 docs: add staged stability fix plan (static serving + hangs)
Detailed 7-stage plan covering: RPC timeouts under the global throttle gate,
background worker task_done/queue fixes, atomic large-video downloads,
FloodWait-to-429 mapping, FileResponse migration, event-loop hygiene for feed
rendering, SQLite write batching, and a lightweight /ping healthcheck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 04:00:20 +03:00
vvzvlad 080dae3e74 feat(cache): add channel info cache with TTL and throttling
Docker Image CI / build (push) Has been cancelled
Introduce `cached_get_chat` in `tg_cache` to store channel metadata on disk with a configurable TTL (default 12 hours). Use the `tg_rpc` throttling context for RPC calls. Update `rss_generator` to use the cached version and document related environment variables in `dockercompose.yml`.
2026-06-05 20:45:08 +03:00
vvzvlad 021f16cf14 feat(client): add watchdog heartbeat and diagnostics counters
Introduce `tg_watchdog_heartbeat_every` to emit periodic INFO heartbeats.
Add cumulative diagnostics counters and richer logging for watchdog probes,
restart reasons, and disconnect‑flap triggers. Update defaults and tests
accordingly.
2026-06-04 19:19:22 +03:00
vvzvlad 79b127d406 feat(client): add watchdog and disconnect flap handling
Introduce an active watchdog that probes the Telegram client to detect
zombie sessions and restart them in‑process. Add configurable disconnect
flap detection with a sliding window to trigger restarts after repeated
disconnects. New environment variables and config entries are added, and
the Kurigram dependency is now version‑pinned.
2026-06-04 19:02:09 +03:00
vvzvlad 577093b9fa test(postparser): use StrWithHtml for Pyrogram text/caption mocks
Replace MagicMock based text/caption mocks with a lightweight StrWithHtml
subclass that provides the required `.html` property while behaving like a
regular string. This fixes length and strip checks in the parser and
simplifies test setup. Also adds default proxy settings to mock_config for
test completeness.
2026-05-18 00:15:53 +03:00
vvzvlad f0e38b9776 perf(core): improve async handling and caching performance
Convert delayed_delete_file to async and use asyncio.sleep.
Add pre‑semaphore cache hit to serve cached files without acquiring the download semaphore.
Set SQLite busy_timeout to reduce lock errors.
Introduce a diagnostics counter for pending _persist_media_file_id_async tasks.
Refactor flag extraction to accept pre‑generated HTML and compute html body once.
Batch reply enrichment in RSS generator to minimize API calls.
Store cached messages directly in tg_cache and add fallback for legacy double‑pickle format.

BREAKING CHANGE: delayed_delete_file is now async and must be awaited.
2026-05-18 00:08:46 +03:00
vvzvlad ab8f15d49d feat(api): add atomic temp file handling for media downloads
- Use unique temporary file paths with UUID to avoid race conditions during concurrent downloads.
- Perform atomic rename of the temp file to the final cache location and handle existing concurrent downloads.
- Clean up zero‑size and stale temporary files, including new “.tmp.<hex>” pattern.
- Extend cleanup logic to detect and remove race‑condition temp files.
- Validate and convert environment variables (TG_API_ID, TG_PROXY_PORT, API_PORT) with clear error messages and proper exit handling.
- Flush prints and replace `os._exit` with `sys.exit` for graceful termination.
- Fix URL regex in `PostParser` and guard against missing channel usernames when generating media URLs.
- Deep‑copy message lists in RSS generator to prevent mutation across calls.
- Propagate `FloodWait` exceptions to be handled by the API server instead of retrying internally.
- Enhance `TelegramClient` disconnect handling with a brief pause, shutdown check, and reconnection reset.
- Refine auth retry logic to check for actual `KeyError` instances.
2026-05-17 23:49:18 +03:00
vvzvlad dd5999b51f fix: guard against missing messages and reaction issues
Added a guard in `api_server.download_media_file` to detect `None` or empty messages and return a 404 error, preventing downstream failures.

Enhanced `PostParser._extract_reactions` to handle paid reactions, custom emojis, and unknown types, aggregating counts correctly. Updated `_extract_flags` to skip paid and custom emoji reactions, ensuring flag detection works reliably.
2026-05-17 23:12:11 +03:00
vvzvlad 40fa9797e8 feat(api): add trusted proxy handling to local request detection
The `is_local_request` function now supports trusted reverse proxies by:
- Reading `TRUSTED_PROXIES` from configuration.
- Resolving the real client IP from `X-Real-IP` or `X-Forwarded-For` headers when the request comes through a trusted proxy.
- Adding IPv6 loopback support and detailed logging for misconfigurations.
- Updating configuration to expose `trusted_proxies` via the `TRUSTED_PROXIES` environment variable.
2026-05-17 22:55:50 +03:00
vvzvlad 64fe6327d8 fix(rss): handle missing dates in media grouping and RSS
Add deterministic fallback sorting for messages without a date to avoid
TypeError during grouping. Use current time as fallback when calculating
time differences and when generating RSS entries. Sort rendered posts
with epoch fallback for None dates and emit a warning when a post lacks a
date. These changes make the RSS generation robust against messages that
do not have timestamps.
2026-05-17 22:37:20 +03:00
vvzvlad 705310da28 fix(parser): log channel arg to avoid UnboundLocalError
Use the original `channel` argument in the error log instead of
`prepared_channel_id` to prevent an UnboundLocalError when an
exception is raised during post parsing.
2026-05-17 22:30:01 +03:00
vvzvlad 6e2c1f79d3 fix(rss): stop popping oldest group in _trim_messages_groups
Removed the pop operation that discarded the oldest message group during trimming, preventing unintended loss of messages.
2026-05-17 22:28:28 +03:00
vvzvlad 45cd18af99 feat(api): add landing page endpoint
Introduce a root GET route that serves a minimal HTML landing page describing the Pyrogram Bridge service and linking to its GitHub repository. This provides a friendly entry point for users accessing the service via a browser.
2026-05-11 02:54:39 +03:00
vvzvlad 9bfef3bf2d fix(rss): remove filter for NEW_CHAT_TITLE messages
Previously NEW_CHAT_TITLE events were ignored in the RSS generator,
preventing title updates from being included. The filter line has been
removed so these messages are now processed correctly.
2026-04-16 04:02:10 +03:00
vvzvlad cd4e6f0c82 fix(api): use MessageMediaType enum for poll checks
Replace string comparisons with the MessageMediaType.POLL enum and add the
necessary import. This ensures poll messages are correctly identified and
skipped during media processing.
2026-04-16 03:46:03 +03:00
vvzvlad 9f9e952c4e fix(parser): handle poll question/option objects with .text
Update poll handling to extract the `text` attribute from question and option objects when present, falling back to string conversion otherwise. This ensures proper rendering of poll content from Telegram objects that expose a `text` field.
2026-04-16 02:43:53 +03:00
vvzvlad f19e2bfe6d fix(parser): cast poll question and option texts to string
Convert poll.question and option.text to strings before processing to avoid type errors when they are not already strings.
2026-04-16 02:40:35 +03:00
vvzvlad 85cee6b295 refactor(api): comment out redundant digest validation log
The else block that logged a valid digest was commented out to reduce
unnecessary log noise and avoid misleading messages during media
validation.
2026-04-06 23:54:23 +03:00
vvzvlad 57a96044bc feat(db): add mime_type column and caching helpers
Add a new `mime_type` column to the media_file_ids table and provide
functions to get and set cached MIME types. The API now attempts to
retrieve the MIME type from the database before falling back to
python-magic, persisting the detected type for future requests. This
reduces I/O overhead and improves response performance. Also adjust
logging to debug level to reduce noise.
2026-04-06 23:46:22 +03:00
vvzvlad 0f297185e4 feat(api): add range-enabled streaming for media files
Implement HTTP Range request support by replacing FileResponse with StreamingResponse.
Added async chunked generator, proper Content-Range, Accept-Ranges, and filename encoding headers.
Updated imports, endpoint signature, and background cleanup handling.
2026-04-06 23:37:46 +03:00
vvzvlad cf8a2e0ce5 feat(api): add immutable cache header to file responses
Add a Cache‑Control header with `public, max-age=86400, immutable`
to file responses, allowing clients to cache files for a day
2026-04-06 23:29:35 +03:00
vvzvlad c9b0d2ffc9 feat(db): replace media file ID JSON store with SQLite
Switch persistence of media file identifiers from a JSON file to a
SQLite database. Introduce DB_PATH, init_db_sync, upsert, update, and
removal functions. Remove json-repair dependency and related locking
logic. Update api_server and post_parser to use the new database
helpers.

BREAKING CHANGE: media_file_ids.json is no longer used; existing
data must be migrated to the new SQLite database.
2026-04-05 20:20:37 +03:00
vvzvlad 17ec871d7c feat(config): switch proxy to SOCKS5 and add auth options
The proxy configuration now uses a SOCKS5 scheme instead of MTProto. The default port is updated to 1080 and optional username/password fields are supported. Docker compose comments are updated accordingly.

BREAKING CHANGE: existing MTProto proxy settings (scheme "mtproto", port 443) are no longer supported and must be migrated to SOCKS5.
2026-04-05 18:18:34 +03:00
vvzvlad 09692edf06 feat(config): add mtproto proxy support
Introduce optional MTProto proxy configuration sourced from environment variables, expose related settings in `config.py`, document the variables in `dockercompose.yml`, and pass the proxy configuration to the Telegram client initialization.
2026-04-05 18:09:23 +03:00
vvzvlad 02c29f8692 fix(telegram): add shutdown guard to avoid duplicate restarts
Add a `_shutting_down` flag to `TelegramClient` to suppress disconnect handling during intentional shutdown. The flag is set before sending SIGTERM in `_restart_app` and checked in `_on_disconnect` to ignore those events. Minor formatting adjustments were also applied to `api_server.py` (try block and `uvicorn.run` arguments).
2026-03-16 04:13:39 +03:00
vvzvlad 14f8db0a32 refactor(client): add optional session param to _on_disconnect 2026-03-16 04:05:39 +03:00
vvzvlad a38a729137 perf(api): add timing diagnostics for media ops
Add monotonic timing instrumentation around media download, semaphore
wait, HTML sanitization, and media file persistence. Log warnings when
operations exceed defined thresholds and provide diagnostic info for
task queue size. This aids performance monitoring without altering
behaviour.
2026-03-16 01:14:24 +03:00
vvzvlad b7b1daa035 chore(logs): remove pyrogram bridge logs file 2026-03-16 01:07:43 +03:00
vvzvlad 4ef7b00c16 refactor(api): split download semaphores and remove diagnostic monitoring
Separate live HTTP media requests and background cache workers by introducing `HTTP_DOWNLOAD_SEMAPHORE` and `BACKGROUND_DOWNLOAD_SEMAPHORE`. Remove the previous global `DOWNLOAD_SEMAPHORE`, diagnostic event‑loop monitor, and related logging. Refactor `download_media_file` to handle large videos with temporary files, clean up zero‑size cache entries, and update access timestamps without semaphore tracking. Adjust background worker and request handling to use the new semaphores, simplifying concurrency control and eliminating unnecessary diagnostics.
2026-03-16 01:07:19 +03:00
vvzvlad 8bcce02368 feat(api): add diagnostic monitoring and semaphore tracking
Introduce a periodic event‑loop monitor that logs active tasks, semaphore
state, queue size and thread‑pool activity. Track the number of coroutine
waiters for the download semaphore and emit detailed diagnostic logs in
download handling and the background worker. This aids debugging of
concurrency and resource‑usage issues.
2026-03-16 01:00:31 +03:00
vvzvlad 7d4322f975 chore(workspace): add VS Code workspace configuration files 2026-03-16 01:00:20 +03:00
vvzvlad 5257c50243 fix(post_parser): correct content order when caption display flag is toggled
The changes ensure that when `show_caption_above` is true, text content is rendered before media, and when false, media is rendered before text. This aligns the actual rendering order with the semantic meaning of the flag, fixing a bug where content ordering did not match user expectations.
2026-01-02 05:38:30 +03:00
vvzvlad 76e163098b feat(post_parser): support show caption above media attribute for message rendering
Add support for Telegram's show_caption_above_media message attribute to conditionally order caption and media content in HTML output. When enabled, media is rendered before the text caption instead of the default text-before-media order.
2026-01-02 05:27:18 +03:00
vvzvlad 5543c0514a feat(post_parser): allow strikethrough tags in HTML sanitization
Add 's' and 'del' tags to the allowed HTML tags list in the sanitization
method to support strikethrough and deleted text formatting.
2026-01-02 02:33:37 +03:00
vvzvlad fef63c1224 chore(rss_generator): add logging for RSS feed date ranges and entry timestamps
Logs the date range (oldest/newest) and total count of posts being added to RSS feeds, plus debug-level logging for each entry's timestamp conversion. This improves observability for feed generation and helps diagnose date-related issues.
2026-01-01 15:02:36 +03:00
vvzvlad 337cabe180 fix(rss_generator): handle Telegram flood wait errors with automatic retry logic
Add flood wait exception handling in both RSS and HTML generation functions.
The bot now automatically waits for the specified duration and retries the
operation when encountering rate limiting from Telegram's API.
2026-01-01 14:57:16 +03:00
vvzvlad 3c2b4ce544 perf(downloads): implement concurrent download queue with semaphore limiting and retry logic
Add queue-based background download system to improve performance and reliability:
- Introduce DOWNLOAD_SEMAPHORE to limit concurrent downloads to 3
- Add asyncio.Queue (maxsize 100) with dedicated worker for background processing
- Implement safe_get_messages and safe_download_media wrappers with timeout protection (30s and 120s)
- Add retry logic for KeyError auth failures with 5s backoff
- Replace synchronous sequential downloads with asynchronous queued processing
- Prevent event loop blocking by queuing files instead of immediate download
2026-01-01 14:14:26 +03:00
vvzvlad deacb8b9a0 refactor rss_generator: offload HTML concatenation and sanitization to asyncio.to_thread to improve CPU performance and prevent event loop blocking 2025-09-15 12:16:53 +03:00
vvzvlad 68a2dbfa04 refactor api_server and post_parser: implement asynchronous JSON file operations with locking mechanism to ensure thread safety, and streamline media file ID persistence logic 2025-09-13 20:47:16 +03:00
vvzvlad 1464ea326e refactor api_server: streamline RSS feed generation by consolidating error handling and removing redundant loop structure 2025-09-13 00:06:36 +03:00
vvzvlad 778aa04904 refactor api_server: implement asynchronous file I/O using asyncio.to_thread for JSON operations, add global python-magic instance for MIME type detection, and enhance delayed file deletion documentation 2025-09-12 23:50:52 +03:00
vvzvlad 43516d6dab refactor api_server and TelegramClient: remove force_exit function and replace with sys.exit for process termination, update restart logic to use SIGTERM instead of SIGKILL for graceful shutdown 2025-06-10 15:03:09 +03:00
vvzvlad 521c3dc394 implement forceful termination in api_server and TelegramClient: add force_exit function for immediate process shutdown, update SIGTERM handler for forceful exit, and adjust restart logic in connection_handler to use SIGKILL 2025-06-10 14:28:51 +03:00
vvzvlad d3366ca7ef refactor tg_cache: update _get_history_from_cache to use max_age_hours instead of max_age_seconds, adjust cache expiration logic accordingly, and remove cache_ttl parameter from cached_get_chat_history 2025-06-10 14:17:16 +03:00
vvzvlad d6c3725b02 implement graceful shutdown in api_server: add SIGTERM handler for proper termination and enhance error handling for server startup issues 2025-06-10 14:06:56 +03:00
vvzvlad 2c9ebf139f refactor connection_handler in TelegramClient: increase wait time to 5 seconds before restart, implement process termination and forking for more reliable restarts 2025-06-10 03:58:33 +03:00
vvzvlad 688b9e1927 refactor tg_cache: update cache file naming convention to use a simpler format by removing '_history' from filename 2025-06-10 03:52:17 +03:00
vvzvlad 0dd4460e35 refactor tg_cache: simplify cache file path generation by removing limit from filename and updating related cache functions 2025-06-10 03:51:51 +03:00
vvzvlad 14081a3d44 refactor rss_generator and tg_cache: replace cached_get_chat with direct client.get_chat calls, update message caching logic to handle limits, and improve cache file naming for message history 2025-06-10 03:48:08 +03:00
vvzvlad 223ae57929 add delay in connection_handler to ensure resources are freed before process restart 2025-06-10 03:38:33 +03:00
vvzvlad 0f992bf3a8 enhance cache expiration logic in _get_from_cache: add randomness to max age check for improved cache validity 2025-06-10 03:28:43 +03:00
vvzvlad c94090c4b5 update _on_disconnect method in TelegramClient: change to async for better handling of disconnections 2025-06-10 03:25:54 +03:00
vvzvlad 95361be6f4 refactor tg_cache: change debug logs to info level for cache operations to enhance log clarity 2025-06-10 03:24:22 +03:00
vvzvlad b7a8f4f60d refactor requirements and rss_generator: replace direct get_chat calls with cached_get_chat for improved performance and update requirements.txt to uncomment Kurigram 2025-06-10 02:40:23 +03:00
31 changed files with 6779 additions and 565 deletions
+781 -260
View File
File diff suppressed because it is too large Load Diff
+90 -6
View File
@@ -9,6 +9,7 @@
# pylance: disable=reportMissingImports, reportMissingModuleSource # pylance: disable=reportMissingImports, reportMissingModuleSource
import os import os
import sys
import logging import logging
from typing import Any from typing import Any
@@ -42,22 +43,105 @@ def get_settings() -> dict[str, Any]:
tg_api_id = os.getenv("TG_API_ID") tg_api_id = os.getenv("TG_API_ID")
tg_api_hash = os.getenv("TG_API_HASH") tg_api_hash = os.getenv("TG_API_HASH")
if not tg_api_id or not tg_api_hash: if not tg_api_id or not tg_api_hash:
print("TG_API_ID and TG_API_HASH must be set") print("TG_API_ID and TG_API_HASH must be set", flush=True)
os._exit(1) 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") 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 { return {
"tg_api_id": int(tg_api_id), "tg_api_id": tg_api_id_int,
"tg_api_hash": tg_api_hash, "tg_api_hash": tg_api_hash,
"session_path": os.getenv("SESSION_PATH", "data") or "data", "session_path": os.getenv("SESSION_PATH", "data") or "data",
"api_host": os.getenv("API_HOST", "0.0.0.0"), "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", ""), "pyrogram_bridge_url": os.getenv("PYROGRAM_BRIDGE_URL", ""),
"log_level": log_level, "log_level": log_level,
"debug": os.getenv("DEBUG", "False") == "True", "debug": os.getenv("DEBUG", "False").strip() in ["True", "true"],
"token": os.getenv("TOKEN", ""), "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"], "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_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"], "show_post_flags": os.getenv("SHOW_POST_FLAGS", "False").strip() in ["True", "true"],
} "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),
}
+29 -3
View File
@@ -8,6 +8,27 @@ services:
environment: environment:
TG_API_ID: XXX TG_API_ID: XXX
TG_API_HASH: 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)
PYROGRAM_BRIDGE_URL: https://pgbridge.example.com PYROGRAM_BRIDGE_URL: https://pgbridge.example.com
API_PORT: 80 API_PORT: 80
TOKEN: ХХХ TOKEN: ХХХ
@@ -32,10 +53,15 @@ services:
com.centurylinklabs.watchtower.enable: "true" com.centurylinklabs.watchtower.enable: "true"
autoheal: true autoheal: true
healthcheck: healthcheck:
test: ["CMD", "curl", "-s", "-f", "-o", "/dev/null", "http://127.0.0.1:80/rss/vvzvlad_lytdybr?limit=1"] # Lightweight process/loop liveness probe. /ping never touches Telegram or the
interval: 30m # 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 timeout: 5s
retries: 2 retries: 3
start_period: 30s start_period: 30s
start_interval: 5s start_interval: 5s
+148
View File
@@ -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
View File
@@ -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),
)
+136
View File
@@ -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, таймауты и один воркер. Дал точечные действия для устранения подвисаний.
+347
View File
@@ -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.
+619
View File
@@ -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
+202
View File
@@ -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.
+525 -144
View File
@@ -9,9 +9,9 @@
# pylance: disable=reportMissingImports, reportMissingModuleSource # pylance: disable=reportMissingImports, reportMissingModuleSource
import logging import logging
import asyncio
import re import re
import os import os
import json
import html import html
import inspect import inspect
from datetime import datetime from datetime import datetime
@@ -21,12 +21,92 @@ from pyrogram.enums import MessageMediaType
from bleach.css_sanitizer import CSSSanitizer from bleach.css_sanitizer import CSSSanitizer
from bleach import clean as HTMLSanitizer from bleach import clean as HTMLSanitizer
from config import get_settings from config import get_settings
from file_io import upsert_media_file_ids_bulk_sync, DB_PATH
from url_signer import generate_media_digest from url_signer import generate_media_digest
Config = get_settings() Config = get_settings()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Media types that never yield a renderable image/video in the feed: they are
# represented by info blocks (or nothing), so posts carrying them get "no_image".
NO_IMAGE_MEDIA_TYPES = {
MessageMediaType.GIVEAWAY,
MessageMediaType.GIVEAWAY_WINNERS,
MessageMediaType.CHECKLIST,
MessageMediaType.CONTACT,
MessageMediaType.LOCATION,
MessageMediaType.VENUE,
MessageMediaType.DICE,
MessageMediaType.GAME,
MessageMediaType.INVOICE,
MessageMediaType.UNSUPPORTED,
MessageMediaType.PAID_MEDIA,
}
def _poll_media_object(message):
"""Return (media_obj, kind) attached to a poll's description_media, or (None, None).
Kurigram 2.2.23 polls may carry media in description_media / explanation_media
(MessageContent objects). Only description_media is considered for rendering:
explanation_media is the quiz-answer explanation attachment and does not belong
in a channel feed, so it is deliberately NOT rendered (api_server's download
lookup still covers it in case a URL for it exists).
kind is the render hint: 'img' for photo/sticker, 'video' for video/animation.
All attribute access goes through getattr: older Poll objects and test mocks do
not define these fields. A candidate is accepted only when its file_unique_id is
a non-empty str — without it nothing can be served through the /media pipeline,
and this also keeps loose MagicMock-based poll mocks from producing false
positives via auto-created attributes.
"""
poll = getattr(message, 'poll', None)
if poll is None:
return None, None
description_media = getattr(poll, 'description_media', None)
if description_media is None:
return None, None
candidates = (
('photo', 'img'),
('video', 'video'),
('animation', 'video'),
('sticker', 'img'),
)
for attr, kind in candidates:
media_obj = getattr(description_media, attr, None)
if media_obj is None:
continue
file_unique_id = getattr(media_obj, 'file_unique_id', None)
if isinstance(file_unique_id, str) and file_unique_id:
return media_obj, kind
return None, None
def _story_media_object(message):
"""Return (media_obj, kind) for message.story media (video wins over photo), or (None, None).
kind is the render hint ('video' or 'img'). Rendering, URL generation and file-id
collection all go through this helper, so they always agree on WHICH story object
is used (e.g. when a story video lacks a usable file_unique_id and the helper
falls back to the photo, the tag type follows the fallback too).
Same defensive rules as _poll_media_object: getattr-only access and a non-empty
str file_unique_id requirement.
"""
story = getattr(message, 'story', None)
if story is None:
return None, None
for attr, kind in (('video', 'video'), ('photo', 'img')):
media_obj = getattr(story, attr, None)
if media_obj is None:
continue
file_unique_id = getattr(media_obj, 'file_unique_id', None)
if isinstance(file_unique_id, str) and file_unique_id:
return media_obj, kind
return None, None
#tests #tests
#http://127.0.0.1:8000/post/html/DragorWW_space/114 — video #http://127.0.0.1:8000/post/html/DragorWW_space/114 — video
@@ -61,6 +141,12 @@ logger = logging.getLogger(__name__)
class PostParser: class PostParser:
def __init__(self, client): def __init__(self, client):
self.client = client self.client = client
# Media file-id records collected during rendering. _save_media_file_ids
# appends (channel, post_id, file_unique_id, ts) tuples here instead of
# touching asyncio/DB directly (see 4.2). A fresh PostParser is created per
# request and rendering runs in a single thread, so this list is thread-safe.
# The caller flushes it once via upsert_media_file_ids_bulk_sync after render.
self._pending_media_ids: List[tuple] = []
@staticmethod @staticmethod
def get_all_possible_flags() -> List[str]: def get_all_possible_flags() -> List[str]:
@@ -88,10 +174,13 @@ class PostParser:
post_id: int, post_id: int,
output_type: str = 'json', output_type: str = 'json',
debug: bool = False) -> Union[str, Dict[Any, Any], None]: debug: bool = False) -> Union[str, Dict[Any, Any], None]:
print(f"Getting post {channel}, {post_id}")
try: try:
prepared_channel_id: Union[str, int] = self.channel_name_prepare(channel) prepared_channel_id: Union[str, int] = self.channel_name_prepare(channel)
message = await self.client.get_messages(prepared_channel_id, post_id) # Bound the single-post fetch so a hung RPC cannot block the request forever.
message = await asyncio.wait_for(
self.client.get_messages(prepared_channel_id, post_id),
timeout=30,
)
if Config["debug"]: print(message) if Config["debug"]: print(message)
@@ -99,7 +188,14 @@ class PostParser:
logger.error(f"post_not_found_or_empty: channel {prepared_channel_id}, post_id {post_id}") logger.error(f"post_not_found_or_empty: channel {prepared_channel_id}, post_id {post_id}")
return None return None
processed_message = self.process_message(message) # Single-post outputs need sanitized body/footer (no whole-feed pass exists here).
# raw_message is only needed for JSON output and for debug HTML.
include_raw = (output_type == 'json') or debug
processed_message = self.process_message(message, include_raw=include_raw, sanitize=True)
# Flush media file-id records collected during rendering with a single bulk upsert.
await self._flush_pending_media_ids()
if output_type == 'html': if output_type == 'html':
return self._format_html(processed_message, debug) return self._format_html(processed_message, debug)
elif output_type == 'json': elif output_type == 'json':
@@ -109,8 +205,8 @@ class PostParser:
return None return None
except Exception as e: except Exception as e:
# Log the specific exception type and message # Log the specific exception type and message (use original channel arg to avoid UnboundLocalError)
logger.error(f"post_parsing_error: channel {prepared_channel_id}, post_id {post_id}, error_type {type(e).__name__}, error_message {str(e)}") logger.error(f"post_parsing_error: channel {channel}, post_id {post_id}, error_type {type(e).__name__}, error_message {str(e)}")
# Optional: include traceback for more detail # Optional: include traceback for more detail
# import traceback # import traceback
# logger.error(traceback.format_exc()) # logger.error(traceback.format_exc())
@@ -187,7 +283,8 @@ class PostParser:
if message.media: if message.media:
if message.media == MessageMediaType.POLL: if message.media == MessageMediaType.POLL:
if message.poll is not None and hasattr(message.poll, 'question'): if message.poll is not None and hasattr(message.poll, 'question'):
poll_question = message.poll.question.strip() q = message.poll.question
poll_question = (q.text if hasattr(q, 'text') else str(q)).strip()
if poll_question: if poll_question:
return f"📊 Poll: {poll_question}" return f"📊 Poll: {poll_question}"
return "📊 Poll" return "📊 Poll"
@@ -203,6 +300,31 @@ class PostParser:
if message.media == MessageMediaType.VOICE: return "🎤 Voice" if message.media == MessageMediaType.VOICE: return "🎤 Voice"
if message.media == MessageMediaType.VIDEO_NOTE: return "📱 Video circle" if message.media == MessageMediaType.VIDEO_NOTE: return "📱 Video circle"
if message.media == MessageMediaType.STICKER: return "🎯 Sticker" if message.media == MessageMediaType.STICKER: return "🎯 Sticker"
# New media types (Kurigram 2.2.23). New Message attributes are accessed
# via getattr only — older objects/mocks may not define them.
if message.media == MessageMediaType.LIVE_PHOTO: return "📸 Live Photo"
if message.media == MessageMediaType.STORY: return "📖 Story"
if message.media == MessageMediaType.GIVEAWAY: return "🎁 Giveaway"
if message.media == MessageMediaType.GIVEAWAY_WINNERS: return "🏆 Giveaway winners"
if message.media == MessageMediaType.PAID_MEDIA: return "⭐ Paid media"
if message.media == MessageMediaType.CHECKLIST:
checklist = getattr(message, 'checklist', None)
title = getattr(checklist, 'title', None) if checklist else None
if isinstance(title, str) and title.strip():
return f"📝 Checklist: {title.strip()[:50]}"
return "📝 Checklist"
if message.media == MessageMediaType.CONTACT: return "👤 Contact"
if message.media == MessageMediaType.LOCATION: return "📍 Location"
if message.media == MessageMediaType.VENUE:
venue = getattr(message, 'venue', None)
venue_title = getattr(venue, 'title', None) if venue else None
if isinstance(venue_title, str) and venue_title.strip():
return f"📍 {venue_title.strip()}"
return "📍 Venue"
if message.media == MessageMediaType.DICE: return "🎲 Dice"
if message.media == MessageMediaType.GAME: return "🎮 Game"
if message.media == MessageMediaType.INVOICE: return "🧾 Invoice"
if message.media == MessageMediaType.UNSUPPORTED: return "⚠️ Unsupported content"
# Web pages (if no text or media title) # Web pages (if no text or media title)
if message.web_page: if message.web_page:
@@ -223,7 +345,7 @@ class PostParser:
if text_stripped: if text_stripped:
text_was_processed = True text_was_processed = True
text_has_urls = bool(re.search(r'https?://[^\\s<>\"\\\']+', text_stripped)) text_has_urls = bool(re.search(r'https?://[^\s<>"\']+', text_stripped))
clean_text = html.unescape(text) # Remove HTML entities clean_text = html.unescape(text) # Remove HTML entities
clean_text = re.sub(r'<[^>]+?>', '', clean_text) # Remove HTML tags clean_text = re.sub(r'<[^>]+?>', '', clean_text) # Remove HTML tags
@@ -307,14 +429,31 @@ class PostParser:
def _extract_reactions(self, message: Message) -> Union[Dict[str, int], None]: def _extract_reactions(self, message: Message) -> Union[Dict[str, int], None]:
if reactions := getattr(message, 'reactions', None): if reactions := getattr(message, 'reactions', None):
return {r.emoji: r.count for r in reactions.reactions} result: Dict[str, int] = {}
for r in reactions.reactions:
# Resolve emoji key using the same logic as _reactions_views_links()
if getattr(r, "is_paid", False):
emoji = ""
elif hasattr(r, "emoji") and r.emoji:
emoji = r.emoji
elif hasattr(r, "custom_emoji_id"):
emoji = "" # custom emoji — no text representation available
else:
emoji = "" # unknown reaction type
# Accumulate counts in case multiple reactions resolve to the same key
result[emoji] = result.get(emoji, 0) + r.count
return result
return None return None
def _extract_flags(self, message: Message) -> List[str]: #Tests: tests/postparser_extract_flags.py def _extract_flags(self, message: Message, html_body: Optional[str] = None) -> List[str]: #Tests: tests/postparser_extract_flags.py
# Use raw text/caption for some checks before HTML processing # Use raw text/caption for some checks before HTML processing
message_text_str = str(message.text or message.caption or '') message_text_str = str(message.text or message.caption or '')
# Use HTML body for checks involving formatted text or links within HTML # Use HTML body for checks involving formatted text or links within HTML
message_body_html = self._generate_html_body(message) # If html_body is provided (pre-computed), use it directly to avoid redundant generation
if html_body is None:
message_body_html = self._generate_html_body(message)
else:
message_body_html = html_body
flags = [] flags = []
# Add "fwd" flag for forwarded messages # Add "fwd" flag for forwarded messages
@@ -322,7 +461,9 @@ class PostParser:
flags.append("fwd") flags.append("fwd")
# Add flag "video" if the message media is VIDEO or ANIMATION and the body text is up to 200 characters. # Add flag "video" if the message media is VIDEO or ANIMATION and the body text is up to 200 characters.
if (message.media in [MessageMediaType.VIDEO, MessageMediaType.ANIMATION, MessageMediaType.VIDEO_NOTE] and # LIVE_PHOTO (Kurigram 2.2.23) renders as a video element, so it counts too.
if (message.media in [MessageMediaType.VIDEO, MessageMediaType.ANIMATION, MessageMediaType.VIDEO_NOTE,
MessageMediaType.LIVE_PHOTO] and
len((message.text or message.caption or '').strip()) <= 200): len((message.text or message.caption or '').strip()) <= 200):
flags.append("video") flags.append("video")
@@ -331,8 +472,12 @@ class PostParser:
len((message.text or message.caption or '').strip()) <= 200): len((message.text or message.caption or '').strip()) <= 200):
flags.append("audio") flags.append("audio")
# Add flag for posts without images # Add flag for posts without images: no media at all, an info-block-only media
if not message.media or message.media == MessageMediaType.POLL: # type (see NO_IMAGE_MEDIA_TYPES), or a poll without renderable description_media.
# A poll WITH description_media renders an image/video, so it is NOT flagged.
if (not message.media
or message.media in NO_IMAGE_MEDIA_TYPES
or (message.media == MessageMediaType.POLL and _poll_media_object(message)[0] is None)):
flags.append("no_image") flags.append("no_image")
# Add flag for sticker messages # Add flag for sticker messages
@@ -363,10 +508,14 @@ class PostParser:
# Check if the post's reactions contain more clown emojis (🤡) or poo emojis (💩). # Check if the post's reactions contain more clown emojis (🤡) or poo emojis (💩).
if reactions := getattr(message, "reactions", None): if reactions := getattr(message, "reactions", None):
for reaction in getattr(reactions, "reactions", []): for reaction in getattr(reactions, "reactions", []):
if reaction.emoji == "🤡" and reaction.count >= 30: # Skip paid reactions and custom emoji — they have no .emoji attribute
if getattr(reaction, "is_paid", False) or not hasattr(reaction, "emoji"):
continue
emoji = reaction.emoji # attribute existence is guaranteed by hasattr guard above
if emoji == "🤡" and reaction.count >= 30:
flags.append("clownpoo") flags.append("clownpoo")
break break
if reaction.emoji == "💩" and reaction.count >= 30: if emoji == "💩" and reaction.count >= 30:
flags.append("clownpoo") flags.append("clownpoo")
break break
@@ -447,15 +596,22 @@ class PostParser:
html_content = [] html_content = []
if debug: if debug:
html_content.append(f'<div class="title">Title: {data["html"]["title"]}</div><br>') # title comes from _generate_title (user-controlled post text) and never goes
# through bleach — escape it before embedding, same as raw_message below.
title_escaped = html.escape(str(data["html"]["title"]))
html_content.append(f'<div class="title">Title: {title_escaped}</div><br>')
html_content.append(f'<div class="message-body">{data["html"]["body"]}</div>') html_content.append(f'<div class="message-body">{data["html"]["body"]}</div>')
html_content.append(f'<div class="message-footer">{data["html"]["footer"]}</div>') html_content.append(f'<div class="message-footer">{data["html"]["footer"]}</div>')
# Add raw JSON debug output if debug is enabled # Add raw JSON debug output if debug is enabled.
# raw_message is the full str(message) serialization and may contain user-controlled
# text with HTML/JS — escape it before dropping it into the <pre> (bleach can't run
# here because <pre> is not in the allowed-tags whitelist and would be stripped).
if debug: if debug:
raw_escaped = html.escape(str(data.get("raw_message", "")))
html_content.append(f'<pre class="debug-json" style="background: #f5f5f5;' html_content.append(f'<pre class="debug-json" style="background: #f5f5f5;'
f'padding: 10px; margin-top: 20px; overflow-x: auto; font-size: 10px;' f'padding: 10px; margin-top: 20px; overflow-x: auto; font-size: 10px;'
f'white-space: pre-wrap;">{data["raw_message"]}</pre>') f'white-space: pre-wrap;">{raw_escaped}</pre>')
html_data = '\n'.join(html_content) html_data = '\n'.join(html_content)
return html_data return html_data
@@ -471,7 +627,39 @@ class PostParser:
return return_html return return_html
def process_message(self, message: Message) -> Dict[Any, Any]: def process_message(self, message: Message, include_raw: bool = False, sanitize: bool = True) -> Dict[Any, Any]:
"""Build the processed representation of a message.
Args:
message: the Pyrogram message.
include_raw: when True, compute the (expensive) full ``str(message)``
serialization into ``result['raw_message']``. Only JSON output and
debug HTML need it; feed generation must pass False to avoid
serializing every post.
sanitize: when True, run the html body and footer through a single
bleach pass each. Single-post HTML and JSON need this (there is no
whole-feed pass on those paths). Feed generation passes False and
relies on the final sanitize in rss_generator (per-post for RSS,
whole-feed for HTML), so no
fragment is sanitized more than once.
"""
# Compute html body once — avoids triple _generate_html_body calls.
# The internal per-fragment sanitize passes were removed (4.4); sanitize
# exactly once per output boundary here when requested.
html_body = self._generate_html_body(message)
# NOTE (stage-4 4.4 consequence): flags are now extracted from the PRE-sanitize
# body (the per-fragment sanitize that used to run inside _generate_html_body was
# removed). Legitimate links are unaffected — bleach keeps whitelisted
# <a href="http(s)://…"> — so link/foreign_channel/mention flags are identical for
# normal content. They can differ ONLY for URL-like text that bleach would strip
# (e.g. a URL inside a disallowed attribute); flags are non-security (used for
# exclude_flags filtering / display), so this edge divergence is accepted rather
# than re-adding a per-message sanitize pass that 4.4 deliberately eliminated.
flags = self._extract_flags(message, html_body=html_body)
footer = self.generate_html_footer(message, flags_list=flags)
if sanitize:
html_body = self._sanitize_html(html_body)
footer = self._sanitize_html(footer)
result = { result = {
'channel': self.get_channel_username(message), 'channel': self.get_channel_username(message),
'message_id': message.id, 'message_id': message.id,
@@ -480,17 +668,20 @@ class PostParser:
'text': message.text or message.caption or '', 'text': message.text or message.caption or '',
'html': { 'html': {
'title': self._generate_title(message), 'title': self._generate_title(message),
'body': self._generate_html_body(message), 'body': html_body,
'footer': self.generate_html_footer(message) 'footer': footer
}, },
'flags': self._extract_flags(message), 'flags': flags,
'author': self._get_author_info(message), 'author': self._get_author_info(message),
'views': message.views, 'views': message.views,
'reactions': self._extract_reactions(message), 'reactions': self._extract_reactions(message),
'media_group_id': message.media_group_id, 'media_group_id': message.media_group_id,
'service': getattr(message, "service", None), 'service': getattr(message, "service", None),
'raw_message': str(message) 'show_caption_above_media': getattr(message, 'show_caption_above_media', False),
} }
if include_raw:
# Full serialization of the message — expensive; only for JSON/debug.
result['raw_message'] = str(message)
# Add webpage data if available # Add webpage data if available
if webpage := getattr(message, "web_page", None): if webpage := getattr(message, "web_page", None):
@@ -509,8 +700,10 @@ class PostParser:
def _sanitize_html(self, html_raw: str) -> str: def _sanitize_html(self, html_raw: str) -> str:
import time as _time
sanitize_start = _time.monotonic()
allowed_tags = ['p', 'a', 'b', 'i', 'strong', allowed_tags = ['p', 'a', 'b', 'i', 'strong',
'em', 'ul', 'ol', 'li', 'br', 'em', 's', 'del', 'ul', 'ol', 'li', 'br',
'div', 'span', 'img', 'video', 'audio', 'div', 'span', 'img', 'video', 'audio',
'source'] 'source']
allowed_attributes = { allowed_attributes = {
@@ -535,6 +728,9 @@ class PostParser:
css_sanitizer=css_sanitizer, css_sanitizer=css_sanitizer,
strip=True, strip=True,
) )
elapsed = _time.monotonic() - sanitize_start
if elapsed > 0.05:
logger.warning(f"diag_sanitize_slow: bleach.clean() took {elapsed:.3f}s, input_len={len(html_raw)}")
return sanitized_html return sanitized_html
except Exception as e: except Exception as e:
logger.error(f"html_sanitization_error: error {str(e)}") logger.error(f"html_sanitization_error: error {str(e)}")
@@ -603,25 +799,40 @@ class PostParser:
media_html = self._generate_html_media(message) media_html = self._generate_html_media(message)
content_body.append(f'<div class="post">') content_body.append(f'<div class="post">')
if forward_html: if forward_html:
content_body.append(forward_html) # Forward info content_body.append(forward_html) # Forward info
content_body.append("<br>") content_body.append("<br>")
if reply_html: if reply_html:
content_body.append(reply_html) # Reply info content_body.append(reply_html) # Reply info
content_body.append("<br>") content_body.append("<br>")
if text_html:
content_body.append(text_html) # Post text show_caption_above = getattr(message, 'show_caption_above_media', False)
content_body.append("<br>")
if show_caption_above:
if text_html:
content_body.append(text_html)
content_body.append("<br>")
if media_html:
content_body.append(media_html)
content_body.append("<br>")
else:
if media_html:
content_body.append(media_html)
content_body.append("<br>")
if text_html:
content_body.append(text_html)
content_body.append("<br>")
content_body.append(media_html) # Media
content_body.append("<br>")
if poll_html: content_body.append(poll_html) # Poll if poll_html: content_body.append(poll_html) # Poll
if special_html := self._format_special_media(message): content_body.append(special_html) # Special media info blocks
if message.forward_origin: content_body.append(f"--- Forwarded post end ---") # Forward info end if message.forward_origin: content_body.append(f"--- Forwarded post end ---") # Forward info end
content_body.append(f'</div><br>') content_body.append(f'</div><br>')
# NOTE: sanitize is NOT applied here. Sanitization happens exactly once per
# output boundary (process_message for single-post/JSON; in rss_generator the
# per-post pass for RSS and the whole-feed pass for HTML). See the map (4.4).
html_body = '\n'.join(content_body) html_body = '\n'.join(content_body)
html_body = self._sanitize_html(html_body)
return html_body return html_body
def _generate_html_media(self, message: Message) -> str: def _generate_html_media(self, message: Message) -> str:
@@ -630,7 +841,27 @@ class PostParser:
content_media = [] content_media = []
base_url = Config['pyrogram_bridge_url'] base_url = Config['pyrogram_bridge_url']
if message.media and message.media != "MessageMediaType.POLL": # Poll media (Kurigram 2.2.23): a poll may carry description_media which IS
# renderable through the regular /media pipeline.
poll_media_obj, poll_media_kind = _poll_media_object(message)
if message.media == MessageMediaType.PAID_MEDIA:
# Paid media cannot be downloaded (it is paid content) — render an info
# block instead of a media element. Attributes via getattr only.
paid_media = getattr(message, 'paid_media', None)
stars_amount = getattr(paid_media, 'stars_amount', 0) if paid_media else 0
paid_items = getattr(paid_media, 'media', None) if paid_media else None
items_count = len(paid_items) if isinstance(paid_items, (list, tuple)) else 0
content_media.append(f'<div class="message-media">')
content_media.append(f'<div class="paid-media">⭐ Paid media ({stars_amount} stars, '
f'{items_count} item(s)) — available in Telegram</div>')
content_media.append('</div>')
# Info-block-only media types (NO_IMAGE_MEDIA_TYPES) are rendered by
# _format_special_media — do not open an empty message-media container for
# them. PAID_MEDIA (also in that set) is handled by the branch above; POLL is
# not in the set and is let in only when it carries renderable poll media.
elif (message.media
and message.media not in NO_IMAGE_MEDIA_TYPES
and (message.media != MessageMediaType.POLL or poll_media_obj is not None)):
content_media.append(f'<div class="message-media">') content_media.append(f'<div class="message-media">')
file_unique_id = self._get_file_unique_id(message) file_unique_id = self._get_file_unique_id(message)
@@ -638,61 +869,82 @@ class PostParser:
logger.debug(f"File unique id not found for message {message.id}") logger.debug(f"File unique id not found for message {message.id}")
elif file_unique_id: elif file_unique_id:
channel_username = self.get_channel_username(message) channel_username = self.get_channel_username(message)
file = f"{channel_username}/{message.id}/{file_unique_id}" # Guard: channel_username may be None for private chats without a username
digest = generate_media_digest(file) if not channel_username:
url = f"{base_url}/media/{file}/{digest}" logger.warning(f"Could not generate media URL for message {message.id}: channel username is missing.")
content_media.append('</div>')
else:
file = f"{channel_username}/{message.id}/{file_unique_id}"
digest = generate_media_digest(file)
url = f"{base_url}/media/{file}/{digest}"
logger.debug(f"Collected media file: {channel_username}/{message.id}/{file_unique_id}") logger.debug(f"Collected media file: {channel_username}/{message.id}/{file_unique_id}")
# Check if document is a PDF file # Check if document is a PDF file
if (message.media == MessageMediaType.DOCUMENT and if (message.media == MessageMediaType.DOCUMENT and
message.document is not None and hasattr(message.document, 'mime_type') and message.document is not None and hasattr(message.document, 'mime_type') and
message.document.mime_type == 'application/pdf'): message.document.mime_type == 'application/pdf'):
# Only attempt to create link if channel_username is available # Only attempt to create link if channel_username is available
if channel_username: if channel_username.startswith('-100'):
if channel_username.startswith('-100'):
tg_link = f"https://t.me/c/{channel_username[4:]}/{message.id}" tg_link = f"https://t.me/c/{channel_username[4:]}/{message.id}"
else: else:
tg_link = f"https://t.me/{channel_username}/{message.id}" tg_link = f"https://t.me/{channel_username}/{message.id}"
content_media.append(f'<div class="document-pdf" style="padding: 10px;">') content_media.append(f'<div class="document-pdf" style="padding: 10px;">')
content_media.append(f'<a href="{tg_link}" target="_blank">[PDF-файл]</a></div>') content_media.append(f'<a href="{tg_link}" target="_blank">[PDF-файл]</a></div>')
else: elif message.media in [MessageMediaType.PHOTO, MessageMediaType.DOCUMENT]:
# Handle case where channel_username is None (e.g., log or add placeholder) content_media.append(f'<img src="{url}" style="max-width:100%; width:auto; height:auto;'
logger.warning(f"Could not generate PDF link for {message.id}: ch username is missing.") f'max-height:400px; object-fit:contain;">')
# Add placeholder without link elif message.media == MessageMediaType.VIDEO:
content_media.append(f'<div class="document-pdf" style="padding: 10px;">[PDF-файл]</div>') content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
elif message.media in [MessageMediaType.PHOTO, MessageMediaType.DOCUMENT]: f'height:auto; max-height:400px;"></video>')
content_media.append(f'<img src="{url}" style="max-width:100%; width:auto; height:auto;' elif message.media == MessageMediaType.ANIMATION:
f'max-height:400px; object-fit:contain;">') content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
elif message.media == MessageMediaType.VIDEO: f'height:auto; max-height:400px;"></video>')
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;' elif message.media == MessageMediaType.VIDEO_NOTE:
f'height:auto; max-height:400px;"></video>') content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
elif message.media == MessageMediaType.ANIMATION: f'height:auto; max-height:400px;"></video>')
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;' elif message.media == MessageMediaType.AUDIO:
f'height:auto; max-height:400px;"></video>') mime_type = getattr(message.audio, 'mime_type', 'audio/mpeg')
elif message.media == MessageMediaType.VIDEO_NOTE: content_media.append(f'<audio controls style="width:100%; max-width:400px;">'
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;' f'<source src="{url}" type="{mime_type}"></audio>')
f'height:auto; max-height:400px;"></video>') content_media.append('<br>')
elif message.media == MessageMediaType.AUDIO: elif message.media == MessageMediaType.VOICE:
mime_type = getattr(message.audio, 'mime_type', 'audio/mpeg') mime_type = getattr(message.voice, 'mime_type', 'audio/ogg')
content_media.append(f'<audio controls style="width:100%; max-width:400px;">' content_media.append(f'<audio controls style="width:100%; max-width:400px;">'
f'<source src="{url}" type="{mime_type}"></audio>') f'<source src="{url}" type="{mime_type}"></audio>')
content_media.append('<br>') content_media.append('<br>')
elif message.media == MessageMediaType.VOICE: elif message.media == MessageMediaType.STICKER:
mime_type = getattr(message.voice, 'mime_type', 'audio/ogg') emoji = getattr(message.sticker, 'emoji', '')
content_media.append(f'<audio controls style="width:100%; max-width:400px;">' if getattr(message.sticker, 'is_video', False):
f'<source src="{url}" type="{mime_type}"></audio>') content_media.append(f'<video controls autoplay loop muted src="{url}"'
content_media.append('<br>') f'style="max-width:100%; width:auto; height:auto; max-height:200px;'
elif message.media == MessageMediaType.STICKER: f'object-fit:contain;"></video>')
emoji = getattr(message.sticker, 'emoji', '') else:
if getattr(message.sticker, 'is_video', False): content_media.append(f'<img src="{url}" alt="Sticker {emoji}" style="max-width:100%;'
f'width:auto; height:auto; max-height:200px; object-fit:contain;">')
elif message.media == MessageMediaType.LIVE_PHOTO:
# Live photo is effectively a short video clip.
content_media.append(f'<video controls autoplay loop muted src="{url}"' content_media.append(f'<video controls autoplay loop muted src="{url}"'
f'style="max-width:100%; width:auto; height:auto; max-height:200px;' f'style="max-width:100%; width:auto; height:auto; max-height:400px;'
f'object-fit:contain;"></video>') f'object-fit:contain;"></video>')
else: elif message.media == MessageMediaType.STORY:
content_media.append(f'<img src="{url}" alt="Sticker {emoji}" style="max-width:100%;' # Choose the tag from the SAME helper that produced the URL's
f'width:auto; height:auto; max-height:200px; object-fit:contain;">') # file_unique_id, so the tag type always matches the URL object.
content_media.append('</div>') story_media_obj, story_media_kind = _story_media_object(message)
if story_media_obj is not None and story_media_kind == 'video':
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
f'height:auto; max-height:400px;"></video>')
elif story_media_obj is not None:
content_media.append(f'<img src="{url}" style="max-width:100%; width:auto; height:auto;'
f'max-height:400px; object-fit:contain;">')
elif message.media == MessageMediaType.POLL and poll_media_obj is not None:
if poll_media_kind == 'video':
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
f'height:auto; max-height:400px;"></video>')
else:
content_media.append(f'<img src="{url}" style="max-width:100%; width:auto; height:auto;'
f'max-height:400px; object-fit:contain;">')
content_media.append('</div>')
if webpage := getattr(message, "web_page", None): # Web page preview if webpage := getattr(message, "web_page", None): # Web page preview
if webpage_html := self._format_webpage(webpage, message): if webpage_html := self._format_webpage(webpage, message):
@@ -701,8 +953,9 @@ class PostParser:
content_media.append(webpage_html) content_media.append(webpage_html)
content_media.append('</div>') content_media.append('</div>')
# Not sanitized here — this fragment is embedded in the body and sanitized
# once at the output boundary (see the 4.4 sanitize coverage map).
html_media = '\n'.join(content_media) html_media = '\n'.join(content_media)
html_media = self._sanitize_html(html_media)
return html_media return html_media
@@ -752,13 +1005,15 @@ class PostParser:
if photo := getattr(webpage, "photo", None): if photo := getattr(webpage, "photo", None):
if file_unique_id := getattr(photo, "file_unique_id", None): if file_unique_id := getattr(photo, "file_unique_id", None):
channel_username = self.get_channel_username(message) channel_username = self.get_channel_username(message)
file = f"{channel_username}/{message.id}/{file_unique_id}" # Guard: skip photo if channel_username is unavailable to avoid broken URLs
digest = generate_media_digest(file) if channel_username:
url = f"{base_url}/media/{file}/{digest}" file = f"{channel_username}/{message.id}/{file_unique_id}"
html_parts.append(f'<div class="webpage-photo" style="margin-top:10px;">') digest = generate_media_digest(file)
html_parts.append(f'<a href="{webpage.url}" target="_blank">') url = f"{base_url}/media/{file}/{digest}"
html_parts.append(f'<img src="{url}" style="max-width:100%; width:auto;' html_parts.append(f'<div class="webpage-photo" style="margin-top:10px;">')
f'height:auto; max-height:200px; object-fit:contain;"></a></div>') html_parts.append(f'<a href="{webpage.url}" target="_blank">')
html_parts.append(f'<img src="{url}" style="max-width:100%; width:auto;'
f'height:auto; max-height:200px; object-fit:contain;"></a></div>')
html_parts.append('</div>') html_parts.append('</div>')
return '\n'.join(html_parts) return '\n'.join(html_parts)
@@ -780,8 +1035,9 @@ class PostParser:
flags_html = self._format_flags(current_flags) flags_html = self._format_flags(current_flags)
content_footer.append('<br>' + flags_html) content_footer.append('<br>' + flags_html)
# Not sanitized here — sanitized once at the output boundary (4.4 coverage map):
# process_message for single-post/JSON; per-post (RSS) / whole-feed (HTML) for feeds.
html_footer = '\n'.join(content_footer) html_footer = '\n'.join(content_footer)
html_footer = self._sanitize_html(html_footer)
return html_footer return html_footer
@@ -867,8 +1123,10 @@ class PostParser:
if links: if links:
parts.append('&nbsp;|&nbsp;'.join(links)) parts.append('&nbsp;|&nbsp;'.join(links))
# Raw fragment — embedded in the footer, sanitized once at the output
# boundary (see the 4.4 sanitize coverage map). Not sanitized here.
result_html = '<br>'.join(parts) if parts else None result_html = '<br>'.join(parts) if parts else None
return self._sanitize_html(result_html) if result_html else None return result_html if result_html else None
except Exception as e: except Exception as e:
logger.error(f"reactions_views_links_error: message_id {message.id}, error {str(e)}") logger.error(f"reactions_views_links_error: message_id {message.id}, error {str(e)}")
@@ -879,10 +1137,14 @@ class PostParser:
try: try:
if poll := getattr(message, "poll", None): if poll := getattr(message, "poll", None):
poll_text = f"📊 Poll: {poll.question}\n" q = poll.question
question_str = q.text if hasattr(q, 'text') else str(q)
poll_text = f"📊 Poll: {question_str}\n"
if hasattr(poll, "options") and poll.options: if hasattr(poll, "options") and poll.options:
for i, option in enumerate(poll.options, 1): for i, option in enumerate(poll.options, 1):
poll_text += f"{i}. {getattr(option, 'text', '')}\n" opt_text = getattr(option, 'text', '')
opt_str = opt_text.text if hasattr(opt_text, 'text') else str(opt_text)
poll_text += f"{i}. {opt_str}\n"
poll_text += "\n→ Vote in Telegram 🔗\n" poll_text += "\n→ Vote in Telegram 🔗\n"
return f'<div class="message-poll">{poll_text.replace(chr(10), "<br>")}</div>' return f'<div class="message-poll">{poll_text.replace(chr(10), "<br>")}</div>'
return None return None
@@ -890,6 +1152,109 @@ class PostParser:
logger.error(f"poll_parsing_error: {str(e)}") logger.error(f"poll_parsing_error: {str(e)}")
return '<div class="message-poll">[Error displaying poll]</div>' return '<div class="message-poll">[Error displaying poll]</div>'
@staticmethod
def _format_osm_link(location) -> Union[str, None]:
"""Build an OpenStreetMap link for a location object, or None if coords are unusable."""
latitude = getattr(location, 'latitude', None) if location else None
longitude = getattr(location, 'longitude', None) if location else None
if not isinstance(latitude, (int, float)) or not isinstance(longitude, (int, float)):
return None
osm_url = f"https://www.openstreetmap.org/?mlat={latitude}&mlon={longitude}#map=16/{latitude}/{longitude}"
return f'<a href="{osm_url}">{latitude:.5f}, {longitude:.5f}</a>'
def _format_special_media(self, message: Message) -> Union[str, None]:
"""Render an info block for media types that carry no downloadable file.
Covers giveaways, giveaway winners, checklists, contacts, locations, venues,
dice, games, invoices and UNSUPPORTED content (Kurigram 2.2.23). Each block is
gated on message.media so unrelated messages never render these. All new
Message attributes are accessed via getattr only (older objects/mocks do not
define them) and ALL user-controlled strings go through html.escape.
"""
try:
media = getattr(message, 'media', None)
block = None
if media == MessageMediaType.GIVEAWAY and (giveaway := getattr(message, 'giveaway', None)):
quantity = getattr(giveaway, 'quantity', None)
months = getattr(giveaway, 'months', None)
stars = getattr(giveaway, 'stars', None)
until_date = getattr(giveaway, 'until_date', None)
description = getattr(giveaway, 'description', None)
block = f"🎁 Giveaway: {quantity} prize(s)"
if months: block += f" × {months} months Premium"
elif stars: block += f" × {stars} Stars"
if until_date is not None and hasattr(until_date, 'strftime'):
block += f" — until {until_date.strftime('%d/%m/%Y')}"
if isinstance(description, str) and description.strip():
block += f"<br>{html.escape(description.strip())}"
elif media == MessageMediaType.GIVEAWAY_WINNERS and (winners := getattr(message, 'giveaway_winners', None)):
winner_count = getattr(winners, 'winner_count', None)
quantity = getattr(winners, 'quantity', None)
prize_description = getattr(winners, 'prize_description', None)
block = f"🏆 Giveaway winners: {winner_count} of {quantity}"
if isinstance(prize_description, str) and prize_description.strip():
block += f"<br>{html.escape(prize_description.strip())}"
elif media == MessageMediaType.CHECKLIST and (checklist := getattr(message, 'checklist', None)):
title = getattr(checklist, 'title', None)
title_str = title if isinstance(title, str) else ''
lines = [f"📝 {html.escape(title_str)}" if title_str else "📝 Checklist"]
for task in (getattr(checklist, 'tasks', None) or []):
completed = bool(getattr(task, 'completed_by', None) or getattr(task, 'completion_date', None))
mark = "" if completed else ""
task_text = getattr(task, 'text', '')
task_str = task_text if isinstance(task_text, str) else str(task_text)
lines.append(f"{mark} {html.escape(task_str)}")
block = '<br>'.join(lines)
elif media == MessageMediaType.CONTACT and (contact := getattr(message, 'contact', None)):
first_name = getattr(contact, 'first_name', None) or ''
last_name = getattr(contact, 'last_name', None) or ''
phone_number = getattr(contact, 'phone_number', None) or ''
full_name = ' '.join(part for part in [str(first_name), str(last_name)] if part)
block = f"👤 {html.escape(full_name)}"
if phone_number:
block += f"{html.escape(str(phone_number))}"
elif media == MessageMediaType.LOCATION and (location := getattr(message, 'location', None)):
osm_link = self._format_osm_link(location)
block = f"📍 Location: {osm_link}" if osm_link else "📍 Location"
elif media == MessageMediaType.VENUE and (venue := getattr(message, 'venue', None)):
venue_title = getattr(venue, 'title', None) or ''
venue_address = getattr(venue, 'address', None) or ''
venue_label = ', '.join(part for part in [str(venue_title), str(venue_address)] if part)
block = f"📍 {html.escape(venue_label)}" if venue_label else "📍 Venue"
if osm_link := self._format_osm_link(getattr(venue, 'location', None)):
block += f"{osm_link}"
elif media == MessageMediaType.DICE and (dice := getattr(message, 'dice', None)):
dice_emoji = getattr(dice, 'emoji', None) or '🎲'
dice_value = getattr(dice, 'value', None)
block = f"🎲 {html.escape(str(dice_emoji))}: {dice_value}"
elif media == MessageMediaType.GAME:
game_title = getattr(getattr(message, 'game', None), 'title', None)
if isinstance(game_title, str) and game_title.strip():
block = f"🎮 Game: {html.escape(game_title.strip())}"
else:
block = "🎮 Game"
elif media == MessageMediaType.INVOICE:
block = "🧾 Invoice"
elif media == MessageMediaType.UNSUPPORTED:
block = "⚠️ This post contains content not supported by the bridge — open it in Telegram."
if block is None:
return None
return f'<div class="message-special">{block}</div>'
except Exception as e:
logger.error(f"special_media_parsing_error: message_id {getattr(message, 'id', 'unknown')}, error {str(e)}")
return None
def _get_file_unique_id(self, message: Message) -> Union[str, None]: def _get_file_unique_id(self, message: Message) -> Union[str, None]:
try: try:
media_mapping = { media_mapping = {
@@ -901,7 +1266,12 @@ class PostParser:
MessageMediaType.VIDEO_NOTE: lambda m: m.video_note.file_unique_id, MessageMediaType.VIDEO_NOTE: lambda m: m.video_note.file_unique_id,
MessageMediaType.ANIMATION: lambda m: m.animation.file_unique_id, MessageMediaType.ANIMATION: lambda m: m.animation.file_unique_id,
MessageMediaType.STICKER: lambda m: m.sticker.file_unique_id, MessageMediaType.STICKER: lambda m: m.sticker.file_unique_id,
MessageMediaType.WEB_PAGE: lambda m: m.web_page.photo.file_unique_id if m.web_page and m.web_page.photo else None MessageMediaType.WEB_PAGE: lambda m: m.web_page.photo.file_unique_id if m.web_page and m.web_page.photo else None,
# New media types (Kurigram 2.2.23): getattr-only access, the
# attributes do not exist on older Message objects/mocks.
MessageMediaType.LIVE_PHOTO: lambda m: getattr(getattr(m, 'live_photo', None), 'file_unique_id', None),
MessageMediaType.STORY: lambda m: getattr(_story_media_object(m)[0], 'file_unique_id', None),
MessageMediaType.POLL: lambda m: getattr(_poll_media_object(m)[0], 'file_unique_id', None),
} }
if message.media in media_mapping: if message.media in media_mapping:
@@ -913,15 +1283,32 @@ class PostParser:
logger.error(f"file_id_extraction_error: media_type {message.media}, error {str(e)}") logger.error(f"file_id_extraction_error: media_type {message.media}, error {str(e)}")
return None return None
def _save_media_file_ids(self, message: Message) -> None: async def _flush_pending_media_ids(self) -> None:
try: """Persist media file-id records collected during rendering with ONE bulk upsert.
file_data = {
'channel': '',
'post_id': 0,
'file_unique_id': '',
'added': .0
}
Called by the caller (get_post / rss_generator) after rendering completes.
Runs the blocking SQLite write in a thread. No-op when nothing was collected.
"""
entries = self._pending_media_ids
if not entries:
return
try:
await asyncio.to_thread(upsert_media_file_ids_bulk_sync, DB_PATH, entries)
logger.debug(f"persist_media_file_ids_bulk: upserted {len(entries)} records")
except Exception as e:
logger.error(f"file_id_bulk_save_error: error bulk-upserting {len(entries)} records, error {str(e)}")
finally:
# Clear regardless of outcome so a retry does not double-persist a stale batch.
self._pending_media_ids = []
def _save_media_file_ids(self, message: Message) -> None:
"""Collect a media file-id record for later bulk persistence.
IMPORTANT (4.2): this runs inside the render thread, so it must NOT touch
asyncio or the DB. It only appends to self._pending_media_ids; the caller
flushes the batch via _flush_pending_media_ids() after rendering.
"""
try:
channel_username = self.get_channel_username(message) channel_username = self.get_channel_username(message)
if not channel_username: if not channel_username:
logger.error(f"channel_username_error: no username found for chat in message {message.id}") logger.error(f"channel_username_error: no username found for chat in message {message.id}")
@@ -929,50 +1316,44 @@ class PostParser:
if message.media: if message.media:
# Skip large videos - they shouldn't be cached permanently # Skip large videos - they shouldn't be cached permanently
if message.video and message.video.file_size and message.video.file_size > 100 * 1024 * 1024: return if message.video and message.video.file_size and message.video.file_size > 100 * 1024 * 1024:
return
if message.photo: file_data['file_unique_id'] = message.photo.file_unique_id
elif message.video: file_data['file_unique_id'] = message.video.file_unique_id
elif message.document: file_data['file_unique_id'] = message.document.file_unique_id
elif message.audio: file_data['file_unique_id'] = message.audio.file_unique_id
elif message.voice: file_data['file_unique_id'] = message.voice.file_unique_id
elif message.video_note: file_data['file_unique_id'] = message.video_note.file_unique_id
elif message.animation: file_data['file_unique_id'] = message.animation.file_unique_id
elif message.sticker: file_data['file_unique_id'] = message.sticker.file_unique_id
elif message.web_page and message.web_page.photo:
file_data['file_unique_id'] = message.web_page.photo.file_unique_id
if file_data['file_unique_id']: file_unique_id = ''
file_data['channel'] = channel_username new_media_obj = None # selected object among the Kurigram 2.2.23 media sources
file_data['post_id'] = message.id if message.photo: file_unique_id = message.photo.file_unique_id
file_data['added'] = datetime.now().timestamp() elif message.video: file_unique_id = message.video.file_unique_id
elif message.document: file_unique_id = message.document.file_unique_id
elif message.audio: file_unique_id = message.audio.file_unique_id
elif message.voice: file_unique_id = message.voice.file_unique_id
elif message.video_note: file_unique_id = message.video_note.file_unique_id
elif message.animation: file_unique_id = message.animation.file_unique_id
elif message.sticker: file_unique_id = message.sticker.file_unique_id
elif message.web_page and message.web_page.photo:
file_unique_id = message.web_page.photo.file_unique_id
# New media types (Kurigram 2.2.23): getattr-only, the attributes do
# not exist on older Message objects/mocks. paid_media is deliberately
# NOT collected — it cannot be downloaded (paid content).
elif getattr(message, 'live_photo', None):
new_media_obj = message.live_photo
elif (story_media := _story_media_object(message)[0]) is not None:
new_media_obj = story_media
elif (poll_media := _poll_media_object(message)[0]) is not None:
new_media_obj = poll_media
file_path = os.path.join(os.path.abspath("./data"), 'media_file_ids.json') if new_media_obj is not None:
try: # The >100MB message.video guard above does not cover these
existing_data = [] # sources (live photo, story video, poll description video) —
if os.path.exists(file_path): # apply the same "don't cache large videos" rule here.
with open(file_path, 'r', encoding='utf-8') as f: file_size = getattr(new_media_obj, 'file_size', None)
existing_data = json.load(f) if isinstance(file_size, int) and file_size > 100 * 1024 * 1024:
return
# Check if file already exists by all three fields file_unique_id = getattr(new_media_obj, 'file_unique_id', '') or ''
found = False
for item in existing_data:
if (item.get('channel') == file_data['channel'] and
item.get('post_id') == file_data['post_id'] and
item.get('file_unique_id') == file_data['file_unique_id']):
item['added'] = datetime.now().timestamp()
found = True
break
# Add new entry if not found
if not found:
existing_data.append(file_data)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(existing_data, f, ensure_ascii=False, indent=2)
except Exception as e: if file_unique_id:
logger.error(f"file_id_save_error: error writing to {file_path}, error {str(e)}") added_ts = datetime.now().timestamp()
# Thread-safe: just append; the caller persists the batch.
self._pending_media_ids.append((channel_username, message.id, file_unique_id, added_ts))
except Exception as e: except Exception as e:
logger.error(f"file_id_collection_error: message_id {message.id}, error {str(e)}") logger.error(f"file_id_collection_error: message_id {message.id}, error {str(e)}")
@@ -17,7 +17,12 @@
"titleBar.activeBackground": "#e5e636", "titleBar.activeBackground": "#e5e636",
"titleBar.activeForeground": "#15202b", "titleBar.activeForeground": "#15202b",
"titleBar.inactiveBackground": "#e5e63699", "titleBar.inactiveBackground": "#e5e63699",
"titleBar.inactiveForeground": "#15202b99" "titleBar.inactiveForeground": "#15202b99",
"statusBar.background": "#e5e636",
"statusBar.foreground": "#15202b",
"statusBarItem.hoverBackground": "#cecf1a",
"statusBarItem.remoteBackground": "#e5e636",
"statusBarItem.remoteForeground": "#15202b"
}, },
"peacock.color": "#e5e636" "peacock.color": "#e5e636"
} }
+9
View File
@@ -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
View File
@@ -1,8 +1,11 @@
fastapi==0.115.8 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 uvicorn==0.34.0
python-multipart==0.0.20 python-multipart==0.0.20
#Kurigram Kurigram==2.2.23
git+https://github.com/KurimuzonAkuma/pyrogram.git@dev#egg=Kurigram #git+https://github.com/KurimuzonAkuma/pyrogram.git@dev#egg=Kurigram
TgCrypto TgCrypto
uvloop uvloop
pillow==11.1.0 pillow==11.1.0
@@ -11,4 +14,5 @@ python-dateutil==2.9.0.post0
python-magic==0.4.27 python-magic==0.4.27
bleach[css]==6.1.0 bleach[css]==6.1.0
types-bleach types-bleach
json-repair pytest-asyncio==1.4.0
httpx==0.28.1
+238 -58
View File
@@ -9,9 +9,12 @@
# pylance: disable=reportMissingImports, reportMissingModuleSource # pylance: disable=reportMissingImports, reportMissingModuleSource
# mypy: disable-error-code="import-untyped" # mypy: disable-error-code="import-untyped"
import copy
import logging import logging
import asyncio
import re import re
import time import time
from html import escape as html_escape
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from typing import Optional from typing import Optional
@@ -20,16 +23,27 @@ from pyrogram import errors, Client
from pyrogram.types import Message from pyrogram.types import Message
from post_parser import PostParser from post_parser import PostParser
from config import get_settings from config import get_settings
from tg_throttle import tg_rpc_bounded
from bleach.css_sanitizer import CSSSanitizer
from bleach import clean as HTMLSanitizer
Config = get_settings() Config = get_settings()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def _create_time_based_media_groups(messages: list[Message], merge_seconds: int = 5) -> list[Message]: def _create_time_based_media_groups(messages: list[Message], merge_seconds: int = 5) -> list[Message]:
""" """
Create media groups based on time difference between messages Create media groups based on time difference between messages
Plain synchronous function (contains no await): runs inside the render thread
via _render_pipeline. Must not touch asyncio.
""" """
messages_sorted = sorted(messages, key=lambda msg: msg.date) # type: ignore # Deep-copy the input list to avoid mutating cached Message objects (the cache may
# reuse the same objects across calls with different merge_seconds values)
messages = copy.deepcopy(messages)
# Compute fallback once so all None-date messages get the same sort key (deterministic order)
_sort_fallback = datetime.now(timezone.utc)
messages_sorted = sorted(messages, key=lambda msg: msg.date or _sort_fallback) # type: ignore
cluster: list[Message] = [] cluster: list[Message] = []
last_msg_date: datetime = datetime.now(timezone.utc) last_msg_date: datetime = datetime.now(timezone.utc)
current_media_group_id: Optional[str] = None current_media_group_id: Optional[str] = None
@@ -38,11 +52,14 @@ async def _create_time_based_media_groups(messages: list[Message], merge_seconds
if not cluster: if not cluster:
cluster.append(msg) cluster.append(msg)
last_msg_date = msg.date # type: ignore # Use current time as fallback when date is None
last_msg_date = msg.date or datetime.now(timezone.utc) # type: ignore
current_media_group_id = getattr(msg, "media_group_id", None) current_media_group_id = getattr(msg, "media_group_id", None)
continue continue
time_diff = (msg.date - last_msg_date).total_seconds() # type: ignore # Use current time as fallback when date is None to avoid TypeError in subtraction
msg_date = msg.date or datetime.now(timezone.utc)
time_diff = (msg_date - last_msg_date).total_seconds()
msg_media_group_id = getattr(msg, "media_group_id", None) msg_media_group_id = getattr(msg, "media_group_id", None)
@@ -54,7 +71,8 @@ async def _create_time_based_media_groups(messages: list[Message], merge_seconds
for m in cluster: for m in cluster:
m.media_group_id = current_media_group_id # type: ignore m.media_group_id = current_media_group_id # type: ignore
cluster.append(msg) cluster.append(msg)
last_msg_date = msg.date # type: ignore # Use current time as fallback when date is None
last_msg_date = msg.date or datetime.now(timezone.utc) # type: ignore
else: else:
if len(cluster) >= 2 and not current_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] dates = [m.date for m in cluster if m.date is not None]
@@ -64,7 +82,8 @@ async def _create_time_based_media_groups(messages: list[Message], merge_seconds
for m in cluster: for m in cluster:
m.media_group_id = new_group_id # type: ignore m.media_group_id = new_group_id # type: ignore
cluster = [msg] cluster = [msg]
last_msg_date = msg.date # type: ignore # Use current time as fallback when date is None
last_msg_date = msg.date or datetime.now(timezone.utc) # type: ignore
current_media_group_id = msg_media_group_id current_media_group_id = msg_media_group_id
if len(cluster) >= 2 and not current_media_group_id: if len(cluster) >= 2 and not current_media_group_id:
@@ -77,9 +96,11 @@ async def _create_time_based_media_groups(messages: list[Message], merge_seconds
return messages_sorted return messages_sorted
async def _create_messages_groups(messages: list[Message]) -> list[list[Message]]: def _create_messages_groups(messages: list[Message]) -> list[list[Message]]:
""" """
Process messages into formatted posts, handling media groups Process messages into formatted posts, handling media groups
Plain synchronous function (contains no await): runs inside the render thread.
""" """
processing_groups: list[list[Message]] = [] processing_groups: list[list[Message]] = []
media_groups: dict[str | int, list[Message]] = {} media_groups: dict[str | int, list[Message]] = {}
@@ -98,7 +119,6 @@ async def _create_messages_groups(messages: list[Message]) -> list[list[Message]
if 'GROUP_CHAT_CREATED' in str(message.service): continue if 'GROUP_CHAT_CREATED' in str(message.service): continue
if 'CHANNEL_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 '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:
if message.media_group_id not in media_groups: if message.media_group_id not in media_groups:
@@ -122,13 +142,12 @@ async def _create_messages_groups(messages: list[Message]) -> list[list[Message]
return processing_groups return processing_groups
async def _trim_messages_groups(messages_groups: list[list[Message]], limit: int): def _trim_messages_groups(messages_groups: list[list[Message]], limit: int):
""" """
Trim messages groups to limit Trim messages groups to limit
Plain synchronous function (contains no await): runs inside the render thread.
""" """
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 if len(messages_groups) > limit: # Trim groups if they exceed the specified limit
messages_groups = messages_groups[:limit] messages_groups = messages_groups[:limit]
@@ -182,12 +201,13 @@ def processed_message_to_tg_message(processed_message: dict) -> Message:
return tg_message_mock # type: ignore return tg_message_mock # type: ignore
async def _render_messages_groups(messages_groups: list[list[Message]], def _render_messages_groups(messages_groups: list[list[Message]],
post_parser: PostParser, post_parser: PostParser,
exclude_flags: str | None = None, exclude_flags: str | None = None,
exclude_text: str | None = None): exclude_text: str | None = None):
""" """
Render message groups into HTML format Render message groups into HTML format
Plain synchronous function (contains no await): runs inside the render thread.
Args: Args:
messages_groups: List of message groups (each group is a list of messages) messages_groups: List of message groups (each group is a list of messages)
post_parser: PostParser instance post_parser: PostParser instance
@@ -202,7 +222,9 @@ async def _render_messages_groups(messages_groups: list[list[Message]],
try: try:
if len(group) == 1: # Single message - simple case if len(group) == 1: # Single message - simple case
one_message = group[0] one_message = group[0]
message_data = post_parser.process_message(one_message) # Feed path: raw_message not needed and sanitize deferred to the final
# whole-feed pass, so each fragment is sanitized exactly once.
message_data = post_parser.process_message(one_message, include_raw=False, sanitize=False)
html_parts = [ html_parts = [
f'<div class="message-body">{message_data["html"]["body"]}</div>', f'<div class="message-body">{message_data["html"]["body"]}</div>',
f'<div class="message-footer">{message_data["html"]["footer"]}</div>' f'<div class="message-footer">{message_data["html"]["footer"]}</div>'
@@ -217,7 +239,7 @@ async def _render_messages_groups(messages_groups: list[list[Message]],
'flags': message_data['flags'] 'flags': message_data['flags']
}) })
else: # Multiple messages in group - merge text and html body 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 # Determine main message for header/footer/title
main_message = next( main_message = next(
@@ -294,11 +316,35 @@ async def _render_messages_groups(messages_groups: list[list[Message]],
logger.debug(f"excluded_post: message_id {post['message_id']}, pattern {exclude_pattern.pattern}") logger.debug(f"excluded_post: message_id {post['message_id']}, pattern {exclude_pattern.pattern}")
rendered_posts = filtered_posts rendered_posts = filtered_posts
# Sort by date # 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'], reverse=True) rendered_posts.sort(key=lambda x: x['date'] if x['date'] is not None else 0.0, reverse=True)
return rendered_posts return rendered_posts
async def generate_channel_rss(channel: str | int,
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):
"""
Full synchronous feed render pipeline (grouping + trimming + rendering).
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 (deepcopy, grouping, bleach-free rendering) 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.
"""
if time_based_merge:
messages = _create_time_based_media_groups(messages, merge_seconds)
message_groups = _create_messages_groups(messages)
message_groups = _trim_messages_groups(message_groups, limit)
return _render_messages_groups(message_groups, post_parser, exclude_flags, exclude_text)
async def generate_channel_rss(channel: str | int,
client: Client, client: Client,
limit: int = 20, limit: int = 20,
exclude_flags: str | None = None, exclude_flags: str | None = None,
@@ -334,9 +380,10 @@ async def generate_channel_rss(channel: str | int,
channel_info_start_time = time.time() channel_info_start_time = time.time()
try: try:
channel = post_parser.channel_name_prepare(channel) channel = post_parser.channel_name_prepare(channel)
channel_info = await post_parser.client.get_chat(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_title = channel_info.title or f"Telegram: {channel}"
channel_username = post_parser.get_channel_username(channel_info) 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: if not channel_username:
# Use prepared channel (which could be int) for error feed if username fails # 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.") logger.warning(f"Could not get username for channel {channel}, using identifier for error feed.")
@@ -344,6 +391,9 @@ async def generate_channel_rss(channel: str | int,
except (errors.UsernameInvalid, errors.UsernameNotOccupied) as e: except (errors.UsernameInvalid, errors.UsernameNotOccupied) as e:
logger.warning(f"Channel not found error for {channel}: {str(e)}") logger.warning(f"Channel not found error for {channel}: {str(e)}")
return create_error_feed(channel, base_url) return create_error_feed(channel, base_url)
except errors.FloodWait:
# Let FloodWait bubble up to api_server.py, which returns HTTP 429 with Retry-After header
raise
except Exception as e: 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 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, # Re-raise the original exception to be caught by the outer handler if needed,
@@ -363,10 +413,10 @@ async def generate_channel_rss(channel: str | int,
# Collect messages # Collect messages
messages_start_time = time.time() messages_start_time = time.time()
messages = []
try: try:
async for message in post_parser.client.get_chat_history(channel, limit=limit*2): from tg_cache import cached_get_chat_history
messages.append(message)
messages = await cached_get_chat_history(post_parser.client, channel, limit=limit*2)
except Exception as e: 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 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 raise ValueError(f"Failed to get chat history for {channel}: {str(e)}") from e # Raise a more specific error
@@ -374,19 +424,37 @@ async def generate_channel_rss(channel: str | int,
messages_elapsed = time.time() - messages_start_time messages_elapsed = time.time() - messages_start_time
logger.debug(f"rss_messages_retrieval_timing: channel {channel}, {len(messages)} messages retrieved in {messages_elapsed:.3f} seconds") logger.debug(f"rss_messages_retrieval_timing: channel {channel}, {len(messages)} messages retrieved in {messages_elapsed:.3f} seconds")
# Process messages into groups and render them # Process messages into groups and render them.
# The whole grouping/trimming/rendering pipeline is CPU-heavy (deepcopy +
# 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() processing_start_time = time.time()
if Config['time_based_merge']: try:
messages = await _create_time_based_media_groups(messages, merge_seconds) final_posts = await asyncio.to_thread(
message_groups = await _create_messages_groups(messages) _render_pipeline, messages, post_parser, limit,
message_groups = await _trim_messages_groups(message_groups, limit) exclude_flags, exclude_text, merge_seconds, Config['time_based_merge'],
final_posts = await _render_messages_groups(message_groups, post_parser, exclude_flags, exclude_text) )
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 processing_elapsed = time.time() - processing_start_time
logger.debug(f"rss_messages_processing_timing: channel {channel}, {len(final_posts)} posts processed in {processing_elapsed:.3f} seconds") logger.debug(f"rss_messages_processing_timing: channel {channel}, {len(final_posts)} posts processed in {processing_elapsed:.3f} seconds")
# Generate feed entries # Generate feed entries
feed_gen_start_time = time.time() 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: for post in final_posts:
fe = fg.add_entry() fe = fg.add_entry()
fe.title(post['title']) fe.title(post['title'])
@@ -395,10 +463,48 @@ async def generate_channel_rss(channel: str | int,
fe.link(href=post_link) fe.link(href=post_link)
fe.description(post['text'].replace('\n', ' ')) fe.description(post['text'].replace('\n', ' '))
fe.content(content=post['html'], type='CDATA') # Sanitize heavy HTML in thread to avoid blocking the loop
try:
def _sanitize_sync(html_raw: str) -> str:
css_sanitizer = CSSSanitizer(
allowed_css_properties=["max-width", "max-height", "object-fit", "width", "height"]
)
return HTMLSanitizer(
html_raw,
tags=['p', 'a', 'b', 'i', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'div', 'span', 'img', 'video', 'audio', 'source'],
attributes={
'a': ['href', 'title', 'target'],
'img': ['src', 'alt', 'style'],
'video': ['controls', 'src', 'style'],
'audio': ['controls', 'style'],
'source': ['src', 'type'],
'div': ['class', 'style'],
'span': ['class']
},
protocols=['http', 'https', 'tg'],
css_sanitizer=css_sanitizer,
strip=True,
)
sanitized_html = await asyncio.to_thread(_sanitize_sync, post['html'])
except Exception as e:
# FAIL CLOSED: since 4.4 removed the per-fragment passes, post['html'] is
# raw channel-controlled HTML, and this is its ONLY sanitize. If bleach
# itself throws (e.g. RecursionError on pathological nested HTML), do NOT
# emit the raw payload (that would be stored XSS) — html.escape it so the
# content survives as inert text.
logger.error(f"rss_html_sanitization_error: channel {channel}, message_id {post['message_id']}, error {str(e)}")
sanitized_html = html_escape(post['html'])
fe.content(content=sanitized_html, type='CDATA')
pub_date = datetime.fromtimestamp(post['date'], tz=timezone.utc) if post['date'] is not None:
fe.pubDate(pub_date) 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) fe.guid(post_link, permalink=True)
if post['author'] and post['author'] != main_name: if post['author'] and post['author'] != main_name:
@@ -407,7 +513,8 @@ async def generate_channel_rss(channel: str | int,
feed_gen_elapsed = time.time() - feed_gen_start_time feed_gen_elapsed = time.time() - feed_gen_start_time
logger.debug(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): if isinstance(rss_feed, bytes):
return rss_feed.decode('utf-8') return rss_feed.decode('utf-8')
@@ -421,17 +528,50 @@ async def generate_channel_rss(channel: str | int,
async def _reply_enrichment(client: Client, messages: list[Message]) -> list[Message]: 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: for message in messages:
if message.reply_to_message_id and message.chat: if message.reply_to_message_id and message.chat:
full_message = await client.get_messages(message.chat.id, message.id) chat_id = message.chat.id
if isinstance(full_message, list): if chat_id not in chat_messages:
if full_message and full_message[0].reply_to_message: chat_messages[chat_id] = []
message.reply_to_message = full_message[0].reply_to_message chat_messages[chat_id].append(message)
else:
if full_message and full_message.reply_to_message: if not chat_messages:
message.reply_to_message = full_message.reply_to_message # 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 return messages
@@ -470,8 +610,9 @@ async def generate_channel_html(channel: str | int,
try: try:
channel = post_parser.channel_name_prepare(channel) channel = post_parser.channel_name_prepare(channel)
logger.debug(f"Prepared channel identifier for HTML: {channel} (type: {type(channel)})") # Log prepared 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) from tg_cache import cached_get_chat
channel_username = post_parser.get_channel_username(channel_info) channel_info = await cached_get_chat(post_parser.client, 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: 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.") 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. # For HTML, returning an error feed string might not be ideal. Consider returning a dedicated HTML error page.
@@ -480,6 +621,9 @@ async def generate_channel_html(channel: str | int,
logger.warning(f"Channel not found error for {channel} in HTML generation: {str(e)}") logger.warning(f"Channel not found error for {channel} in HTML generation: {str(e)}")
# Consider returning a dedicated HTML error page. # Consider returning a dedicated HTML error page.
return create_error_feed(channel, base_url) return create_error_feed(channel, base_url)
except errors.FloodWait:
# Let FloodWait bubble up to api_server.py, which returns HTTP 429 with Retry-After header
raise
except Exception as e: 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) 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 raise ValueError(f"Failed to get chat info for {channel} in HTML generation: {str(e)}") from e
@@ -489,10 +633,10 @@ async def generate_channel_html(channel: str | int,
# Collect messages # Collect messages
messages_start_time = time.time() messages_start_time = time.time()
messages = []
try: try:
async for message in post_parser.client.get_chat_history(channel, limit=limit): from tg_cache import cached_get_chat_history
messages.append(message)
messages = await cached_get_chat_history(post_parser.client, channel, limit=limit)
except Exception as e: 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) 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 raise ValueError(f"Failed to get chat history for {channel} in HTML generation: {str(e)}") from e
@@ -506,23 +650,59 @@ async def generate_channel_html(channel: str | int,
enrichment_elapsed = time.time() - enrichment_start_time enrichment_elapsed = time.time() - enrichment_start_time
logger.debug(f"html_reply_enrichment_timing: channel {channel}, replies enriched in {enrichment_elapsed:.3f} seconds") logger.debug(f"html_reply_enrichment_timing: channel {channel}, replies enriched in {enrichment_elapsed:.3f} seconds")
# Process messages into groups and render them # Process messages into groups and render them in ONE worker thread
# (CPU-heavy, no await) to keep the event loop responsive.
processing_start_time = time.time() processing_start_time = time.time()
if Config['time_based_merge']: try:
messages = await _create_time_based_media_groups(messages, merge_seconds) final_posts = await asyncio.to_thread(
_render_pipeline, messages, post_parser, limit,
exclude_flags, exclude_text, merge_seconds, Config['time_based_merge'],
)
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.
await post_parser._flush_pending_media_ids()
# 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 processing_elapsed = time.time() - processing_start_time
logger.debug(f"html_messages_processing_timing: channel {channel}, {len(final_posts)} posts processed in {processing_elapsed:.3f} seconds") logger.debug(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_gen_start_time = time.time()
# Concatenate HTML in thread to avoid blocking the loop on large payloads
html_posts = [post['html'] for post in final_posts] html_posts = [post['html'] for post in final_posts]
html = '\n<hr class="post-divider">\n'.join(html_posts) def _concat_html(parts: list[str]) -> str:
return '\n<hr class="post-divider">\n'.join(parts)
html = await asyncio.to_thread(_concat_html, html_posts)
# Optionally re-sanitize the final big HTML to ensure safety without blocking the loop
try:
def _sanitize_sync(html_raw: str) -> str:
css_sanitizer = CSSSanitizer(
allowed_css_properties=["max-width", "max-height", "object-fit", "width", "height"]
)
return HTMLSanitizer(
html_raw,
tags=['p', 'a', 'b', 'i', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'div', 'span', 'img', 'video', 'audio', 'source'],
attributes={
'a': ['href', 'title', 'target'],
'img': ['src', 'alt', 'style'],
'video': ['controls', 'src', 'style'],
'audio': ['controls', 'style'],
'source': ['src', 'type'],
'div': ['class', 'style'],
'span': ['class']
},
protocols=['http', 'https', 'tg'],
css_sanitizer=css_sanitizer,
strip=True,
)
html = await asyncio.to_thread(_sanitize_sync, html)
except Exception as e:
# FAIL CLOSED (see the RSS path): `html` is now the raw concatenated feed
# (4.4 removed the per-fragment passes). If bleach throws, html.escape the
# whole feed rather than returning raw channel HTML/JS to the client.
logger.error(f"html_final_sanitization_error: channel {channel}, error {str(e)}")
html = html_escape(html)
html_gen_elapsed = time.time() - html_gen_start_time html_gen_elapsed = time.time() - html_gen_start_time
logger.debug(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")
+256 -24
View File
@@ -14,6 +14,7 @@ import asyncio
import sys import sys
import signal import signal
import uvloop import uvloop
import time
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
from pyrogram import Client from pyrogram import Client
@@ -31,9 +32,27 @@ class TelegramClient:
api_id=settings["tg_api_id"], api_id=settings["tg_api_id"],
api_hash=settings["tg_api_hash"], api_hash=settings["tg_api_hash"],
workdir=settings["session_path"], workdir=settings["session_path"],
proxy=settings["proxy"], # MTProto proxy config, None if not set
) )
self.disconnect_count = 0 self.max_disconnects = settings["tg_disconnect_flap_limit"] # Max disconnects within the flap window before restart
self.max_disconnects = 3 self._shutting_down = False # Guard to prevent re-triggering restart during shutdown
self._restarting = False # Guard: an intentional in-process restart is in progress
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() self._setup_connection_handlers()
def _ensure_session_directory(self): def _ensure_session_directory(self):
@@ -49,44 +68,257 @@ class TelegramClient:
self.client.add_handler(DisconnectHandler(self._on_disconnect)) self.client.add_handler(DisconnectHandler(self._on_disconnect))
logger.info("connection_handlers: connection handlers set up") logger.info("connection_handlers: connection handlers set up")
def _on_disconnect(self, _client): async def _on_disconnect(self, _client, _session=None):
"""Handles disconnection from Telegram servers""" """Handles disconnection events from Telegram servers (best-effort safety net).
self.disconnect_count += 1
logger.warning(f"connection_handler: connection lost (#{self.disconnect_count})") 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
if self.disconnect_count >= self.max_disconnects: the active watchdog (_watchdog_loop). Keep both paths.
logger.critical(f"connection_handler: reached disconnect limit ({self.max_disconnects})") """
# 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() self._restart_app()
finally:
self._restarting = False
async def start(self): async def start(self):
try: try:
if not self.client.is_connected: if not self.client.is_connected:
await self.client.start() await self.client.start()
logger.info("Telegram client connected successfully") logger.info("Telegram client connected successfully")
# Reset disconnect counter on successful connection # Reset flap history on a fresh successful connection
self.disconnect_count = 0 self._disconnect_times.clear()
logger.info("connection_handler: connection established") logger.info("connection_handler: connection established")
self._start_watchdog()
except Exception as e: except Exception as e:
logger.error(f"Failed to start Telegram client: {str(e)}") logger.error(f"Failed to start Telegram client: {str(e)}")
raise raise
def _restart_app(self): def _restart_app(self):
"""Restarts the application""" """Restarts the application by sending SIGTERM to the process"""
logger.warning("connection_handler: restarting application") self._shutting_down = True # Set flag before sending signal to suppress subsequent disconnect events
logger.warning("connection_handler: restarting application by sending SIGTERM")
try: try:
# Properly shutdown client before restart # Use SIGTERM for proper Docker container restart
loop = asyncio.get_event_loop() logger.critical("connection_handler: sending SIGTERM signal")
if loop.is_running(): os.kill(os.getpid(), signal.SIGTERM)
loop.create_task(self.stop())
# Use os.execv to restart the process with the same arguments
os.execv(sys.executable, [sys.executable] + sys.argv)
except Exception as e: except Exception as e:
logger.error(f"connection_handler: error during restart: {str(e)}") logger.error(f"connection_handler: error during restart: {str(e)}")
# Emergency termination in case of restart failure # Emergency termination
os.kill(os.getpid(), signal.SIGTERM) 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
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)."""
for attempt in range(max_retries):
try:
return await asyncio.wait_for(
self.client.download_media(file_id, file_name=file_name),
timeout=timeout
)
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): 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: if self.client.is_connected:
await self.client.stop() try:
logger.info("Telegram client disconnected") 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}")
+17
View File
@@ -0,0 +1,17 @@
"""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
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
+21 -1
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- 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(): def get_settings():
""" """
Mock config for testing without requiring TG_API_ID and TG_API_HASH Mock config for testing without requiring TG_API_ID and TG_API_HASH
@@ -18,4 +22,20 @@ def get_settings():
"time_based_merge": False, "time_based_merge": False,
"show_bridge_link": False, "show_bridge_link": False,
"show_post_flags": True, "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,
}
-4
View File
@@ -1,4 +0,0 @@
[pytest]
addopts = -ra
filterwarnings =
ignore::DeprecationWarning
+580
View File
@@ -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 = "&lt;script&gt;alert(1)&lt;/script&gt;"
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
View File
@@ -7,13 +7,6 @@
import unittest import unittest
from unittest.mock import MagicMock 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 pyrogram.types import Message
from post_parser import PostParser from post_parser import PostParser
+23 -47
View File
@@ -7,20 +7,28 @@
import unittest import unittest
from unittest.mock import MagicMock, PropertyMock 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.types import Message, Chat, Reaction, MessageReactions
from pyrogram.enums import MessageMediaType from pyrogram.enums import MessageMediaType
from post_parser import PostParser # Import after mocking config 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): class TestPostParserExtractFlags(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -72,18 +80,14 @@ class TestPostParserExtractFlags(unittest.TestCase):
else: else:
message.forward_origin = None 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: if text:
mock_text = MagicMock() message.text = StrWithHtml(text)
mock_text.html = text
mock_text.__str__.return_value = text # Add __str__ mock
message.text = mock_text
message.caption = None message.caption = None
elif caption: elif caption:
mock_caption = MagicMock() message.caption = StrWithHtml(caption)
mock_caption.html = caption
mock_caption.__str__.return_value = caption # Add __str__ mock
message.caption = mock_caption
message.text = None message.text = None
else: else:
message.text = None message.text = None
@@ -126,36 +130,8 @@ class TestPostParserExtractFlags(unittest.TestCase):
def test_flag_video_media_video_long_caption(self): 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 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
message = self._create_mock_message(media=MessageMediaType.VIDEO, caption=long_caption)
# Create a simple mock message for this specific test # Sanity-check: the caption must actually be longer than the threshold
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
self.assertGreater(len(message.caption), 200) self.assertGreater(len(message.caption), 200)
self.assertNotIn("video", self.parser._extract_flags(message)) self.assertNotIn("video", self.parser._extract_flags(message))
-7
View File
@@ -7,13 +7,6 @@
# pylance: disable=reportMissingImports, reportMissingModuleSource # pylance: disable=reportMissingImports, reportMissingModuleSource
import unittest import unittest
from unittest.mock import MagicMock, PropertyMock 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.types import Message, Document
from pyrogram.enums import MessageMediaType from pyrogram.enums import MessageMediaType
+218
View File
@@ -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()
+398
View File
@@ -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
+378
View File
@@ -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
+487
View File
@@ -0,0 +1,487 @@
# 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 copy
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,
_create_time_based_media_groups,
_create_messages_groups,
_trim_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():
for fn in (_create_time_based_media_groups, _create_messages_groups,
_trim_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_deepcopy_of_pickled_message_does_not_crash():
from pyrogram.types import Message, Chat
from pyrogram.enums import ChatType
m = Message(id=7, date=datetime.now(timezone.utc), text="hello",
chat=Chat(id=-1001, type=ChatType.CHANNEL, username="testchan"))
roundtripped = pickle.loads(pickle.dumps(m)) # mimics the pickle cache
clone = copy.deepcopy(roundtripped) # what _create_time_based_media_groups does
assert clone.id == 7
assert clone.chat.username == "testchan"
# ---------------------------------------------------------------------------
# 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, _create_time_based_media_groups, _create_messages_groups,
_trim_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 "&lt;script&gt;" 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 "&lt;script&gt;alert(1)&lt;/script&gt;" 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)")
# --------------------------------------------------------------------------- #
# 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).
# rss_generator imports it as `from bleach import clean as HTMLSanitizer`.
def boom(*a, **k):
raise RecursionError("bleach exploded")
monkeypatch.setattr(rss_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 `&lt;`. (The letters "javascript:" still appear, but as inert text
# inside an escaped &quot;…&quot;, 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 "&lt;script&gt;" in chunk, "expected the payload html-escaped, not dropped"
+258
View File
@@ -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
+182
View File
@@ -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
+343
View File
@@ -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
View File
@@ -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)
+92
View File
@@ -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