session-python 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,729 @@
1
+ import os
2
+ import time
3
+ import random
4
+ import binascii
5
+ import uuid
6
+ import hashlib
7
+ from typing import List, Dict, Any, Union, Optional
8
+ import nacl.signing
9
+ import nacl.public
10
+ import nacl.exceptions
11
+ from cryptography.hazmat.primitives import hashes
12
+
13
+ from .network import SessionNetwork
14
+ from .crypto import (
15
+ SessionKeys,
16
+ add_message_padding,
17
+ remove_message_padding,
18
+ encrypt_using_session_protocol,
19
+ decrypt_with_session_protocol,
20
+ wrap_envelope,
21
+ encrypt_attachment,
22
+ decrypt_attachment,
23
+ remove_prefix_if_needed
24
+ )
25
+ from .protobuf.signalservice_pb2 import Content, Envelope, WebSocketMessage, WebSocketRequestMessage
26
+ from .mnemonic import decode as decode_mnemonic, encode as encode_mnemonic
27
+
28
+ class Session:
29
+ def __init__(self, proxy: Optional[str] = None):
30
+ self.network = SessionNetwork(proxy=proxy)
31
+ self.mnemonic: Optional[str] = None
32
+ self.keys: Optional[SessionKeys] = None
33
+ self.session_id: Optional[str] = None
34
+ self.display_name: Optional[str] = None
35
+ self.avatar: Optional[dict] = None
36
+ self.snodes: Optional[list] = None
37
+ self.our_swarms: Optional[list] = None
38
+ self.our_swarm: Optional[dict] = None
39
+ self.last_hashes: Dict[int, str] = {}
40
+ self.swarms_cache: Dict[str, list] = {}
41
+
42
+ @staticmethod
43
+ def generate_mnemonic() -> str:
44
+ """
45
+ Generates a new random 13-word mnemonic phrase for creating a new account.
46
+ """
47
+ import os
48
+ import binascii
49
+ from .mnemonic import encode as encode_mnemonic
50
+ seed_hex = binascii.hexlify(os.urandom(16)).decode('utf-8')
51
+ return encode_mnemonic(seed_hex)
52
+
53
+ def close(self):
54
+ """
55
+ Closes HTTP session connection pool.
56
+ """
57
+ if hasattr(self, 'network') and self.network.session:
58
+ self.network.session.close()
59
+
60
+ def __enter__(self):
61
+ return self
62
+
63
+ def __exit__(self, exc_type, exc_val, exc_tb):
64
+ self.close()
65
+
66
+ def set_mnemonic(self, mnemonic: str, display_name: Optional[str] = None):
67
+ """
68
+ Sets mnemonic for this instance and derives keys.
69
+ """
70
+ self.mnemonic = mnemonic
71
+ seed_hex = decode_mnemonic(mnemonic)
72
+ self.keys = SessionKeys(seed_hex)
73
+ # Session ID is X25519 public key (starts with 05)
74
+ self.session_id = self.keys.x25519.public_key.hex()
75
+
76
+ if display_name:
77
+ self.display_name = display_name
78
+
79
+ def get_session_id(self) -> str:
80
+ if not self.session_id:
81
+ raise ValueError("Session client not initialized with mnemonic.")
82
+ return self.session_id
83
+
84
+ def get_snodes(self) -> list:
85
+ if not self.snodes:
86
+ self.snodes = self.network.get_snodes_from_seeds()
87
+ return self.snodes
88
+
89
+ def get_swarms_for(self, session_id: str) -> list:
90
+ if session_id in self.swarms_cache:
91
+ return self.swarms_cache[session_id]
92
+
93
+ snodes = self.get_snodes()
94
+ if not snodes:
95
+ raise ValueError("No service nodes available.")
96
+
97
+ # Try a few snodes in case of 421 errors
98
+ for _ in range(5):
99
+ snode = random.choice(snodes)
100
+ try:
101
+ results = self.network.snode_batch_request(
102
+ snode=snode,
103
+ requests_list=[{
104
+ "method": "get_swarm",
105
+ "params": {"pubkey": session_id}
106
+ }]
107
+ )
108
+ if results and results[0].get("code") == 200:
109
+ swarms = results[0]["body"]["snodes"]
110
+ self.swarms_cache[session_id] = swarms
111
+ return swarms
112
+ except Exception:
113
+ continue
114
+
115
+ raise ValueError("Failed to get swarms for " + session_id)
116
+
117
+ def get_our_swarm(self) -> dict:
118
+ session_id = self.get_session_id()
119
+ if self.our_swarm:
120
+ return self.our_swarm
121
+
122
+ self.our_swarms = self.get_swarms_for(session_id)
123
+ if not self.our_swarms:
124
+ raise ValueError("No swarms found for this account.")
125
+
126
+ self.our_swarm = random.choice(self.our_swarms)
127
+ return self.our_swarm
128
+
129
+ def _store_message(self, recipient: str, data64: str, timestamp: int, namespace: int = 0) -> str:
130
+ """
131
+ Sends store request to recipient's swarm with retries/failover.
132
+ """
133
+ swarms = list(self.get_swarms_for(recipient))
134
+ while swarms:
135
+ swarm = random.choice(swarms)
136
+ try:
137
+ res = self.network.snode_batch_request(
138
+ snode={
139
+ "public_ip": swarm["ip"],
140
+ "storage_port": int(swarm["port"])
141
+ },
142
+ requests_list=[{
143
+ "method": "store",
144
+ "params": {
145
+ "pubkey": recipient,
146
+ "ttl": 14 * 24 * 60 * 60 * 1000,
147
+ "timestamp": timestamp,
148
+ "data": data64,
149
+ "namespace": namespace
150
+ }
151
+ }],
152
+ timeout=5
153
+ )
154
+ if res and res[0].get("code") == 200:
155
+ return res[0]["body"]["hash"]
156
+ except Exception:
157
+ swarms.remove(swarm)
158
+ if not swarms:
159
+ raise
160
+ raise ValueError("Failed to store message (empty results)")
161
+
162
+ def send_message(
163
+ self,
164
+ to: str,
165
+ text: Optional[str] = None,
166
+ attachments: Optional[List[dict]] = None
167
+ ) -> Dict[str, Any]:
168
+ """
169
+ Sends a message to a recipient.
170
+ to: Recipient Session ID (starts with 05)
171
+ text: Message text
172
+ attachments: List of dicts, e.g. [{'data': b'...', 'name': 'file.jpg', 'content_type': 'image/jpeg'}]
173
+ """
174
+ if not self.keys or not self.session_id:
175
+ raise ValueError("Session client not initialized.")
176
+
177
+ if len(to) != 66 or not to.startswith("05"):
178
+ raise ValueError("Invalid recipient Session ID.")
179
+
180
+ attachment_pointers = []
181
+ if attachments:
182
+ for att in attachments:
183
+ enc = encrypt_attachment(att["data"])
184
+ # Upload ciphertext
185
+ res = self.network.upload_attachment(enc["ciphertext"])
186
+ attachment_pointers.append({
187
+ "id": res["id"],
188
+ "url": res["url"],
189
+ "key": enc["key"],
190
+ "digest": enc["digest"],
191
+ "fileName": att.get("name", "file"),
192
+ "contentType": att.get("content_type", "application/octet-stream"),
193
+ "size": len(att["data"])
194
+ })
195
+
196
+ timestamp = int(time.time() * 1000)
197
+
198
+ # Build visible message for recipient
199
+ msg_content = Content()
200
+ msg_content.dataMessage.body = text or ""
201
+ msg_content.dataMessage.timestamp = timestamp
202
+
203
+ if self.display_name:
204
+ msg_content.dataMessage.profile.displayName = self.display_name
205
+
206
+ # Add attachments
207
+ for att_ptr in attachment_pointers:
208
+ ptr = msg_content.dataMessage.attachments.add()
209
+ ptr.id = att_ptr["id"]
210
+ ptr.url = att_ptr["url"]
211
+ ptr.key = att_ptr["key"]
212
+ ptr.digest = att_ptr["digest"]
213
+ ptr.fileName = att_ptr["fileName"]
214
+ ptr.contentType = att_ptr["contentType"]
215
+ ptr.size = att_ptr["size"]
216
+
217
+ # Build sync message for self
218
+ sync_content = Content()
219
+ sync_content.dataMessage.body = text or ""
220
+ sync_content.dataMessage.timestamp = timestamp
221
+ sync_content.dataMessage.syncTarget = to
222
+
223
+ for att_ptr in attachment_pointers:
224
+ ptr = sync_content.dataMessage.attachments.add()
225
+ ptr.id = att_ptr["id"]
226
+ ptr.url = att_ptr["url"]
227
+ ptr.key = att_ptr["key"]
228
+ ptr.digest = att_ptr["digest"]
229
+ ptr.fileName = att_ptr["fileName"]
230
+ ptr.contentType = att_ptr["contentType"]
231
+ ptr.size = att_ptr["size"]
232
+
233
+ # Encrypt messages
234
+ raw_msg_bytes = msg_content.SerializeToString()
235
+ padded_raw = add_message_padding(raw_msg_bytes)
236
+ ciphertext = encrypt_using_session_protocol(self.keys, to, padded_raw)
237
+
238
+ raw_sync_bytes = sync_content.SerializeToString()
239
+ padded_sync = add_message_padding(raw_sync_bytes)
240
+ sync_ciphertext = encrypt_using_session_protocol(self.keys, self.session_id, padded_sync)
241
+
242
+ # Build envelopes
243
+ envelope = Envelope(
244
+ type=Envelope.Type.SESSION_MESSAGE,
245
+ timestamp=timestamp,
246
+ content=ciphertext
247
+ )
248
+
249
+ sync_envelope = Envelope(
250
+ type=Envelope.Type.SESSION_MESSAGE,
251
+ timestamp=timestamp,
252
+ content=sync_ciphertext
253
+ )
254
+
255
+ # Wrap envelopes inside WebSocket messages
256
+ websocket_bytes = wrap_envelope(envelope.SerializeToString())
257
+ sync_websocket_bytes = wrap_envelope(sync_envelope.SerializeToString())
258
+
259
+ data64 = binascii.b2a_base64(websocket_bytes, newline=False).decode('utf-8')
260
+ sync_data64 = binascii.b2a_base64(sync_websocket_bytes, newline=False).decode('utf-8')
261
+
262
+ # Store recipient message & sync copy
263
+ message_hash = self._store_message(to, data64, timestamp, namespace=0)
264
+ sync_message_hash = self._store_message(self.session_id, sync_data64, timestamp, namespace=0)
265
+
266
+ return {
267
+ "messageHash": message_hash,
268
+ "syncMessageHash": sync_message_hash,
269
+ "timestamp": timestamp
270
+ }
271
+
272
+ def get_file(self, att_ptr: dict) -> bytes:
273
+ """
274
+ Downloads and decrypts attachment from Session file server using the attachment pointer dict.
275
+ att_ptr: dict representing attachment pointer, containing:
276
+ 'id', 'key', 'digest', 'size'
277
+ """
278
+ enc_data = self.network.download_attachment(att_ptr["id"])
279
+ return decrypt_attachment(
280
+ data=enc_data,
281
+ key=att_ptr["key"],
282
+ digest=att_ptr["digest"],
283
+ size=att_ptr.get("size")
284
+ )
285
+
286
+ # --- Message Reactions ---
287
+
288
+ def add_reaction(self, message_timestamp: int, message_author: str, emoji: str):
289
+ """
290
+ Adds emoji reaction to specific message.
291
+ """
292
+ self._send_reaction_message(message_timestamp, message_author, emoji, action=0)
293
+
294
+ def remove_reaction(self, message_timestamp: int, message_author: str, emoji: str):
295
+ """
296
+ Removes emoji reaction from specific message.
297
+ """
298
+ self._send_reaction_message(message_timestamp, message_author, emoji, action=1)
299
+
300
+ def _send_reaction_message(self, message_timestamp: int, message_author: str, emoji: str, action: int):
301
+ if not self.keys or not self.session_id:
302
+ raise ValueError("Session client not initialized.")
303
+
304
+ timestamp = int(time.time() * 1000)
305
+
306
+ # Build visible reaction content
307
+ content = Content()
308
+ content.dataMessage.timestamp = timestamp
309
+ content.dataMessage.reaction.id = message_timestamp
310
+ content.dataMessage.reaction.action = action
311
+ content.dataMessage.reaction.author = message_author
312
+ content.dataMessage.reaction.emoji = emoji
313
+ if self.display_name:
314
+ content.dataMessage.profile.displayName = self.display_name
315
+
316
+ # Build sync copy
317
+ sync_content = Content()
318
+ sync_content.dataMessage.timestamp = timestamp
319
+ sync_content.dataMessage.reaction.id = message_timestamp
320
+ sync_content.dataMessage.reaction.action = action
321
+ sync_content.dataMessage.reaction.author = message_author
322
+ sync_content.dataMessage.reaction.emoji = emoji
323
+ sync_content.dataMessage.syncTarget = message_author
324
+
325
+ # Encrypt and wrap
326
+ raw_msg_bytes = content.SerializeToString()
327
+ padded_raw = add_message_padding(raw_msg_bytes)
328
+ ciphertext = encrypt_using_session_protocol(self.keys, message_author, padded_raw)
329
+
330
+ raw_sync_bytes = sync_content.SerializeToString()
331
+ padded_sync = add_message_padding(raw_sync_bytes)
332
+ sync_ciphertext = encrypt_using_session_protocol(self.keys, self.session_id, padded_sync)
333
+
334
+ envelope = Envelope(
335
+ type=Envelope.Type.SESSION_MESSAGE,
336
+ timestamp=timestamp,
337
+ content=ciphertext
338
+ )
339
+ sync_envelope = Envelope(
340
+ type=Envelope.Type.SESSION_MESSAGE,
341
+ timestamp=timestamp,
342
+ content=sync_ciphertext
343
+ )
344
+
345
+ data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
346
+ sync_data64 = binascii.b2a_base64(wrap_envelope(sync_envelope.SerializeToString()), newline=False).decode('utf-8')
347
+
348
+ self._store_message(message_author, data64, timestamp, namespace=0)
349
+ self._store_message(self.session_id, sync_data64, timestamp, namespace=0)
350
+
351
+ # --- Typing Indicators ---
352
+
353
+ def show_typing_indicator(self, conversation: str):
354
+ """
355
+ Sends typing started event.
356
+ """
357
+ self._update_typing_indicator(conversation, is_typing=True)
358
+
359
+ def hide_typing_indicator(self, conversation: str):
360
+ """
361
+ Sends typing stopped event.
362
+ """
363
+ self._update_typing_indicator(conversation, is_typing=False)
364
+
365
+ def _update_typing_indicator(self, conversation: str, is_typing: bool):
366
+ if not self.keys or not self.session_id:
367
+ raise ValueError("Session client not initialized.")
368
+
369
+ timestamp = int(time.time() * 1000)
370
+ content = Content()
371
+ content.typingMessage.timestamp = timestamp
372
+ content.typingMessage.action = 0 if is_typing else 1 # STARTED = 0, STOPPED = 1
373
+
374
+ raw_msg_bytes = content.SerializeToString()
375
+ padded_raw = add_message_padding(raw_msg_bytes)
376
+ ciphertext = encrypt_using_session_protocol(self.keys, conversation, padded_raw)
377
+
378
+ envelope = Envelope(
379
+ type=Envelope.Type.SESSION_MESSAGE,
380
+ timestamp=timestamp,
381
+ content=ciphertext
382
+ )
383
+ data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
384
+ self._store_message(conversation, data64, timestamp, namespace=0)
385
+
386
+ # --- Read Receipts ---
387
+
388
+ def mark_messages_as_read(self, conversation: str, timestamps: List[int], read_at: Optional[int] = None):
389
+ """
390
+ Marks messages with specified timestamps as read.
391
+ """
392
+ if not self.keys or not self.session_id:
393
+ raise ValueError("Session client not initialized.")
394
+
395
+ timestamp = read_at or int(time.time() * 1000)
396
+ content = Content()
397
+ content.receiptMessage.type = 1 # READ = 1
398
+ content.receiptMessage.timestamp.extend(timestamps)
399
+
400
+ raw_msg_bytes = content.SerializeToString()
401
+ padded_raw = add_message_padding(raw_msg_bytes)
402
+ ciphertext = encrypt_using_session_protocol(self.keys, conversation, padded_raw)
403
+
404
+ envelope = Envelope(
405
+ type=Envelope.Type.SESSION_MESSAGE,
406
+ timestamp=timestamp,
407
+ content=ciphertext
408
+ )
409
+ data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
410
+ self._store_message(conversation, data64, timestamp, namespace=0)
411
+
412
+ # --- Message Deletion / Unsends ---
413
+
414
+ def delete_message(self, conversation: str, timestamp: int, hash_val: str):
415
+ """
416
+ Deletes a single sent message locally and sends an unsend command.
417
+ """
418
+ self.delete_messages([{"to": conversation, "timestamp": timestamp, "hash": hash_val}])
419
+
420
+ def delete_messages(self, messages: List[dict]):
421
+ """
422
+ Deletes multiple sent messages.
423
+ messages: List of dicts: [{'to': '...', 'timestamp': ..., 'hash': '...'}]
424
+ """
425
+ if not self.keys or not self.session_id:
426
+ raise ValueError("Session client not initialized.")
427
+
428
+ hashes = [m["hash"] for m in messages]
429
+
430
+ # 1. Permanently delete from our own swarm
431
+ verification_data = f"delete{''.join(hashes)}"
432
+ signature = self.keys.signing_key.sign(verification_data.encode('utf-8')).signature
433
+ signature_b64 = binascii.b2a_base64(signature, newline=False).decode('utf-8')
434
+
435
+ our_swarms = list(self.get_swarms_for(self.session_id))
436
+ while our_swarms:
437
+ our_swarm = random.choice(our_swarms)
438
+ try:
439
+ res = self.network.snode_batch_request(
440
+ snode={
441
+ "public_ip": our_swarm["ip"],
442
+ "storage_port": int(our_swarm["port"])
443
+ },
444
+ requests_list=[{
445
+ "method": "delete",
446
+ "params": {
447
+ "messages": hashes,
448
+ "pubkey": self.session_id,
449
+ "pubkey_ed25519": self.keys.ed25519.public_key.hex(),
450
+ "signature": signature_b64
451
+ }
452
+ }],
453
+ timeout=5
454
+ )
455
+ if res and res[0].get("code") == 200:
456
+ break
457
+ except Exception:
458
+ our_swarms.remove(our_swarm)
459
+ if not our_swarms:
460
+ raise
461
+
462
+ # 2. Propagate Unsend command to recipients
463
+ timestamp = int(time.time() * 1000)
464
+ for m in messages:
465
+ unsend_content = Content()
466
+ unsend_content.unsendMessage.timestamp = m["timestamp"]
467
+ unsend_content.unsendMessage.author = self.session_id
468
+
469
+ raw_msg_bytes = unsend_content.SerializeToString()
470
+ padded_raw = add_message_padding(raw_msg_bytes)
471
+ ciphertext = encrypt_using_session_protocol(self.keys, m["to"], padded_raw)
472
+ sync_ciphertext = encrypt_using_session_protocol(self.keys, self.session_id, padded_raw)
473
+
474
+ envelope = Envelope(
475
+ type=Envelope.Type.SESSION_MESSAGE,
476
+ timestamp=timestamp,
477
+ content=ciphertext
478
+ )
479
+ sync_envelope = Envelope(
480
+ type=Envelope.Type.SESSION_MESSAGE,
481
+ timestamp=timestamp,
482
+ content=sync_ciphertext
483
+ )
484
+
485
+ data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
486
+ sync_data64 = binascii.b2a_base64(wrap_envelope(sync_envelope.SerializeToString()), newline=False).decode('utf-8')
487
+
488
+ self._store_message(m["to"], data64, timestamp, namespace=0)
489
+ self._store_message(self.session_id, sync_data64, timestamp, namespace=0)
490
+
491
+ # --- SOGS / Open Groups Blinding (Matching session.js empty blinding implementation) ---
492
+
493
+ def blind_session_id(self, server_pk: str) -> str:
494
+ raise NotImplementedError("Blinding is not implemented yet in the original session.js library.")
495
+
496
+ def sign_sogs_request(
497
+ self,
498
+ server_pk: str,
499
+ timestamp: int,
500
+ endpoint: str,
501
+ nonce: bytes,
502
+ method: str,
503
+ body: Optional[Union[str, bytes]] = None
504
+ ) -> bytes:
505
+ """
506
+ Signs HTTP requests to SOGS.
507
+ """
508
+ if not self.keys or not self.session_id:
509
+ raise ValueError("Session client not initialized.")
510
+
511
+ pk = binascii.unhexlify(server_pk)
512
+ to_sign = pk + nonce + str(timestamp).encode('utf-8') + method.encode('utf-8') + endpoint.encode('utf-8')
513
+ if body:
514
+ body_bytes = body.encode('utf-8') if isinstance(body, str) else body
515
+ body_hashed = hashlib.blake2b(body_bytes, digest_size=64).digest()
516
+ to_sign += body_hashed
517
+
518
+ # SOGS signature is ed25519 sign
519
+ return self.keys.signing_key.sign(to_sign).signature
520
+
521
+ def send_sogs_request(
522
+ self,
523
+ host: str,
524
+ server_pk: str,
525
+ endpoint: str,
526
+ method: str,
527
+ body: Optional[Union[str, bytes]] = None
528
+ ) -> dict:
529
+ """
530
+ Sends requests to SOGS (Open Groups).
531
+ """
532
+ if not self.keys or not self.session_id:
533
+ raise ValueError("Session client not initialized.")
534
+
535
+ nonce = os.urandom(16)
536
+ timestamp = int(time.time())
537
+ req_sig = self.sign_sogs_request(server_pk, timestamp, endpoint, nonce, method, body)
538
+
539
+ pubkey = "00" + self.keys.ed25519.public_key.hex()
540
+
541
+ content_type = None
542
+ if body is not None:
543
+ content_type = "application/octet-stream" if isinstance(body, bytes) else "application/json"
544
+
545
+ headers = {
546
+ "X-SOGS-Pubkey": pubkey,
547
+ "X-SOGS-Timestamp": str(timestamp),
548
+ "X-SOGS-Nonce": binascii.b2a_base64(nonce, newline=False).decode('utf-8'),
549
+ "X-SOGS-Signature": binascii.b2a_base64(req_sig, newline=False).decode('utf-8')
550
+ }
551
+ if content_type:
552
+ headers["Content-Type"] = content_type
553
+
554
+ return self.network.sogs_request(
555
+ host=host,
556
+ endpoint=endpoint,
557
+ method=method,
558
+ body=body,
559
+ headers=headers
560
+ )
561
+
562
+ def encode_sogs_message(self, text: str) -> dict:
563
+ raise NotImplementedError("SOGS message encoding is not implemented in the original session.js library.")
564
+
565
+ def get_mnemonic(self) -> Optional[str]:
566
+ """
567
+ Returns mnemonic string of this instance.
568
+ """
569
+ return self.mnemonic
570
+
571
+ def get_display_name(self) -> Optional[str]:
572
+ """
573
+ Returns cached display name of this instance.
574
+ """
575
+ return self.display_name
576
+
577
+ def get_avatar(self) -> Optional[dict]:
578
+ """
579
+ Returns cached avatar profile dict.
580
+ """
581
+ return self.avatar
582
+
583
+ def get_keys(self) -> Optional[SessionKeys]:
584
+ """
585
+ Returns derived cryptographic keys.
586
+ """
587
+ return self.keys
588
+
589
+ def download_avatar(self, avatar: dict) -> bytes:
590
+ """
591
+ Downloads and decrypts avatar image using Profile.avatar dictionary containing 'url' and 'key'.
592
+ """
593
+ file_server_url = "http://filev2.getsession.org/file/"
594
+ url = avatar["url"]
595
+ if not url.startswith(file_server_url):
596
+ raise ValueError("Avatar must be hosted on Session file server")
597
+ file_id = url[len(file_server_url):]
598
+ raw_bytes = self.network.download_attachment(file_id)
599
+ from .crypto import decrypt_profile
600
+ return decrypt_profile(raw_bytes, avatar["key"])
601
+
602
+ def upload_avatar(self, avatar_data: bytes) -> dict:
603
+ """
604
+ Encrypts and uploads avatar data to Session file server.
605
+ """
606
+ profile_key = os.urandom(32)
607
+ from .crypto import encrypt_profile
608
+ ciphertext = encrypt_profile(avatar_data, profile_key)
609
+ res = self.network.upload_attachment(ciphertext)
610
+ return {
611
+ "profileKey": profile_key,
612
+ "avatarPointer": res["url"]
613
+ }
614
+
615
+ def set_avatar(self, avatar_data: bytes):
616
+ """
617
+ Uploads, encrypts and sets avatar.
618
+ """
619
+ res = self.upload_avatar(avatar_data)
620
+ self.avatar = {
621
+ "key": res["profileKey"],
622
+ "url": res["avatarPointer"]
623
+ }
624
+
625
+ config_content = Content()
626
+ if self.display_name:
627
+ config_content.configurationMessage.displayName = self.display_name
628
+ config_content.configurationMessage.profilePicture = self.avatar["url"]
629
+ config_content.configurationMessage.profileKey = self.avatar["key"]
630
+
631
+ raw_msg_bytes = config_content.SerializeToString()
632
+ padded_raw = add_message_padding(raw_msg_bytes)
633
+ sync_ciphertext = encrypt_using_session_protocol(self.keys, self.session_id, padded_raw)
634
+
635
+ envelope = Envelope(
636
+ type=Envelope.Type.SESSION_MESSAGE,
637
+ timestamp=int(time.time() * 1000),
638
+ content=sync_ciphertext
639
+ )
640
+ data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
641
+ self._store_message(self.session_id, data64, envelope.timestamp, namespace=0)
642
+
643
+ def set_display_name(self, display_name: str):
644
+ """
645
+ Sets display name and propagates ConfigurationMessage sync copy.
646
+ """
647
+ if not self.keys or not self.session_id:
648
+ raise ValueError("Session client not initialized.")
649
+ if len(display_name) > 64 or len(display_name) == 0:
650
+ raise ValueError("Display name must be between 1 and 64 characters.")
651
+
652
+ self.display_name = display_name
653
+
654
+ config_content = Content()
655
+ config_content.configurationMessage.displayName = self.display_name
656
+ if self.avatar:
657
+ config_content.configurationMessage.profilePicture = self.avatar["url"]
658
+ config_content.configurationMessage.profileKey = self.avatar["key"]
659
+
660
+ raw_msg_bytes = config_content.SerializeToString()
661
+ padded_raw = add_message_padding(raw_msg_bytes)
662
+ sync_ciphertext = encrypt_using_session_protocol(self.keys, self.session_id, padded_raw)
663
+
664
+ envelope = Envelope(
665
+ type=Envelope.Type.SESSION_MESSAGE,
666
+ timestamp=int(time.time() * 1000),
667
+ content=sync_ciphertext
668
+ )
669
+ data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
670
+ self._store_message(self.session_id, data64, envelope.timestamp, namespace=0)
671
+
672
+ def notify_screenshot_taken(self, conversation: str):
673
+ """
674
+ Sends screenshot taken notification to conversation.
675
+ """
676
+ self._send_data_extraction_notification(conversation, action=1, timestamp=int(time.time() * 1000))
677
+
678
+ def notify_media_saved(self, conversation: str, saved_message_timestamp: int):
679
+ """
680
+ Sends media saved notification to conversation.
681
+ """
682
+ self._send_data_extraction_notification(conversation, action=2, timestamp=saved_message_timestamp)
683
+
684
+ def _send_data_extraction_notification(self, conversation: str, action: int, timestamp: int):
685
+ if not self.keys or not self.session_id:
686
+ raise ValueError("Session client not initialized.")
687
+
688
+ content = Content()
689
+ content.dataExtractionNotification.type = action # SCREENSHOT = 1, MEDIA_SAVED = 2
690
+ content.dataExtractionNotification.timestamp = timestamp
691
+
692
+ raw_msg_bytes = content.SerializeToString()
693
+ padded_raw = add_message_padding(raw_msg_bytes)
694
+ ciphertext = encrypt_using_session_protocol(self.keys, conversation, padded_raw)
695
+
696
+ envelope = Envelope(
697
+ type=Envelope.Type.SESSION_MESSAGE,
698
+ timestamp=int(time.time() * 1000),
699
+ content=ciphertext
700
+ )
701
+ data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
702
+ self._store_message(conversation, data64, envelope.timestamp, namespace=0)
703
+
704
+ def accept_conversation_request(self, conversation: str):
705
+ """
706
+ Accepts conversation request from another Session ID.
707
+ """
708
+ if not self.keys or not self.session_id:
709
+ raise ValueError("Session client not initialized.")
710
+
711
+ content = Content()
712
+ content.messageRequestResponse.isApproved = True
713
+ if self.avatar:
714
+ content.messageRequestResponse.profileKey = self.avatar["key"]
715
+ content.messageRequestResponse.profile.profilePicture = self.avatar["url"]
716
+ if self.display_name:
717
+ content.messageRequestResponse.profile.displayName = self.display_name
718
+
719
+ raw_msg_bytes = content.SerializeToString()
720
+ padded_raw = add_message_padding(raw_msg_bytes)
721
+ ciphertext = encrypt_using_session_protocol(self.keys, conversation, padded_raw)
722
+
723
+ envelope = Envelope(
724
+ type=Envelope.Type.SESSION_MESSAGE,
725
+ timestamp=int(time.time() * 1000),
726
+ content=ciphertext
727
+ )
728
+ data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
729
+ self._store_message(conversation, data64, envelope.timestamp, namespace=0)