refactor(render): этап 2 — футер merged-групп из настоящего Message, выпил конвертера (#29)
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>
This commit is contained in:
2026-07-06 17:39:49 +03:00
parent 53da67eacf
commit d8af0aeb44
13 changed files with 279 additions and 141 deletions
+17 -67
View File
@@ -15,7 +15,6 @@ import asyncio
import re
import time
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Optional
from feedgen.feed import FeedGenerator
from pyrogram import errors, Client
@@ -151,54 +150,6 @@ def _trim_messages_groups(messages_groups: list[list[Message]], limit: int):
return messages_groups
def processed_message_to_tg_message(processed_message: dict) -> Message:
"""
Convert processed message dictionary into a Message-like object
containing only the attributes needed by generate_html_footer.
"""
# Create a simple chat object
chat_info = SimpleNamespace()
channel_identifier = processed_message.get('channel')
if isinstance(channel_identifier, str) and channel_identifier.startswith('-100'):
setattr(chat_info, 'id', int(channel_identifier))
setattr(chat_info, 'username', None)
else:
setattr(chat_info, 'id', None) # Or some placeholder if needed
setattr(chat_info, 'username', channel_identifier)
# Convert reactions dict to list of objects
reactions_list = []
if reactions_dict := processed_message.get('reactions'):
for emoji, count in reactions_dict.items():
# Assuming no custom/paid reactions in this simplified structure
reactions_list.append(SimpleNamespace(emoji=emoji, count=count, is_paid=False, custom_emoji_id=None))
# Recreate reactions structure expected by Pyrogram's reaction handling
reactions_obj = SimpleNamespace(reactions=reactions_list) if reactions_list else None
# Create the message-like object
tg_message_mock = SimpleNamespace(
id=processed_message.get('message_id'),
date=datetime.fromtimestamp(processed_message['date'], tz=timezone.utc) if processed_message.get('date') else None,
views=processed_message.get('views'),
reactions=reactions_obj,
chat=chat_info,
# Add other attributes if generate_html_footer or its dependencies need them
# For now, these seem sufficient based on the analysis of generate_html_footer
# and _reactions_views_links.
text=processed_message.get('text'), # Add text just in case
caption=None, # Assume caption is merged into text by process_message
forward_origin=None, # Not directly needed by footer generation logic itself
reply_to_message=None, # Not directly needed by footer generation logic itself
media=None, # Not needed by footer
service=processed_message.get('service') # Potentially needed? Added just in case.
)
# Cast to Message type hint for static analysis, although it's a mock object
return tg_message_mock # type: ignore
def _render_messages_groups(messages_groups: list[list[Message]],
post_parser: PostParser,
exclude_flags: str | None = None,
@@ -240,12 +191,14 @@ def _render_messages_groups(messages_groups: list[list[Message]],
else: # Multiple messages in group - merge text and html body
processed_messages = [post_parser.process_message(msg, include_raw=False, sanitize=False) for msg in group]
# Determine main message for header/footer/title
main_message = next(
(msg for msg in processed_messages if msg['text']),
processed_messages[0] # fallback if no message contains text
)
# Determine the main message with the SAME criterion the processed dicts
# used: first message that has text or caption, else the first of the
# group. processed_messages[i] corresponds to group[i], so main_message
# (dict, for title/date/author) and main_raw (the real Message, for the
# footer) point at the same index.
main_idx = next((i for i, m in enumerate(group) if (m.text or m.caption)), 0)
main_raw = group[main_idx]
main_message = processed_messages[main_idx]
# Merge text fields from all messages
all_texts = [msg['text'] for msg in processed_messages if msg['text']]
@@ -255,19 +208,16 @@ def _render_messages_groups(messages_groups: list[list[Message]],
all_html_bodies = [msg['html']['body'] for msg in processed_messages if msg['html']['body']]
combined_html_body = '\n<br><br>\n'.join(all_html_bodies)
# Collect all unique flags from all messages in the group
all_flags = set()
for msg in processed_messages:
if msg.get('flags'): # Check if flags exist and are not empty
all_flags.update(msg['flags'])
all_flags.add("merged")
merged_flags = list(all_flags) # Convert back to list if needed, or keep as set
# Deterministic merged flags: first-seen order across the group, then
# 'merged' (registry §3.8 — replaces the hash-ordered list(set(...))).
merged_flags = list(dict.fromkeys(f for msg in processed_messages for f in msg['flags']))
merged_flags.append("merged")
# generate tg-message from processed message
tg_message = processed_message_to_tg_message(main_message)
footer_html = post_parser.generate_html_footer(tg_message, flags_list=merged_flags)
# Render the merged footer DIRECTLY from the real main Message — no
# dict->mock round-trip. The raw Message carries its real reactions
# (custom emojis get their own span, registry §3.6) and its naive-local
# 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'<div class="message-body">{combined_html_body}</div>',