Files
chronicle.nixg.ru/telegram-archiver/app/main.py
T

238 lines
6.8 KiB
Python

"""
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)