tgdata 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tgdata/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ tgdata - Telegram Data Extraction Library
3
+
4
+ A production-grade Python library for extracting and processing Telegram group and channel messages.
5
+ """
6
+
7
+ # Main class
8
+ from .tgdata import TgData
9
+
10
+ # Models
11
+ from .models import (
12
+ MessageData,
13
+ GroupInfo,
14
+ ConnectionConfig,
15
+ RateLimitInfo
16
+ )
17
+
18
+ # Utilities
19
+ from .utils import (
20
+ format_message_for_display,
21
+ export_to_json,
22
+ export_to_csv,
23
+ filter_messages_by_sender,
24
+ filter_messages_by_content,
25
+ get_message_statistics,
26
+ save_profile_photos,
27
+ create_metrics_report
28
+ )
29
+
30
+ # Progress tracking
31
+ from .progress import ProgressTracker
32
+
33
+ __version__ = "0.0.0"
34
+
35
+ __all__ = [
36
+ # Main class
37
+ "TgData",
38
+
39
+ # Models
40
+ "MessageData",
41
+ "GroupInfo",
42
+ "ConnectionConfig",
43
+ "RateLimitInfo",
44
+
45
+ # Utilities
46
+ "format_message_for_display",
47
+ "export_to_json",
48
+ "export_to_csv",
49
+ "filter_messages_by_sender",
50
+ "filter_messages_by_content",
51
+ "get_message_statistics",
52
+ "save_profile_photos",
53
+ "create_metrics_report",
54
+
55
+ # Progress
56
+ "ProgressTracker"
57
+ ]
@@ -0,0 +1,316 @@
1
+ """
2
+ Connection management engine for Telegram.
3
+ Handles connection pooling, rate limiting, retries, and health checks.
4
+ """
5
+
6
+ import asyncio
7
+ import configparser
8
+ import logging
9
+ import time
10
+ from typing import Optional, List, Dict, Any, Union
11
+ from telethon import TelegramClient
12
+ from telethon.errors import FloodWaitError, AuthKeyUnregisteredError
13
+ from telethon.sessions import StringSession
14
+
15
+ from .models import ConnectionConfig, RateLimitInfo
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ConnectionPool:
21
+ """Manage multiple connections with rate limiting"""
22
+
23
+ def __init__(self, max_connections: int = 3):
24
+ self.max_connections = max_connections
25
+ self.connections: List[TelegramClient] = []
26
+ self.current_index = 0
27
+ self.rate_limits: Dict[int, RateLimitInfo] = {}
28
+
29
+ async def get_connection(self) -> TelegramClient:
30
+ """Get next available connection using round-robin"""
31
+ if not self.connections:
32
+ raise ValueError("No connections in pool")
33
+
34
+ # Try to find a connection not rate-limited
35
+ attempts = 0
36
+ while attempts < len(self.connections):
37
+ conn = self.connections[self.current_index]
38
+ self.current_index = (self.current_index + 1) % len(self.connections)
39
+
40
+ rate_info = self.rate_limits.get(id(conn), RateLimitInfo())
41
+ if time.time() < rate_info.flood_wait_until:
42
+ attempts += 1
43
+ continue
44
+
45
+ return conn
46
+
47
+ # All connections rate-limited, return the one with shortest wait
48
+ return min(self.connections,
49
+ key=lambda c: self.rate_limits.get(id(c), RateLimitInfo()).flood_wait_until)
50
+
51
+ def mark_rate_limited(self, conn: TelegramClient, wait_seconds: float):
52
+ """Mark a connection as rate-limited"""
53
+ rate_info = self.rate_limits.get(id(conn), RateLimitInfo())
54
+ rate_info.flood_wait_until = time.time() + wait_seconds
55
+ self.rate_limits[id(conn)] = rate_info
56
+ logger.warning(f"Connection {id(conn)} rate-limited for {wait_seconds}s")
57
+
58
+ def add_connection(self, conn: TelegramClient):
59
+ """Add a connection to the pool"""
60
+ self.connections.append(conn)
61
+
62
+ async def close_all(self):
63
+ """Close all connections in the pool"""
64
+ for conn in self.connections:
65
+ await conn.disconnect()
66
+ self.connections.clear()
67
+
68
+
69
+ class ConnectionEngine:
70
+ """
71
+ Manages Telegram connections with advanced features.
72
+ """
73
+
74
+ def __init__(self,
75
+ config_path: str = "config.ini",
76
+ pool_size: int = 1,
77
+ max_retries: int = 3,
78
+ retry_delay: float = 1.0,
79
+ exponential_backoff: bool = True):
80
+ """
81
+ Initialize connection engine.
82
+
83
+ Args:
84
+ config_path: Path to configuration file
85
+ pool_size: Number of connections in pool (1 = no pooling)
86
+ max_retries: Maximum retry attempts
87
+ retry_delay: Initial retry delay in seconds
88
+ exponential_backoff: Whether to use exponential backoff
89
+ """
90
+ self.config_path = config_path
91
+ self.pool_size = pool_size
92
+ self.max_retries = max_retries
93
+ self.retry_delay = retry_delay
94
+ self.exponential_backoff = exponential_backoff
95
+
96
+ self._config: Optional[ConnectionConfig] = None
97
+ self._primary_client: Optional[TelegramClient] = None
98
+ self._pool: Optional[ConnectionPool] = None
99
+ self._health_check_interval = 300 # 5 minutes
100
+ self._last_health_check = 0
101
+
102
+ def _load_config(self) -> ConnectionConfig:
103
+ """Load configuration from file"""
104
+ if self._config:
105
+ return self._config
106
+
107
+ config = configparser.ConfigParser()
108
+ config.read(self.config_path)
109
+
110
+ # Handle case-insensitive section names
111
+ telegram_section = None
112
+ for section in config.sections():
113
+ if section.lower() == 'telegram':
114
+ telegram_section = section
115
+ break
116
+
117
+ if telegram_section is None:
118
+ raise ValueError("No [telegram] or [Telegram] section found in config file")
119
+
120
+ self._config = ConnectionConfig(
121
+ api_id=config[telegram_section]['api_id'],
122
+ api_hash=config[telegram_section]['api_hash'],
123
+ session_file=config[telegram_section].get('session_file', 'telegram_session'),
124
+ phone=config[telegram_section].get('phone'),
125
+ username=config[telegram_section].get('username'),
126
+ max_retries=self.max_retries,
127
+ retry_delay=self.retry_delay,
128
+ exponential_backoff=self.exponential_backoff
129
+ )
130
+
131
+ return self._config
132
+
133
+ async def get_client(self) -> TelegramClient:
134
+ """
135
+ Get a connected Telegram client.
136
+ Uses pooling if enabled, otherwise returns primary client.
137
+ """
138
+ # Check if health check needed
139
+ current_time = time.time()
140
+ if current_time - self._last_health_check > self._health_check_interval:
141
+ await self.health_check()
142
+ self._last_health_check = current_time
143
+
144
+ # Use pool if enabled
145
+ if self.pool_size > 1 and self._pool:
146
+ return await self._pool.get_connection()
147
+
148
+ # Otherwise ensure primary client is connected
149
+ if not self._primary_client:
150
+ await self._init_primary_client()
151
+
152
+ if not self._primary_client.is_connected():
153
+ await self._connect_with_retry(self._primary_client)
154
+
155
+ return self._primary_client
156
+
157
+ async def _init_primary_client(self):
158
+ """Initialize the primary client"""
159
+ config = self._load_config()
160
+
161
+ # Use username as session name for compatibility with original code
162
+ session_name = config.username if config.username else config.session_file
163
+ self._primary_client = TelegramClient(
164
+ session_name,
165
+ config.api_id,
166
+ config.api_hash
167
+ )
168
+
169
+ await self._connect_with_retry(self._primary_client)
170
+
171
+ # Initialize pool if needed
172
+ if self.pool_size > 1:
173
+ await self._init_pool()
174
+
175
+ async def _init_pool(self):
176
+ """Initialize connection pool"""
177
+ config = self._load_config()
178
+ self._pool = ConnectionPool(self.pool_size)
179
+
180
+ # Add primary client to pool
181
+ if self._primary_client:
182
+ self._pool.add_connection(self._primary_client)
183
+
184
+ # Create additional connections
185
+ for i in range(1, self.pool_size):
186
+ session_file = f"{config.session_file}_{i}"
187
+ client = TelegramClient(
188
+ session_file,
189
+ config.api_id,
190
+ config.api_hash
191
+ )
192
+
193
+ await self._connect_with_retry(client)
194
+ self._pool.add_connection(client)
195
+
196
+ logger.info(f"Initialized connection pool with {self.pool_size} connections")
197
+
198
+ async def _connect_with_retry(self, client: TelegramClient):
199
+ """Connect a client with retry logic"""
200
+ config = self._load_config()
201
+
202
+ try:
203
+ # Use start() directly - it handles everything including retries
204
+ await client.start(phone=config.phone)
205
+ logger.info("Successfully connected and authenticated!")
206
+
207
+ except FloodWaitError as e:
208
+ wait_time = e.seconds
209
+ logger.warning(f"Rate limited, waiting {wait_time} seconds...")
210
+
211
+ if self._pool:
212
+ self._pool.mark_rate_limited(client, wait_time)
213
+
214
+ await asyncio.sleep(wait_time)
215
+ # Retry after rate limit
216
+ await client.start(phone=config.phone)
217
+
218
+ except Exception as e:
219
+ logger.error(f"Connection failed: {e}")
220
+ raise ConnectionError(f"Failed to connect: {e}")
221
+
222
+ async def health_check(self) -> Dict[str, Any]:
223
+ """
224
+ Perform health check on all connections.
225
+
226
+ Returns:
227
+ Dictionary with health status
228
+ """
229
+ logger.info("Performing connection health check...")
230
+
231
+ status = {
232
+ 'timestamp': time.time(),
233
+ 'primary_connection': False,
234
+ 'pool_connections': [],
235
+ 'errors': []
236
+ }
237
+
238
+ try:
239
+ # Check primary connection
240
+ if self._primary_client:
241
+ try:
242
+ if self._primary_client.is_connected():
243
+ await self._primary_client.get_me()
244
+ status['primary_connection'] = True
245
+ logger.info("Primary connection healthy")
246
+ except Exception as e:
247
+ status['errors'].append(f"Primary connection error: {e}")
248
+ logger.warning(f"Primary connection unhealthy: {e}")
249
+
250
+ # Check pool connections
251
+ if self._pool:
252
+ for i, conn in enumerate(self._pool.connections):
253
+ try:
254
+ if conn.is_connected():
255
+ await conn.get_me()
256
+ status['pool_connections'].append({
257
+ 'index': i,
258
+ 'healthy': True,
259
+ 'rate_limited': time.time() < self._pool.rate_limits.get(
260
+ id(conn), RateLimitInfo()
261
+ ).flood_wait_until
262
+ })
263
+ else:
264
+ status['pool_connections'].append({
265
+ 'index': i,
266
+ 'healthy': False
267
+ })
268
+ except Exception as e:
269
+ status['errors'].append(f"Pool connection {i} error: {e}")
270
+ status['pool_connections'].append({
271
+ 'index': i,
272
+ 'healthy': False,
273
+ 'error': str(e)
274
+ })
275
+
276
+ except Exception as e:
277
+ status['errors'].append(f"Health check error: {e}")
278
+ logger.error(f"Health check failed: {e}")
279
+
280
+ return status
281
+
282
+ async def validate_connection(self) -> bool:
283
+ """
284
+ Validate that we have a working connection.
285
+
286
+ Returns:
287
+ True if connection is valid
288
+ """
289
+ try:
290
+ client = await self.get_client()
291
+ await client.get_me()
292
+ return True
293
+ except Exception as e:
294
+ logger.error(f"Connection validation failed: {e}")
295
+ return False
296
+
297
+ async def handle_rate_limit(self, error: FloodWaitError, client: Optional[TelegramClient] = None):
298
+ """Handle rate limit errors"""
299
+ wait_time = error.seconds
300
+ logger.warning(f"Rate limit hit! Waiting {wait_time} seconds...")
301
+
302
+ if self._pool and client:
303
+ self._pool.mark_rate_limited(client, wait_time)
304
+
305
+ await asyncio.sleep(wait_time)
306
+
307
+ async def close(self):
308
+ """Close all connections"""
309
+ if self._pool:
310
+ await self._pool.close_all()
311
+ elif self._primary_client:
312
+ await self._primary_client.disconnect()
313
+
314
+ self._primary_client = None
315
+ self._pool = None
316
+ logger.info("All connections closed")
@@ -0,0 +1,299 @@
1
+ """
2
+ Message processing engine for Telegram.
3
+ Handles message fetching, filtering, deduplication, and formatting.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ from datetime import datetime
9
+ from typing import Optional, List, Dict, Any, Callable
10
+ import pandas as pd
11
+ from telethon.errors import FloodWaitError
12
+
13
+ from .connection_engine import ConnectionEngine
14
+ from .models import MessageData
15
+ from .progress import ProgressTracker
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class MessageEngine:
21
+ """
22
+ Handles all message-related operations.
23
+ """
24
+
25
+ def __init__(self,
26
+ connection_engine: ConnectionEngine):
27
+ """
28
+ Initialize message engine.
29
+
30
+ Args:
31
+ connection_engine: Connection engine instance
32
+ """
33
+ self.connection_engine = connection_engine
34
+
35
+ # Message cache for performance
36
+ self._message_cache: Dict[str, pd.DataFrame] = {}
37
+
38
+ async def fetch_messages(self,
39
+ group_id: int,
40
+ limit: Optional[int] = None,
41
+ start_date: Optional[datetime] = None,
42
+ end_date: Optional[datetime] = None,
43
+ include_profile_photos: bool = False,
44
+ use_cache: bool = True,
45
+ progress_callback: Optional[Callable] = None,
46
+ min_id: Optional[int] = None) -> pd.DataFrame:
47
+ """
48
+ Fetch messages from a group with various filters.
49
+
50
+ Args:
51
+ group_id: Telegram group/channel ID
52
+ limit: Maximum number of messages to fetch
53
+ start_date: Get messages after this date
54
+ end_date: Get messages before this date
55
+ include_profile_photos: Whether to download profile photos
56
+ use_cache: Whether to use cached results
57
+ progress_callback: Optional callback for progress updates
58
+ min_id: Minimum message ID (for resuming)
59
+
60
+ Returns:
61
+ DataFrame with messages
62
+ """
63
+ # Check cache
64
+ cache_key = f"{group_id}_{start_date}_{end_date}_{limit}"
65
+ if use_cache and cache_key in self._message_cache:
66
+ logger.info("Returning cached messages")
67
+ return self._message_cache[cache_key]
68
+
69
+ # Set up progress tracking
70
+ progress_tracker = None
71
+ if progress_callback:
72
+ progress_tracker = ProgressTracker(
73
+ total_expected=limit,
74
+ callback=progress_callback
75
+ )
76
+ progress_tracker.start()
77
+
78
+ messages_data = []
79
+ processed_count = 0
80
+
81
+ try:
82
+ client = await self.connection_engine.get_client()
83
+
84
+ async with client:
85
+ # Get the entity
86
+ channel = await client.get_entity(group_id)
87
+ logger.info(f"Fetching messages from: {channel.title}")
88
+
89
+ # Determine iteration parameters
90
+ offset_date = end_date if end_date else datetime.now()
91
+ reverse = bool(start_date) # Reverse if we have a start date
92
+
93
+ # Build kwargs for iter_messages
94
+ iter_kwargs = {
95
+ 'entity': channel,
96
+ 'limit': limit,
97
+ 'offset_date': offset_date,
98
+ 'reverse': reverse
99
+ }
100
+
101
+ # Only add min_id if it's not None
102
+ if min_id is not None:
103
+ iter_kwargs['min_id'] = min_id
104
+
105
+ # Iterate through messages
106
+ async for msg in client.iter_messages(**iter_kwargs):
107
+ # Apply date filters
108
+ if start_date and msg.date < start_date:
109
+ continue
110
+ if end_date and msg.date > end_date:
111
+ break
112
+
113
+ # Check deduplication
114
+ if self.enable_deduplication and self.tracker:
115
+ if await self.tracker.is_processed(msg.id, group_id):
116
+ logger.debug(f"Skipping duplicate message {msg.id}")
117
+ continue
118
+
119
+ # Process message
120
+ message_data = await self._process_message(
121
+ msg,
122
+ client,
123
+ include_profile_photos
124
+ )
125
+
126
+ if message_data:
127
+ messages_data.append(message_data.to_dict())
128
+
129
+
130
+ processed_count += 1
131
+
132
+ # Update progress
133
+ if progress_tracker:
134
+ progress_tracker.update()
135
+
136
+ # Log progress
137
+ if processed_count % 100 == 0:
138
+ logger.info(f"Processed {processed_count} messages...")
139
+
140
+ except FloodWaitError as e:
141
+ await self.connection_engine.handle_rate_limit(e, client)
142
+ # Retry after rate limit
143
+ return await self.fetch_messages(
144
+ group_id=group_id,
145
+ limit=limit,
146
+ start_date=start_date,
147
+ end_date=end_date,
148
+ include_profile_photos=include_profile_photos,
149
+ use_cache=use_cache,
150
+ progress_callback=progress_callback,
151
+ min_id=min_id
152
+ )
153
+
154
+ except Exception as e:
155
+ logger.error(f"Error fetching messages: {e}")
156
+ raise
157
+
158
+ # Create DataFrame
159
+ df = pd.DataFrame(messages_data)
160
+
161
+ # Cache results
162
+ if use_cache and not df.empty:
163
+ self._message_cache[cache_key] = df
164
+
165
+ logger.info(f"Retrieved {len(df)} messages")
166
+
167
+ # Log tracker stats
168
+ if self.tracker:
169
+ stats = await self.tracker.get_stats(group_id)
170
+ logger.info(f"Tracker stats: {stats}")
171
+
172
+ return df
173
+
174
+ async def _process_message(self,
175
+ msg,
176
+ client,
177
+ include_profile_photos: bool) -> Optional[MessageData]:
178
+ """Process a single message"""
179
+ try:
180
+ sender = await msg.get_sender()
181
+ if not sender:
182
+ return None
183
+
184
+ # Extract sender info
185
+ sender_name = f"{getattr(sender, 'first_name', '') or ''} {getattr(sender, 'last_name', '') or ''}".strip()
186
+ username = f"@{sender.username}" if hasattr(sender, 'username') and sender.username else "No username"
187
+
188
+ # Create message data
189
+ message_data = MessageData(
190
+ message_id=msg.id,
191
+ sender_id=sender.id,
192
+ sender_name=sender_name,
193
+ username=username,
194
+ message=msg.message,
195
+ date=msg.date,
196
+ reply_to_id=msg.reply_to_msg_id,
197
+ forwarded_from=msg.fwd_from.from_id if msg.fwd_from else None
198
+ )
199
+
200
+ # Download profile photo if requested
201
+ if include_profile_photos and hasattr(sender, 'photo') and sender.photo:
202
+ try:
203
+ photo_bytes = await client.download_profile_photo(sender, file=bytes)
204
+ message_data.photo_data = photo_bytes
205
+ except Exception as e:
206
+ logger.warning(f"Failed to download photo for {sender.id}: {e}")
207
+
208
+ return message_data
209
+
210
+ except Exception as e:
211
+ logger.error(f"Error processing message {msg.id}: {e}")
212
+ return None
213
+
214
+
215
+ def clear_cache(self, group_id: Optional[int] = None):
216
+ """
217
+ Clear message cache.
218
+
219
+ Args:
220
+ group_id: Optional group ID to clear cache for specific group
221
+ """
222
+ if group_id:
223
+ # Clear cache for specific group
224
+ keys_to_remove = [k for k in self._message_cache.keys() if k.startswith(f"{group_id}_")]
225
+ for key in keys_to_remove:
226
+ del self._message_cache[key]
227
+ logger.info(f"Cleared cache for group {group_id}")
228
+ else:
229
+ # Clear all cache
230
+ self._message_cache.clear()
231
+ logger.info("Cleared all message cache")
232
+
233
+ async def get_message_count(self, group_id: int) -> int:
234
+ """
235
+ Get total message count for a group.
236
+
237
+ Args:
238
+ group_id: Telegram group/channel ID
239
+
240
+ Returns:
241
+ Total message count
242
+ """
243
+ try:
244
+ client = await self.connection_engine.get_client()
245
+
246
+ async with client:
247
+ # Get the messages with limit=1 to access count
248
+ messages = await client.get_messages(group_id, limit=1)
249
+
250
+ # TotalList objects have a 'total' attribute
251
+ if hasattr(messages, 'total'):
252
+ return messages.total
253
+ else:
254
+ # If it's just a regular list, we can't get total efficiently
255
+ logger.warning("Could not get message count efficiently")
256
+ return 0
257
+
258
+ except Exception as e:
259
+ logger.error(f"Error getting message count: {e}")
260
+ raise
261
+
262
+ async def search_messages(self,
263
+ group_id: int,
264
+ query: str,
265
+ limit: Optional[int] = None) -> pd.DataFrame:
266
+ """
267
+ Search for messages containing specific text.
268
+
269
+ Args:
270
+ group_id: Telegram group/channel ID
271
+ query: Search query
272
+ limit: Maximum number of results
273
+
274
+ Returns:
275
+ DataFrame with matching messages
276
+ """
277
+ try:
278
+ client = await self.connection_engine.get_client()
279
+ messages_data = []
280
+
281
+ async with client:
282
+ channel = await client.get_entity(group_id)
283
+
284
+ async for msg in client.iter_messages(
285
+ channel,
286
+ search=query,
287
+ limit=limit
288
+ ):
289
+ message_data = await self._process_message(msg, client, False)
290
+ if message_data:
291
+ messages_data.append(message_data.to_dict())
292
+
293
+ df = pd.DataFrame(messages_data)
294
+ logger.info(f"Found {len(df)} messages matching '{query}'")
295
+ return df
296
+
297
+ except Exception as e:
298
+ logger.error(f"Error searching messages: {e}")
299
+ raise