From 2e1289edd26bccee805a42b3a90f2a0aad4c341a Mon Sep 17 00:00:00 2001 From: Carlos de la Lama-Noriega Date: Wed, 10 Jun 2026 13:10:33 +0000 Subject: [PATCH 1/2] fix(backup): preserve service action metadata in raw_data Historical backfills store service messages but drop message.action, losing metadata the live listener already preserves since v6.0.0 (raw_data.service_type / action_type / new_title). Capture the action type (normalized class name) and, when present, the title via the existing TextWithEntities helper. Forum topic creations and renames become auditable and linkable: topic_create via messages.id, topic_edit via reply_to_top_id. No schema change, no new logs. --- src/telegram_backup.py | 25 +++++++++++++++++ tests/test_telegram_backup.py | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/telegram_backup.py b/src/telegram_backup.py index fd825841..5a9ce6b6 100644 --- a/src/telegram_backup.py +++ b/src/telegram_backup.py @@ -8,6 +8,7 @@ import logging import os import random +import re from datetime import UTC, datetime from telethon import TelegramClient @@ -289,6 +290,17 @@ async def iter_messages_with_flood_retry(client, entity, *, min_id=0, **kwargs): await asyncio.sleep(sleep_duration) +def _service_action_type(action: object) -> str: + """Normalize a Telethon MessageAction class name to snake_case. + + Examples: MessageActionTopicCreate -> "topic_create", + MessageActionTopicEdit -> "topic_edit", + MessageActionChatEditTitle -> "chat_edit_title". + """ + name = type(action).__name__.removeprefix("MessageAction") + return re.sub(r"(? dict: "is_pinned": 1 if getattr(message, "pinned", False) else 0, } + # Preserve service-action metadata (e.g. forum topic creations and + # renames) so historical backfills keep parity with the listener's + # raw_data convention (service_type / action_type, since v6.0.0). + # Without this, service events are stored without their payload and + # the information is irrecoverable once the history is archived. + action = getattr(message, "action", None) + if action is not None: + message_data["raw_data"]["service_type"] = "service" + message_data["raw_data"]["action_type"] = _service_action_type(action) + action_title = getattr(action, "title", None) + if action_title is not None: + message_data["raw_data"]["new_title"] = self._text_with_entities_to_string(action_title) + # Capture grouped_id for album detection (multiple photos/videos sent together) if message.grouped_id: message_data["raw_data"]["grouped_id"] = str(message.grouped_id) diff --git a/tests/test_telegram_backup.py b/tests/test_telegram_backup.py index 694a3f6d..4d6986e8 100644 --- a/tests/test_telegram_backup.py +++ b/tests/test_telegram_backup.py @@ -12,6 +12,8 @@ from telethon.tl.types import ( Channel, Chat, + MessageActionTopicCreate, + MessageActionTopicEdit, MessageMediaContact, MessageMediaDocument, MessageMediaGeo, @@ -366,6 +368,9 @@ def _make_message(self, msg_id, reply_to=None): # topic-skip guard in _backup_dialog doesn't accidentally filter # every message via MagicMock truthiness. msg.reply_to = reply_to + # Explicitly None so the service-action capture in _process_message + # is not triggered by MagicMock truthiness. + msg.action = None return msg def test_checkpoint_after_every_batch(self): @@ -1164,6 +1169,9 @@ def _make_message(self, msg_id, text="hello", sender_id=42): msg.media = None msg.reactions = None msg.post_author = None + # Explicitly None so the service-action capture in _process_message + # is not triggered by MagicMock truthiness. + msg.action = None return msg def test_basic_text_message(self): @@ -1215,6 +1223,49 @@ def test_post_author_stored(self): result = self._run(self.backup._process_message(msg, 100)) self.assertEqual(result["raw_data"]["post_author"], "Editor Name") + def test_topic_create_action_stored_in_raw_data(self): + """Topic-create service action stores service metadata in raw_data.""" + msg = self._make_message(8, text=None) + msg.action = MessageActionTopicCreate(title="Synthetic Topic", icon_color=0) + result = self._run(self.backup._process_message(msg, 100)) + self.assertEqual(result["raw_data"]["service_type"], "service") + self.assertEqual(result["raw_data"]["action_type"], "topic_create") + self.assertEqual(result["raw_data"]["new_title"], "Synthetic Topic") + # Topic creations are linkable: the topic id equals the service + # message id (forum_topics.id == messages.id). + self.assertEqual(result["id"], 8) + + def test_topic_edit_action_stores_new_title(self): + """Topic rename stores the new title and stays linkable by topic id.""" + msg = self._make_message(9, text=None) + msg.action = MessageActionTopicEdit(title="Renamed Topic") + msg.reply_to = MagicMock() + msg.reply_to.forum_topic = True + msg.reply_to.reply_to_top_id = 8 + result = self._run(self.backup._process_message(msg, 100)) + self.assertEqual(result["raw_data"]["service_type"], "service") + self.assertEqual(result["raw_data"]["action_type"], "topic_edit") + self.assertEqual(result["raw_data"]["new_title"], "Renamed Topic") + # Topic edits are linkable through reply_to_top_id. + self.assertEqual(result["reply_to_top_id"], 8) + + def test_topic_edit_without_title_has_no_new_title(self): + """Topic edits that do not rename (e.g. close) store no new_title.""" + msg = self._make_message(10, text=None) + msg.action = MessageActionTopicEdit(closed=True) + result = self._run(self.backup._process_message(msg, 100)) + self.assertEqual(result["raw_data"]["service_type"], "service") + self.assertEqual(result["raw_data"]["action_type"], "topic_edit") + self.assertNotIn("new_title", result["raw_data"]) + + def test_regular_message_has_no_service_metadata(self): + """Regular messages carry no service metadata in raw_data.""" + msg = self._make_message(11) + result = self._run(self.backup._process_message(msg, 100)) + self.assertNotIn("service_type", result["raw_data"]) + self.assertNotIn("action_type", result["raw_data"]) + self.assertNotIn("new_title", result["raw_data"]) + def test_none_text_becomes_empty_string(self): """Message with None text stores empty string.""" msg = self._make_message(7, text=None) From af6fb96f182ba1ac42c29e2c3f3a438c4086ca40 Mon Sep 17 00:00:00 2001 From: Carlos de la Lama-Noriega Date: Wed, 10 Jun 2026 15:52:09 +0000 Subject: [PATCH 2/2] test: set action=None in the extended mock message fixture Same MagicMock-truthiness guard the other fixtures use: without it the extended tests flow a truthy mock action through the new service-action capture and store phantom metadata in raw_data. --- tests/test_telegram_backup_extended.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_telegram_backup_extended.py b/tests/test_telegram_backup_extended.py index 52dc3dc5..a8276d89 100644 --- a/tests/test_telegram_backup_extended.py +++ b/tests/test_telegram_backup_extended.py @@ -62,6 +62,9 @@ def _make_message(msg_id, *, reply_to=None, text="hello", media=None): msg.media = media msg.reactions = None msg.post_author = None + # Explicitly None so the service-action capture in _process_message + # is not triggered by MagicMock truthiness. + msg.action = None return msg