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,7 @@
1
+ from .high_level import JetraClient
2
+ from .message import Message
3
+ from .session import JetraSession
4
+
5
+ __all__ = ["JetraClient", "Message", "JetraSession"]
6
+ __version__ = "1.0.0"
7
+ __author__ = "Ali VEX"
jetrauserapi/client.py ADDED
@@ -0,0 +1,548 @@
1
+ import asyncio
2
+ import websockets
3
+ import os
4
+ import struct
5
+ import time
6
+ import math
7
+ import random
8
+ import hashlib
9
+ from Crypto.Cipher import AES
10
+ from Crypto.Util import Counter
11
+
12
+ from jetrauserapi.tl_builder import tl_string, tl_bytes
13
+
14
+ RSA_MODULUS_HEX = (
15
+ "a1d79eb1ffcc0edd282c52215e60491200cc4d5f907e6d5ceb759958282d389e9c"
16
+ "9443b0a8e4505a608bfe2f14a157c3e24b553017cb8d310d0c48fd8a63340cd556f"
17
+ "83bc4cd48400cd582dd881dc7c52b14d6b7f469f0e889444734f65736ad6ace97d5"
18
+ "7ef551a572414272760e2bffcca018e5c9951488cfbbcd0e87dc33a9242b2f71eca"
19
+ "049a4050df82f3e630a2725887f8b1498146fc9d0f253d0746b690655e6ac65485c"
20
+ "e8d257e22f7a95d75f24d8efa7a679deb794dc66bd888155ae5f3334a40861b4afa"
21
+ "c807843cc253df4595b7ef709c8395663b38f37db18778f7ae1ca4b7f30cbe714bc"
22
+ "e8de57ab54610273fd685c11dad77e3ad26031bda32f"
23
+ )
24
+ RSA_MODULUS = int(RSA_MODULUS_HEX, 16)
25
+ RSA_EXPONENT = 65537
26
+
27
+ JETRA_DH_PRIME = int("c71caeb9c6b1c9048e6c522f70f13f73980d40238e3e21c14934d037563d930f48198a0aa7c14058229493d22530f4dbfa336f6e0ac925139543aed44cce7c3720fd51f69458705ac68cd4fe6b6b13abdc9746512969328454f18faf8c595f642477fe96bb2a941d5bcd1d4ac8cc49880708fa9b378e3c4f3a9060bee67cf9a4a4a695811051907e162753b56b0f6b410dba74d8a84b2a14b3144e0ef1284754fd17ed950d5965b4b9dd46582db1178d169c6bc465b0d6ff9ca3928fef5b9ae4e418fc15e83ebea0f87fa9ff5eed70050ded2849f47bf959d956850ce929851f0d8115f635b105ee2e4e15d04b2454bf6f4fadf034b10403119cd8e3b92fcc5b", 16)
28
+
29
+ def tl_store_bytes(data):
30
+ n = len(data)
31
+ if n <= 253:
32
+ buf = bytes([n]) + data
33
+ else:
34
+ buf = b'\xfe' + n.to_bytes(3, 'little') + data
35
+ return buf + b'\x00' * ((4 - len(buf) % 4) % 4)
36
+
37
+ def aes_ige_encrypt(plaintext, key, iv):
38
+ cipher = AES.new(key, AES.MODE_ECB)
39
+ n = len(plaintext)
40
+ out = bytearray(n)
41
+ prev_c = iv[:16]
42
+ prev_p = iv[16:]
43
+ for i in range(0, n, 16):
44
+ block = plaintext[i:i+16]
45
+ x = bytes(a ^ b for a, b in zip(block, prev_c))
46
+ y = cipher.encrypt(x)
47
+ c = bytes(a ^ b for a, b in zip(y, prev_p))
48
+ out[i:i+16] = c
49
+ prev_c = c
50
+ prev_p = block
51
+ return bytes(out)
52
+
53
+ def aes_ige_decrypt(ciphertext, key, iv):
54
+ cipher = AES.new(key, AES.MODE_ECB)
55
+ n = len(ciphertext)
56
+ out = bytearray(n)
57
+ prev_c = iv[:16]
58
+ prev_p = iv[16:]
59
+ for i in range(0, n, 16):
60
+ block = ciphertext[i:i+16]
61
+ x = bytes(a ^ b for a, b in zip(block, prev_p))
62
+ y = cipher.decrypt(x)
63
+ c = bytes(a ^ b for a, b in zip(y, prev_c))
64
+ out[i:i+16] = c
65
+ prev_c = block
66
+ prev_p = c
67
+ return bytes(out)
68
+
69
+ def rsa_pad(inner_data):
70
+ n = RSA_MODULUS
71
+ for _ in range(100):
72
+ padded = inner_data + os.urandom(192 - len(inner_data))
73
+ rev = padded[::-1]
74
+ tk = os.urandom(32)
75
+ dwh = rev + hashlib.sha256(tk + padded).digest()
76
+ ige = aes_ige_encrypt(dwh, tk, b'\x00' * 32)
77
+ xored = bytes(a ^ b for a, b in zip(tk, hashlib.sha256(ige).digest()))
78
+ blob = xored + ige
79
+ if int.from_bytes(blob, 'big') < n:
80
+ c = pow(int.from_bytes(blob, 'big'), RSA_EXPONENT, n)
81
+ return c.to_bytes(256, 'big')
82
+ raise RuntimeError("RSA pad failed")
83
+
84
+ def wrap_init_connection(inner_query):
85
+ API_ID = 3893396
86
+ LAYER = 228
87
+ init_body = (
88
+ struct.pack("<I", 0xC1CD5EA9) +
89
+ struct.pack("<I", 0) +
90
+ struct.pack("<I", API_ID) +
91
+ tl_string("Samsung SM-G973F") +
92
+ tl_string("Android 12") +
93
+ tl_string("0.1.0 K voiceotp=1") +
94
+ tl_string("fa") +
95
+ tl_string("webk") +
96
+ tl_string("fa") +
97
+ inner_query
98
+ )
99
+ return (
100
+ struct.pack("<I", 0xDA9B0D0D) +
101
+ struct.pack("<i", LAYER) +
102
+ init_body
103
+ )
104
+
105
+ class JetraClient:
106
+ def __init__(self, ws_url="wss://web.jetra.ir/apiws", session=None):
107
+ self.ws_url = ws_url
108
+ self.ws = None
109
+ self.enc_cipher = None
110
+ self.dec_cipher = None
111
+ self.new_nonce = None
112
+ self.b = None
113
+ self.auth_key = None
114
+ self.auth_key_id = None
115
+
116
+ if session:
117
+ self.server_salt = session.server_salt
118
+ self.session_id = session.session_id
119
+ self.auth_key = session.auth_key
120
+ self.auth_key_id = session.auth_key_id
121
+ else:
122
+ self.server_salt = b'\x00' * 8
123
+ self.session_id = os.urandom(8)
124
+
125
+ self.seq_no = 0
126
+
127
+ def _pack_msg_id(self):
128
+ return struct.pack("<Q", (int(time.time()) << 32) | 4)
129
+
130
+ def _abridged_frame(self, data):
131
+ length = len(data)
132
+ if length < 127:
133
+ return struct.pack("<B", length >> 2) + data
134
+ else:
135
+ return b'\x7f' + struct.pack("<I", length >> 2)[:3] + data
136
+
137
+ async def connect(self):
138
+ print(f"Connecting to {self.ws_url}...")
139
+ self.ws = await websockets.connect(self.ws_url)
140
+ print("WebSocket connected!")
141
+
142
+ while True:
143
+ header = bytearray(os.urandom(64))
144
+ if header[0] == 0xEF:
145
+ continue
146
+ if struct.unpack("<I", header[0:4])[0] in (0x44414548, 0x54534F50, 0x20544745, 0x4954504F, 0xEEEEEEEE, 0xDDDDDDDD):
147
+ continue
148
+ if struct.unpack("<I", header[4:8])[0] == 0:
149
+ continue
150
+ break
151
+
152
+ header[56:60] = b'\xEF\xEF\xEF\xEF'
153
+ header_rev = header[::-1]
154
+
155
+ enc_key = bytes(header[8:40])
156
+ enc_iv = bytes(header[40:56])
157
+ dec_key = bytes(header_rev[8:40])
158
+ dec_iv = bytes(header_rev[40:56])
159
+
160
+ self.enc_cipher = AES.new(enc_key, AES.MODE_CTR, counter=Counter.new(128, initial_value=int.from_bytes(enc_iv, 'big')))
161
+ self.dec_cipher = AES.new(dec_key, AES.MODE_CTR, counter=Counter.new(128, initial_value=int.from_bytes(dec_iv, 'big')))
162
+
163
+ encrypted_tail = self.enc_cipher.encrypt(bytes(header))[56:64]
164
+ header[56:64] = encrypted_tail
165
+
166
+ print("Sending header...")
167
+ await self.ws.send(bytes(header))
168
+
169
+ async def send_req_pq(self):
170
+ self.new_nonce = os.urandom(32)
171
+ print(f"Generated new_nonce: {self.new_nonce.hex()}")
172
+
173
+ nonce = os.urandom(16)
174
+ msg_id = self._pack_msg_id()
175
+ method_buf = struct.pack("<I", 0xBE7E8EF1) + nonce
176
+ packet = b"\x00"*8 + msg_id + struct.pack("<I", len(method_buf)) + method_buf
177
+
178
+ framed = self._abridged_frame(packet)
179
+ encrypted = self.enc_cipher.encrypt(framed)
180
+
181
+ print(f"Sending req_pq_multi...")
182
+ await self.ws.send(encrypted)
183
+ return nonce
184
+
185
+ async def recv(self):
186
+ response = await self.ws.recv()
187
+ decrypted = self.dec_cipher.decrypt(response)
188
+
189
+ if len(decrypted) < 1:
190
+ return b''
191
+
192
+ first_byte = decrypted[0]
193
+ if first_byte < 0x7F:
194
+ frame_len = first_byte << 2
195
+ payload = decrypted[1:1+frame_len]
196
+ elif first_byte == 0x7F:
197
+ if len(decrypted) < 4:
198
+ return b''
199
+ frame_len = int.from_bytes(decrypted[1:4], 'little') << 2
200
+ payload = decrypted[4:4+frame_len]
201
+ else:
202
+ return decrypted
203
+
204
+ return payload
205
+
206
+ def parse_res_pq(self, data):
207
+ offset = 0
208
+
209
+ auth_key_id = data[offset:offset+8]
210
+ offset += 8
211
+ msg_id = data[offset:offset+8]
212
+ offset += 8
213
+ msg_len = struct.unpack("<I", data[offset:offset+4])[0]
214
+ offset += 4
215
+ res_pq_ctor = struct.unpack("<I", data[offset:offset+4])[0]
216
+ offset += 4
217
+
218
+ if res_pq_ctor != 0x05162463:
219
+ raise ValueError(f"Invalid resPQ constructor: {hex(res_pq_ctor)}")
220
+
221
+ nonce = data[offset:offset+16]
222
+ offset += 16
223
+ server_nonce = data[offset:offset+16]
224
+ offset += 16
225
+ pq_len = data[offset]
226
+ offset += 1
227
+ pq = data[offset:offset+pq_len]
228
+ offset += pq_len
229
+ offset = (offset + 3) & ~3
230
+
231
+ vector_ctor = struct.unpack("<I", data[offset:offset+4])[0]
232
+ offset += 4
233
+
234
+ fp_count = struct.unpack("<I", data[offset:offset+4])[0]
235
+ offset += 4
236
+
237
+ fingerprints = []
238
+ for _ in range(fp_count):
239
+ fp = struct.unpack("<Q", data[offset:offset+8])[0]
240
+ fingerprints.append(fp)
241
+ offset += 8
242
+
243
+ return {
244
+ 'nonce': nonce,
245
+ 'server_nonce': server_nonce,
246
+ 'pq': pq,
247
+ 'fingerprints': fingerprints
248
+ }
249
+
250
+ def factorize_pq(self, pq):
251
+ pq_int = int.from_bytes(pq, 'big')
252
+ if pq_int % 2 == 0:
253
+ return 2, pq_int // 2
254
+
255
+ def pollard_rho(n):
256
+ if n % 2 == 0:
257
+ return 2
258
+ x = random.randint(2, n-1)
259
+ y = x
260
+ c = random.randint(1, n-1)
261
+ d = 1
262
+ while d == 1:
263
+ x = (x*x + c) % n
264
+ y = (y*y + c) % n
265
+ y = (y*y + c) % n
266
+ d = math.gcd(abs(x-y), n)
267
+ return d
268
+
269
+ p = pollard_rho(pq_int)
270
+ q = pq_int // p
271
+ return p, q
272
+
273
+ async def send_req_dh_params(self, parsed_respq):
274
+ nonce = parsed_respq['nonce']
275
+ server_nonce = parsed_respq['server_nonce']
276
+ pq = parsed_respq['pq']
277
+ fingerprints = parsed_respq['fingerprints']
278
+
279
+ fingerprint = fingerprints[0]
280
+
281
+ p, q = self.factorize_pq(pq)
282
+ if p > q:
283
+ p, q = q, p
284
+
285
+ p_bytes = p.to_bytes((p.bit_length() + 7) // 8, 'big')
286
+ q_bytes = q.to_bytes((q.bit_length() + 7) // 8, 'big')
287
+
288
+ pq_tl = tl_store_bytes(pq)
289
+ p_tl = tl_store_bytes(p_bytes)
290
+ q_tl = tl_store_bytes(q_bytes)
291
+
292
+ inner = (
293
+ struct.pack("<I", 0xA9F55F95) +
294
+ pq_tl +
295
+ p_tl +
296
+ q_tl +
297
+ nonce +
298
+ server_nonce +
299
+ self.new_nonce +
300
+ struct.pack("<i", 2)
301
+ )
302
+
303
+ encrypted_data = rsa_pad(inner)
304
+
305
+ req_body = (
306
+ struct.pack("<I", 0xD712E4BE) +
307
+ nonce +
308
+ server_nonce +
309
+ p_tl +
310
+ q_tl +
311
+ struct.pack("<Q", fingerprint) +
312
+ tl_store_bytes(encrypted_data)
313
+ )
314
+
315
+ msg_id = self._pack_msg_id()
316
+ packet = b"\x00"*8 + msg_id + struct.pack("<I", len(req_body)) + req_body
317
+
318
+ framed = self._abridged_frame(packet)
319
+ encrypted = self.enc_cipher.encrypt(framed)
320
+
321
+ await self.ws.send(encrypted)
322
+
323
+ def parse_server_dh_params_ok(self, data):
324
+ if data[0] == 0x7f:
325
+ data = data[4:]
326
+ else:
327
+ data = data[1:]
328
+
329
+ offset = 24
330
+
331
+ nonce = data[offset:offset+16]
332
+ offset += 16
333
+ server_nonce = data[offset:offset+16]
334
+ offset += 16
335
+
336
+ enc_len_byte = data[offset]
337
+ if enc_len_byte < 0xFE:
338
+ enc_len = enc_len_byte
339
+ offset += 1
340
+ else:
341
+ enc_len = int.from_bytes(data[offset+1:offset+4], 'little')
342
+ offset += 4
343
+
344
+ encrypted_answer = data[offset:offset+enc_len]
345
+
346
+ return {
347
+ 'nonce': nonce,
348
+ 'server_nonce': server_nonce,
349
+ 'encrypted_answer': encrypted_answer
350
+ }
351
+
352
+ async def complete_auth(self, parsed_respq, decrypted_answer):
353
+ nonce = parsed_respq['nonce']
354
+ server_nonce = parsed_respq['server_nonce']
355
+
356
+ inner_ctor = struct.pack("<I", 0xB5890DBA)
357
+ pos = decrypted_answer.find(inner_ctor)
358
+ if pos == -1:
359
+ raise ValueError("server_DH_inner_data not found")
360
+
361
+ server_dh_inner = decrypted_answer[pos:]
362
+
363
+ offset = 4 + 16 + 16 + 4
364
+ dh_prime_len = server_dh_inner[offset]
365
+ if dh_prime_len < 0xFE:
366
+ offset += 1 + dh_prime_len
367
+ else:
368
+ dh_prime_len = int.from_bytes(server_dh_inner[offset+1:offset+4], 'little')
369
+ offset += 4 + dh_prime_len
370
+ offset = (offset + 3) & ~3
371
+
372
+ g_a_len = server_dh_inner[offset]
373
+ if g_a_len < 0xFE:
374
+ g_a = server_dh_inner[offset+1:offset+1+g_a_len]
375
+ offset += 1 + g_a_len
376
+ else:
377
+ g_a_len = int.from_bytes(server_dh_inner[offset+1:offset+4], 'little')
378
+ g_a = server_dh_inner[offset+4:offset+4+g_a_len]
379
+ offset += 4 + g_a_len
380
+
381
+ self.server_salt = bytes(a ^ b for a, b in zip(self.new_nonce[:8], server_nonce[:8]))
382
+
383
+ self.b = os.urandom(256)
384
+ b_int = int.from_bytes(self.b, 'big')
385
+
386
+ g = 3
387
+ g_b_int = pow(g, b_int, JETRA_DH_PRIME)
388
+ g_b_bytes = g_b_int.to_bytes(256, 'big')
389
+
390
+ g_a_int = int.from_bytes(g_a, 'big')
391
+ auth_key_int = pow(g_a_int, b_int, JETRA_DH_PRIME)
392
+ self.auth_key = auth_key_int.to_bytes(256, 'big')
393
+ self.auth_key_id = hashlib.sha1(self.auth_key).digest()[-8:]
394
+
395
+ client_inner = (
396
+ struct.pack("<I", 0x6643B654) +
397
+ nonce +
398
+ server_nonce +
399
+ struct.pack("<Q", 0) +
400
+ tl_store_bytes(g_b_bytes)
401
+ )
402
+
403
+ inner_hash = hashlib.sha1(client_inner).digest()
404
+ inner_with_hash = inner_hash + client_inner
405
+
406
+ sha1_a = hashlib.sha1(self.new_nonce + server_nonce).digest()
407
+ sha1_b = hashlib.sha1(server_nonce + self.new_nonce).digest()
408
+ sha1_c = hashlib.sha1(self.new_nonce + self.new_nonce).digest()
409
+
410
+ tmp_aes_key = sha1_a + sha1_b[:12]
411
+ tmp_aes_iv = sha1_b[12:] + sha1_c + self.new_nonce[:4]
412
+
413
+ padding_len = 16 - (len(inner_with_hash) % 16)
414
+ if padding_len < 16:
415
+ inner_with_hash = inner_with_hash + os.urandom(padding_len)
416
+
417
+ encrypted_inner = aes_ige_encrypt(inner_with_hash, tmp_aes_key, tmp_aes_iv)
418
+
419
+ req_body = (
420
+ struct.pack("<I", 0xF5045F1F) +
421
+ nonce +
422
+ server_nonce +
423
+ tl_store_bytes(encrypted_inner)
424
+ )
425
+
426
+ msg_id = self._pack_msg_id()
427
+ packet = b"\x00"*8 + msg_id + struct.pack("<I", len(req_body)) + req_body
428
+
429
+ framed = self._abridged_frame(packet)
430
+ encrypted = self.enc_cipher.encrypt(framed)
431
+
432
+ await self.ws.send(encrypted)
433
+
434
+ def decrypt_response(self, response):
435
+ if len(response) < 24:
436
+ return None
437
+
438
+ auth_key_id = response[0:8]
439
+ msg_key_received = response[8:24]
440
+ encrypted_data = response[24:]
441
+
442
+ if len(encrypted_data) % 16 != 0:
443
+ encrypted_data = encrypted_data[:len(encrypted_data) - (len(encrypted_data) % 16)]
444
+
445
+ if len(encrypted_data) == 0:
446
+ return None
447
+
448
+ i = 8
449
+
450
+ a = bytearray(52)
451
+ a[0:16] = msg_key_received
452
+ a[16:52] = self.auth_key[i:i+36]
453
+ s0 = hashlib.sha256(bytes(a)).digest()
454
+
455
+ o = bytearray(52)
456
+ o[0:36] = self.auth_key[40+i:40+i+36]
457
+ o[36:52] = msg_key_received
458
+ s1 = hashlib.sha256(bytes(o)).digest()
459
+
460
+ aes_key = s0[0:8] + s1[8:24] + s0[24:32]
461
+ aes_iv = s1[0:8] + s0[8:24] + s1[24:32]
462
+
463
+ decrypted = aes_ige_decrypt(encrypted_data, aes_key, aes_iv)
464
+
465
+ offset = 0
466
+ salt = decrypted[offset:offset+8]; offset += 8
467
+ session_id = decrypted[offset:offset+8]; offset += 8
468
+ msg_id = struct.unpack("<Q", decrypted[offset:offset+8])[0]; offset += 8
469
+ seq_no = struct.unpack("<I", decrypted[offset:offset+4])[0]; offset += 4
470
+ msg_len = struct.unpack("<I", decrypted[offset:offset+4])[0]; offset += 4
471
+ msg_data = decrypted[offset:offset+msg_len]
472
+
473
+ return msg_data
474
+
475
+ async def send_request(self, method_bytes):
476
+ msg_id_bytes = self._pack_msg_id()
477
+ msg_id_int = struct.unpack("<Q", msg_id_bytes)[0]
478
+
479
+ self.seq_no += 1
480
+ seq_no = struct.pack("<I", self.seq_no * 2 - 1)
481
+ msg_len = struct.pack("<I", len(method_bytes))
482
+
483
+ buffer_without_padding = self.server_salt + self.session_id + msg_id_bytes + seq_no + msg_len + method_bytes
484
+ offset = len(buffer_without_padding)
485
+ padding_len = 16 - (offset % 16) + 16 * (1 + random.randint(0, 4))
486
+ padding = os.urandom(padding_len)
487
+
488
+ payload = buffer_without_padding + padding
489
+
490
+ msg_key = hashlib.sha256(self.auth_key[88:120] + payload).digest()[8:24]
491
+
492
+ a = bytearray(52)
493
+ a[0:16] = msg_key
494
+ a[16:52] = self.auth_key[0:36]
495
+ s0 = hashlib.sha256(bytes(a)).digest()
496
+
497
+ o = bytearray(52)
498
+ o[0:36] = self.auth_key[40:76]
499
+ o[36:52] = msg_key
500
+ s1 = hashlib.sha256(bytes(o)).digest()
501
+
502
+ aes_key = s0[0:8] + s1[8:24] + s0[24:32]
503
+ aes_iv = s1[0:8] + s0[8:24] + s1[24:32]
504
+
505
+ encrypted_data = aes_ige_encrypt(payload, aes_key, aes_iv)
506
+
507
+ packet = self.auth_key_id + msg_key + encrypted_data
508
+ framed = self._abridged_frame(packet)
509
+ encrypted = self.enc_cipher.encrypt(framed)
510
+
511
+ await self.ws.send(encrypted)
512
+ print(f"Request sent (msg_id: {msg_id_int})")
513
+
514
+ for _ in range(10):
515
+ response = await asyncio.wait_for(self.recv(), timeout=10.0)
516
+ msg_data = self.decrypt_response(response)
517
+
518
+ if msg_data and len(msg_data) >= 4:
519
+ ctor = struct.unpack("<I", msg_data[:4])[0]
520
+ if ctor == 0xF35C6D01:
521
+ req_msg_id = struct.unpack("<Q", msg_data[4:12])[0]
522
+ if req_msg_id == msg_id_int:
523
+ result_data = msg_data[12:]
524
+ return result_data
525
+ return None
526
+
527
+ async def invoke(self, method_id: int, *args):
528
+ """
529
+ Generic invoke method for any TL method.
530
+ Usage:
531
+ await client.invoke(0xFEF48F62, peer, message, random_id)
532
+ """
533
+ body = struct.pack("<I", method_id)
534
+ for arg in args:
535
+ if isinstance(arg, bytes):
536
+ body += arg
537
+ elif isinstance(arg, int):
538
+ body += struct.pack("<i", arg)
539
+ elif isinstance(arg, str):
540
+ body += tl_string(arg)
541
+ else:
542
+ raise ValueError(f"Unsupported arg type: {type(arg)}")
543
+
544
+ return await self.send_request(wrap_init_connection(body))
545
+
546
+ async def close(self):
547
+ if self.ws:
548
+ await self.ws.close()
jetrauserapi/config.py ADDED
@@ -0,0 +1,12 @@
1
+ import os
2
+
3
+ # Base directory of the project
4
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5
+
6
+ # Data directory
7
+ DATA_DIR = os.path.join(BASE_DIR, "data")
8
+
9
+ # Default paths
10
+ DEFAULT_SESSION_FILE = os.path.join(DATA_DIR, "jetra_session.json")
11
+ DEFAULT_DB_FILE = os.path.join(DATA_DIR, "jetra_local.db")
12
+ DEFAULT_GROUPS_FILE = os.path.join(DATA_DIR, "groups_only.json")
@@ -0,0 +1,112 @@
1
+ import sqlite3
2
+ import os
3
+ import json
4
+
5
+
6
+ class Database:
7
+ """
8
+ Local SQLite database for storing messages, dialogs, and groups.
9
+ """
10
+
11
+ def __init__(self, db_path="jetra_local.db"):
12
+ self.db_path = db_path
13
+ self.conn = None
14
+ self._connect()
15
+
16
+ def _connect(self):
17
+ self.conn = sqlite3.connect(self.db_path)
18
+ self.conn.row_factory = sqlite3.Row
19
+ self._create_tables()
20
+
21
+ def _create_tables(self):
22
+ cursor = self.conn.cursor()
23
+
24
+ # Messages table
25
+ cursor.execute('''
26
+ CREATE TABLE IF NOT EXISTS messages (
27
+ id INTEGER NOT NULL,
28
+ peer_id INTEGER NOT NULL,
29
+ peer_type TEXT NOT NULL,
30
+ from_id INTEGER NOT NULL,
31
+ date INTEGER NOT NULL,
32
+ text TEXT,
33
+ PRIMARY KEY (id, peer_id, peer_type)
34
+ )
35
+ ''')
36
+
37
+ # Dialogs table (users and groups)
38
+ cursor.execute('''
39
+ CREATE TABLE IF NOT EXISTS dialogs (
40
+ peer_id INTEGER PRIMARY KEY,
41
+ peer_type TEXT NOT NULL,
42
+ title TEXT,
43
+ access_hash INTEGER,
44
+ updated_at INTEGER
45
+ )
46
+ ''')
47
+
48
+ self.conn.commit()
49
+
50
+ def save_message(self, message):
51
+ """Save a message to the local database."""
52
+ cursor = self.conn.cursor()
53
+ try:
54
+ cursor.execute('''
55
+ INSERT OR REPLACE INTO messages
56
+ (id, peer_id, peer_type, from_id, date, text)
57
+ VALUES (?, ?, ?, ?, ?, ?)
58
+ ''', (
59
+ message.id,
60
+ message.peer_id,
61
+ message.peer_type,
62
+ message.from_id,
63
+ message.date,
64
+ message.text,
65
+ ))
66
+ self.conn.commit()
67
+ except Exception as e:
68
+ print(f"Failed to save message: {e}")
69
+
70
+ def get_messages(self, peer_id=None, limit=100):
71
+ """Get messages from a specific peer or all peers."""
72
+ cursor = self.conn.cursor()
73
+ if peer_id is not None:
74
+ cursor.execute('''
75
+ SELECT * FROM messages
76
+ WHERE peer_id = ?
77
+ ORDER BY date DESC
78
+ LIMIT ?
79
+ ''', (peer_id, limit))
80
+ else:
81
+ cursor.execute('''
82
+ SELECT * FROM messages
83
+ ORDER BY date DESC
84
+ LIMIT ?
85
+ ''', (limit,))
86
+
87
+ rows = cursor.fetchall()
88
+ return [dict(row) for row in rows]
89
+
90
+ def save_dialog(self, peer_id, peer_type, title, access_hash):
91
+ """Save or update a dialog."""
92
+ cursor = self.conn.cursor()
93
+ import time
94
+ cursor.execute('''
95
+ INSERT OR REPLACE INTO dialogs
96
+ (peer_id, peer_type, title, access_hash, updated_at)
97
+ VALUES (?, ?, ?, ?, ?)
98
+ ''', (peer_id, peer_type, title, access_hash, int(time.time())))
99
+ self.conn.commit()
100
+
101
+ def get_dialogs(self, peer_type=None):
102
+ """Get all dialogs, optionally filtered by type."""
103
+ cursor = self.conn.cursor()
104
+ if peer_type:
105
+ cursor.execute('SELECT * FROM dialogs WHERE peer_type = ? ORDER BY title', (peer_type,))
106
+ else:
107
+ cursor.execute('SELECT * FROM dialogs ORDER BY title')
108
+ return [dict(row) for row in cursor.fetchall()]
109
+
110
+ def close(self):
111
+ if self.conn:
112
+ self.conn.close()