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'
')
+ # 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'')
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..03be873
--- /dev/null
+++ b/tests/test_new_media_types.py
@@ -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