diff --git a/api_server.py b/api_server.py index 6fbe2ea..14234c2 100644 --- a/api_server.py +++ b/api_server.py @@ -287,9 +287,26 @@ if __name__ == "__main__": async def find_file_id_in_message(message: Message, file_unique_id: str) -> Union[str, None]: """Find file_id by checking all possible media types in message""" if message.media == MessageMediaType.POLL: - logger.debug(f"Message {message.id} is a poll, skipping media search") + # Kurigram 2.2.23: polls may carry media in description_media and + # explanation_media (MessageContent objects). The bridge renders only + # description_media, but explanation_media is searched too: if a signed URL + # for it ever exists, the download must still work. getattr-only access โ€” + # older Poll objects/mocks do not define these fields. + poll = getattr(message, 'poll', None) + for container_name in ('description_media', 'explanation_media'): + content = getattr(poll, container_name, None) if poll else None + if content is None: + continue + for media_attr in ('photo', 'video', 'animation', 'sticker', + 'document', 'audio', 'voice', 'video_note'): + media_obj = getattr(content, media_attr, None) + if media_obj is None: + continue + if getattr(media_obj, 'file_unique_id', None) == file_unique_id: + return getattr(media_obj, 'file_id', None) + logger.debug(f"Message {message.id} is a poll, media '{file_unique_id}' not found in poll content") return None - + media_found = [] if message.photo: media_found.append(f"photo ({message.photo.file_unique_id})") @@ -327,7 +344,21 @@ async def find_file_id_in_message(message: Message, file_unique_id: str) -> Unio media_found.append(f"document ({message.document.file_unique_id})") if message.document.file_unique_id == file_unique_id: return message.document.file_id - + # New media types (Kurigram 2.2.23): getattr-only access, the attributes do not + # exist on older Message objects/mocks. + if live_photo := getattr(message, 'live_photo', None): + media_found.append(f"live_photo ({getattr(live_photo, 'file_unique_id', None)})") + if getattr(live_photo, 'file_unique_id', None) == file_unique_id: + return getattr(live_photo, 'file_id', None) + if story := getattr(message, 'story', None): + for story_attr in ('photo', 'video'): + story_media = getattr(story, story_attr, None) + if story_media is None: + continue + media_found.append(f"story.{story_attr} ({getattr(story_media, 'file_unique_id', None)})") + if getattr(story_media, 'file_unique_id', None) == file_unique_id: + return getattr(story_media, 'file_id', None) + # If we reached here, the file_unique_id was not found channel_id_log = message.chat.id if message.chat else 'unknown_chat' logger.warning(f"Could not find media with file_unique_id '{file_unique_id}' in message {message.id} (channel: {channel_id_log}). Found media: {', '.join(media_found) or 'None'}") diff --git a/post_parser.py b/post_parser.py index c370e7a..d50caff 100644 --- a/post_parser.py +++ b/post_parser.py @@ -28,6 +28,85 @@ 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 + #tests #http://127.0.0.1:8000/post/html/DragorWW_space/114 โ€” video @@ -221,6 +300,31 @@ class PostParser: if message.media == MessageMediaType.VOICE: return "๐ŸŽค Voice" if message.media == MessageMediaType.VIDEO_NOTE: return "๐Ÿ“ฑ Video circle" 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) if message.web_page: @@ -357,7 +461,9 @@ class PostParser: flags.append("fwd") # 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): flags.append("video") @@ -366,8 +472,12 @@ class PostParser: len((message.text or message.caption or '').strip()) <= 200): flags.append("audio") - # Add flag for posts without images - if not message.media or message.media == MessageMediaType.POLL: + # Add flag for posts without images: no media at all, an info-block-only media + # 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") # Add flag for sticker messages @@ -715,6 +825,7 @@ class PostParser: 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 content_body.append(f'
') @@ -730,7 +841,27 @@ class PostParser: content_media = [] 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'
') + content_media.append(f'') + content_media.append('
') + # 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'
') file_unique_id = self._get_file_unique_id(message) @@ -791,6 +922,28 @@ class PostParser: else: content_media.append(f'Sticker {emoji}') + elif message.media == MessageMediaType.LIVE_PHOTO: + # Live photo is effectively a short video clip. + content_media.append(f'') + elif message.media == MessageMediaType.STORY: + # Choose the tag from the SAME helper that produced the URL's + # file_unique_id, so the tag type always matches the URL object. + 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'') + elif story_media_obj is not None: + content_media.append(f'') + elif message.media == MessageMediaType.POLL and poll_media_obj is not None: + if poll_media_kind == 'video': + content_media.append(f'') + else: + content_media.append(f'') content_media.append('
') if webpage := getattr(message, "web_page", None): # Web page preview @@ -999,6 +1152,109 @@ class PostParser: logger.error(f"poll_parsing_error: {str(e)}") return '
[Error displaying poll]
' + @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'{latitude:.5f}, {longitude:.5f}' + + 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"
{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"
{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 = '
'.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'
{block}
' + 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]: try: media_mapping = { @@ -1010,7 +1266,12 @@ class PostParser: MessageMediaType.VIDEO_NOTE: lambda m: m.video_note.file_unique_id, MessageMediaType.ANIMATION: lambda m: m.animation.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: @@ -1059,6 +1320,7 @@ class PostParser: return file_unique_id = '' + new_media_obj = None # selected object among the Kurigram 2.2.23 media sources if message.photo: file_unique_id = message.photo.file_unique_id elif message.video: file_unique_id = message.video.file_unique_id elif message.document: file_unique_id = message.document.file_unique_id @@ -1069,6 +1331,24 @@ class PostParser: 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 + + if new_media_obj is not None: + # The >100MB message.video guard above does not cover these + # sources (live photo, story video, poll description video) โ€” + # apply the same "don't cache large videos" rule here. + file_size = getattr(new_media_obj, 'file_size', None) + if isinstance(file_size, int) and file_size > 100 * 1024 * 1024: + return + file_unique_id = getattr(new_media_obj, 'file_unique_id', '') or '' if file_unique_id: added_ts = datetime.now().timestamp() diff --git a/requirements.txt b/requirements.txt index a30a961..d867415 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ fastapi==0.115.8 starlette==0.45.3 uvicorn==0.34.0 python-multipart==0.0.20 -Kurigram==2.2.22 +Kurigram==2.2.23 #git+https://github.com/KurimuzonAkuma/pyrogram.git@dev#egg=Kurigram TgCrypto uvloop diff --git a/tests/test_new_media_types.py b/tests/test_new_media_types.py new file mode 100644 index 0000000..21194b6 --- /dev/null +++ b/tests/test_new_media_types.py @@ -0,0 +1,513 @@ +# 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