diff --git a/post_parser.py b/post_parser.py
index 1b306e3..ad1e61e 100644
--- a/post_parser.py
+++ b/post_parser.py
@@ -175,6 +175,23 @@ MEDIA_SOURCES: Dict[Any, Callable[[Message], Tuple[Any, Optional[str]]]] = {
}
+# Inline media-sizing style literals, named so the renderers below read as intent
+# rather than magic numbers. Values are substituted verbatim into the style strings,
+# so the emitted bytes are unchanged.
+MEDIA_MAX_HEIGHT_PX = "400px" # standard image/video max-height
+MEDIA_MAX_HEIGHT_SMALL_PX = "200px" # looping/sticker media max-height
+MEDIA_MAX_WIDTH_PX = "400px" # audio player max-width
+
+
+def _wrap_post_html(body: str, footer: str) -> str:
+ """Shared post-HTML shell: the message-body + message-footer div pair joined by a
+ single newline. Used by PostParser._format_html (single/debug post) and by both
+ branches of rss_generator._render_messages_groups (single message and merged
+ group), so the emitted byte structure stays identical across all three call sites."""
+ return (f'
{body}
\n'
+ f'')
+
+
@dataclass
class RenderCtx:
"""Everything a renderer needs. _generate_html_media assembles it (URL signing,
@@ -193,34 +210,34 @@ class RenderCtx:
# byte-for-byte fidelity is the stage-5a contract.
def _render_img_400(ctx: 'RenderCtx') -> List[str]:
return [f' ']
+ f'max-height:{MEDIA_MAX_HEIGHT_PX}; object-fit:contain;">']
def _render_video_400(ctx: 'RenderCtx') -> List[str]:
return [f' ']
+ f'height:auto; max-height:{MEDIA_MAX_HEIGHT_PX};">']
def _render_audio(ctx: 'RenderCtx') -> List[str]:
- return [f''
+ return [f''
f' ',
' ']
def _render_video_loop_200(ctx: 'RenderCtx') -> List[str]:
return [f' ']
def _render_img_200_sticker(ctx: 'RenderCtx') -> List[str]:
return [f' ']
+ f'width:auto; height:auto; max-height:{MEDIA_MAX_HEIGHT_SMALL_PX}; object-fit:contain;">']
def _render_video_loop_400(ctx: 'RenderCtx') -> List[str]:
return [f' ']
@@ -732,8 +749,7 @@ class PostParser:
# through bleach — escape it before embedding, same as raw_message below.
title_escaped = html.escape(str(data["html"]["title"]))
html_content.append(f'Title: {title_escaped}
')
- html_content.append(f'{data["html"]["body"]}
')
- html_content.append(f'')
+ html_content.append(_wrap_post_html(data["html"]["body"], data["html"]["footer"]))
# Add raw JSON debug output if debug is enabled.
# raw_message is the full str(message) serialization and may contain user-controlled
@@ -776,17 +792,17 @@ class PostParser:
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.
+ # The internal per-fragment sanitize passes are gone; sanitize runs exactly
+ # once at the output boundary here when requested, never inside body/footer.
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
- # — 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.
+ # NOTE: flags are extracted from the PRE-sanitize body (there is no longer a
+ # per-fragment sanitize inside _generate_html_body). Legitimate links are
+ # unaffected — bleach keeps whitelisted — 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 the
+ # per-message sanitize pass that was deliberately eliminated.
flags = self._extract_flags(message, html_body=html_body)
footer = self.generate_html_footer(message, flags_list=flags)
if sanitize:
@@ -930,9 +946,9 @@ class PostParser:
if message.forward_origin: content_body.append(f"--- Forwarded post end ---") # Forward info end
content_body.append(f' ')
- # NOTE: sanitize is NOT applied here. Sanitization happens exactly once per
+ # NOTE: sanitize is NOT applied here. Sanitization happens exactly once at the
# output boundary (process_message for single-post/JSON; in rss_generator the
- # per-post pass in _render_pipeline for BOTH RSS and HTML). See the map (4.4).
+ # per-post pass in _render_pipeline for BOTH RSS and HTML).
html_body = '\n'.join(content_body)
return html_body
@@ -1017,7 +1033,7 @@ class PostParser:
content_media.append('')
# Not sanitized here — this fragment is embedded in the body and sanitized
- # once at the output boundary (see the 4.4 sanitize coverage map).
+ # once at the output boundary (single sanitize pass, see process_message).
html_media = '\n'.join(content_media)
return html_media
@@ -1098,7 +1114,7 @@ class PostParser:
flags_html = self._format_flags(current_flags)
content_footer.append(' ' + flags_html)
- # Not sanitized here — sanitized once at the output boundary (4.4 coverage map):
+ # Not sanitized here — sanitized once at the output boundary:
# process_message for single-post/JSON; per-post in _render_pipeline for feeds
# (both RSS and HTML).
html_footer = '\n'.join(content_footer)
@@ -1193,7 +1209,7 @@ class PostParser:
parts.append(' | '.join(links))
# Raw fragment — embedded in the footer, sanitized once at the output
- # boundary (see the 4.4 sanitize coverage map). Not sanitized here.
+ # boundary (single sanitize pass, see process_message). Not sanitized here.
result_html = ' '.join(parts) if parts else None
return result_html if result_html else None
diff --git a/rss_generator.py b/rss_generator.py
index 673f1a2..0650a48 100644
--- a/rss_generator.py
+++ b/rss_generator.py
@@ -19,7 +19,7 @@ from typing import Optional
from feedgen.feed import FeedGenerator
from pyrogram import errors, Client
from pyrogram.types import Message
-from post_parser import PostParser
+from post_parser import PostParser, _wrap_post_html
from config import get_settings
from tg_throttle import tg_rpc_bounded
from sanitizer import sanitize_html
@@ -175,17 +175,6 @@ def _create_messages_groups(messages: list[Message], group_ids: dict[int, str |
return processing_groups
-def _trim_messages_groups(messages_groups: list[list[Message]], limit: int):
- """
- Trim messages groups to limit
-
- Plain synchronous function (contains no await): runs inside the render thread.
- """
- if len(messages_groups) > limit: # Trim groups if they exceed the specified limit
- messages_groups = messages_groups[:limit]
-
- return messages_groups
-
def _render_messages_groups(messages_groups: list[list[Message]],
post_parser: PostParser,
exclude_flags: str | None = None,
@@ -211,12 +200,8 @@ def _render_messages_groups(messages_groups: list[list[Message]],
# pass in _render_pipeline (both RSS and HTML), so each fragment is
# sanitized exactly once.
message_data = post_parser.process_message(one_message, include_raw=False, sanitize=False)
- html_parts = [
- f'{message_data["html"]["body"]}
',
- f''
- ]
rendered_posts.append({
- 'html': '\n'.join(html_parts),
+ 'html': _wrap_post_html(message_data["html"]["body"], message_data["html"]["footer"]),
'date': message_data['date'],
'message_id': message_data['message_id'],
'title': message_data['html']['title'],
@@ -255,13 +240,8 @@ def _render_messages_groups(messages_groups: list[list[Message]],
# date (registry §3.7), matching a single post of the same message.
footer_html = post_parser.generate_html_footer(main_raw, flags_list=merged_flags)
- html_parts = [
- f'{combined_html_body}
',
- f''
- ]
-
rendered_posts.append({
- 'html': '\n'.join(html_parts),
+ 'html': _wrap_post_html(combined_html_body, footer_html),
'date': main_message['date'],
'message_id': main_message['message_id'],
'title': main_message['html']['title'],
@@ -277,16 +257,13 @@ def _render_messages_groups(messages_groups: list[list[Message]],
# Filter posts by exclude_flags
if exclude_flags:
exclude_flag_list = [flag.strip() for flag in exclude_flags.split(',')] # Split comma-separated flags into list
- filtered_posts = []
- for post in rendered_posts:
- # If "all" is specified and the post has any flags, exclude the post.
- if "all" in exclude_flag_list and post['flags']:
- continue
- # Exclude post if any flag in the exclude list is present in the post's flags.
- if any(flag in post['flags'] for flag in exclude_flag_list):
- continue
- filtered_posts.append(post)
- rendered_posts = filtered_posts
+ rendered_posts = [
+ post for post in rendered_posts
+ # Keep a post unless "all" is requested and it carries any flag, or any of
+ # its flags appears in the exclude list.
+ if not ("all" in exclude_flag_list and post['flags'])
+ and not any(flag in post['flags'] for flag in exclude_flag_list)
+ ]
# Filter posts by exclude_text
if exclude_text:
@@ -336,7 +313,8 @@ def _render_pipeline(messages: list[Message],
message_groups = _create_messages_groups(messages, group_ids)
else:
message_groups = _create_messages_groups(messages)
- message_groups = _trim_messages_groups(message_groups, limit)
+ # Trim groups if they exceed the requested limit (slice is a no-op when shorter).
+ message_groups = message_groups[:limit]
posts = _render_messages_groups(message_groups, post_parser, exclude_flags, exclude_text)
# Sanitize each surviving (post-filter) post exactly once, here in the worker
# thread — no per-post thread hop / per-post CSSSanitizer anymore. Per-post
diff --git a/tests/test_group_ids.py b/tests/test_group_ids.py
index a280370..7fb0078 100644
--- a/tests/test_group_ids.py
+++ b/tests/test_group_ids.py
@@ -244,7 +244,7 @@ async def test_none_date_post_renders_and_lands_at_feed_end(monkeypatch):
@pytest.mark.asyncio
async def test_none_date_group_survives_limit_slice(monkeypatch):
# §3.12 retention: the group sort key gives None-date groups float('inf') so they sort
- # as NEWEST and deterministically survive the [:limit] slice in _trim_messages_groups.
+ # as NEWEST and deterministically survive the [:limit] slice in _render_pipeline.
# This test applies REAL limit pressure (limit < number of groups) so the slice actually
# drops groups — otherwise reverting 'inf' to 0.0 (or any small key) would go unnoticed.
# With 5 dated posts + 1 None-date post and limit=3, the surviving 3 groups must be the
diff --git a/tests/test_stage4_eventloop.py b/tests/test_stage4_eventloop.py
index 3089345..5ce8c35 100644
--- a/tests/test_stage4_eventloop.py
+++ b/tests/test_stage4_eventloop.py
@@ -36,7 +36,6 @@ from rss_generator import (
_render_pipeline,
_compute_time_based_group_ids,
_create_messages_groups,
- _trim_messages_groups,
_render_messages_groups,
)
@@ -91,8 +90,10 @@ def _co_names(func):
# ---------------------------------------------------------------------------
def test_render_functions_are_sync():
+ # _trim_messages_groups was inlined into _render_pipeline as a `[:limit]` slice
+ # (render-pipeline cosmetics stage); the trimming path is now covered via _render_pipeline.
for fn in (_compute_time_based_group_ids, _create_messages_groups,
- _trim_messages_groups, _render_messages_groups, _render_pipeline):
+ _render_messages_groups, _render_pipeline):
assert not asyncio.iscoroutinefunction(fn), f"{fn.__name__} must be a plain sync function"
@@ -154,7 +155,7 @@ def test_render_path_has_no_asyncio_side_effects():
banned = {"create_task", "get_running_loop", "to_thread", "ensure_future"}
funcs = [
_render_pipeline, _compute_time_based_group_ids, _create_messages_groups,
- _trim_messages_groups, _render_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,
diff --git a/tests/test_stage6_exclude_flags.py b/tests/test_stage6_exclude_flags.py
new file mode 100644
index 0000000..6ed53c3
--- /dev/null
+++ b/tests/test_stage6_exclude_flags.py
@@ -0,0 +1,86 @@
+# flake8: noqa
+# pylint: disable=missing-function-docstring, redefined-outer-name
+"""Stage-6: unit lock for the `exclude_flags` feed filter (issue #33, epic #34).
+
+The filter (rss_generator._render_messages_groups) is a user-facing query-param
+that drops posts from the feed by flag. It is NOT exercised by the stage-0 golden
+oracle (golden_replay runs without exclude_flags), and stage 6 rewrote it from a
+`for/continue` loop into a list-comprehension. These tests pin the membership
+semantics so the rewrite (and any future one) stays honest — this is the
+"dedicated unit tests" that golden_replay.py's header comment refers to.
+
+Flag shapes under the test config: a plain text post carries ['no_image']; a
+merged group additionally carries 'merged'. (Every rendered post carries at least
+one flag, so the `"all"` filter drops every real post; the guard's flagless-keep
+branch — `and post['flags']` — is a media-path concern proven separately by the
+equivalence check in the PR, not reproducible with plain fixtures here.)
+"""
+from datetime import datetime, timezone
+from types import SimpleNamespace
+
+import pytest
+
+from rss_generator import _render_messages_groups
+from post_parser import PostParser
+from tests.test_stage4_eventloop import make_message
+
+
+@pytest.fixture
+def parser():
+ return PostParser(SimpleNamespace())
+
+
+def _plain(mid, date=None):
+ # Plain text post -> flags == ['no_image'].
+ return make_message(mid, text="plain", date=date)
+
+
+def _merged_group(mid, date=None):
+ # Merged group -> flags == ['no_image', 'merged']; message_id is the main id.
+ main = make_message(mid, text="m", date=date)
+ main.media_group_id = f"g{mid}"
+ other = make_message(mid + 1, text="o", date=date)
+ other.media_group_id = f"g{mid}"
+ return [main, other]
+
+
+def _ids(posts):
+ return [p["message_id"] for p in posts]
+
+
+def test_exclude_flags_all_drops_flagged_posts(parser):
+ # `"all"` special case: any post carrying >=1 flag is dropped.
+ posts = _render_messages_groups(
+ [[_plain(10)], [_plain(11)]], parser, exclude_flags="all"
+ )
+ assert _ids(posts) == [] # both ['no_image'] -> dropped
+
+
+def test_exclude_flags_specific_drops_matching_keeps_nonmatching(parser):
+ # Exclude a concrete flag: the post carrying it is dropped, one without it kept.
+ posts = _render_messages_groups(
+ [_merged_group(20), [_plain(22)]], parser, exclude_flags="merged"
+ )
+ ids = _ids(posts)
+ assert 22 in ids # ['no_image'] has no 'merged' -> kept
+ assert 20 not in ids # ['no_image', 'merged'] -> dropped
+
+
+def test_exclude_flags_none_or_nonmatching_keeps_all(parser):
+ base = [[_plain(30)], [_plain(31)]]
+ assert len(_render_messages_groups(base, parser)) == 2 # no filter
+ assert (
+ len(_render_messages_groups(base, parser, exclude_flags="nonexistent")) == 2
+ ) # flag matches nothing -> all kept
+
+
+def test_exclude_flags_preserves_survivor_order(parser):
+ # Distinct descending dates so the trailing date-sort is deterministic;
+ # dropping the middle (merged) post must not reorder the survivors.
+ a = make_message(40, text="a", date=datetime(2024, 1, 3, tzinfo=timezone.utc))
+ mid = _merged_group(41, date=datetime(2024, 1, 2, tzinfo=timezone.utc))
+ c = make_message(43, text="c", date=datetime(2024, 1, 1, tzinfo=timezone.utc))
+ posts = _render_messages_groups(
+ [[a], mid, [c]], parser, exclude_flags="merged"
+ )
+ assert _ids(posts) == [40, 43] # merged(41) dropped; a before c by date-desc