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/tgdata.py ADDED
@@ -0,0 +1,554 @@
1
+ """
2
+ Unified Telegram Group Message Crawler.
3
+ Single class with all features, delegating to specialized engines.
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ from datetime import datetime, timedelta
10
+ from typing import Optional, List, Dict, Any, Union, Callable
11
+ import pandas as pd
12
+
13
+ from .connection_engine import ConnectionEngine
14
+ from .message_engine import MessageEngine
15
+ from .models import GroupInfo
16
+ from .utils import (
17
+ format_message_for_display,
18
+ export_to_json,
19
+ export_to_csv,
20
+ filter_messages_by_sender,
21
+ filter_messages_by_content,
22
+ get_message_statistics,
23
+ save_profile_photos,
24
+ create_metrics_report
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ class TgData:
30
+ """
31
+ Unified interface for Telegram group operations.
32
+ All features in one place, with clean delegation to specialized engines.
33
+ """
34
+
35
+ def __init__(self,
36
+ config_path: str = "config.ini",
37
+ connection_pool_size: int = 1,
38
+ log_file: Optional[str] = None):
39
+ """
40
+ Initialize Telegram group handler.
41
+
42
+ Args:
43
+ config_path: Path to configuration file
44
+ connection_pool_size: Number of connections (1 = no pooling)
45
+ log_file: Optional log file path
46
+ """
47
+ # Set up logging
48
+ if log_file:
49
+ file_handler = logging.FileHandler(log_file)
50
+ file_handler.setFormatter(
51
+ logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
52
+ )
53
+ logger.addHandler(file_handler)
54
+
55
+ # Initialize engines
56
+ self.connection_engine = ConnectionEngine(
57
+ config_path=config_path,
58
+ pool_size=connection_pool_size
59
+ )
60
+
61
+ self.message_engine = MessageEngine(
62
+ connection_engine=self.connection_engine
63
+ )
64
+
65
+ # State
66
+ self.current_group: Optional[GroupInfo] = None
67
+ self._metrics: Dict[str, Any] = {}
68
+
69
+ logger.info("TgData initialized")
70
+
71
+ # ==================== Group Management ====================
72
+
73
+ async def list_groups(self) -> pd.DataFrame:
74
+ """
75
+ List all accessible groups/channels.
76
+
77
+ Returns:
78
+ DataFrame with group information
79
+ """
80
+ try:
81
+ client = await self.connection_engine.get_client()
82
+ groups_data = []
83
+
84
+ async with client:
85
+ async for dialog in client.iter_dialogs():
86
+ if dialog.is_group or dialog.is_channel:
87
+ entity = dialog.entity
88
+
89
+ group_info = {
90
+ 'GroupID': entity.id,
91
+ 'Title': entity.title,
92
+ 'Username': f"@{entity.username}" if hasattr(entity, 'username') and entity.username else None,
93
+ 'IsChannel': dialog.is_channel,
94
+ 'IsMegagroup': getattr(entity, 'megagroup', False),
95
+ 'ParticipantsCount': getattr(entity, 'participants_count', None)
96
+ }
97
+
98
+ groups_data.append(group_info)
99
+
100
+ df = pd.DataFrame(groups_data)
101
+ logger.info(f"Found {len(df)} groups/channels")
102
+ return df
103
+
104
+ except Exception as e:
105
+ logger.error(f"Error listing groups: {e}")
106
+ raise
107
+
108
+ def set_group(self, group_id: int) -> None:
109
+ """
110
+ Set the current group for operations.
111
+
112
+ Args:
113
+ group_id: Telegram group/channel ID
114
+ """
115
+ # Clear cache for the OLD group before setting new one
116
+ if self.current_group:
117
+ self.message_engine.clear_cache(self.current_group.id)
118
+
119
+ self.current_group = GroupInfo(
120
+ id=group_id,
121
+ title="", # Will be populated on first operation
122
+ username=None
123
+ )
124
+ logger.info(f"Set current group to {group_id}")
125
+
126
+ # ==================== Message Operations ====================
127
+
128
+ async def get_messages(self,
129
+ group_id: Optional[int] = None,
130
+ limit: Optional[int] = None,
131
+ start_date: Optional[datetime] = None,
132
+ end_date: Optional[datetime] = None,
133
+ after_id: int = 0,
134
+ include_profile_photos: bool = False,
135
+ with_progress: bool = False,
136
+ progress_callback: Optional[Callable] = None) -> pd.DataFrame:
137
+ """
138
+ Get messages from a group with various options.
139
+
140
+ Args:
141
+ group_id: Target group ID (uses current if not specified)
142
+ limit: Maximum number of messages
143
+ start_date: Get messages after this date
144
+ end_date: Get messages before this date
145
+ after_id: Get messages after this message ID (for incremental extraction)
146
+ include_profile_photos: Whether to download profile photos
147
+ with_progress: Enable progress tracking
148
+ progress_callback: Optional callback for progress updates
149
+
150
+ Returns:
151
+ DataFrame with messages
152
+ """
153
+ # Use provided group_id or current
154
+ target_group_id = group_id or (self.current_group.id if self.current_group else None)
155
+ if not target_group_id:
156
+ raise ValueError("No group specified. Use set_group() or provide group_id")
157
+
158
+ # Set as current if different
159
+ if group_id and (not self.current_group or self.current_group.id != group_id):
160
+ self.set_group(group_id)
161
+
162
+ # Enable progress callback if requested
163
+ if with_progress and not progress_callback:
164
+ def default_progress(current, total, rate):
165
+ if total:
166
+ percent = (current / total) * 100
167
+ print(f"\rProgress: {current}/{total} ({percent:.1f}%) - {rate:.1f} msg/s", end="")
168
+ else:
169
+ print(f"\rProgress: {current} messages - {rate:.1f} msg/s", end="")
170
+
171
+ progress_callback = default_progress
172
+
173
+ # Fetch messages
174
+ df = await self.message_engine.fetch_messages(
175
+ group_id=target_group_id,
176
+ limit=limit,
177
+ start_date=start_date,
178
+ end_date=end_date,
179
+ min_id=after_id if after_id > 0 else None,
180
+ include_profile_photos=include_profile_photos,
181
+ progress_callback=progress_callback
182
+ )
183
+
184
+ if with_progress:
185
+ print() # New line after progress
186
+
187
+ return df
188
+
189
+
190
+ async def get_message_count(self, group_id: Optional[int] = None) -> int:
191
+ """
192
+ Get total message count for a group without fetching all messages.
193
+
194
+ Args:
195
+ group_id: Target group ID (uses current if not specified)
196
+
197
+ Returns:
198
+ Total message count
199
+ """
200
+ target_group_id = group_id or (self.current_group.id if self.current_group else None)
201
+ if not target_group_id:
202
+ raise ValueError("No group specified")
203
+
204
+ return await self.message_engine.get_message_count(target_group_id)
205
+
206
+ async def search_messages(self,
207
+ query: str,
208
+ group_id: Optional[int] = None,
209
+ limit: Optional[int] = None) -> pd.DataFrame:
210
+ """
211
+ Search for messages containing specific text.
212
+
213
+ Args:
214
+ query: Search query
215
+ group_id: Target group ID (uses current if not specified)
216
+ limit: Maximum number of results
217
+
218
+ Returns:
219
+ DataFrame with matching messages
220
+ """
221
+ target_group_id = group_id or (self.current_group.id if self.current_group else None)
222
+ if not target_group_id:
223
+ raise ValueError("No group specified")
224
+
225
+ return await self.message_engine.search_messages(
226
+ group_id=target_group_id,
227
+ query=query,
228
+ limit=limit
229
+ )
230
+
231
+ # ==================== Message Display and Export ====================
232
+
233
+ def print_messages(self,
234
+ df: pd.DataFrame,
235
+ limit: Optional[int] = None,
236
+ max_length: int = 100) -> None:
237
+ """
238
+ Print messages in a readable format.
239
+
240
+ Args:
241
+ df: DataFrame with messages
242
+ limit: Maximum number of messages to print
243
+ max_length: Maximum message length to display
244
+ """
245
+ if df.empty:
246
+ print("No messages to display")
247
+ return
248
+
249
+ messages_to_print = df.head(limit) if limit else df
250
+
251
+ print(f"\n{'='*80}")
252
+ print(f"Displaying {len(messages_to_print)} of {len(df)} messages")
253
+ print(f"{'='*80}\n")
254
+
255
+ for _, msg in messages_to_print.iterrows():
256
+ print(format_message_for_display(msg, max_length))
257
+ print(f"{'-'*80}")
258
+
259
+ def filter_messages(self,
260
+ df: pd.DataFrame,
261
+ sender_id: Optional[int] = None,
262
+ username: Optional[str] = None,
263
+ keyword: Optional[str] = None,
264
+ start_date: Optional[datetime] = None,
265
+ end_date: Optional[datetime] = None) -> pd.DataFrame:
266
+ """
267
+ Filter messages by various criteria.
268
+
269
+ Args:
270
+ df: Messages DataFrame
271
+ sender_id: Filter by sender ID
272
+ username: Filter by username
273
+ keyword: Filter by message content
274
+ start_date: Filter messages after this date
275
+ end_date: Filter messages before this date
276
+
277
+ Returns:
278
+ Filtered DataFrame
279
+ """
280
+ # Apply filters
281
+ if sender_id or username:
282
+ df = filter_messages_by_sender(df, sender_id, username)
283
+
284
+ if keyword:
285
+ df = filter_messages_by_content(df, keyword)
286
+
287
+ if start_date:
288
+ df = df[df['Date'] >= start_date]
289
+
290
+ if end_date:
291
+ df = df[df['Date'] <= end_date]
292
+
293
+ return df
294
+
295
+ def export_messages(self,
296
+ df: pd.DataFrame,
297
+ filepath: str,
298
+ format: str = 'csv') -> None:
299
+ """
300
+ Export messages to file.
301
+
302
+ Args:
303
+ df: Messages DataFrame
304
+ filepath: Output file path
305
+ format: Export format ('csv', 'json')
306
+ """
307
+ if format.lower() == 'csv':
308
+ export_to_csv(df, filepath)
309
+ elif format.lower() == 'json':
310
+ export_to_json(df, filepath)
311
+ else:
312
+ raise ValueError(f"Unsupported format: {format}")
313
+
314
+ # ==================== Statistics and Metrics ====================
315
+
316
+ def get_statistics(self, df: pd.DataFrame) -> Dict[str, Any]:
317
+ """
318
+ Get statistics about messages.
319
+
320
+ Args:
321
+ df: Messages DataFrame
322
+
323
+ Returns:
324
+ Dictionary with statistics
325
+ """
326
+ return get_message_statistics(df)
327
+
328
+ async def get_metrics(self) -> Dict[str, Any]:
329
+ """
330
+ Get current session metrics.
331
+
332
+ Returns:
333
+ Dictionary with metrics
334
+ """
335
+ health_status = await self.connection_engine.health_check()
336
+
337
+ metrics = {
338
+ 'timestamp': datetime.now().isoformat(),
339
+ 'current_group': self.current_group.id if self.current_group else None,
340
+ 'connection_health': health_status,
341
+ 'message_cache_size': len(self.message_engine._message_cache)
342
+ }
343
+
344
+ return metrics
345
+
346
+ async def export_metrics(self, filepath: str = "telegram_metrics.json") -> None:
347
+ """
348
+ Export session metrics to file.
349
+
350
+ Args:
351
+ filepath: Output file path
352
+ """
353
+ metrics = await self.get_metrics()
354
+
355
+ with open(filepath, 'w') as f:
356
+ json.dump(metrics, f, indent=2)
357
+
358
+ logger.info(f"Exported metrics to {filepath}")
359
+
360
+ # ==================== Utility Methods ====================
361
+
362
+ async def validate_connection(self) -> bool:
363
+ """
364
+ Validate connection status.
365
+
366
+ Returns:
367
+ True if connection is valid
368
+ """
369
+ return await self.connection_engine.validate_connection()
370
+
371
+ async def health_check(self) -> Dict[str, Any]:
372
+ """
373
+ Perform health check.
374
+
375
+ Returns:
376
+ Health check results
377
+ """
378
+ return await self.connection_engine.health_check()
379
+
380
+ def save_photos(self, df: pd.DataFrame, output_dir: str) -> None:
381
+ """
382
+ Save profile photos from messages.
383
+
384
+ Args:
385
+ df: Messages DataFrame with PhotoData
386
+ output_dir: Directory to save photos
387
+ """
388
+ save_profile_photos(df, output_dir)
389
+
390
+ async def close(self) -> None:
391
+ """Close all connections."""
392
+ await self.connection_engine.close()
393
+ logger.info("TgData closed")
394
+
395
+ # ==================== Polling and Real-time Updates ====================
396
+
397
+ def on_new_message(self, group_id: Optional[int] = None):
398
+ """
399
+ Decorator to register a handler for new messages in real-time.
400
+ Uses Telethon's event system.
401
+
402
+ Args:
403
+ group_id: Optional group ID to filter messages. If None, receives from all groups.
404
+
405
+ Example:
406
+ @tg.on_new_message(group_id=12345)
407
+ async def handler(event):
408
+ print(f"New message: {event.message.text}")
409
+ """
410
+ from telethon import events
411
+
412
+ def decorator(func):
413
+ async def wrapped_handler(event):
414
+ # If group_id specified, filter by it
415
+ if group_id and event.chat_id != group_id:
416
+ return
417
+ await func(event)
418
+
419
+ # Register the event handler on the client
420
+ async def register():
421
+ client = await self.connection_engine.get_client()
422
+ client.add_event_handler(wrapped_handler, events.NewMessage())
423
+ logger.info(f"Registered new message handler for group {group_id or 'all groups'}")
424
+
425
+ # Schedule registration
426
+ asyncio.create_task(register())
427
+
428
+ return func
429
+
430
+ return decorator
431
+
432
+ async def poll_for_messages(self,
433
+ group_id: int,
434
+ interval: int = 60,
435
+ after_id: int = 0,
436
+ callback: Optional[Callable] = None,
437
+ max_iterations: Optional[int] = None) -> None:
438
+ """
439
+ Poll for new messages at specified intervals.
440
+
441
+ Args:
442
+ group_id: Group ID to poll messages from
443
+ interval: Polling interval in seconds (default: 60)
444
+ after_id: Message ID to start polling after (default: 0)
445
+ callback: Optional async callback function to process new messages
446
+ max_iterations: Maximum number of polling iterations (None = infinite)
447
+
448
+ Example:
449
+ async def process_messages(messages_df):
450
+ print(f"Got {len(messages_df)} new messages")
451
+
452
+ await tg.poll_for_messages(
453
+ group_id=12345,
454
+ interval=30,
455
+ callback=process_messages
456
+ )
457
+ """
458
+ logger.info(f"Starting polling for group {group_id} with interval {interval}s")
459
+
460
+ current_after_id = after_id
461
+ iterations = 0
462
+
463
+ while max_iterations is None or iterations < max_iterations:
464
+ try:
465
+ # Get new messages since last check
466
+ new_messages = await self.get_messages(
467
+ group_id=group_id,
468
+ after_id=current_after_id
469
+ )
470
+
471
+ if not new_messages.empty:
472
+ # Update the after_id to the latest message
473
+ current_after_id = new_messages['MessageId'].max()
474
+
475
+ logger.info(f"Poll iteration {iterations + 1}: Found {len(new_messages)} new messages")
476
+
477
+ # Call the callback if provided
478
+ if callback:
479
+ await callback(new_messages)
480
+ else:
481
+ logger.debug(f"Poll iteration {iterations + 1}: No new messages")
482
+
483
+ iterations += 1
484
+
485
+ # Wait for next interval (unless this is the last iteration)
486
+ if max_iterations is None or iterations < max_iterations:
487
+ await asyncio.sleep(interval)
488
+
489
+ except Exception as e:
490
+ logger.error(f"Error during polling: {e}")
491
+ # Continue polling after error
492
+ await asyncio.sleep(interval)
493
+
494
+ async def run_with_event_loop(self):
495
+ """
496
+ Run the Telegram client with event loop to handle real-time events.
497
+ This method blocks until disconnected.
498
+
499
+ Example:
500
+ tg = TgData()
501
+
502
+ @tg.on_new_message()
503
+ async def handler(event):
504
+ print(f"New message: {event.message.text}")
505
+
506
+ await tg.run_with_event_loop()
507
+ """
508
+ client = await self.connection_engine.get_client()
509
+ logger.info("Running with event loop. Press Ctrl+C to stop...")
510
+ await client.run_until_disconnected()
511
+
512
+ # ==================== Context Manager Support ====================
513
+
514
+ async def __aenter__(self):
515
+ """Async context manager entry."""
516
+ return self
517
+
518
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
519
+ """Async context manager exit."""
520
+ await self.close()
521
+
522
+
523
+ # Example usage
524
+ if __name__ == "__main__":
525
+ async def main():
526
+ # Simple usage
527
+ tg = TgData()
528
+
529
+ # List groups
530
+ groups = await tg.list_groups()
531
+ print(f"Found {len(groups)} groups")
532
+
533
+ if not groups.empty:
534
+ # Select first group
535
+ group_id = groups.iloc[0]['GroupID']
536
+ tg.set_group(group_id)
537
+
538
+ # Get recent messages with progress
539
+ messages = await tg.get_messages(
540
+ limit=100,
541
+ with_progress=True
542
+ )
543
+
544
+ # Display messages
545
+ tg.print_messages(messages, limit=10)
546
+
547
+ # Get statistics
548
+ stats = tg.get_statistics(messages)
549
+ print(f"\nStatistics: {stats}")
550
+
551
+ # Export
552
+ tg.export_messages(messages, "messages.csv")
553
+
554
+ asyncio.run(main())