#!/usr/bin/env python3 # -*- coding: utf-8 -*- # flake8: noqa # pylint: disable=broad-exception-raised, raise-missing-from, too-many-arguments, redefined-outer-name # pylint: disable=multiple-statements, logging-fstring-interpolation, trailing-whitespace, line-too-long # pylint: disable=broad-exception-caught, missing-function-docstring, missing-class-docstring # pylint: disable=f-string-without-interpolation # pylance: disable=reportMissingImports, reportMissingModuleSource import logging import asyncio import re import os import html import inspect from dataclasses import dataclass from datetime import datetime from typing import Union, Dict, Any, List, Optional, Callable, Tuple from pyrogram.types import Message from pyrogram.enums import MessageMediaType from sanitizer import sanitize_html from config import get_settings from file_io import upsert_media_file_ids_bulk_sync, DB_PATH from url_signer import generate_media_digest Config = get_settings() 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 # --------------------------------------------------------------------------- # # Single source of truth for media handling (render-pipeline refactor, stage 5). # # MEDIA_SOURCES maps a media type to a selector returning (media object, render # kind). It replaces the three hand-synced ladders that used to live in # _get_file_unique_id (dict map), _save_media_file_ids (if/elif) and # _generate_html_media (elif cascade). All three now go through this table. # # kind=None means "selector-only entry": the object participates in file-id # extraction/collection but is NOT rendered by the dispatcher. The ONLY kind=None # entry is WEB_PAGE (its preview is rendered by _format_webpage). PAID_MEDIA has # NO entry at all: its info block is a dedicated branch in _generate_html_media # and it is deliberately never collected/downloaded (paid content). # --------------------------------------------------------------------------- # def _select_document(message): """DOCUMENT selector: PDF documents render as a t.me link ('pdf'), everything else as an image ('img_400'). The selected object is always message.document.""" document = message.document if (document is not None and hasattr(document, 'mime_type') and document.mime_type == 'application/pdf'): return document, 'pdf' return document, 'img_400' def _select_sticker(message): """STICKER selector: video stickers loop as