From 6433f55b1f4aa9dc889ac94c9671399146cd5051 Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Fri, 17 Jul 2026 19:33:44 +0100 Subject: [PATCH 1/6] Add Telegram contacts backup with full metadata and avatars --- tg_contacts_backup.py | 322 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 tg_contacts_backup.py diff --git a/tg_contacts_backup.py b/tg_contacts_backup.py new file mode 100644 index 0000000..ce076c7 --- /dev/null +++ b/tg_contacts_backup.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Export Telegram contacts, full user metadata, and current profile photos.""" + +from __future__ import annotations + +import argparse +import asyncio +import csv +import json +import os +import re +from datetime import date, datetime, timezone +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv +from telethon import TelegramClient, functions, types +from telethon.errors import FloodWaitError + + +CSV_FIELDS = [ + "id", + "access_hash", + "first_name", + "last_name", + "display_name", + "username", + "usernames", + "phone", + "lang_code", + "status", + "status_timestamp", + "contact", + "mutual_contact", + "close_friend", + "bot", + "verified", + "premium", + "restricted", + "scam", + "fake", + "deleted", + "about", + "birthday", + "common_chats_count", + "photo_id", + "avatar_path", + "details_error", + "avatar_error", +] + + +def normalize(value: Any) -> Any: + """Convert Telethon values into JSON-serializable Python values.""" + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, bytes): + return value.hex() + if isinstance(value, dict): + return {str(key): normalize(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [normalize(item) for item in value] + if hasattr(value, "to_dict"): + return normalize(value.to_dict()) + return value + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def display_name(user: types.User) -> str: + name = " ".join( + part.strip() + for part in (getattr(user, "first_name", None), getattr(user, "last_name", None)) + if part and part.strip() + ) + return name or getattr(user, "username", None) or f"user_{user.id}" + + +def safe_filename(value: str, fallback: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._") + return (cleaned[:80] or fallback).strip("._") or fallback + + +def status_fields(user: types.User) -> tuple[str, str]: + status = getattr(user, "status", None) + if status is None: + return "", "" + + timestamp = getattr(status, "expires", None) or getattr(status, "was_online", None) + return type(status).__name__, normalize(timestamp) if timestamp else "" + + +def usernames_for(user: types.User) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + primary = getattr(user, "username", None) + if primary: + result.append({"username": primary, "active": True, "primary": True}) + + for item in getattr(user, "usernames", None) or []: + entry = normalize(item) + if not isinstance(entry, dict): + continue + username = entry.get("username") + if not username: + continue + entry["primary"] = username == primary + if not any(existing.get("username") == username for existing in result): + result.append(entry) + return result + + +async def with_flood_wait(operation, label: str): + """Run an async Telegram operation, respecting Telegram flood waits.""" + while True: + try: + return await operation() + except FloodWaitError as exc: + wait_seconds = max(1, int(exc.seconds)) + print(f"[FLOOD WAIT] {label}: sleeping for {wait_seconds}s") + await asyncio.sleep(wait_seconds) + + +async def export_contacts(args: argparse.Namespace) -> Path: + load_dotenv() + + api_id = int(os.getenv("TELEGRAM_API_ID", "0")) + api_hash = os.getenv("TELEGRAM_API_HASH", "changeme") + session_name = os.getenv("TELEGRAM_SESSION_NAME", "telegram_maintenance") + if not api_id or api_hash == "changeme": + raise RuntimeError("Please set TELEGRAM_API_ID and TELEGRAM_API_HASH in .env") + + output_dir = Path(args.output_dir or f"telegram_contacts_{datetime.now().strftime('%Y%m%d_%H%M%S')}") + output_dir.mkdir(parents=True, exist_ok=True) + avatars_dir = output_dir / "avatars" + if not args.skip_avatars: + avatars_dir.mkdir(parents=True, exist_ok=True) + + client = TelegramClient(session_name, api_id, api_hash) + async with client: + me = await client.get_me() + contacts_result = await with_flood_wait( + lambda: client(functions.contacts.GetContactsRequest(hash=0)), + "fetch contacts", + ) + + users = [user for user in getattr(contacts_result, "users", []) if isinstance(user, types.User)] + contact_ids = { + getattr(contact, "user_id", None) + for contact in getattr(contacts_result, "contacts", []) + } + users = [user for user in users if user.id in contact_ids] + users.sort(key=lambda user: (display_name(user).casefold(), user.id)) + + records: list[dict[str, Any]] = [] + total = len(users) + print(f"Exporting {total} Telegram contacts to {output_dir}") + + for index, user in enumerate(users, start=1): + name = display_name(user) + print(f"[{index}/{total}] {name} (id={user.id})") + status_name, status_timestamp = status_fields(user) + photo = getattr(user, "photo", None) + + record: dict[str, Any] = { + "id": user.id, + "access_hash": getattr(user, "access_hash", None), + "first_name": getattr(user, "first_name", None), + "last_name": getattr(user, "last_name", None), + "display_name": name, + "username": getattr(user, "username", None), + "usernames": usernames_for(user), + "phone": getattr(user, "phone", None), + "lang_code": getattr(user, "lang_code", None), + "status": status_name, + "status_timestamp": status_timestamp, + "contact": bool(getattr(user, "contact", False)), + "mutual_contact": bool(getattr(user, "mutual_contact", False)), + "close_friend": bool(getattr(user, "close_friend", False)), + "bot": bool(getattr(user, "bot", False)), + "verified": bool(getattr(user, "verified", False)), + "premium": bool(getattr(user, "premium", False)), + "restricted": bool(getattr(user, "restricted", False)), + "scam": bool(getattr(user, "scam", False)), + "fake": bool(getattr(user, "fake", False)), + "deleted": bool(getattr(user, "deleted", False)), + "photo_id": getattr(photo, "photo_id", None), + "avatar_path": None, + "details_error": None, + "avatar_error": None, + "user": normalize(user), + "full_user": None, + } + + if not args.skip_full_details: + try: + full = await with_flood_wait( + lambda user=user: client(functions.users.GetFullUserRequest(user)), + f"fetch full details for {user.id}", + ) + record["full_user"] = normalize(getattr(full, "full_user", None)) + except Exception as exc: + record["details_error"] = f"{type(exc).__name__}: {exc}" + print(f" [WARN] Full details unavailable: {exc}") + + full_user = record.get("full_user") or {} + if isinstance(full_user, dict): + record["about"] = full_user.get("about") + record["birthday"] = full_user.get("birthday") + record["common_chats_count"] = full_user.get("common_chats_count") + else: + record["about"] = None + record["birthday"] = None + record["common_chats_count"] = None + + if not args.skip_avatars and photo is not None: + base = safe_filename(name, f"user_{user.id}") + requested_path = avatars_dir / f"{user.id}_{base}.jpg" + try: + downloaded = await with_flood_wait( + lambda user=user, requested_path=requested_path: client.download_profile_photo( + user, + file=str(requested_path), + download_big=not args.small_avatars, + ), + f"download avatar for {user.id}", + ) + if downloaded: + record["avatar_path"] = Path(os.path.relpath(downloaded, output_dir)).as_posix() + except Exception as exc: + record["avatar_error"] = f"{type(exc).__name__}: {exc}" + print(f" [WARN] Avatar unavailable: {exc}") + + records.append(record) + + payload = { + "schema_version": 1, + "exported_at": utc_now(), + "account": { + "id": me.id, + "username": getattr(me, "username", None), + "display_name": display_name(me), + }, + "contact_count": len(records), + "contacts": records, + } + + json_path = output_dir / "contacts.json" + with json_path.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + + csv_path = output_dir / "contacts.csv" + with csv_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=CSV_FIELDS) + writer.writeheader() + for record in records: + row = {field: record.get(field) for field in CSV_FIELDS} + row["usernames"] = json.dumps(row["usernames"], ensure_ascii=False) + if isinstance(row["birthday"], (dict, list)): + row["birthday"] = json.dumps(row["birthday"], ensure_ascii=False) + writer.writerow(row) + + manifest = { + "schema_version": 1, + "exported_at": payload["exported_at"], + "contact_count": len(records), + "files": { + "json": json_path.name, + "csv": csv_path.name, + "avatars": None if args.skip_avatars else avatars_dir.name, + }, + "options": { + "full_details": not args.skip_full_details, + "avatars": not args.skip_avatars, + "avatar_size": "small" if args.small_avatars else "large", + }, + } + with (output_dir / "manifest.json").open("w", encoding="utf-8") as handle: + json.dump(manifest, handle, ensure_ascii=False, indent=2) + + print(f"Done. Exported {len(records)} contacts.") + print(f"JSON: {json_path}") + print(f"CSV: {csv_path}") + return output_dir + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Export Telegram contacts, full user details, and current avatars.", + ) + parser.add_argument( + "--output-dir", + help="Destination directory (default: telegram_contacts_)", + ) + parser.add_argument( + "--skip-full-details", + action="store_true", + help="Skip users.GetFullUserRequest and only export base contact data", + ) + parser.add_argument( + "--skip-avatars", + action="store_true", + help="Do not download current profile photos", + ) + parser.add_argument( + "--small-avatars", + action="store_true", + help="Download the small current profile photo instead of the large one", + ) + return parser + + +def main() -> None: + args = build_parser().parse_args() + asyncio.run(export_contacts(args)) + + +if __name__ == "__main__": + main() From 4560bfaa979bbc8024dbcec8799d6c3fd6ef42a3 Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Fri, 17 Jul 2026 19:34:10 +0100 Subject: [PATCH 2/6] Document contact export and refresh project README --- README.md | 387 +++++++++++++++++++----------------------------------- 1 file changed, 137 insertions(+), 250 deletions(-) diff --git a/README.md b/README.md index e538a2c..df9f26b 100644 --- a/README.md +++ b/README.md @@ -1,369 +1,256 @@ -# Telegram Maintenance Suite (Backup + Cleanup) +# Telegram Maintenance Scripts -A small **maintenance toolkit** around [Telethon](https://github.com/LonamiWebs/Telethon) to: +A small Telethon-based toolkit for creating local Telegram archives and carefully cleaning up a long-lived account. -- πŸ“‹ Export a CSV index of all your dialogs -- πŸ’Ύ Backup all messages from remaining dialogs (except Saved Messages by default) -- πŸ’ΎπŸ“₯ Backup **Saved Messages** (with optional media/files) -- πŸ“¦ Optionally download media/files for each chat into a local folder -- πŸ“Š Count media files per chat in an archive -- πŸ—‚οΈ Render a backed-up chat as a standalone **HTML transcript** -- 🧹 Interactively cleanup old/lightweight chats you've already backed up -- πŸ” Retry deletions that hit flood limits -- πŸ‘» Force-delete ghost / stuck chats that Telegram Desktop won't let you remove +It can export dialogs, messages, media, Saved Messages, and contacts; render backed-up chats as HTML; and help remove old or stuck conversations after they have been archived. -This is basically the toolbox I used to **backup and clean a long-lived Telegram account**. +> [!WARNING] +> Some commands can delete message history or leave chats. Keep verified backups before using cleanup commands. ---- +## Features -## βš™οΈ Requirements +- Export a CSV index of all dialogs. +- Back up messages from all current dialogs. +- Back up Saved Messages separately. +- Optionally download message media and files. +- Export Telegram contacts with IDs, names, usernames, phone numbers when available, account flags, presence metadata, full-user details, and current avatars. +- Count media-bearing messages per archived chat. +- Render an archived chat as a standalone HTML transcript. +- Interactively clean old, low-file-count chats. +- Retry operations interrupted by Telegram flood limits. +- Attempt removal of legacy ghost or stuck chats by numeric ID. -- Python **3.10+** -- A Telegram API ID and hash from [my.telegram.org](https://my.telegram.org/) -- A basic understanding that this script can **delete chats** if you ask it to πŸ™‚ +## Requirements ---- +- Python 3.10 or newer +- A Telegram API ID and API hash from [my.telegram.org](https://my.telegram.org/) +- A Telegram user account; these tools are not intended for bot accounts -## 🧰 Setup +## Installation ```bash -git clone telegram-maintenance-suite -cd telegram-maintenance-suite - -python -m venv venv -source venv/bin/activate # on Windows: venv\Scripts\activate +git clone https://github.com/diogogithub/telegram-maintenance-scripts.git +cd telegram-maintenance-scripts +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt -```` - -Create your `.env` file: -```bash cp .env.example .env ``` -Edit `.env` and fill: +Edit `.env`: ```env TELEGRAM_API_ID=123456 TELEGRAM_API_HASH=your_api_hash_here -# optional, to re-use an existing Telethon session: # TELEGRAM_SESSION_NAME=telegram_maintenance ``` -On first run, Telethon will ask for your phone number and login code, and create a `.session` file. +On first use, Telethon asks for your phone number and login code and creates a local `.session` file. Treat that file as a credential and never publish it. ---- +## Commands -## 🧭 Commands Overview - -All commands are run via: +The main maintenance suite uses: ```bash -python tg_maintenance.py [options...] +python tg_maintenance.py [options] ``` Available commands: -* `index-dialogs` - export CSV index of dialogs -* `backup-remaining` - backup all current dialogs (except Saved Messages by default) -* `backup-saved` - backup your **Saved Messages** dialog -* `count-files` - count media files in an archive and write `meta.json` -* `chat-to-html` - render a backed-up chat as a standalone HTML file -* `cleanup-interactive` - interactively delete/leave backed-up, old, low-file chats -* `retry-failed` - retry deletions that hit flood limits -* `force-delete-ghosts` - manually purge ghost chats by numeric ID +| Command | Purpose | +|---|---| +| `index-dialogs` | Export a CSV index of dialogs | +| `backup-remaining` | Back up current dialogs, excluding Saved Messages by default | +| `backup-saved` | Back up Saved Messages | +| `count-files` | Count media-bearing messages and update each chat's `meta.json` | +| `chat-to-html` | Render one archived chat as HTML | +| `cleanup-interactive` | Review and remove old, backed-up, low-file-count chats | +| `retry-failed` | Retry deletions previously interrupted by errors or flood waits | +| `force-delete-ghosts` | Attempt to remove legacy stuck chats by ID | + +Contact backup is provided as a separate focused command: ---- +```bash +python tg_contacts_backup.py [options] +``` -## 1. Index all dialogs (optional but recommended) +## Export contacts and current avatars ```bash -python tg_maintenance.py index-dialogs +python tg_contacts_backup.py ``` -This creates a CSV named something like: +The command uses Telegram's contact and full-user methods and creates a timestamped directory: ```text -telegram_dialog_index_20251118_123456.csv +telegram_contacts_20260717_190000/ +β”œβ”€β”€ contacts.csv +β”œβ”€β”€ contacts.json +β”œβ”€β”€ manifest.json +└── avatars/ + β”œβ”€β”€ 123456_Alice_Example.jpg + └── 987654_Bob_Example.jpg ``` -Columns include: `title`, `chat_type`, `id`, `access_hash`, `is_me`, `is_bot`, `is_private`, `is_admin`, `invite_link`, `members_count`, `last_message_date`. +`contacts.csv` is convenient for inspection and recovery. `contacts.json` preserves substantially more information, including the raw Telethon `User` and `UserFull` structures when Telegram makes them available. -You can later pass this CSV into `cleanup-interactive`. +The export includes, when available: ---- +- numeric user ID and access hash; +- first name, last name, display name, primary username, and additional usernames; +- phone number and language code; +- contact, mutual-contact, close-friend, bot, verified, premium, restricted, scam, fake, and deleted flags; +- presence/status type and associated timestamp; +- bio, birthday, and common-chat count from full-user metadata; +- current profile-photo metadata and a downloaded active avatar. -## 2. Backup all remaining dialogs - -This backs up **everything currently visible** in your account (except Saved Messages by default) into a timestamped archive folder: +Useful options: ```bash -python tg_maintenance.py backup-remaining -``` +# Choose the destination +python tg_contacts_backup.py --output-dir my_contacts_backup -By default, it creates: +# Export metadata without profile photos +python tg_contacts_backup.py --skip-avatars -```text -telegram_archive_remaining_/ - Some_Group_123456789/ - meta.json - messages.jsonl - Some_Channel_987654321/ - meta.json - messages.jsonl - ... +# Avoid the per-contact full-user request +python tg_contacts_backup.py --skip-full-details + +# Download smaller current avatars +python tg_contacts_backup.py --small-avatars ``` -You can customize the archive directory: +Telegram privacy settings and account state determine which fields can be retrieved. An unavailable field is left empty, and individual lookup or avatar failures are recorded without aborting the rest of the export. Telegram flood waits are respected automatically. -```bash -python tg_maintenance.py backup-remaining \ - --archive-dir telegram_archive_full_2025-11-18 -``` +> [!IMPORTANT] +> A contacts archive can contain phone numbers, biographies, usernames, presence information, and photographs. Store it on encrypted media and do not commit it to Git. -To also include Saved Messages in this "everything" backup: +## Index dialogs ```bash -python tg_maintenance.py backup-remaining --include-saved +python tg_maintenance.py index-dialogs ``` -### 2.1 Also download media/files during backup +This creates a file such as `telegram_dialog_index_20260717_190000.csv` with dialog title, type, ID, access hash, administrative flags, public link, member count, and last-message date. -Both `backup-remaining` and `backup-saved` support a flag to download media/files: +## Back up dialogs ```bash -python tg_maintenance.py backup-remaining \ - --archive-dir telegram_archive_remaining_2025-11-18 \ - --download-media +python tg_maintenance.py backup-remaining ``` -With `--download-media`: +By default, the command excludes Saved Messages and writes a directory such as: ```text -telegram_archive_remaining_2025-11-18/ - Some_Group_123456789/ - meta.json - messages.jsonl - media/ - -``` - -Each `messages.jsonl` line is still **one JSON object per line**, in chronological order, but if a message had media and the download succeeded, the JSON object also gets an extra field: - -```json -{ - "...": "...", - "media": { ... }, - "_local_media": "media/photo_123.jpg" -} -``` - -That `_local_media` relative path is used by `chat-to-html` (and any other tooling you write) to link to the downloaded file. - -`meta.json` contains basic metadata plus `total_messages`/`file_count` once you run `count-files`. - ---- - -## 2b. Backup Saved Messages (with optional media) - -There is a dedicated command for **Saved Messages**, which backs up your self-chat only: - -```bash -python tg_maintenance.py backup-saved +telegram_archive_remaining_2026-07-17/ +└── Some_Group_123456789/ + β”œβ”€β”€ meta.json + └── messages.jsonl ``` -You can choose the archive directory: +Include media files: ```bash -python tg_maintenance.py backup-saved \ - --archive-dir telegram_saved_messages_2025-11-18 +python tg_maintenance.py backup-remaining --download-media ``` -And you can also download media/files from Saved Messages: +Choose the archive directory or include Saved Messages: ```bash -python tg_maintenance.py backup-saved \ - --archive-dir telegram_saved_messages_2025-11-18 \ +python tg_maintenance.py backup-remaining \ + --archive-dir telegram_archive_full_2026-07-17 \ + --include-saved \ --download-media ``` -The folder structure is similar: +Each line in `messages.jsonl` is one normalized Telethon message object. When media is downloaded successfully, the object receives a `_local_media` path relative to the chat directory. -```text -telegram_saved_messages_2025-11-18/ - Saved_Messages_/ - meta.json - messages.jsonl - media/ # only if --download-media was used - -``` - -This is particularly handy if you use Saved Messages as a personal **inbox / bookmark / file stash** and want a full local copy. +## Back up Saved Messages ---- +```bash +python tg_maintenance.py backup-saved --download-media +``` -## 3. Count files per chat in an archive +A dedicated export is useful when Saved Messages is used as an inbox, bookmark collection, or file store. -After you've backed up, run: +## Count files in an archive ```bash -python tg_maintenance.py count-files --archive-root telegram_archive_remaining_2025-11-18 +python tg_maintenance.py count-files \ + --archive-root telegram_archive_remaining_2026-07-17 ``` -This: - -* Scans every `messages.jsonl` -* Counts messages with a non-null `media` field -* Writes/updates a `meta.json` inside each chat folder with: - -```json -{ - "title": "...", - "id": 123456789, - "export_timestamp": "...", - "entity_type": "Channel", - "total_messages": 1234, - "file_count": 42 -} -``` - -You can then use `file_count` to avoid deleting **file-heavy** chats. +The command counts messages with a non-null `media` field and writes `total_messages` and `file_count` into each chat's `meta.json`. ---- +## Render a chat as HTML -## 4. Interactive cleanup of backed-up old chats +```bash +python tg_maintenance.py chat-to-html \ + --archive-root telegram_archive_remaining_2026-07-17 \ + --chat-id 123456789 +``` -Once you're happy with your backups, you can interactively clean up **older** chats that: +Use `--output` to choose another output file. Locally downloaded media is linked from the transcript when `_local_media` is present. -* Have been inactive since a cutoff date -* Have fewer than N media files -* Have a backup folder + `meta.json` +## Interactive cleanup -Example: +First create and verify backups, then index the current dialogs and count files. Run cleanup with conservative thresholds: ```bash python tg_maintenance.py cleanup-interactive \ - --archive-root telegram_archive_remaining_2025-11-18 \ - --csv telegram_dialog_index_20251118_123456.csv \ + --archive-root telegram_archive_remaining_2026-07-17 \ + --csv telegram_dialog_index_20260717_190000.csv \ --cutoff 2025-01-01T00:00:00+00:00 \ --file-limit 10 \ --failed-json cleanup_failed_deletes.json ``` -For each chat matching the filters, you'll see a prompt like: - -```text -Chat: Some Old Group -Type: group -ID: 123456789 -Last message: 2022-03-15 20:51:50+00:00 -Backup folder: telegram_archive_remaining_2025-11-18/Some_Old_Group_123456789 -Files: 2 | Total messages: 75 ------------------------------------------------------------- -Leave group 'Some Old Group'? [y/N]: -``` - -* `y` β†’ actually delete/leave -* anything else β†’ skip - -Rate-limited deletions (flood wait) are recorded in `cleanup_failed_deletes.json` so you can retry them later. - ---- +Only chats that are older than the cutoff, have a matching backup directory and metadata, and contain fewer than the configured number of media-bearing messages are offered for deletion or leaving. Every action still requires interactive confirmation. -## 5. Retry deletions that hit flood limits - -If `cleanup-interactive` reported flood waits and wrote `cleanup_failed_deletes.json`, you can retry them more slowly: +Retry recorded failures: ```bash -python tg_maintenance.py retry-failed --failed-json cleanup_failed_deletes.json +python tg_maintenance.py retry-failed \ + --failed-json cleanup_failed_deletes.json ``` -This: - -* Reloads each failed chat ID -* Tries to delete/leave again -* Handles `FloodWaitError` by waiting the specified number of seconds and retrying - ---- - -## 6. Force-delete ghost / stuck chats - -Some very old legacy groups can get **stuck** in Telegram clients (e.g. show as "Hound" group with no way to delete in the UI). - -You can surgically target these ghosts by **numeric ID**: +## Force-delete a stuck chat ```bash -python tg_maintenance.py force-delete-ghosts --ids 132006505 212817379 +python tg_maintenance.py force-delete-ghosts --ids 132006505 ``` -To dry-run first: +Dry-run the operation first: ```bash python tg_maintenance.py force-delete-ghosts --ids 132006505 --test-mode ``` -This will: - -* Try clearing history (`DeleteHistoryRequest`) -* Try removing yourself from the group (`DeleteChatUserRequest`) -* Respect flood limits - -Use carefully; this is meant for chats you **cannot** remove via the normal Telegram UI. - ---- - -## 7. Export a chat archive to HTML +This command attempts low-level history and legacy-group operations. Use it only for chats that cannot be removed normally. -Once you have a backup (from `backup-remaining` or `backup-saved`), you can turn any chat into a **self-contained HTML transcript**. - -```bash -python tg_maintenance.py chat-to-html \ - --archive-root telegram_archive_remaining_2025-11-18 \ - --chat-id 123456789 -``` +## Safety and privacy -This: +- Verify archives before deleting or leaving anything. +- Keep `.env`, Telethon `.session` files, exported contacts, and message archives private. +- Prefer encrypted storage for backups. +- Remember that deleting a direct-message history with `revoke=True` may affect the other participant where Telegram permits it. +- Telegram may impose flood waits; do not try to bypass them. +- This project is independent and is not affiliated with Telegram or Telethon. -* Locates the per-chat folder whose name ends with `_` (e.g. `Some_Group_123456789`) -* Reads `messages.jsonl` -* Writes `chat.html` inside that folder by default (or wherever you point `--output`) +## Development -Example with custom output path: +Basic checks run in GitHub Actions for supported Python versions. Locally: ```bash -python tg_maintenance.py chat-to-html \ - --archive-root telegram_saved_messages_2025-11-18 \ - --chat-id 123456789 \ - --output saved_messages.html +python -m py_compile tg_maintenance.py tg_contacts_backup.py +TELEGRAM_API_ID=1 TELEGRAM_API_HASH=test python tg_maintenance.py --help +python tg_contacts_backup.py --help ``` -The HTML: - -* Renders messages as chat-style **bubbles** (`Me` vs `Them`) -* Uses timestamps and message IDs from the backup -* If you used `--download-media`, any message with a `_local_media` field will show a local `[media]` link to that file (e.g. an image, document, etc.) - -You can open the resulting `chat.html` in any browser, or print to PDF if you want a "frozen" transcript. - ---- - -## πŸ›‘οΈ Safety tips - -* Always make backups (`backup-remaining` / `backup-saved`) **before** aggressive cleanup. -* Use `cleanup-interactive` conservatively at first (e.g. very old cutoff date + low file_limit). -* Keep your archives somewhere safe (e.g. encrypted disk / backup drive). -* You can re-run `index-dialogs` and keep a CSV snapshot of your **post-cleanup** state. -* If you care about attachments, remember to use `--download-media` during backups. - ---- - -## πŸ”§ Development notes +Contributions, fixes, additional archive formats, and carefully scoped maintenance features are welcome. -* Written with [Telethon](https://github.com/LonamiWebs/Telethon) -* Tested with Python 3.10+ and recent Telethon versions -* Single-file script (`tg_maintenance.py`) for portability, but you can split it into modules if you prefer -* `chat-to-html` deliberately keeps HTML/CSS minimal so you can easily tweak the look +## License -PRs / tweaks / feature ideas are welcome. -If you find a better heuristic for "ghost" chats or want more backup formats / viewers, extend away. πŸ™‚ +MIT. See [LICENSE](LICENSE). From 69097d2b22c1f93f072007960559e7032c1965e8 Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Fri, 17 Jul 2026 19:34:18 +0100 Subject: [PATCH 3/6] Ignore Telethon sessions and generated Telegram archives --- .gitignore | 184 ++++++++--------------------------------------------- 1 file changed, 26 insertions(+), 158 deletions(-) diff --git a/.gitignore b/.gitignore index ab3e8ce..ceaf7d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,164 +1,32 @@ -# ---> Python -# Byte-compiled / optimized / DLL files +# Local secrets and Telethon credentials +.env +*.session +*.session-journal + +# Python __pycache__/ *.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python +.venv/ +venv/ +env/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Telegram exports and cleanup state +telegram_archive_*/ +telegram_saved_messages_*/ +telegram_contacts_*/ +telegram_dialog_index_*.csv +cleanup_failed_deletes.json + +# Editors and operating systems +.idea/ +.vscode/ +.DS_Store +Thumbs.db From c3990c27a9d5b0d0e6acd537acf71f70ece97992 Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Fri, 17 Jul 2026 19:34:26 +0100 Subject: [PATCH 4/6] Keep compatibility with the Telethon 1.x API --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 794507f..41517a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -telethon>=1.36.0 +telethon>=1.36.0,<2.0 python-dotenv>=1.0.0 pandas>=2.0.0 From 040c9ebb2bf98640c8a2989259cb3994b5c98f0b Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Fri, 17 Jul 2026 19:34:33 +0100 Subject: [PATCH 5/6] Correct the MIT license attribution --- LICENSE | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/LICENSE b/LICENSE index c763be0..482540e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,21 @@ MIT License -Copyright (c) 2025 diogo +Copyright (c) 2025-2026 Diogo Peralta Cordeiro -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 74fb2214cc9d69d469cfe54a07d7aa93bdc1c4f8 Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Fri, 17 Jul 2026 19:34:41 +0100 Subject: [PATCH 6/6] Add basic Python CI checks --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a159d35 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + checks: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: python -m pip install --upgrade pip + - run: pip install -r requirements.txt + - run: python -m py_compile tg_maintenance.py tg_contacts_backup.py + - run: TELEGRAM_API_ID=1 TELEGRAM_API_HASH=test python tg_maintenance.py --help + - run: python tg_contacts_backup.py --help