Files

411 lines
13 KiB
Python

"""
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="Desktop (Ubuntu)", # Helps with code delivery
system_version="Ubuntu 22.04",
app_version="1.0.0",
lang_code="ru", # Russian language
system_lang_code="ru",
)
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:
# Try app code first
logger.info("Requesting auth code via Telegram app...")
sent_code = await self.client.send_code_request(
self.settings.phone,
force_sms=False
)
logger.info(f"Code request sent. Type: {sent_code.type}")
# Get code from user
print("\n" + "=" * 50)
print("AUTHENTICATION REQUIRED")
print("=" * 50)
print("Check your Telegram app (NOT SMS) for the code.")
print("Look for a message from 'Telegram' with the code.")
print("=" * 50)
print("\nOptions:")
print(" 1. Enter the code from Telegram app")
print(" 2. Type 'sms' to receive code via SMS")
print(" 3. Press Ctrl+C to cancel")
print("=" * 50)
user_input = input("\nEnter code (or 'sms'): ").strip()
# If user requests SMS
if user_input.lower() == 'sms':
logger.info("Requesting SMS code...")
print("\n📱 Requesting SMS code...")
sent_code = await self.client.send_code_request(
self.settings.phone,
force_sms=True
)
print("SMS sent! Enter the code:")
user_input = input("SMS Code: ").strip()
code = user_input
try:
await self.client.sign_in(
phone=self.settings.phone,
code=code,
phone_code_hash=sent_code.phone_code_hash
)
except SessionPasswordNeededError:
# 2FA is enabled
logger.info("2FA enabled. Enter password:")
print("\n🔐 2FA enabled")
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")
print(f"\n⚠️ You must wait {e.seconds} seconds before trying again.")
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
error_count = 0
# Build iter_messages kwargs
iter_kwargs = {"limit": limit}
if from_message_id:
iter_kwargs["min_id"] = from_message_id
async for message in self.client.iter_messages(entity, **iter_kwargs):
try:
yield message
message_count += 1
if message_count % 50 == 0:
logger.info(f"Progress: {message_count} messages fetched...")
except Exception as e:
error_count += 1
logger.error(f"Error processing message {message.id}: {e}")
continue # Skip problematic messages
logger.info(f"Finished fetching. Total: {message_count}, Errors: {error_count}")
async def download_media(
self,
message: Message,
output_path: Path,
max_size: Optional[int] = None,
timeout: int = 60, # Timeout in seconds
) -> 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)
timeout: Timeout for each file download in seconds
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)
try:
# Handle different media types with timeout
if isinstance(message.media, MessageMediaPhoto):
# Photo
info = await asyncio.wait_for(
self._download_photo(message, output_path, max_size),
timeout=timeout
)
if info:
media_info.append(info)
elif isinstance(message.media, MessageMediaDocument):
# Document, video, audio, etc.
info = await asyncio.wait_for(
self._download_document(message, output_path, max_size),
timeout=timeout
)
if info:
media_info.append(info)
except asyncio.TimeoutError:
logger.warning(f"Timeout downloading media for message {message.id}")
# Add info about skipped file
media_info.append({
"filename": "timeout_skipped.bin",
"type": "timeout",
"caption": None,
"size": 0,
"path": None,
"is_too_large": False,
"timeout": True,
})
except Exception as e:
logger.error(f"Error downloading media for message {message.id}: {e}")
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()