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 +57 -0
- tgdata/connection_engine.py +316 -0
- tgdata/message_engine.py +299 -0
- tgdata/models.py +69 -0
- tgdata/progress.py +105 -0
- tgdata/smoke_tests/__init__.py +1 -0
- tgdata/smoke_tests/my_test.py +149 -0
- tgdata/smoke_tests/test_00_connection.py +286 -0
- tgdata/smoke_tests/test_01_list_group_chats.py +408 -0
- tgdata/smoke_tests/test_02_get_message_count.py +73 -0
- tgdata/smoke_tests/test_03_get_all_messages.py +69 -0
- tgdata/smoke_tests/test_04_get_all_messages_in_batches.py +98 -0
- tgdata/smoke_tests/test_06_advanced_features.py +253 -0
- tgdata/smoke_tests/test_10_polling.py +244 -0
- tgdata/tgdata.py +554 -0
- tgdata/utils.py +247 -0
- tgdata-0.0.0.dist-info/METADATA +309 -0
- tgdata-0.0.0.dist-info/RECORD +20 -0
- tgdata-0.0.0.dist-info/WHEEL +5 -0
- tgdata-0.0.0.dist-info/top_level.txt +1 -0
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")
|
tgdata/message_engine.py
ADDED
|
@@ -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
|