jetrauserapi 1.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.
@@ -0,0 +1,519 @@
1
+ import asyncio
2
+ import struct
3
+ import gzip
4
+ import hashlib
5
+ import datetime
6
+ import random
7
+ import json
8
+ import os
9
+ import time
10
+
11
+ from jetrauserapi.client import JetraClient as LowLevelClient, wrap_init_connection, aes_ige_decrypt
12
+ from jetrauserapi.session import JetraSession
13
+ from jetrauserapi.message import Message
14
+ from jetrauserapi.database import Database
15
+
16
+
17
+ INPUT_PEER_USER = 0xDDE8A54C
18
+ INPUT_PEER_SELF = 0x7DA07EC9
19
+ INPUT_PEER_CHANNEL = 0x27BCBBFC
20
+
21
+ PEER_USER = 0x59511722
22
+ PEER_CHANNEL = 0xA2A5371E
23
+
24
+ MSG_CTOR_JETRA = 0x7600B9D3
25
+ SERVICE_MSG_CTOR_JETRA = 0x7A800E0A
26
+
27
+ SEND_MESSAGE_ID = 0xFEF48F62
28
+ GET_DIALOGS_ID = 0xA0F4CB4F
29
+ GET_STATE_ID = 0xEDD4882A
30
+
31
+ RPC_ERROR = 0x2144CA19
32
+ GZIP_PACKED = 0x3072CFA1
33
+ UPDATES = 0x74AE4240
34
+ UPDATE_NEW_MESSAGE = 0x1F2B0AFD
35
+ UPDATE_NEW_CHANNEL_MESSAGE = 0x62BA04D9
36
+
37
+
38
+ class JetraClient:
39
+ def __init__(self, session_file="jetra_session.json", db_file="jetra_local.db"):
40
+ self.session = JetraSession(session_file)
41
+ self.db = Database(db_file)
42
+ self.client = LowLevelClient(session=self.session)
43
+
44
+ self._message_handlers = []
45
+ self._ready_handlers = []
46
+ self._is_running = False
47
+ self._listen_task = None
48
+ self._reconnect_delay = 5
49
+ self._max_reconnect_attempts = 10
50
+ self._ready_event = asyncio.Event()
51
+
52
+ # Lock for WebSocket recv() to prevent concurrency
53
+ self._recv_lock = asyncio.Lock()
54
+
55
+ client_ref = self.client
56
+
57
+ def _patched_pack_msg_id():
58
+ client_ref._msg_counter = getattr(client_ref, '_msg_counter', 0) + 1
59
+ current_time = int(time.time())
60
+ msg_id = (current_time << 32) | (client_ref._msg_counter << 2)
61
+ return struct.pack("<Q", msg_id)
62
+
63
+ self.client._pack_msg_id = _patched_pack_msg_id
64
+
65
+ # Patch send_request to use the lock
66
+ original_send_request = self.client.send_request
67
+ client_ref_for_patch = self
68
+
69
+ async def _patched_send_request(method_bytes):
70
+ async with client_ref_for_patch._recv_lock:
71
+ return await original_send_request(method_bytes)
72
+
73
+ self.client.send_request = _patched_send_request
74
+
75
+ def on_message(self, func):
76
+ self._message_handlers.append(func)
77
+ return func
78
+
79
+ def on_ready(self, func):
80
+ self._ready_handlers.append(func)
81
+ return func
82
+
83
+ async def _trigger_message_handlers(self, message):
84
+ for handler in self._message_handlers:
85
+ try:
86
+ if asyncio.iscoroutinefunction(handler):
87
+ await handler(message)
88
+ else:
89
+ handler(message)
90
+ except Exception as e:
91
+ print(f"Error in message handler: {e}")
92
+
93
+ async def _trigger_ready_handlers(self):
94
+ for handler in self._ready_handlers:
95
+ try:
96
+ if asyncio.iscoroutinefunction(handler):
97
+ await handler()
98
+ else:
99
+ handler()
100
+ except Exception as e:
101
+ print(f"Error in ready handler: {e}")
102
+
103
+ async def start(self, wait_ready=True, timeout=30):
104
+ if not self.session.is_authorized():
105
+ raise Exception("Not logged in. Please run login.py first.")
106
+
107
+ self._is_running = True
108
+ self._listen_task = asyncio.create_task(self._main_loop())
109
+
110
+ if wait_ready:
111
+ try:
112
+ await asyncio.wait_for(self._ready_event.wait(), timeout=timeout)
113
+ except asyncio.TimeoutError:
114
+ raise Exception(f"Client did not become ready within {timeout}s")
115
+
116
+ async def _main_loop(self):
117
+ attempt = 0
118
+ while self._is_running:
119
+ try:
120
+ await self._connect_and_run()
121
+ attempt = 0
122
+ except Exception as e:
123
+ if not self._is_running:
124
+ break
125
+
126
+ attempt += 1
127
+ if attempt > self._max_reconnect_attempts:
128
+ print(f"Max reconnect attempts ({self._max_reconnect_attempts}) reached. Giving up.")
129
+ self._is_running = False
130
+ break
131
+
132
+ print(f"Connection lost: {e}")
133
+ print(f"Reconnecting in {self._reconnect_delay}s... (attempt {attempt}/{self._max_reconnect_attempts})")
134
+ await asyncio.sleep(self._reconnect_delay)
135
+
136
+ async def _connect_and_run(self):
137
+ print("Connecting to Jetra...")
138
+ await self.client.connect()
139
+
140
+ print("Syncing state...")
141
+ get_state = struct.pack("<I", GET_STATE_ID)
142
+ result = await self.client.send_request(wrap_init_connection(get_state))
143
+ if result:
144
+ result = self._unwrap_gzip(result)
145
+ if len(result) >= 4 and struct.unpack("<I", result[:4])[0] == 0xA56C2A3E:
146
+ pts = struct.unpack("<i", result[4:8])[0]
147
+ print(f"State synced. pts={pts}")
148
+
149
+ await asyncio.sleep(0.2)
150
+
151
+ print("Loading dialogs...")
152
+ await self._load_dialogs()
153
+
154
+ print("Client started. Listening for messages...")
155
+ self._ready_event.set()
156
+ await self._trigger_ready_handlers()
157
+
158
+ await self._listen_loop()
159
+
160
+ async def _listen_loop(self):
161
+ while self._is_running:
162
+ try:
163
+ # Use lock with short timeout to avoid blocking send_request
164
+ raw = None
165
+ try:
166
+ async with asyncio.timeout(0.1):
167
+ await self._recv_lock.acquire()
168
+ try:
169
+ raw = await self.client.ws.recv()
170
+ finally:
171
+ self._recv_lock.release()
172
+ except (asyncio.TimeoutError, TimeoutError):
173
+ await asyncio.sleep(0.01)
174
+ continue
175
+
176
+ if raw is None:
177
+ continue
178
+
179
+ decrypted = self.client.dec_cipher.decrypt(raw)
180
+
181
+ if len(decrypted) < 1:
182
+ continue
183
+
184
+ first_byte = decrypted[0]
185
+ if first_byte < 0x7F:
186
+ payload = decrypted[1:1 + (first_byte << 2)]
187
+ elif first_byte == 0x7F:
188
+ frame_len = int.from_bytes(decrypted[1:4], 'little') << 2
189
+ payload = decrypted[4:4 + frame_len]
190
+ else:
191
+ payload = decrypted
192
+
193
+ if len(payload) < 24 or payload[0:8] != self.client.auth_key_id:
194
+ continue
195
+
196
+ msg_key_received = payload[8:24]
197
+ encrypted_data = payload[24:]
198
+ if len(encrypted_data) % 16 != 0:
199
+ encrypted_data = encrypted_data[:-(len(encrypted_data) % 16)]
200
+
201
+ i = 8
202
+ a = bytearray(52)
203
+ a[0:16] = msg_key_received
204
+ a[16:52] = self.client.auth_key[i:i + 36]
205
+ s0 = hashlib.sha256(bytes(a)).digest()
206
+
207
+ o = bytearray(52)
208
+ o[0:36] = self.client.auth_key[40 + i:40 + i + 36]
209
+ o[36:52] = msg_key_received
210
+ s1 = hashlib.sha256(bytes(o)).digest()
211
+
212
+ aes_key = s0[0:8] + s1[8:24] + s0[24:32]
213
+ aes_iv = s1[0:8] + s0[8:24] + s1[24:32]
214
+
215
+ inner = aes_ige_decrypt(encrypted_data, aes_key, aes_iv)
216
+ offset = 8 + 8 + 8 + 4
217
+ msg_len = struct.unpack('<I', inner[offset:offset + 4])[0]
218
+ offset += 4
219
+ msg_data = inner[offset:offset + msg_len]
220
+
221
+ if len(msg_data) >= 4:
222
+ ctor = struct.unpack('<I', msg_data[:4])[0]
223
+ if ctor == UPDATES:
224
+ await self._handle_updates(msg_data)
225
+
226
+ except Exception as e:
227
+ if not self._is_running:
228
+ break
229
+ if self.client.ws.closed:
230
+ raise Exception("WebSocket closed")
231
+ await asyncio.sleep(0.1)
232
+
233
+ async def stop(self):
234
+ print("Stopping client...")
235
+ self._is_running = False
236
+ self._ready_event.clear()
237
+ if self._listen_task:
238
+ self._listen_task.cancel()
239
+ try:
240
+ await self._listen_task
241
+ except asyncio.CancelledError:
242
+ pass
243
+ await self.client.close()
244
+ self.db.close()
245
+ print("Client stopped.")
246
+
247
+ async def _handle_updates(self, msg_data):
248
+ pos = msg_data.find(struct.pack("<I", UPDATE_NEW_MESSAGE))
249
+ channel_pos = msg_data.find(struct.pack("<I", UPDATE_NEW_CHANNEL_MESSAGE))
250
+
251
+ if channel_pos != -1:
252
+ pos = channel_pos
253
+
254
+ if pos == -1:
255
+ return
256
+
257
+ msg_pos = pos + 4
258
+ if len(msg_data) < msg_pos + 4:
259
+ return
260
+
261
+ msg_ctor = struct.unpack("<I", msg_data[msg_pos:msg_pos + 4])[0]
262
+ if msg_ctor not in (MSG_CTOR_JETRA, SERVICE_MSG_CTOR_JETRA):
263
+ return
264
+
265
+ message = self._parse_message(msg_data, msg_pos, msg_ctor)
266
+ if message:
267
+ self.db.save_message(message)
268
+ await self._trigger_message_handlers(message)
269
+
270
+ def _parse_message(self, data, offset, ctor):
271
+ try:
272
+ offset += 4
273
+
274
+ if ctor == SERVICE_MSG_CTOR_JETRA:
275
+ offset += 4
276
+ msg_id = struct.unpack("<i", data[offset:offset + 4])[0]
277
+ offset += 4
278
+ else:
279
+ offset += 4
280
+ offset += 4
281
+ msg_id = struct.unpack("<i", data[offset:offset + 4])[0]
282
+ offset += 4
283
+
284
+ from_peer_ctor = struct.unpack("<I", data[offset:offset + 4])[0]
285
+ offset += 4
286
+ from_id = struct.unpack("<q", data[offset:offset + 8])[0]
287
+ offset += 8
288
+
289
+ peer_ctor = struct.unpack("<I", data[offset:offset + 4])[0]
290
+ offset += 4
291
+ peer_id = struct.unpack("<q", data[offset:offset + 8])[0]
292
+ offset += 8
293
+
294
+ date = struct.unpack("<i", data[offset:offset + 4])[0]
295
+ offset += 4
296
+
297
+ text = ""
298
+ try:
299
+ text_len = data[offset]
300
+ text = data[offset + 1:offset + 1 + text_len].decode('utf-8', errors='ignore')
301
+ except:
302
+ pass
303
+
304
+ if peer_ctor == PEER_USER:
305
+ peer_type = 'user'
306
+ elif peer_ctor == PEER_CHANNEL:
307
+ peer_type = 'group'
308
+ else:
309
+ peer_type = 'unknown'
310
+
311
+ return Message(
312
+ msg_id=msg_id,
313
+ from_id=from_id,
314
+ peer_id=peer_id,
315
+ peer_type=peer_type,
316
+ date=date,
317
+ text=text,
318
+ )
319
+ except Exception as e:
320
+ print(f"Failed to parse message: {e}")
321
+ return None
322
+
323
+ async def send_message(self, peer_id, text, peer_type=None):
324
+ if not self._is_running:
325
+ raise Exception("Client is not started. Call await client.start() first.")
326
+
327
+ if peer_type is None:
328
+ dialogs = self.db.get_dialogs()
329
+ for d in dialogs:
330
+ if d['peer_id'] == peer_id:
331
+ peer_type = d['peer_type']
332
+ break
333
+ else:
334
+ peer_type = 'group' if peer_id > 1000 else 'user'
335
+
336
+ if peer_type == 'user':
337
+ input_peer = (
338
+ struct.pack("<I", INPUT_PEER_USER) +
339
+ struct.pack("<Q", peer_id) +
340
+ struct.pack("<q", 0)
341
+ )
342
+ else:
343
+ access_hash = 0
344
+ for d in self.db.get_dialogs():
345
+ if d['peer_id'] == peer_id:
346
+ access_hash = d['access_hash'] or 0
347
+ break
348
+ input_peer = (
349
+ struct.pack("<I", INPUT_PEER_CHANNEL) +
350
+ struct.pack("<q", peer_id) +
351
+ struct.pack("<q", access_hash)
352
+ )
353
+
354
+ random_id = random.getrandbits(63)
355
+ body = (
356
+ struct.pack("<I", SEND_MESSAGE_ID) +
357
+ struct.pack("<I", 0) +
358
+ input_peer +
359
+ self._tl_string(text) +
360
+ struct.pack("<q", random_id)
361
+ )
362
+
363
+ result = await self.client.send_request(wrap_init_connection(body))
364
+ if not result:
365
+ raise Exception("No response from sendMessage")
366
+
367
+ result = self._unwrap_gzip(result)
368
+ ctor = struct.unpack('<I', result[:4])[0]
369
+ if ctor == RPC_ERROR:
370
+ raise Exception(f"Server error: {self._parse_rpc_error(result)}")
371
+
372
+ sent_msg = Message(
373
+ msg_id=0,
374
+ from_id=self.session.user_id,
375
+ peer_id=peer_id,
376
+ peer_type=peer_type,
377
+ date=int(datetime.datetime.now().timestamp()),
378
+ text=text,
379
+ )
380
+ self.db.save_message(sent_msg)
381
+ return True
382
+
383
+ async def send_to_saved(self, text):
384
+ if not self._is_running:
385
+ raise Exception("Client is not started.")
386
+
387
+ random_id = random.getrandbits(63)
388
+ body = (
389
+ struct.pack("<I", SEND_MESSAGE_ID) +
390
+ struct.pack("<I", 0) +
391
+ struct.pack("<I", INPUT_PEER_SELF) +
392
+ self._tl_string(text) +
393
+ struct.pack("<q", random_id)
394
+ )
395
+
396
+ result = await self.client.send_request(wrap_init_connection(body))
397
+ if not result:
398
+ raise Exception("No response from sendMessage")
399
+ return True
400
+
401
+ async def _load_dialogs(self):
402
+ get_dialogs = (
403
+ struct.pack("<I", GET_DIALOGS_ID) +
404
+ struct.pack("<I", 0) +
405
+ struct.pack("<i", 0) +
406
+ struct.pack("<i", 0) +
407
+ struct.pack("<I", 0x7F3B18EA) +
408
+ struct.pack("<i", 100) +
409
+ struct.pack("<q", 0)
410
+ )
411
+
412
+ result = await self.client.send_request(wrap_init_connection(get_dialogs))
413
+ if not result:
414
+ return
415
+
416
+ data = self._unwrap_gzip(result)
417
+
418
+ CHANNEL_CTOR = 0xD49F34C6
419
+ pos = 0
420
+ count = 0
421
+ while True:
422
+ pos = data.find(struct.pack("<I", CHANNEL_CTOR), pos)
423
+ if pos == -1:
424
+ break
425
+
426
+ try:
427
+ offset = pos + 4
428
+ flags = struct.unpack("<q", data[offset:offset + 8])[0]
429
+ offset += 8
430
+ channel_id = struct.unpack("<q", data[offset:offset + 8])[0]
431
+ offset += 8
432
+ access_hash = struct.unpack("<q", data[offset:offset + 8])[0]
433
+ offset += 8
434
+
435
+ title_len = data[offset]
436
+ offset += 1
437
+ if title_len < 0xFE:
438
+ title = data[offset:offset + title_len].decode('utf-8', errors='ignore')
439
+ offset += title_len
440
+ else:
441
+ title_len = int.from_bytes(data[offset:offset + 3], 'little')
442
+ offset += 3
443
+ title = data[offset:offset + title_len].decode('utf-8', errors='ignore')
444
+ offset += title_len
445
+
446
+ if flags & 0x100:
447
+ self.db.save_dialog(channel_id, 'group', title, access_hash)
448
+ count += 1
449
+ except:
450
+ pass
451
+
452
+ pos += 4
453
+
454
+ USER_CTOR = 0xB1B8CC83
455
+ pos = 0
456
+ while True:
457
+ pos = data.find(struct.pack("<I", USER_CTOR), pos)
458
+ if pos == -1:
459
+ break
460
+
461
+ try:
462
+ offset = pos + 4
463
+ offset += 8
464
+ user_id = struct.unpack("<q", data[offset:offset + 8])[0]
465
+ offset += 8
466
+ access_hash = struct.unpack("<q", data[offset:offset + 8])[0]
467
+
468
+ self.db.save_dialog(user_id, 'user', f'User {user_id}', access_hash)
469
+ count += 1
470
+ except:
471
+ pass
472
+
473
+ pos += 4
474
+
475
+ print(f"Loaded {count} dialogs into database.")
476
+
477
+ async def get_dialogs(self, peer_type=None):
478
+ return self.db.get_dialogs(peer_type)
479
+
480
+ async def get_groups(self):
481
+ return self.db.get_dialogs('group')
482
+
483
+ async def get_users(self):
484
+ return self.db.get_dialogs('user')
485
+
486
+ def get_history(self, peer_id=None, limit=100):
487
+ return self.db.get_messages(peer_id, limit)
488
+
489
+ def _unwrap_gzip(self, result):
490
+ if len(result) < 4:
491
+ return result
492
+ if struct.unpack('<I', result[:4])[0] == GZIP_PACKED:
493
+ offset = 4
494
+ if result[offset] < 0xFE:
495
+ length = result[offset]
496
+ offset += 1
497
+ else:
498
+ length = int.from_bytes(result[offset + 1:offset + 4], 'little')
499
+ offset += 4
500
+ return gzip.decompress(result[offset:offset + length])
501
+ return result
502
+
503
+ def _parse_rpc_error(self, data):
504
+ if len(data) < 8:
505
+ return "unknown"
506
+ err_data = data[4:]
507
+ if len(err_data) < 1:
508
+ return "unknown"
509
+ s_len = err_data[0]
510
+ return err_data[1:1 + s_len].decode('utf-8', errors='ignore')
511
+
512
+ def _tl_string(self, s):
513
+ data = s.encode('utf-8')
514
+ n = len(data)
515
+ if n <= 253:
516
+ buf = bytes([n]) + data
517
+ else:
518
+ buf = b'\xfe' + n.to_bytes(3, 'little') + data
519
+ return buf + b'\x00' * ((4 - len(buf) % 4) % 4)
@@ -0,0 +1,62 @@
1
+ import datetime
2
+
3
+
4
+ class Message:
5
+ """
6
+ Represents a message in Jetra.
7
+
8
+ Attributes:
9
+ id (int): Message ID
10
+ from_id (int): Sender's user ID
11
+ peer_id (int): Chat/Group/Channel ID
12
+ peer_type (str): 'user', 'group', or 'channel'
13
+ date (int): Unix timestamp
14
+ text (str): Message text
15
+ """
16
+
17
+ def __init__(self, msg_id, from_id, peer_id, peer_type, date, text):
18
+ self.id = msg_id
19
+ self.from_id = from_id
20
+ self.peer_id = peer_id
21
+ self.peer_type = peer_type
22
+ self.date = date
23
+ self.text = text
24
+
25
+ @property
26
+ def is_private(self):
27
+ return self.peer_type == 'user'
28
+
29
+ @property
30
+ def is_group(self):
31
+ return self.peer_type == 'group'
32
+
33
+ @property
34
+ def date_str(self):
35
+ try:
36
+ return datetime.datetime.fromtimestamp(self.date).strftime('%Y-%m-%d %H:%M:%S')
37
+ except:
38
+ return str(self.date)
39
+
40
+ def to_dict(self):
41
+ return {
42
+ 'id': self.id,
43
+ 'from_id': self.from_id,
44
+ 'peer_id': self.peer_id,
45
+ 'peer_type': self.peer_type,
46
+ 'date': self.date,
47
+ 'text': self.text,
48
+ }
49
+
50
+ @classmethod
51
+ def from_dict(cls, data):
52
+ return cls(
53
+ msg_id=data['id'],
54
+ from_id=data['from_id'],
55
+ peer_id=data['peer_id'],
56
+ peer_type=data['peer_type'],
57
+ date=data['date'],
58
+ text=data['text'],
59
+ )
60
+
61
+ def __repr__(self):
62
+ return f"<Message id={self.id} from={self.from_id} peer={self.peer_id} ({self.peer_type}) text='{self.text[:30]}'>"
@@ -0,0 +1,75 @@
1
+ import json
2
+ import os
3
+ import hashlib
4
+
5
+ class JetraSession:
6
+ def __init__(self, session_file="jetra_session.json"):
7
+ self.session_file = session_file
8
+ self.dc_id = 2
9
+ self.server_address = "web.jetra.ir"
10
+ self.port = 443
11
+ self.auth_key = None
12
+ self.auth_key_id = None
13
+ self.server_salt = None
14
+ self.session_id = None
15
+ self.user_id = None
16
+ self.access_hash = None
17
+ self.load()
18
+
19
+ def set_auth_key(self, auth_key_bytes):
20
+ self.auth_key = auth_key_bytes
21
+ self.auth_key_id = hashlib.sha1(auth_key_bytes).digest()[-8:]
22
+ self.save()
23
+
24
+ def save(self):
25
+ data = {
26
+ "dc_id": self.dc_id,
27
+ "server_address": self.server_address,
28
+ "port": self.port,
29
+ "auth_key": self.auth_key.hex() if self.auth_key else None,
30
+ "auth_key_id": self.auth_key_id.hex() if self.auth_key_id else None,
31
+ "server_salt": self.server_salt.hex() if self.server_salt else None,
32
+ "session_id": self.session_id.hex() if self.session_id else None,
33
+ "user_id": self.user_id,
34
+ "access_hash": self.access_hash,
35
+ }
36
+ with open(self.session_file, "w") as f:
37
+ json.dump(data, f, indent=4)
38
+ print(f"Session saved to {self.session_file}")
39
+
40
+ def load(self):
41
+ if not os.path.exists(self.session_file):
42
+ print("No session file found. Creating new session...")
43
+ self.session_id = os.urandom(8)
44
+ self.save()
45
+ return
46
+
47
+ try:
48
+ with open(self.session_file, "r") as f:
49
+ data = json.load(f)
50
+
51
+ self.dc_id = data.get("dc_id", 2)
52
+ self.server_address = data.get("server_address", "web.jetra.ir")
53
+ self.port = data.get("port", 443)
54
+ self.user_id = data.get("user_id")
55
+ self.access_hash = data.get("access_hash")
56
+
57
+ if data.get("auth_key"):
58
+ self.auth_key = bytes.fromhex(data["auth_key"])
59
+ if data.get("auth_key_id"):
60
+ self.auth_key_id = bytes.fromhex(data["auth_key_id"])
61
+ if data.get("server_salt"):
62
+ self.server_salt = bytes.fromhex(data["server_salt"])
63
+ if data.get("session_id"):
64
+ self.session_id = bytes.fromhex(data["session_id"])
65
+ else:
66
+ self.session_id = os.urandom(8)
67
+
68
+ print(f"Session loaded from {self.session_file}")
69
+ except Exception as e:
70
+ print(f"Error loading session: {e}. Creating new one...")
71
+ self.session_id = os.urandom(8)
72
+ self.save()
73
+
74
+ def is_authorized(self):
75
+ return self.auth_key is not None and self.user_id is not None