Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/telegram_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import logging
import os
import random
import re
from datetime import UTC, datetime

from telethon import TelegramClient
Expand Down Expand Up @@ -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"(?<!^)(?=[A-Z])", "_", name).lower()


class TelegramBackup:
"""Main class for managing Telegram backups."""

Expand Down Expand Up @@ -1412,6 +1424,19 @@ async def _process_message(self, message: Message, chat_id: int) -> 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)
Expand Down
51 changes: 51 additions & 0 deletions tests/test_telegram_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from telethon.tl.types import (
Channel,
Chat,
MessageActionTopicCreate,
MessageActionTopicEdit,
MessageMediaContact,
MessageMediaDocument,
MessageMediaGeo,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions tests/test_telegram_backup_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading