- _is_safe_channel_segment: отвергает имя канала с '/'/'\\'/'..' или не-basename;
вызывается в начале цикла миграции ДО любого os.rename/_merge_dir_tree/rmtree,
покрывает оба источника кандидатов (SELECT DISTINCT channel + листинг каталогов).
Грязный канал (напр. из pre-existing route-traversal) -> skip+warning+failures++,
старт не падает, деструктивная rename/rmtree не уходит за cache_dir.
- Тесты (мутационно проверены): FS-fail -> SQL пропущен, строки old-cased,
failures++ (снять continue -> тест краснеет); reverse max(added) (Durov=100/
durov=300 -> 300); traversal-гард скипает '../Evil'/'a/B' без FS-операций.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Один канал жил под ключами Durov/durov/@name в трёх слоях (tgcache-файлы,
data/cache/ каталоги, SQLite-строки) -> дубли кеша, двойные походы в Telegram,
осиротевшие деревья. ID<->username НЕ унифицируем (сознательно).
- channel_key.py: canonical_channel_key (strip @, -100... числовые verbatim,
иначе lowercase; безопасно и для API-вызовов).
- tg_cache._cache_file_path прогоняет ключ через canonical.
- post_parser.get_channel_username -> lowercase (обе ветки; канонизирует
SQLite/media-URL/t.me). Только эти 2 строки.
- api_server.get_media: fs_channel = canonical_channel_key(channel) ПОСЛЕ проверки
дайджеста (дайджест — по ИСХОДНОМУ url, иначе ломаются выданные ссылки); fs_channel
во всех путях/ключах/download ниже.
- migrate_channel_keys_sync в lifespan (после init_db_sync, до client.start, через
asyncio.to_thread): канал целиком FS-потом-SQL. Обязательный os.path.samefile
guard ДО merge/rmtree — на case-insensitive FS Durov/durov один inode, иначе
снесли бы кеш. FS-сбой -> SQL пропуск (строки старорегистровые, дерево видно
свиперу, нет вечных orphan). SQL-merge PK-safe (twin -> merge added=max/mime
non-NULL + DELETE, иначе plain UPDATE). Идемпотентно.
closes#24
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Docker Image CI / build (pull_request) Waiting to run
- test_stage_e_media_hotpath: добавлен тест cold-start magic-пути (dict-miss ->
SQLite-miss -> magic_mime.from_file -> write-through в ОБА кеша). Раньше все
тесты мокали get_mime_type_sync с возвратом (SQLite-hit), magic-ветка не
исполнялась — мутационно подтверждено (закомментировать dict-write на magic
пути -> тесты зелёные). Новый тест ассертит set_mime_type_sync И _mime_types[key].
- test_stage7_integration: два chdir-теста (:220, :251) не патчили MEDIA_CACHE_DIR
-> латентная зависимость от реального data/cache репо (MEDIA_CACHE_DIR теперь
import-time). Добавлен monkeypatch.setattr как в 4 соседних тестах.
Только тесты; прод-код не тронут.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Docker Image CI / build (pull_request) Waiting to run
Ревью #44: allowlist снапшота был неполон -> спец-медиа блоки тихо выпадали на
cache-hit (нарушение golden-render инварианта). Исправлено:
- Снапшот+восстановление 11 типов (story, contact, location, venue, dice, game,
giveaway, giveaway_winners, checklist, paid_media + live_photo) — ровно те
подполя, что читает _format_special_media/find_file_id_in_message.
SNAPSHOT_VERSION 1->2 (v1-файлы без новых ключей инвалидируются).
- Render-parity тест (tests/test_snapshot_render_parity.py): корпус из 35+ кейсов
(все медиа/спец-медиа/web_page/poll/forward Case 1-5/reactions/media group),
live-рендер == restore(snapshot(live))-рендер байт-в-байт через реальный
пайплайн; мутационно проверено (выброс поля -> тест краснеет).
- F1: file_size досниман для document/audio/animation/video_note/story-media
(правило '>100MB не кэшировать' в _save_media_file_ids читает file_size у любого
выбранного медиа-объекта; раньше был только у video/live_photo -> >100MB документ
ошибочно кэшировался на cache-hit).
- flake8/pylint-хедеры на 3 новых тест-файла.
post_parser.py/rss_generator.py не тронуты; pickle отсутствует.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Docker Image CI / build (pull_request) Waiting to run
Задание 15: MEDIA_CACHE_DIR (резолвится один раз при импорте) + media_cache_path(
channel, post_id, fid=None) -> <root>/<channel>/<post_id>[/<fid>] как единый
источник истины формата пути. Заменены все ручные сборки os.path.abspath('./data/
cache')+join (7 мест); строки путей дословно идентичны (drop-in). remove_old_
cached_files_sync/download_new_files сохраняют параметр cache_dir (тестируемость).
Задание 16: in-memory _mime_types (bound _MIME_CACHE_MAX=50000, clear() при
переполнении) перед SQLite в prepare_file_response. Порядок: dict -> SQLite ->
python-magic; SQLite-хит и magic-детект наполняют dict. MIME по file_unique_id
иммутабелен -> повторный /media-хит не открывает SQLite-соединение (ни на чтение
MIME, ни на запись access-time — она уже в аккумуляторе). Кэшируются только
реальные типы.
closes#26
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Docker Image CI / build (pull_request) Waiting to run
Задание 12 (баг): диф+удаление строк из SQLite в cache_media_files считались только
if files_removed>0, поэтому протухшая (>20д) запись без файла на диске выпадала из
remove_old_cached_files_sync (ветка continue без инкремента счётчика) и оставалась в
БД навсегда. Диф теперь считается безусловно: removed_entries = старый набор минус
выживший, purge при непустом removed_entries независимо от files_removed.
Задание 13: cache_sweep_interval (env CACHE_SWEEP_INTERVAL, дефолт 900, пол 60 —
значение ниже отвергается на старте) по существующему _parse_int_env-паттерну;
delay=60 -> Config['cache_sweep_interval'].
Задание 14: дедуп очереди фоновых загрузок через module-level _queued_media
(set[tuple[str,int,str]]): enqueue пропускает уже-в-очереди ключ, discard в finally
воркера (упавшая/затротленная загрузка переочередится), discard+break при QueueFull.
Однопоточный loop, между проверкой и мутацией нет await -> гонок нет.
closes#25
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Docker Image CI / build (pull_request) Waiting to run
Кеш истории (data/tgcache/) больше не хранит pickle живых объектов pyrogram.
Новый модуль message_snapshot.py: при записи из Message извлекается JSON-словарь
по явному allowlist (снапшот), при чтении восстанавливается duck-typed
CachedMessage (CachedStr с .html, mutable, deepcopy-safe), неотличимый для
пайплайна рендера от Message. post_parser.py и rss_generator.py НЕ изменены.
tg_cache.py переписан на generic JSON-store (pickle удалён полностью):
- _store_entry/_load_entry: атомарная запись через уникальный .tmp.<uuid4> +
os.replace, finally чистит свой tmp; версия/TTL/битый JSON -> None;
- пути <safe_key>.history.json / .chatinfo.json;
- пакет B: префиксный limit (miss только когда cached_limit<limit и есть ещё);
- пакет F: джиттер TTL при записи (random.uniform(0.8,1.0), на чтении random не
зовётся) + sweep_tgcache(age) + cleanup_legacy_cache_files;
- api_server.py: старт-cleanup+sweep в lifespan, per-pass sweep в cache_media_files,
удаление legacy data/media_file_ids.json.
closes#23
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Media downloads jammed process-wide: Kurigram serializes them through a single
get_file slot (max_concurrent_transmissions=1), and a zombie media-DC connection
(upload.GetFile timing out) held that slot forever. The main-DC watchdog (get_me)
never noticed, and the 60s cache sweep kept re-queuing the failing file, so all
feed images turned into broken placeholders.
- telegram_client: count consecutive download timeouts; after N (default 5) reuse
_restart_client() to rebuild the media connection — the only recovery signal the
main-DC watchdog cannot provide. Any success resets the streak.
- telegram_client/config: set max_concurrent_transmissions (default 3) so one hung
download is no longer an instant total outage (blast-radius limiter only).
- api_server: negative-cache with exponential backoff for repeatedly-failing files
(skip in background sweep, fast 503+Retry-After in get_media, record/clear in the
dedup runner and background worker; FloodWait excluded).
- dockercompose/tests: document TG_MAX_CONCURRENT_TRANSMISSIONS and
MEDIA_TIMEOUT_RESTART_THRESHOLD; update mock config; add self-heal/backoff tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Docker Image CI / build (pull_request) Has been cancelled
DO ревьюера: exclude_flags — golden-невидимая логика, переписанная в этом
PR (loop → comprehension) и без единого юнит-теста. Добавил
test_stage6_exclude_flags.py: пинит семантику мембершипа —
(1) спец-случай "all" исключает пост с флагами, (2) исключение по
конкретному флагу (merged дропается, ['no_image'] выживает),
(3) None/несовпадающий флаг оставляет все, (4) сохранение порядка
выживших. Мутация (обнуление предиката фильтра) роняет 3 из 4 тестов —
логика реально залочена. Это и есть «dedicated unit tests» из шапки
golden_replay.py. Голдены не тронуты, полный набор 447 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Docker Image CI / build (pull_request) Has been cancelled
Финальный этап render-pipeline рефактора. Чисто косметика, вывод
байт-в-байт неизменен (golden-оракул нетронут, 0 изменений в
tests/test_data/).
1. `_wrap_post_html(body, footer)` — единая обёртка поста вместо трёх
байт-идентичных копий (_format_html + обе ветки
_render_messages_groups).
2. Инлайн-стили 400px/200px/max-width → именованные константы.
3. `_trim_messages_groups` заинлайнен как `[:limit]`; импорты в
test_stage4_eventloop.py/test_group_ids.py перевязаны на _render_pipeline
без потери покрытия тримминга.
4. Устаревшие комментарии «stage-4»/«4.4» переписаны под текущую границу
санитайза.
5. Фильтр exclude_flags → list-comprehension (эквивалент по де Моргану);
фильтр exclude_text и его excluded_post-debug-лог сохранены дословно.
Гейт: pytest 443 passed (без дельты); test_stage0_golden зелёный под
PYTHONHASHSEED 0/1; ни одной golden-фикстуры не изменено.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Golden-oracle DO: the fragment snapshot (media_fragments.json) was a
circular self-snapshot generated from the refactored code. Regenerated
it from the pre-refactor BASE post_parser.py (old ladders) so it holds
base bytes — byte-identical to base for all 24 cases. The only 2 §3.14
deltas (file_unique_id_none, webpage_without_photo) moved to a new
REGISTERED_DELTAS registry in media_fragment_replay.py; the fragment
test asserts base bytes for 22 cases and the registered 5b value for 2.
Docstrings corrected to describe base-anchoring (no overclaim); a
WARNING in generate_snapshot() notes it must run against base code only.
Test-coverage DO: added test_exactly_100mb_object_is_collected pinning
the §3.13 guard as strictly '>' (exactly 100MB is still collected).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Убран copy.deepcopy всех сообщений на каждый запрос фида и мутация media_group_id.
Новая чистая функция _compute_time_based_group_ids(messages) → {msg.id:
effective_group_id}, сообщения не трогает; заменяет _create_time_based_media_groups.
_create_messages_groups читает маппинг (fallback на msg.media_group_id при
отсутствии). import copy удалён. Алгоритм воспроизводит старый байт-в-байт для
прод-входов (naive-safe сорт, кластеризация по naive-вычитанию дат, синтетический
time_{min} id, singletons без записи). Не-мутация закреплена тестами.
§3.11/§3.12 (ЖИВОЙ прод-500): дефолтный путь падал на сорт-ключе групп с aware
datetime.now(utc) против naive kurigram-дат → TypeError на ЛЮБОМ фиде с None-date
постом. Ключ переведён на timestamp (None → +inf, выживает в [:limit], в финале
садится в хвост через 0.0). Эмпирически подтверждено: и дефолтный, и
time-cluster путь крашились на старом коде, теперь проходят.
Golden — БЕЗ изменений (структурный рефактор; в корпусе 0 None-date, edge-фикс
байты не меняет; None-date поведение закреплено отдельным e2e-тестом, оракул не
тронут). test_group_ids.py (13 тестов) + обновления test_stage4_eventloop.
Полный сюит 378 passed, детерминирован PYTHONHASHSEED 1/42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Поверх байт-в-байт 5a — только фиксы реестра:
- §3.14: закрывающий </div> эмитится в КАЖДОЙ ветке media-рендера (раньше
несбалансированный div в конце фрагмента авто-закрывался bleach позже). На
фрагмент-оракуле изменились ровно 2 кейса (file_unique_id_none,
webpage_without_photo); в feed-голденах — чисто РЕЛОКАЦИЯ </div> с конца поста
на место сразу после пустого media-div (счётчик </div> не меняется, контент
идентичен). Затронуты каналы с webpage-без-фото (theyforcedme без изменений).
- §3.13: гард >100MB теперь на ЛЮБОЙ media-объект (не только message.video);
collection-only, байты фида не трогает.
test_stage5b_media_fixes.py (14 тестов). Полный сюит 427 passed, PYTHONHASHSEED
1/42. Golden строго по §3 (проверял независимо: net-теги 0, только </div>-релокация).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Три параллельные ручно-синхронизируемые «лестницы» выбора media-объекта
(_get_file_unique_id dict-map, _save_media_file_ids if/elif, _generate_html_media
elif-каскад) сведены в одну точку правды: MEDIA_SOURCES (селектор → (media_obj,
render_kind), 12 записей) + RenderCtx + RENDERERS (7 рендереров подняты дословно
из старых веток). Все три консьюмера теперь ходят через таблицу; uid единообразно
через getattr(file_unique_id).
Байт-в-байт: fragment-oracle (media_fragment_replay + media_fragments.json, 24
кейса) без изменений, feed-golden без изменений. Единственная унификация —
_save_media_file_ids теперь ключуется на message.media (как две другие лестницы),
что заодно чинит латентную рассинхронизацию (в корпусе не встречается → оракул 0).
test_stage5_media_table.py (48 тестов). Полный сюит 413 passed, PYTHONHASHSEED 1/42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Развилка history_limit=limit*2 (RSS) vs limit (HTML) невидима голдену (golden-фейк
игнорирует аргумент limit и всегда отдаёт полный корпус → оба пути тримятся до
GOLDEN_LIMIT одинаково). test_rss_overfetches_history_html_does_not спаит
tg_cache.cached_get_chat_history, гоняет оба форматтера с N=7 и ассертит:
RSS форвардит limit=2*N, HTML форвардит limit=N. Мутационно проверено — красит
при унификации в любую сторону. Полный сюит 366 passed, golden не менялся.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
~80% дублированных тел generate_channel_rss / generate_channel_html сведены в
одну корутину _prepare_feed_posts (validate limit → cached_get_chat →
cached_get_chat_history → опц. _reply_enrichment → to_thread(_render_pipeline)
с _flush_pending_media_ids в finally). channel_name_prepare/cached_get_chat/
cached_get_chat_history/_render_pipeline/_flush_pending_media_ids теперь по
одной копии (было по две). Новые @dataclass PreparedFeed + ChannelNotFound
(→ create_error_feed, байт-эквивалентно). Форматтеры тонкие: RSS
(history_limit=limit*2, enrich_replies=False), HTML (limit, enrich_replies=True).
_reply_enrichment (живой client.get_messages) перенесён в _prepare_feed_posts
(гейт enrich_replies, HTML-only; RSS намеренно пропускает). §3.9 FloodWait из
истории ре-райзится (→429) вместо ValueError (→400); §3.10 тексты ValueError
унифицированы.
Enrichment закрыт unit-тестом (по требованию мейнтейнера в #30): батчинг по
chat_id (2 вызова get_messages на 3+2 реплая, не 5), проставление таргета на
правильный пост, рендер <div class="message-reply"> end-to-end; плюс
RSS-не-обогащает и error-path тесты обоих форматтеров.
Golden — БЕЗ изменений (чистый структурный дедуп; harness no-op'ит enrichment
на bare-клиенте). Оракул зелёный и детерминирован (PYTHONHASHSEED 1/42).
Полный сюит 365 passed (+12).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Docker Image CI / build (pull_request) Has been cancelled
Убрал обратный ход данных: merged-группы больше не конвертируют отрендеренный
dict обратно в мок-Message (processed_message_to_tg_message удалён, −84 строки).
Футер рендерится прямо из реального главного Message группы. main_message (dict)
по-прежнему выбирается тем же критерием-индексом → title/date/author байт-в-байт.
Реестр §3:
- §3.6 custom-emoji реакции merged-футера — отдельный span на каждую (через
реальный _reactions_views_links), а не агрегированная «❓ N»;
- §3.7 дата merged-футера из naive-local реального Message (не UTC-мок); в
UTC-голдене не видна → выделенный не-UTC тест (TZ=Europe/Moscow);
- §3.8 порядок флагов merged-поста детерминирован (first-seen через
dict.fromkeys вместо hash-ordered list(set)); временная сортировка флагов
снята из оракула (golden_replay нормализатор убран, normalize_html теперь
identity); проверено — оракул стабилен на PYTHONHASHSEED 0/1/42;
- §3.15 пустой reactions-объект больше не даёт ведущий разделитель (и одиночные).
Golden обновлён строго по §3: span +2 (§3.6 сплит custom-реакции), reorder
флагов только на merged-постах (§3.8), снят ведущий разделитель на одиночном
poll-посте (§3.15). Оракул зелёный и детерминированный (2 прогона идентичны).
Полный сюит 353 passed (+6 тестов этапа 2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Docker Image CI / build (pull_request) Has been cancelled
Do 1 [test-coverage]: залочил security-инвариант §3.4 (per-post изоляция
фрагмента) именованным тестом test_unbalanced_post_does_not_swallow_next_post_html_feed
(в test_stage4_eventloop.py — гоняет реальный per-post HTML-пайплайн на истории из
2 сообщений). Пост A несёт висячий `<div><b>DANGLING_A` без закрывашек, пост B
целый с маркером. Ассертит: фид бьётся на 2 фрагмента по `<hr post-divider>`;
теги A сбалансированы В ПРЕДЕЛАХ фрагмента A; маркер B в фрагменте B и НЕ в A
(не заглочен). Проверено, что тест краснеет на регрессии «join-before-sanitize»:
при склейке raw-постов до единого whole-feed bleach.clean невайтлистнутый `<hr>`
стрипается (strip=True) → 0 разделителей → 1 фрагмент → первый ассерт падает.
То есть будущий этап, вернувший склейку до санитайза, ловится этим тестом.
Do 2 [documentation]: 4 устаревших коммента про удалённый «whole-feed sanitize
для HTML» обновлены на «per-post в _render_pipeline для обоих (RSS и HTML)»
(post_parser.py process_message docstring / _generate_html_body NOTE /
generate_html_footer coverage-map; rss_generator.py feed-path коммент). Вывод
«санитайзится ровно один раз» сохранён. Исполняемый код не менялся.
Полный сюит 347 passed (+1 тест изоляции); оракул этапа 0 — 11 passed, golden не менялся.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Часть эпика рефакторинга рендер-пайплайна (#34). Один bleach-конфиг на весь
проект, один санитайз на пост, ноль thread-hop'ов в циклах (раньше конфиг был
скопирован трижды и разъехался; RSS-путь делал to_thread + новый CSSSanitizer +
вложенную функцию НА КАЖДЫЙ пост, хотя _render_pipeline уже в worker-треде).
Новый sanitizer.py — единственная bleach-конфигурация: ALLOWED_TAGS (union трёх
старых копий, вкл. s/del), ALLOWED_PROTOCOLS=['http','https','tg'],
sanitize_html() зовёт bleach.clean(..., protocols=..., strip=True) (оба не-дефолт),
fail-CLOSED (html.escape при любом исключении), единое имя лога
html_sanitization_error + log_context. _render_pipeline санитайзит каждый пост
после рендера (в своём worker-треде); rss_generator −71 строка.
Реестр §3:
- §3.1 s/del теперь в фидах (раньше выживали только в одиночном посте);
- §3.2 fail-open → fail-closed (вкл. single-post/JSON путь);
- §3.3 <hr class="post-divider"> реально виден (join ПОСЛЕ санитайза; hr не в
whitelist, поэтому раньше вырезался strip=True);
- §3.4 несбалансированный фрагмент нормализуется в границах своего поста;
- §3.5 гранулярный fail-closed HTML-фида (падает один пост, не весь фид);
- §3.16 три имени санитайз-логов объединены в html_sanitization_error+log_context.
Golden-эталоны обновлены: КАЖДЫЙ изменённый байт привязан к пункту §3 (net-diff
тегов: только +<s>/§3.1 и +<hr>/§3.3; <div> сбалансирован +/- = §3.4;
после снятия этих токенов + нормализатора все 6 голденов побайтово old==new).
Оракул этапа 0 зелёный. Полный сюит 346 passed (+14 sanitizer-тестов).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The spec now reflects that the `refactor/render-pipeline` branch is cut from the current `main` (merge commit 88ac436) instead of the old `f9550d8` commit. Updated notes on cache behavior and clarified that no code changes are required at this stage. References updated for epic #34.
Refs #34
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>
Add binary `.cache` and `.chatinfo` files for four public channels to
`tests/test_data/recorded/` to serve as a frozen test corpus. Update the
render pipeline refactor plan to reflect the completed snapshot,
selection, and inventory steps.
Bump the specification version from v5 to v6 and replace the synthetic
golden‑corpus with a recorded real‑message corpus from production cache.
Add detailed inventory statistics, clarify handling of None‑date fixtures,
and update test‑data handling notes. Adjust related documentation sections
to reflect the new corpus and its impact on golden tests.
Add a comprehensive specification document for the render‑pipeline refactor, detailing the problem statement, target architecture, intentional behavior changes, stage‑by‑stage plan, and review notes. This serves as the guiding blueprint for the upcoming refactor work.
Closes#34
Implementation-ready specification for the cache overhaul, accepted after
4 rounds of adversarial review (1 blocker and 3 majors found and fixed).
Work is tracked in gitea issues #23 (packages A+B+F), #24 (C, depends
on #23), #25 (D), #26 (E).
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>
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>