mirror of
https://gitverse.ru/kpa39l/chronicle.nixg.ru.git
synced 2026-09-29 09:55:08 +00:00
Add telegram-archiver microservice (MVP)
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# Telegram API credentials
|
||||||
|
# Get these from https://my.telegram.org/apps
|
||||||
|
API_ID=your_api_id_here
|
||||||
|
API_HASH=your_api_hash_here
|
||||||
|
PHONE=+79991234567
|
||||||
|
|
||||||
|
# Session name (will create telegram-archiver.session file)
|
||||||
|
SESSION_NAME=telegram-archiver
|
||||||
|
|
||||||
|
# Max file size to download (in bytes)
|
||||||
|
# Default: 200 MB = 209715200
|
||||||
|
MAX_FILE_SIZE=209715200
|
||||||
|
|
||||||
|
# Output directory for archives
|
||||||
|
OUTPUT_DIR=./archives
|
||||||
|
|
||||||
|
# Log level: DEBUG, INFO, WARNING, ERROR
|
||||||
|
LOG_LEVEL=INFO
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Telegram Archiver - Git Ignore
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Session files (Telegram auth)
|
||||||
|
*.session
|
||||||
|
*.session-journal
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Archives output
|
||||||
|
archives/
|
||||||
|
*/2big2get.md
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Telegram Archiver
|
||||||
|
|
||||||
|
Микросервис для архивирования Telegram-каналов в локальную файловую систему с генерацией Markdown для Hugo.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- ✅ Скачивание всех постов канала (без ограничений)
|
||||||
|
- ✅ Сохранение текста в Markdown с front-matter для Hugo
|
||||||
|
- ✅ Скачивание медиа: фото, видео, документы, аудио
|
||||||
|
- ✅ Ограничение на размер файла (настраивается, по умолчанию 200 MB)
|
||||||
|
- ✅ Отчёт о слишком больших файлах в `2big2get.md`
|
||||||
|
- ✅ Дедупликация по ID сообщения
|
||||||
|
- ✅ Обработка репостов и ответов (reply-to)
|
||||||
|
- ✅ REST API + CLI интерфейс
|
||||||
|
- ✅ Логирование в файл и консоль
|
||||||
|
|
||||||
|
## Структура выходных данных
|
||||||
|
|
||||||
|
```
|
||||||
|
<channel_name>/
|
||||||
|
├── 12345/
|
||||||
|
│ ├── index.md # Контент поста + front-matter
|
||||||
|
│ ├── photo.jpg # Медиафайлы
|
||||||
|
│ └── document.pdf
|
||||||
|
├── 12346/
|
||||||
|
│ └── index.md # Только текст
|
||||||
|
└── 2big2get.md # Отчёт о больших файлах
|
||||||
|
```
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Клонирование и зависимости
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd telegram-archiver
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Получение Telegram API ключей
|
||||||
|
|
||||||
|
1. Перейди на https://my.telegram.org/apps
|
||||||
|
2. Войди по номеру телефона
|
||||||
|
3. Создай новое приложение (любое название)
|
||||||
|
4. Скопируй `API_ID` и `API_HASH`
|
||||||
|
|
||||||
|
### 3. Настройка .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Отредактируй `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
API_ID=12345678
|
||||||
|
API_HASH=abcdef1234567890
|
||||||
|
PHONE=+79991234567
|
||||||
|
MAX_FILE_SIZE=209715200
|
||||||
|
OUTPUT_DIR=./archives
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### CLI (Command Line Interface)
|
||||||
|
|
||||||
|
#### Скачать весь канал:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m app.main --channel dedinit
|
||||||
|
```
|
||||||
|
|
||||||
|
#### С опциями:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m app.main \
|
||||||
|
--channel dedinit \
|
||||||
|
--output ./my-archives \
|
||||||
|
--limit 100 \
|
||||||
|
--from-message-id 5000 \
|
||||||
|
--force
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Опции CLI:
|
||||||
|
|
||||||
|
| Опция | Кратко | Описание |
|
||||||
|
|-------|--------|----------|
|
||||||
|
| `--channel` | `-c` | Username канала (с @ или без) |
|
||||||
|
| `--output-dir` | `-o` | Папка для архива |
|
||||||
|
| `--limit` | `-l` | Лимит постов (для теста) |
|
||||||
|
| `--from-message-id` | `-f` | Начать с этого ID |
|
||||||
|
| `--force` | | Перескачать существующие |
|
||||||
|
| `--env-file` | | Путь к .env файлу |
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
#### Запуск сервера:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Endpoints:
|
||||||
|
|
||||||
|
**GET /health** - Проверка здоровья
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST /archive** - Запуск архивации
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/archive \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"channel": "dedinit",
|
||||||
|
"limit": 100
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**GET /docs** - Swagger UI документация
|
||||||
|
|
||||||
|
Открой в браузере: http://localhost:8000/docs
|
||||||
|
|
||||||
|
## Front-matter формат
|
||||||
|
|
||||||
|
Каждый `index.md` содержит YAML front-matter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
message_id: 12345
|
||||||
|
date: 2024-02-19T14:30:00
|
||||||
|
author: "Channel Name"
|
||||||
|
reply_to: "../12340/index.md"
|
||||||
|
repost_from: 12300
|
||||||
|
repost_channel: "Other Channel"
|
||||||
|
media_files:
|
||||||
|
- filename: photo.jpg
|
||||||
|
type: photo
|
||||||
|
caption: "Описание"
|
||||||
|
size: 102400
|
||||||
|
is_too_large: false
|
||||||
|
---
|
||||||
|
|
||||||
|
Текст сообщения в Markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка больших файлов
|
||||||
|
|
||||||
|
Файлы > `MAX_FILE_SIZE` (по умолчанию 200 MB) не скачиваются. Вместо этого:
|
||||||
|
|
||||||
|
1. В `index.md` добавляется `is_too_large: true`
|
||||||
|
2. В корне канала создаётся `2big2get.md` со списком всех больших файлов:
|
||||||
|
```markdown
|
||||||
|
# Files Too Large to Download
|
||||||
|
|
||||||
|
| Message ID | Filename | Size (bytes) |
|
||||||
|
|------------|----------|-------------|
|
||||||
|
| 12345 | video.mp4 | 524288000 |
|
||||||
|
```
|
||||||
|
|
||||||
|
## Логирование
|
||||||
|
|
||||||
|
Логи пишутся:
|
||||||
|
- В консоль (stdout)
|
||||||
|
- В файл: `YYYYMMDD-channel.log`
|
||||||
|
|
||||||
|
Пример: `20260219-dedinit.log`
|
||||||
|
|
||||||
|
## Docker (опционально)
|
||||||
|
|
||||||
|
Создай `Dockerfile`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t telegram-archiver .
|
||||||
|
docker run -v $(pwd)/.env:/app/.env -v $(pwd)/archives:/app/archives telegram-archiver
|
||||||
|
```
|
||||||
|
|
||||||
|
## Интеграция с Hugo
|
||||||
|
|
||||||
|
После архивации:
|
||||||
|
|
||||||
|
1. Скопируй содержимое канала в `content/posts/` Hugo
|
||||||
|
2. Front-matter совместим с Hugo (date, author, tags)
|
||||||
|
3. Медиафайлы будут доступны по относительным ссылкам
|
||||||
|
|
||||||
|
## Разработчикам
|
||||||
|
|
||||||
|
### Структура проекта:
|
||||||
|
|
||||||
|
```
|
||||||
|
telegram-archiver/
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py # FastAPI + CLI entry point
|
||||||
|
│ ├── telethon_client.py # Telethon wrapper
|
||||||
|
│ ├── archiver.py # Core logic
|
||||||
|
│ ├── models.py # Pydantic models
|
||||||
|
│ └── logger.py # Logging setup
|
||||||
|
├── config.py # Settings management
|
||||||
|
├── requirements.txt
|
||||||
|
├── .env.example
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск тестов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Package entry point for telegram-archiver.
|
||||||
|
|
||||||
|
Allows running as: python -m telegram-archiver
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.main import cli_main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli_main()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Telegram Archiver - Archive Telegram channels to Markdown bundles.
|
||||||
|
|
||||||
|
This package provides both CLI and REST API interfaces for archiving
|
||||||
|
Telegram channels with their media content to local filesystem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "Evgeny Storozhenko"
|
||||||
|
__email__ = "dedinit"
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
"""
|
||||||
|
Core archiver logic.
|
||||||
|
Handles message processing, markdown generation, and file organization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from telethon.tl.types import Message
|
||||||
|
|
||||||
|
from app.models import PostData, MediaFile, MediaType
|
||||||
|
from app.telethon_client import TelethonArchiver
|
||||||
|
from config import Settings, get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelArchiver:
|
||||||
|
"""
|
||||||
|
Handles archiving of a single Telegram channel.
|
||||||
|
Manages bundle creation, markdown generation, and media downloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
telethon_client: TelethonArchiver,
|
||||||
|
settings: Optional[Settings] = None,
|
||||||
|
):
|
||||||
|
self.client = telethon_client
|
||||||
|
self.settings = settings or get_settings()
|
||||||
|
self.channel_path: Optional[Path] = None
|
||||||
|
self.log_file: Optional[Path] = None
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
self.stats = {
|
||||||
|
"posts_archived": 0,
|
||||||
|
"posts_skipped": 0,
|
||||||
|
"media_downloaded": 0,
|
||||||
|
"media_skipped_large": 0,
|
||||||
|
"large_files": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _setup_logging(self, channel_username: str) -> None:
|
||||||
|
"""Setup file logging for this channel archive session."""
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d")
|
||||||
|
log_filename = f"{timestamp}-{channel_username}.log"
|
||||||
|
self.log_file = Path(__file__).parent.parent / log_filename
|
||||||
|
|
||||||
|
# Create file handler
|
||||||
|
file_handler = logging.FileHandler(self.log_file, encoding="utf-8")
|
||||||
|
file_handler.setLevel(self.settings.log_level)
|
||||||
|
|
||||||
|
# Create formatter
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
# Add handler to logger
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
logger.info(f"=== Archive session started for {channel_username} ===")
|
||||||
|
|
||||||
|
def _get_channel_path(self, channel_username: str) -> Path:
|
||||||
|
"""Get the output directory path for this channel."""
|
||||||
|
# Remove @ prefix if present
|
||||||
|
clean_username = channel_username.lstrip("@")
|
||||||
|
base_path = self.settings.output_dir / clean_username
|
||||||
|
base_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return base_path
|
||||||
|
|
||||||
|
async def archive_channel(
|
||||||
|
self,
|
||||||
|
channel: str,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
from_message_id: Optional[int] = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Archive all messages from a channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Channel username or ID
|
||||||
|
limit: Maximum number of messages to archive
|
||||||
|
from_message_id: Start from this message ID
|
||||||
|
force: Force re-download of already archived posts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Statistics dictionary
|
||||||
|
"""
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
self._setup_logging(channel)
|
||||||
|
self.channel_path = self._get_channel_path(channel)
|
||||||
|
|
||||||
|
logger.info(f"Starting archive of {channel}")
|
||||||
|
logger.info(f"Output directory: {self.channel_path}")
|
||||||
|
if limit:
|
||||||
|
logger.info(f"Limit: {limit} messages")
|
||||||
|
if from_message_id:
|
||||||
|
logger.info(f"Starting from message ID: {from_message_id}")
|
||||||
|
|
||||||
|
# Get channel info
|
||||||
|
entity = await self.client.get_channel_info(channel)
|
||||||
|
channel_title = getattr(entity, "title", channel)
|
||||||
|
|
||||||
|
# Reset stats
|
||||||
|
self.stats = {
|
||||||
|
"posts_archived": 0,
|
||||||
|
"posts_skipped": 0,
|
||||||
|
"media_downloaded": 0,
|
||||||
|
"media_skipped_large": 0,
|
||||||
|
"large_files": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process messages
|
||||||
|
async for message in self.client.fetch_messages(
|
||||||
|
channel, limit=limit, from_message_id=from_message_id
|
||||||
|
):
|
||||||
|
await self._process_message(message, force=force)
|
||||||
|
|
||||||
|
# Write large files report
|
||||||
|
large_files_report = await self._write_large_files_report()
|
||||||
|
|
||||||
|
# Calculate duration
|
||||||
|
duration = (datetime.now() - start_time).total_seconds()
|
||||||
|
|
||||||
|
logger.info(f"=== Archive completed in {duration:.2f}s ===")
|
||||||
|
logger.info(f"Posts archived: {self.stats['posts_archived']}")
|
||||||
|
logger.info(f"Posts skipped: {self.stats['posts_skipped']}")
|
||||||
|
logger.info(f"Media downloaded: {self.stats['media_downloaded']}")
|
||||||
|
logger.info(f"Media skipped (large): {self.stats['media_skipped_large']}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"channel": channel,
|
||||||
|
"channel_title": channel_title,
|
||||||
|
"posts_archived": self.stats["posts_archived"],
|
||||||
|
"posts_skipped": self.stats["posts_skipped"],
|
||||||
|
"media_downloaded": self.stats["media_downloaded"],
|
||||||
|
"media_skipped_large": self.stats["media_skipped_large"],
|
||||||
|
"output_path": str(self.channel_path),
|
||||||
|
"log_file": str(self.log_file),
|
||||||
|
"large_files_report": large_files_report,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _process_message(
|
||||||
|
self, message: Message, force: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Process a single message: create bundle, download media, generate markdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message to process
|
||||||
|
force: Force re-processing if bundle exists
|
||||||
|
"""
|
||||||
|
message_id = message.id
|
||||||
|
bundle_dir = self.channel_path / str(message_id)
|
||||||
|
|
||||||
|
# Check if already archived (skip if exists and not force)
|
||||||
|
if bundle_dir.exists() and not force:
|
||||||
|
logger.debug(f"Skipping message {message_id}: already archived")
|
||||||
|
self.stats["posts_skipped"] += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(f"Processing message {message_id}...")
|
||||||
|
|
||||||
|
# Create bundle directory
|
||||||
|
bundle_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Extract post data
|
||||||
|
post_data = await self._extract_post_data(message)
|
||||||
|
|
||||||
|
# Download media
|
||||||
|
if message.media:
|
||||||
|
media_info = await self.client.download_media(
|
||||||
|
message, bundle_dir, self.settings.max_file_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update post_data with media info
|
||||||
|
for info in media_info:
|
||||||
|
media_file = MediaFile(
|
||||||
|
filename=info["filename"],
|
||||||
|
type=info["type"],
|
||||||
|
caption=info.get("caption"),
|
||||||
|
size=info.get("size"),
|
||||||
|
is_too_large=info.get("is_too_large", False),
|
||||||
|
)
|
||||||
|
post_data.media_files.append(media_file)
|
||||||
|
|
||||||
|
if info.get("is_too_large"):
|
||||||
|
self.stats["media_skipped_large"] += 1
|
||||||
|
self.stats["large_files"].append(
|
||||||
|
{
|
||||||
|
"message_id": message_id,
|
||||||
|
"filename": info["filename"],
|
||||||
|
"size": info.get("size"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stats["media_downloaded"] += 1
|
||||||
|
|
||||||
|
# Generate markdown
|
||||||
|
markdown_content = self._generate_markdown(post_data)
|
||||||
|
|
||||||
|
# Write index.md
|
||||||
|
index_path = bundle_dir / "index.md"
|
||||||
|
index_path.write_text(markdown_content, encoding="utf-8")
|
||||||
|
|
||||||
|
self.stats["posts_archived"] += 1
|
||||||
|
logger.debug(f"Message {message_id} archived successfully")
|
||||||
|
|
||||||
|
async def _extract_post_data(self, message: Message) -> PostData:
|
||||||
|
"""
|
||||||
|
Extract structured data from a Telegram message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PostData object with all metadata
|
||||||
|
"""
|
||||||
|
# Get channel info
|
||||||
|
channel = await message.get_chat()
|
||||||
|
channel_username = getattr(channel, "username", "unknown")
|
||||||
|
channel_title = getattr(channel, "title", channel_username)
|
||||||
|
|
||||||
|
# Convert message text to markdown
|
||||||
|
text = message.message or ""
|
||||||
|
if message.message:
|
||||||
|
# Use Telethon's built-in markdown converter
|
||||||
|
text = message.get_message_text(markdown=True)
|
||||||
|
|
||||||
|
# Handle reply-to
|
||||||
|
reply_to = None
|
||||||
|
if message.reply_to and message.reply_to.reply_to_msg_id:
|
||||||
|
reply_to = message.reply_to.reply_to_msg_id
|
||||||
|
|
||||||
|
# Handle repost/forward
|
||||||
|
repost_from = None
|
||||||
|
repost_channel = None
|
||||||
|
if message.fwd_from:
|
||||||
|
repost_from = message.fwd_from.channel_post
|
||||||
|
if message.fwd_from.from_id:
|
||||||
|
# Try to get original channel info
|
||||||
|
try:
|
||||||
|
fwd_channel = await self.client.client.get_entity(
|
||||||
|
message.fwd_from.from_id
|
||||||
|
)
|
||||||
|
repost_channel = getattr(fwd_channel, "title", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return PostData(
|
||||||
|
message_id=message.id,
|
||||||
|
date=message.date,
|
||||||
|
text=text,
|
||||||
|
author=channel_title,
|
||||||
|
channel_username=channel_username,
|
||||||
|
reply_to=reply_to,
|
||||||
|
repost_from=repost_from,
|
||||||
|
repost_channel=repost_channel,
|
||||||
|
views=getattr(message, "views", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_markdown(self, post: PostData) -> str:
|
||||||
|
"""
|
||||||
|
Generate markdown content with front-matter for Hugo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post: PostData object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Markdown string with YAML front-matter
|
||||||
|
"""
|
||||||
|
# Build front-matter
|
||||||
|
frontmatter = {
|
||||||
|
"message_id": post.message_id,
|
||||||
|
"date": post.date.isoformat(),
|
||||||
|
"author": post.author,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add optional fields
|
||||||
|
if post.reply_to:
|
||||||
|
# Relative link to the replied post's index.md
|
||||||
|
frontmatter["reply_to"] = f"../{post.reply_to}/index.md"
|
||||||
|
|
||||||
|
if post.repost_from:
|
||||||
|
frontmatter["repost_from"] = post.repost_from
|
||||||
|
if post.repost_channel:
|
||||||
|
frontmatter["repost_channel"] = post.repost_channel
|
||||||
|
|
||||||
|
if post.media_files:
|
||||||
|
frontmatter["media_files"] = [
|
||||||
|
{
|
||||||
|
"filename": mf.filename,
|
||||||
|
"type": mf.type,
|
||||||
|
"caption": mf.caption,
|
||||||
|
"size": mf.size,
|
||||||
|
"is_too_large": mf.is_too_large,
|
||||||
|
}
|
||||||
|
for mf in post.media_files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build markdown content
|
||||||
|
content = []
|
||||||
|
|
||||||
|
# Add media embeds for downloaded files
|
||||||
|
for mf in post.media_files:
|
||||||
|
if not mf.is_too_large:
|
||||||
|
if mf.type == "photo":
|
||||||
|
content.append(f"")
|
||||||
|
elif mf.type == "video":
|
||||||
|
content.append(f"@[video]({mf.filename})")
|
||||||
|
elif mf.type == "audio":
|
||||||
|
content.append(f"@[audio]({mf.filename})")
|
||||||
|
elif mf.type == "document":
|
||||||
|
content.append(f"📎 [{mf.filename}]({mf.filename})")
|
||||||
|
|
||||||
|
# Add text content
|
||||||
|
if post.text:
|
||||||
|
content.append(post.text)
|
||||||
|
|
||||||
|
# Mark reposts visually
|
||||||
|
if post.repost_from:
|
||||||
|
content.insert(0, f"> *Repost from {post.repost_channel or 'Unknown'}*")
|
||||||
|
content.insert(1, "")
|
||||||
|
|
||||||
|
# Combine front-matter and content
|
||||||
|
yaml_frontmatter = yaml.dump(
|
||||||
|
frontmatter,
|
||||||
|
allow_unicode=True,
|
||||||
|
default_flow_style=False,
|
||||||
|
sort_keys=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"---\n{yaml_frontmatter}---\n\n{''.join(content)}"
|
||||||
|
|
||||||
|
async def _write_large_files_report(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Write report of files that were too large to download.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to report file or None if no large files
|
||||||
|
"""
|
||||||
|
if not self.stats["large_files"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
report_path = self.channel_path / "2big2get.md"
|
||||||
|
|
||||||
|
content = ["# Files Too Large to Download\n\n"]
|
||||||
|
content.append(
|
||||||
|
f"Generated: {datetime.now().isoformat()}\n\n"
|
||||||
|
)
|
||||||
|
content.append(
|
||||||
|
f"Total files skipped: {len(self.stats['large_files'])}\n\n"
|
||||||
|
)
|
||||||
|
content.append("| Message ID | Filename | Size (bytes) |\n")
|
||||||
|
content.append("|------------|----------|-------------|\n")
|
||||||
|
|
||||||
|
for file_info in self.stats["large_files"]:
|
||||||
|
content.append(
|
||||||
|
f"| {file_info['message_id']} | {file_info['filename']} | "
|
||||||
|
f"{file_info['size']} |\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
report_path.write_text("".join(content), encoding="utf-8")
|
||||||
|
logger.info(f"Large files report written to {report_path}")
|
||||||
|
|
||||||
|
return str(report_path)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
Logging configuration for Telegram Archiver.
|
||||||
|
Supports both console and file output with OpenTelemetry integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from opentelemetry import trace
|
||||||
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
|
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
|
||||||
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(
|
||||||
|
log_level: str = "INFO",
|
||||||
|
log_file: Optional[Path] = None,
|
||||||
|
enable_otel: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Configure logging for the application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
log_file: Optional file path for log output
|
||||||
|
enable_otel: Enable OpenTelemetry tracing
|
||||||
|
"""
|
||||||
|
# Create root logger
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(getattr(logging, log_level.upper()))
|
||||||
|
|
||||||
|
# Clear existing handlers
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
|
||||||
|
# Console handler
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setLevel(getattr(logging, log_level.upper()))
|
||||||
|
console_formatter = logging.Formatter(
|
||||||
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
console_handler.setFormatter(console_formatter)
|
||||||
|
root_logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# File handler (if specified)
|
||||||
|
if log_file:
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
file_handler.setLevel(getattr(logging, log_level.upper()))
|
||||||
|
file_formatter = logging.Formatter(
|
||||||
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(file_formatter)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# OpenTelemetry configuration
|
||||||
|
if enable_otel:
|
||||||
|
setup_opentelemetry()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_opentelemetry() -> None:
|
||||||
|
"""Configure OpenTelemetry tracing."""
|
||||||
|
# Set up tracer provider
|
||||||
|
trace.set_tracer_provider(TracerProvider())
|
||||||
|
|
||||||
|
# Add console exporter (for debugging)
|
||||||
|
trace.get_tracer_provider().add_span_processor(
|
||||||
|
SimpleSpanProcessor(ConsoleSpanExporter())
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info("OpenTelemetry tracing enabled")
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
"""
|
||||||
|
Get a logger instance with the specified name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name (usually __name__)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured logger instance
|
||||||
|
"""
|
||||||
|
return logging.getLogger(name)
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""
|
||||||
|
Telegram Archiver - Main Application Entry Point.
|
||||||
|
|
||||||
|
Provides both FastAPI REST API and CLI interfaces for archiving
|
||||||
|
Telegram channels to local filesystem bundles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
ArchiveRequest,
|
||||||
|
ArchiveResponse,
|
||||||
|
HealthCheck,
|
||||||
|
)
|
||||||
|
from app.telethon_client import TelethonArchiver
|
||||||
|
from app.archiver import ChannelArchiver
|
||||||
|
from app.logger import setup_logging, get_logger
|
||||||
|
from config import get_settings, Settings
|
||||||
|
|
||||||
|
# Initialize logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Global archiver client (shared across requests)
|
||||||
|
archiver_client: TelethonArchiver | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
"""Application lifespan manager."""
|
||||||
|
global archiver_client
|
||||||
|
|
||||||
|
# Startup
|
||||||
|
logger.info("Starting Telegram Archiver...")
|
||||||
|
settings = get_settings()
|
||||||
|
setup_logging(
|
||||||
|
log_level=settings.log_level,
|
||||||
|
enable_otel=settings.otel_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
archiver_client = TelethonArchiver(settings)
|
||||||
|
await archiver_client.connect()
|
||||||
|
|
||||||
|
logger.info("Telegram Archiver started successfully")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
logger.info("Shutting down Telegram Archiver...")
|
||||||
|
if archiver_client:
|
||||||
|
await archiver_client.disconnect()
|
||||||
|
logger.info("Telegram Archiver shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title="Telegram Archiver",
|
||||||
|
description="Archive Telegram channels to local filesystem with media",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthCheck)
|
||||||
|
async def health_check() -> HealthCheck:
|
||||||
|
"""Health check endpoint."""
|
||||||
|
is_connected = archiver_client is not None and archiver_client._connected
|
||||||
|
return HealthCheck(
|
||||||
|
status="healthy" if is_connected else "disconnected",
|
||||||
|
version="1.0.0",
|
||||||
|
session_active=is_connected,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/archive", response_model=ArchiveResponse)
|
||||||
|
async def archive_channel(request: ArchiveRequest) -> ArchiveResponse:
|
||||||
|
"""
|
||||||
|
Archive a Telegram channel to local filesystem.
|
||||||
|
|
||||||
|
Creates a bundle directory for each message with:
|
||||||
|
- index.md: Message content in Markdown with front-matter
|
||||||
|
- Media files: Photos, videos, documents (if under size limit)
|
||||||
|
|
||||||
|
Large files (>MAX_FILE_SIZE) are skipped and logged in 2big2get.md
|
||||||
|
"""
|
||||||
|
if not archiver_client:
|
||||||
|
raise HTTPException(status_code=503, detail="Archiver not initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Archive request for channel: {request.channel}")
|
||||||
|
|
||||||
|
# Get settings and override output_dir if specified
|
||||||
|
settings = get_settings()
|
||||||
|
if request.output_dir:
|
||||||
|
settings.output_dir = Path(request.output_dir)
|
||||||
|
|
||||||
|
# Create archiver for this request
|
||||||
|
channel_archiver = ChannelArchiver(archiver_client, settings)
|
||||||
|
|
||||||
|
# Run archive process
|
||||||
|
result = await channel_archiver.archive_channel(
|
||||||
|
channel=request.channel,
|
||||||
|
limit=request.limit,
|
||||||
|
from_message_id=request.from_message_id,
|
||||||
|
force=request.force,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Archive completed: {result['posts_archived']} posts")
|
||||||
|
|
||||||
|
return ArchiveResponse(**result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Archive failed: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root() -> dict:
|
||||||
|
"""Root endpoint with API info."""
|
||||||
|
return {
|
||||||
|
"name": "Telegram Archiver",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Archive Telegram channels to local filesystem",
|
||||||
|
"endpoints": {
|
||||||
|
"health": "GET /health",
|
||||||
|
"archive": "POST /archive",
|
||||||
|
"docs": "GET /docs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# CLI entry point
|
||||||
|
def cli_main() -> None:
|
||||||
|
"""Command-line interface entry point."""
|
||||||
|
import click
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--channel",
|
||||||
|
"-c",
|
||||||
|
required=True,
|
||||||
|
help="Telegram channel username (with or without @)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--output-dir",
|
||||||
|
"-o",
|
||||||
|
type=click.Path(),
|
||||||
|
default=None,
|
||||||
|
help="Output directory for archived channel",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--limit",
|
||||||
|
"-l",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Limit number of messages to archive",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--from-message-id",
|
||||||
|
"-f",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Start archiving from this message ID",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
help="Force re-download of already archived posts",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--env-file",
|
||||||
|
type=click.Path(exists=True),
|
||||||
|
default=".env",
|
||||||
|
help="Path to .env file",
|
||||||
|
)
|
||||||
|
def archive(
|
||||||
|
channel: str,
|
||||||
|
output_dir: str | None,
|
||||||
|
limit: int | None,
|
||||||
|
from_message_id: int | None,
|
||||||
|
force: bool,
|
||||||
|
env_file: str,
|
||||||
|
) -> None:
|
||||||
|
"""Archive a Telegram channel to local filesystem."""
|
||||||
|
# Load settings
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if output_dir:
|
||||||
|
settings.output_dir = Path(output_dir)
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
setup_logging(log_level=settings.log_level)
|
||||||
|
|
||||||
|
# Run archive
|
||||||
|
async def run_archive() -> None:
|
||||||
|
async with TelethonArchiver(settings) as client:
|
||||||
|
archiver = ChannelArchiver(client, settings)
|
||||||
|
result = await archiver.archive_channel(
|
||||||
|
channel=channel,
|
||||||
|
limit=limit,
|
||||||
|
from_message_id=from_message_id,
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
click.echo("\n" + "=" * 50)
|
||||||
|
click.echo("Archive Summary")
|
||||||
|
click.echo("=" * 50)
|
||||||
|
click.echo(f"Channel: {result['channel_title']} ({result['channel']})")
|
||||||
|
click.echo(f"Posts archived: {result['posts_archived']}")
|
||||||
|
click.echo(f"Posts skipped: {result['posts_skipped']}")
|
||||||
|
click.echo(f"Media downloaded: {result['media_downloaded']}")
|
||||||
|
click.echo(f"Media skipped (large): {result['media_skipped_large']}")
|
||||||
|
click.echo(f"Output path: {result['output_path']}")
|
||||||
|
click.echo(f"Log file: {result['log_file']}")
|
||||||
|
if result['large_files_report']:
|
||||||
|
click.echo(f"Large files report: {result['large_files_report']}")
|
||||||
|
click.echo(f"Duration: {result['duration_seconds']:.2f}s")
|
||||||
|
click.echo("=" * 50)
|
||||||
|
|
||||||
|
asyncio.run(run_archive())
|
||||||
|
|
||||||
|
archive()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# For development: run with uvicorn
|
||||||
|
# uvicorn app.main:app --reload
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Pydantic models for Telegram Archiver.
|
||||||
|
Used for data validation and serialization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Literal
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class MediaType(str, Enum):
|
||||||
|
"""Types of media files supported by the archiver."""
|
||||||
|
PHOTO = "photo"
|
||||||
|
VIDEO = "video"
|
||||||
|
DOCUMENT = "document"
|
||||||
|
AUDIO = "audio"
|
||||||
|
VOICE = "voice"
|
||||||
|
VIDEO_NOTE = "video_note"
|
||||||
|
STICKER = "sticker"
|
||||||
|
ANIMATION = "animation" # GIF
|
||||||
|
|
||||||
|
|
||||||
|
class MediaFile(BaseModel):
|
||||||
|
"""Represents a media file attached to a Telegram message."""
|
||||||
|
filename: str = Field(..., description="Original filename")
|
||||||
|
type: MediaType = Field(..., description="Type of media")
|
||||||
|
caption: Optional[str] = Field(None, description="Caption text for the media")
|
||||||
|
size: Optional[int] = Field(None, description="File size in bytes")
|
||||||
|
is_too_large: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="True if file exceeds MAX_FILE_SIZE limit"
|
||||||
|
)
|
||||||
|
download_url: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Telegram URL for files that weren't downloaded"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = {"use_enum_values": True}
|
||||||
|
|
||||||
|
|
||||||
|
class PostData(BaseModel):
|
||||||
|
"""
|
||||||
|
Represents a single Telegram post with all metadata.
|
||||||
|
This is the main data structure passed to the markdown generator.
|
||||||
|
"""
|
||||||
|
message_id: int = Field(..., description="Telegram message ID")
|
||||||
|
date: datetime = Field(..., description="Message timestamp")
|
||||||
|
text: str = Field(default="", description="Message text in Markdown format")
|
||||||
|
author: str = Field(..., description="Channel name or author")
|
||||||
|
channel_username: str = Field(..., description="Channel username (for output path)")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
reply_to: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description="Message ID this post is replying to"
|
||||||
|
)
|
||||||
|
repost_from: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description="Original message ID if this is a forwarded post"
|
||||||
|
)
|
||||||
|
repost_channel: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Original channel name if this is a forwarded post"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Media attachments
|
||||||
|
media_files: list[MediaFile] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of media files attached to the post"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
views: Optional[int] = Field(None, description="View count (if available)")
|
||||||
|
has_large_files: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="True if some files were not downloaded due to size limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bundle_dir(self) -> str:
|
||||||
|
"""Return the directory name for this post's bundle."""
|
||||||
|
return str(self.message_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def index_path(self) -> str:
|
||||||
|
"""Return relative path to index.md within bundle."""
|
||||||
|
return f"{self.message_id}/index.md"
|
||||||
|
|
||||||
|
def has_media(self) -> bool:
|
||||||
|
"""Check if post has any media files."""
|
||||||
|
return len(self.media_files) > 0
|
||||||
|
|
||||||
|
def has_downloaded_media(self) -> bool:
|
||||||
|
"""Check if post has any downloaded (not too large) media files."""
|
||||||
|
return any(not f.is_too_large for f in self.media_files)
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveRequest(BaseModel):
|
||||||
|
"""Request model for FastAPI endpoint."""
|
||||||
|
channel: str = Field(..., description="Telegram channel username or ID")
|
||||||
|
output_dir: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Override default output directory"
|
||||||
|
)
|
||||||
|
limit: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description="Limit number of posts to archive (for testing)"
|
||||||
|
)
|
||||||
|
from_message_id: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description="Start archiving from this message ID"
|
||||||
|
)
|
||||||
|
force: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Force re-download of already archived posts"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveResponse(BaseModel):
|
||||||
|
"""Response model for FastAPI endpoint."""
|
||||||
|
channel: str
|
||||||
|
channel_title: str
|
||||||
|
posts_archived: int
|
||||||
|
posts_skipped: int
|
||||||
|
media_downloaded: int
|
||||||
|
media_skipped_large: int
|
||||||
|
output_path: str
|
||||||
|
log_file: str
|
||||||
|
large_files_report: Optional[str] = None
|
||||||
|
duration_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheck(BaseModel):
|
||||||
|
"""Health check response."""
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
session_active: bool
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""
|
||||||
|
Telethon client wrapper for Telegram API interaction.
|
||||||
|
Handles connection, authentication, and message fetching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncGenerator, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.sessions import StringSession
|
||||||
|
from telethon.tl.types import (
|
||||||
|
Message,
|
||||||
|
Channel,
|
||||||
|
Chat,
|
||||||
|
User,
|
||||||
|
MessageMediaPhoto,
|
||||||
|
MessageMediaDocument,
|
||||||
|
MessageMediaWebPage,
|
||||||
|
MessageMediaGeo,
|
||||||
|
MessageMediaPoll,
|
||||||
|
MessageMediaDice,
|
||||||
|
MessageMediaInvoice,
|
||||||
|
MessageMediaContact,
|
||||||
|
MessageMediaGame,
|
||||||
|
MessageMediaGeoLive,
|
||||||
|
MessageMediaVenue,
|
||||||
|
MessageMediaGiveaway,
|
||||||
|
MessageMediaUnsupported,
|
||||||
|
DocumentAttributeFilename,
|
||||||
|
)
|
||||||
|
from telethon.errors import SessionPasswordNeededError, FloodWaitError
|
||||||
|
|
||||||
|
from config import Settings, get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelethonArchiver:
|
||||||
|
"""
|
||||||
|
Wrapper around Telethon client for archiving purposes.
|
||||||
|
Manages connection, authentication, and message iteration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings: Optional[Settings] = None):
|
||||||
|
self.settings = settings or get_settings()
|
||||||
|
self.client: Optional[TelegramClient] = None
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""
|
||||||
|
Initialize and connect the Telegram client.
|
||||||
|
Handles phone number verification and 2FA if needed.
|
||||||
|
"""
|
||||||
|
if self._connected:
|
||||||
|
logger.debug("Already connected to Telegram")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Connecting to Telegram as {self.settings.phone}...")
|
||||||
|
|
||||||
|
# Create client with session file
|
||||||
|
self.client = TelegramClient(
|
||||||
|
session=self.settings.session_path,
|
||||||
|
api_id=self.settings.api_id,
|
||||||
|
api_hash=self.settings.api_hash,
|
||||||
|
device_model="Telegram Archiver",
|
||||||
|
app_version="1.0.0",
|
||||||
|
lang_code="en",
|
||||||
|
system_lang_code="en",
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.client.connect()
|
||||||
|
|
||||||
|
# Check if authorized
|
||||||
|
if not await self.client.is_user_authorized():
|
||||||
|
logger.info("Not authorized. Starting authentication...")
|
||||||
|
await self._authenticate()
|
||||||
|
|
||||||
|
self._connected = True
|
||||||
|
logger.info("Connected to Telegram successfully")
|
||||||
|
|
||||||
|
async def _authenticate(self) -> None:
|
||||||
|
"""Handle the authentication flow."""
|
||||||
|
try:
|
||||||
|
# Send code request
|
||||||
|
await self.client.send_code_request(self.settings.phone)
|
||||||
|
logger.info(f"Code sent to {self.settings.phone}")
|
||||||
|
|
||||||
|
# Get code from user (in MVP, we'll use input())
|
||||||
|
code = input("Enter the code you received: ")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.client.sign_in(
|
||||||
|
phone=self.settings.phone,
|
||||||
|
code=code
|
||||||
|
)
|
||||||
|
except SessionPasswordNeededError:
|
||||||
|
# 2FA is enabled
|
||||||
|
logger.info("2FA enabled. Enter password:")
|
||||||
|
password = input("2FA Password: ")
|
||||||
|
await self.client.sign_in(password=password)
|
||||||
|
|
||||||
|
except FloodWaitError as e:
|
||||||
|
logger.error(f"Flood wait: must wait {e.seconds} seconds")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Authentication failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Disconnect from Telegram."""
|
||||||
|
if self.client:
|
||||||
|
await self.client.disconnect()
|
||||||
|
self._connected = False
|
||||||
|
logger.info("Disconnected from Telegram")
|
||||||
|
|
||||||
|
async def get_channel_info(self, channel: str) -> Channel | Chat | User:
|
||||||
|
"""
|
||||||
|
Get channel/chat/user information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Channel username (with or without @) or ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Channel, Chat, or User object
|
||||||
|
"""
|
||||||
|
if not self._connected or not self.client:
|
||||||
|
raise RuntimeError("Not connected to Telegram")
|
||||||
|
|
||||||
|
entity = await self.client.get_entity(channel)
|
||||||
|
logger.info(f"Found channel: {getattr(entity, 'title', 'Unknown')}")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def fetch_messages(
|
||||||
|
self,
|
||||||
|
channel: str,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
from_message_id: Optional[int] = None,
|
||||||
|
) -> AsyncGenerator[Message, None]:
|
||||||
|
"""
|
||||||
|
Fetch messages from a channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Channel username or ID
|
||||||
|
limit: Maximum number of messages to fetch (None = all)
|
||||||
|
from_message_id: Start from this message ID (None = latest)
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Message objects from newest to oldest
|
||||||
|
"""
|
||||||
|
if not self._connected or not self.client:
|
||||||
|
raise RuntimeError("Not connected to Telegram")
|
||||||
|
|
||||||
|
entity = await self.get_channel_info(channel)
|
||||||
|
|
||||||
|
# Get total message count for progress
|
||||||
|
total = entity.messages_count if hasattr(entity, 'messages_count') else None
|
||||||
|
if limit:
|
||||||
|
total = min(total, limit) if total else limit
|
||||||
|
|
||||||
|
logger.info(f"Fetching messages from {getattr(entity, 'title', channel)}...")
|
||||||
|
if from_message_id:
|
||||||
|
logger.info(f"Starting from message ID: {from_message_id}")
|
||||||
|
|
||||||
|
# Iterate messages (newest first)
|
||||||
|
message_count = 0
|
||||||
|
async for message in self.client.iter_messages(
|
||||||
|
entity,
|
||||||
|
limit=limit,
|
||||||
|
min_id=from_message_id, # Messages with ID > from_message_id
|
||||||
|
):
|
||||||
|
yield message
|
||||||
|
message_count += 1
|
||||||
|
|
||||||
|
if message_count % 100 == 0:
|
||||||
|
logger.debug(f"Fetched {message_count} messages...")
|
||||||
|
|
||||||
|
logger.info(f"Finished fetching. Total messages: {message_count}")
|
||||||
|
|
||||||
|
async def download_media(
|
||||||
|
self,
|
||||||
|
message: Message,
|
||||||
|
output_path: Path,
|
||||||
|
max_size: Optional[int] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Download all media from a message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message with media
|
||||||
|
output_path: Directory to save media files
|
||||||
|
max_size: Maximum file size to download (bytes)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with file info (filename, type, size, path, is_too_large)
|
||||||
|
"""
|
||||||
|
if not message.media:
|
||||||
|
return []
|
||||||
|
|
||||||
|
max_size = max_size or self.settings.max_file_size
|
||||||
|
media_info = []
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
output_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Handle different media types
|
||||||
|
if isinstance(message.media, MessageMediaPhoto):
|
||||||
|
# Photo
|
||||||
|
info = await self._download_photo(message, output_path, max_size)
|
||||||
|
if info:
|
||||||
|
media_info.append(info)
|
||||||
|
|
||||||
|
elif isinstance(message.media, MessageMediaDocument):
|
||||||
|
# Document, video, audio, etc.
|
||||||
|
info = await self._download_document(message, output_path, max_size)
|
||||||
|
if info:
|
||||||
|
media_info.append(info)
|
||||||
|
|
||||||
|
# Note: Other media types (geo, poll, etc.) are not downloadable
|
||||||
|
|
||||||
|
return media_info
|
||||||
|
|
||||||
|
async def _download_photo(
|
||||||
|
self,
|
||||||
|
message: Message,
|
||||||
|
output_path: Path,
|
||||||
|
max_size: int,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Download photo from message."""
|
||||||
|
try:
|
||||||
|
# Get file size
|
||||||
|
photo = message.media.photo
|
||||||
|
# Get highest resolution
|
||||||
|
size = max(photo.sizes, key=lambda s: getattr(s, 'w', 0) * getattr(s, 'h', 0))
|
||||||
|
file_size = getattr(size, 'size', 0)
|
||||||
|
|
||||||
|
# Generate filename
|
||||||
|
timestamp = message.date.strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"photo_{timestamp}.jpg"
|
||||||
|
filepath = output_path / filename
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"filename": filename,
|
||||||
|
"type": "photo",
|
||||||
|
"size": file_size,
|
||||||
|
"path": str(filepath),
|
||||||
|
"is_too_large": file_size > max_size,
|
||||||
|
"caption": message.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_size > max_size:
|
||||||
|
logger.debug(f"Photo too large: {file_size} > {max_size}")
|
||||||
|
return info
|
||||||
|
|
||||||
|
# Download
|
||||||
|
await self.client.download_media(message.photo, filepath)
|
||||||
|
logger.debug(f"Downloaded photo: {filename}")
|
||||||
|
return info
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download photo: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _download_document(
|
||||||
|
self,
|
||||||
|
message: Message,
|
||||||
|
output_path: Path,
|
||||||
|
max_size: int,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Download document/video/audio from message."""
|
||||||
|
try:
|
||||||
|
doc = message.media.document
|
||||||
|
file_size = doc.size
|
||||||
|
|
||||||
|
# Get original filename
|
||||||
|
filename = None
|
||||||
|
for attr in doc.attributes:
|
||||||
|
if isinstance(attr, DocumentAttributeFilename):
|
||||||
|
filename = attr.file_name
|
||||||
|
break
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
# Generate filename based on type
|
||||||
|
ext = self._get_extension(doc)
|
||||||
|
timestamp = message.date.strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"file_{timestamp}{ext}"
|
||||||
|
|
||||||
|
filepath = output_path / filename
|
||||||
|
|
||||||
|
# Determine media type
|
||||||
|
media_type = self._get_media_type(doc)
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"filename": filename,
|
||||||
|
"type": media_type,
|
||||||
|
"size": file_size,
|
||||||
|
"path": str(filepath),
|
||||||
|
"is_too_large": file_size > max_size,
|
||||||
|
"caption": message.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_size > max_size:
|
||||||
|
logger.debug(f"File too large: {file_size} > {max_size}")
|
||||||
|
return info
|
||||||
|
|
||||||
|
# Download
|
||||||
|
await self.client.download_media(doc, filepath)
|
||||||
|
logger.debug(f"Downloaded {media_type}: {filename}")
|
||||||
|
return info
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download document: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_extension(self, doc) -> str:
|
||||||
|
"""Get file extension from document attributes."""
|
||||||
|
for attr in doc.attributes:
|
||||||
|
if isinstance(attr, DocumentAttributeFilename):
|
||||||
|
name = attr.file_name
|
||||||
|
if "." in name:
|
||||||
|
return "." + name.rsplit(".", 1)[-1]
|
||||||
|
return ".bin"
|
||||||
|
|
||||||
|
def _get_media_type(self, doc) -> str:
|
||||||
|
"""Determine media type from document attributes."""
|
||||||
|
mime_type = getattr(doc, 'mime_type', '')
|
||||||
|
|
||||||
|
if mime_type.startswith('video/'):
|
||||||
|
return 'video'
|
||||||
|
elif mime_type.startswith('audio/'):
|
||||||
|
return 'audio'
|
||||||
|
elif mime_type.startswith('image/'):
|
||||||
|
return 'photo'
|
||||||
|
elif mime_type == 'application/x-tgsticker':
|
||||||
|
return 'sticker'
|
||||||
|
else:
|
||||||
|
return 'document'
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""Async context manager entry."""
|
||||||
|
await self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Async context manager exit."""
|
||||||
|
await self.disconnect()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
Configuration management for Telegram Archiver.
|
||||||
|
Uses pydantic-settings for .env file parsing and validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables and .env file."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Telegram API credentials
|
||||||
|
api_id: int = Field(..., description="Telegram API ID from my.telegram.org")
|
||||||
|
api_hash: str = Field(..., description="Telegram API Hash from my.telegram.org")
|
||||||
|
phone: str = Field(..., description="Phone number for Telegram account")
|
||||||
|
|
||||||
|
# Session configuration
|
||||||
|
session_name: str = Field(
|
||||||
|
default="telegram-archiver",
|
||||||
|
description="Session name for Telethon (creates .session file)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# File download limits
|
||||||
|
max_file_size: int = Field(
|
||||||
|
default=209715200, # 200 MB
|
||||||
|
description="Maximum file size to download in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Output configuration
|
||||||
|
output_dir: Path = Field(
|
||||||
|
default=Path("./archives"),
|
||||||
|
description="Directory to store archived channels"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
log_level: str = Field(
|
||||||
|
default="INFO",
|
||||||
|
description="Logging level: DEBUG, INFO, WARNING, ERROR"
|
||||||
|
)
|
||||||
|
|
||||||
|
# OpenTelemetry (optional, for future)
|
||||||
|
otel_service_name: str = Field(
|
||||||
|
default="telegram-archiver",
|
||||||
|
description="Service name for OpenTelemetry traces"
|
||||||
|
)
|
||||||
|
otel_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Enable OpenTelemetry tracing"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session_path(self) -> Path:
|
||||||
|
"""Return full path to session file."""
|
||||||
|
return Path(__file__).parent / f"{self.session_name}.session"
|
||||||
|
|
||||||
|
def validate_credentials(self) -> bool:
|
||||||
|
"""Validate that required credentials are present."""
|
||||||
|
if self.api_id <= 0:
|
||||||
|
raise ValueError("API_ID must be a positive integer")
|
||||||
|
if len(self.api_hash) < 10:
|
||||||
|
raise ValueError("API_HASH seems too short")
|
||||||
|
if not self.phone.startswith("+"):
|
||||||
|
raise ValueError("PHONE must start with +")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Global settings instance
|
||||||
|
settings: Optional[Settings] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Get or create settings instance."""
|
||||||
|
global settings
|
||||||
|
if settings is None:
|
||||||
|
settings = Settings()
|
||||||
|
settings.validate_credentials()
|
||||||
|
return settings
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# FastAPI + CLI
|
||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
click==8.1.7
|
||||||
|
|
||||||
|
# Telegram
|
||||||
|
telethon==1.34.0
|
||||||
|
|
||||||
|
# Config & Validation
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
|
||||||
|
# YAML for front-matter
|
||||||
|
pyyaml==6.0.1
|
||||||
|
|
||||||
|
# Logging & Observability
|
||||||
|
opentelemetry-api==1.22.0
|
||||||
|
opentelemetry-sdk==1.22.0
|
||||||
|
opentelemetry-instrumentation-fastapi==0.43b0
|
||||||
|
|
||||||
|
# HTTP client for large file checks
|
||||||
|
httpx==0.26.0
|
||||||
|
|
||||||
|
# Utils
|
||||||
|
aiofiles==23.2.1
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
CLI entry point for telegram-archiver.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m telegram-archiver --channel dedinit
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.main import cli_main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli_main()
|
||||||
Reference in New Issue
Block a user