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.
- jetrauserapi/__init__.py +7 -0
- jetrauserapi/client.py +548 -0
- jetrauserapi/config.py +12 -0
- jetrauserapi/database.py +112 -0
- jetrauserapi/high_level.py +519 -0
- jetrauserapi/message.py +62 -0
- jetrauserapi/session.py +75 -0
- jetrauserapi/tl_builder.py +105 -0
- jetrauserapi-1.0.0.dist-info/METADATA +67 -0
- jetrauserapi-1.0.0.dist-info/RECORD +13 -0
- jetrauserapi-1.0.0.dist-info/WHEEL +5 -0
- jetrauserapi-1.0.0.dist-info/licenses/LICENSE +21 -0
- jetrauserapi-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
jetrauserapi/message.py
ADDED
|
@@ -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]}'>"
|
jetrauserapi/session.py
ADDED
|
@@ -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
|