mirror of
https://gitverse.ru/kpa39l/chronicle.nixg.ru.git
synced 2026-09-29 18:05:07 +00:00
411 lines
13 KiB
Python
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()
|