rubiflow 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.
rubiflow/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """
2
+ RubiFlow: Next-Generation High-Performance Async Python Framework for Rubika.
3
+ """
4
+
5
+ from .client import Client
6
+ from .crypto import RubiFlowCrypto
7
+ from .exceptions import (
8
+ ApiError,
9
+ CryptoError,
10
+ InvalidAuthError,
11
+ NetworkError,
12
+ NotFoundError,
13
+ RubiFlowError,
14
+ )
15
+ from .models import Chat, Message, User
16
+ from .session import Session
17
+
18
+ __version__ = "1.0.0"
19
+ __author__ = "RubiFlow Team"
20
+
21
+ __all__ = [
22
+ "Client",
23
+ "Session",
24
+ "RubiFlowCrypto",
25
+ "User",
26
+ "Chat",
27
+ "Message",
28
+ "RubiFlowError",
29
+ "ApiError",
30
+ "InvalidAuthError",
31
+ "NetworkError",
32
+ "CryptoError",
33
+ "NotFoundError",
34
+ ]
rubiflow/client.py ADDED
@@ -0,0 +1,203 @@
1
+ """
2
+ Core asynchronous Client for RubiFlow.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from typing import Any, Callable, Dict, List, Optional, Union
9
+
10
+ from .crypto import RubiFlowCrypto
11
+ from .exceptions import RubiFlowError, check_api_status
12
+ from .methods import Methods
13
+ from .models import Message, User
14
+ from .network import AsyncTransport
15
+ from .session import Session
16
+
17
+ logger = logging.getLogger("rubiflow")
18
+
19
+
20
+ class Client(Methods):
21
+ """
22
+ High-performance, asynchronous Rubika client framework.
23
+ Inherits 300+ methods covering Messenger, Rubino, VOD, and Wallet.
24
+ """
25
+
26
+ DEFAULT_DC_URL = "https://messengerg2c332.iranlms.ir"
27
+ LIB_NAME = "RubiFlow"
28
+ LIB_VERSION = "1.0.0"
29
+
30
+ # Rubika protocol standard client signature
31
+ _INTERNAL_CLIENT = {
32
+ "app_name": "Main",
33
+ "app_version": "2.5.9",
34
+ "platform": "PWA",
35
+ "package": "m.rubika.ir",
36
+ "lang_code": "fa",
37
+ }
38
+
39
+ def __init__(
40
+ self,
41
+ session: Optional[Union[Session, str]] = None,
42
+ auth: Optional[str] = None,
43
+ private_key_pem: Optional[str] = None,
44
+ dc_url: Optional[str] = None,
45
+ timeout: float = 20.0,
46
+ ):
47
+ if isinstance(session, str):
48
+ if session.startswith("RubiFlow$") or session.startswith("Rubix$"):
49
+ self.session = Session.from_string(session)
50
+ else:
51
+ self.session = Session.load_from_file(session)
52
+ elif isinstance(session, Session):
53
+ self.session = session
54
+ elif auth and private_key_pem:
55
+ self.session = Session(
56
+ auth=auth,
57
+ private_key_pem=private_key_pem,
58
+ dc_url=dc_url or self.DEFAULT_DC_URL,
59
+ )
60
+ else:
61
+ # Temporary unauthenticated session
62
+ priv, _ = RubiFlowCrypto.generate_rsa_key_pair()
63
+ tmp = RubiFlowCrypto.generate_tmp_session(32)
64
+ self.session = Session(
65
+ auth=tmp,
66
+ private_key_pem=priv,
67
+ dc_url=dc_url or self.DEFAULT_DC_URL,
68
+ )
69
+
70
+ self.transport = AsyncTransport(timeout_seconds=timeout)
71
+ self._me: Optional[User] = None
72
+ self._message_handlers: List[Callable[[Message], Any]] = []
73
+ self._is_running = False
74
+
75
+ @classmethod
76
+ def from_firefox(cls) -> "Client":
77
+ """
78
+ Creates a Client connected directly to the user's active session in Firefox.
79
+ """
80
+ session = Session.from_firefox()
81
+ return cls(session=session)
82
+
83
+ async def __aenter__(self) -> "Client":
84
+ await self.start()
85
+ return self
86
+
87
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
88
+ await self.stop()
89
+
90
+ async def start(self) -> None:
91
+ """Initializes client and verifies session authentication."""
92
+ try:
93
+ me = await self.get_me()
94
+ self._me = me
95
+ logger.info("RubiFlow connected as @%s (%s)", me.username, me.guid)
96
+ except Exception as exc:
97
+ logger.warning("RubiFlow started with unverified session: %s", exc)
98
+
99
+ async def stop(self) -> None:
100
+ """Closes transport and background tasks."""
101
+ self._is_running = False
102
+ await self.transport.close()
103
+
104
+ # --- Core RPC Dispatcher ---
105
+
106
+ async def execute(
107
+ self,
108
+ method: str,
109
+ input_data: Optional[Dict[str, Any]] = None,
110
+ api_version: str = "6",
111
+ ) -> Dict[str, Any]:
112
+ """
113
+ Executes an encrypted and RSA-signed Rubika V6 RPC method.
114
+ """
115
+ inner_payload = {
116
+ "method": method,
117
+ "input": input_data or {},
118
+ "client": self._INTERNAL_CLIENT,
119
+ }
120
+
121
+ # 1. AES encrypt inner payload with derived secret
122
+ data_enc = RubiFlowCrypto.encrypt_aes(inner_payload, self.session.auth)
123
+
124
+ # 2. RSA-SHA256 signature
125
+ sign = RubiFlowCrypto.sign_rsa_sha256(data_enc, self.session.private_key_pem)
126
+
127
+ # 3. Disguise auth token
128
+ disguised_auth = RubiFlowCrypto.disguise_auth(self.session.auth)
129
+
130
+ request_body = {
131
+ "api_version": api_version,
132
+ "auth": disguised_auth,
133
+ "data_enc": data_enc,
134
+ "sign": sign,
135
+ }
136
+
137
+ response = await self.transport.post_json(self.session.dc_url, request_body)
138
+
139
+ if "data_enc" in response:
140
+ decrypted = RubiFlowCrypto.decrypt_aes(response["data_enc"], self.session.auth)
141
+ if isinstance(decrypted, str):
142
+ decrypted = json.loads(decrypted)
143
+ check_api_status(decrypted)
144
+ return decrypted
145
+
146
+ check_api_status(response)
147
+ return response
148
+
149
+ async def invoke(self, method: str, **kwargs) -> Dict[str, Any]:
150
+ """
151
+ Directly invokes any Rubika RPC method with arbitrary input arguments.
152
+ Example: await app.invoke('sendMessage', object_guid='u0...', text='Hello')
153
+ """
154
+ return await self.execute(method, kwargs)
155
+
156
+ # --- Event Handling & Bot Loop ---
157
+
158
+ def on_message(self) -> Callable[[Callable[[Message], Any]], Callable[[Message], Any]]:
159
+ """Decorator to register an asynchronous handler for incoming messages."""
160
+ def decorator(func: Callable[[Message], Any]) -> Callable[[Message], Any]:
161
+ self._message_handlers.append(func)
162
+ return func
163
+ return decorator
164
+
165
+ async def _polling_loop(self) -> None:
166
+ """Background poller for message updates."""
167
+ while self._is_running:
168
+ try:
169
+ chats = await self.get_chats()
170
+ for chat in chats:
171
+ if chat.unread_count > 0:
172
+ msgs = await self.get_messages(chat.guid, limit=chat.unread_count)
173
+ for msg in msgs:
174
+ for handler in self._message_handlers:
175
+ if asyncio.iscoroutinefunction(handler):
176
+ await handler(msg)
177
+ else:
178
+ handler(msg)
179
+ await asyncio.sleep(2)
180
+ except Exception as exc:
181
+ logger.error("Polling error: %s", exc)
182
+ await asyncio.sleep(5)
183
+
184
+ def run(self, coro: Optional[Any] = None) -> None:
185
+ """
186
+ Convenience runner for scripts and bot processes.
187
+ """
188
+ loop = asyncio.get_event_loop()
189
+ if coro is not None:
190
+ loop.run_until_complete(coro)
191
+ return
192
+
193
+ async def _main():
194
+ async with self:
195
+ self._is_running = True
196
+ await self._polling_loop()
197
+
198
+ try:
199
+ loop.run_until_complete(_main())
200
+ except (KeyboardInterrupt, SystemExit):
201
+ pass
202
+ finally:
203
+ loop.run_until_complete(self.stop())
rubiflow/crypto.py ADDED
@@ -0,0 +1,169 @@
1
+ """
2
+ Cryptographic engine for RubiFlow (Rubika V6 Protocol).
3
+ Implements AES-256-CBC, custom key derivation, substitution auth disguising,
4
+ and RSA-SHA256 asymmetric signatures.
5
+ """
6
+
7
+ import base64
8
+ import json
9
+ import random
10
+ import string
11
+ from typing import Any, Dict, Union
12
+
13
+ from cryptography.hazmat.primitives import hashes, padding
14
+ from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
15
+ from cryptography.hazmat.primitives.asymmetric import rsa
16
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
17
+ from cryptography.hazmat.primitives.serialization import (
18
+ Encoding,
19
+ NoEncryption,
20
+ PrivateFormat,
21
+ PublicFormat,
22
+ load_pem_private_key,
23
+ )
24
+
25
+ from .exceptions import CryptoError
26
+
27
+
28
+ class RubiFlowCrypto:
29
+ """Cryptographic operations required by the Rubika V6 protocol."""
30
+
31
+ STATIC_IV = b"\x00" * 16
32
+
33
+ @staticmethod
34
+ def generate_rsa_key_pair() -> tuple[str, str]:
35
+ """
36
+ Generates a new 1024-bit RSA key pair in PEM format.
37
+ Returns: (private_key_pem, public_key_pem)
38
+ """
39
+ private_key = rsa.generate_private_key(
40
+ public_exponent=65537,
41
+ key_size=1024,
42
+ )
43
+ private_pem = private_key.private_bytes(
44
+ encoding=Encoding.PEM,
45
+ format=PrivateFormat.PKCS8,
46
+ encryption_algorithm=NoEncryption(),
47
+ ).decode("utf-8")
48
+
49
+ public_pem = private_key.public_key().public_bytes(
50
+ encoding=Encoding.PEM,
51
+ format=PublicFormat.SubjectPublicKeyInfo,
52
+ ).decode("utf-8")
53
+
54
+ return private_pem, public_pem
55
+
56
+ @staticmethod
57
+ def generate_tmp_session(length: int = 32) -> str:
58
+ """Generates a random lowercase ASCII string for unauthenticated sessions."""
59
+ return "".join(random.choices(string.ascii_lowercase, k=length))
60
+
61
+ @staticmethod
62
+ def create_secret_passphrase(auth: str) -> str:
63
+ """
64
+ Derives the 32-character AES secret key from an auth token or tmp_session.
65
+ """
66
+ if len(auth) < 32:
67
+ raise CryptoError("Auth key or tmp_session must have at least 32 characters.")
68
+
69
+ t = auth[0:8]
70
+ i = auth[8:16]
71
+ p1 = auth[16:24]
72
+ p2 = auth[24:32]
73
+
74
+ chars = list(p1 + t + p2 + i)
75
+ for idx, char in enumerate(chars):
76
+ if "0" <= char <= "9":
77
+ chars[idx] = chr((ord(char) - ord("0") + 5) % 10 + ord("0"))
78
+ elif "a" <= char <= "z":
79
+ chars[idx] = chr((ord(char) - ord("a") + 9) % 26 + ord("a"))
80
+
81
+ return "".join(chars)
82
+
83
+ @staticmethod
84
+ def disguise_auth(auth: str) -> str:
85
+ """
86
+ Applies the Rubika V6 character substitution cipher to the auth string.
87
+ """
88
+ result = []
89
+ for char in auth:
90
+ if "0" <= char <= "9":
91
+ result.append(chr((13 + (ord(char) - ord("0"))) % 10 + ord("0")))
92
+ elif "A" <= char <= "Z":
93
+ result.append(chr((29 - (ord(char) - ord("A"))) % 26 + ord("A")))
94
+ elif "a" <= char <= "z":
95
+ result.append(chr((32 - (ord(char) - ord("a"))) % 26 + ord("a")))
96
+ else:
97
+ result.append(char)
98
+ return "".join(result)
99
+
100
+ @classmethod
101
+ def encrypt_aes(cls, data: Union[Dict[str, Any], str], auth: str) -> str:
102
+ """
103
+ Encrypts a dictionary or string payload using AES-256-CBC and returns Base64.
104
+ """
105
+ try:
106
+ passphrase = cls.create_secret_passphrase(auth).encode("utf-8")
107
+ if isinstance(data, dict):
108
+ raw_bytes = json.dumps(
109
+ data, separators=(",", ":"), ensure_ascii=False
110
+ ).encode("utf-8")
111
+ else:
112
+ raw_bytes = data.encode("utf-8")
113
+
114
+ padder = padding.PKCS7(128).padder()
115
+ padded = padder.update(raw_bytes) + padder.finalize()
116
+
117
+ cipher = Cipher(algorithms.AES(passphrase), modes.CBC(cls.STATIC_IV))
118
+ encryptor = cipher.encryptor()
119
+ ciphertext = encryptor.update(padded) + encryptor.finalize()
120
+
121
+ return base64.b64encode(ciphertext).decode("utf-8")
122
+ except Exception as exc:
123
+ raise CryptoError(f"AES encryption failed: {exc}") from exc
124
+
125
+ @classmethod
126
+ def decrypt_aes(cls, encrypted_b64: str, auth: str) -> Union[Dict[str, Any], str]:
127
+ """
128
+ Decrypts a Base64 AES-256-CBC ciphertext and parses it as JSON if possible.
129
+ """
130
+ try:
131
+ passphrase = cls.create_secret_passphrase(auth).encode("utf-8")
132
+ raw_ciphertext = base64.b64decode(encrypted_b64)
133
+
134
+ cipher = Cipher(algorithms.AES(passphrase), modes.CBC(cls.STATIC_IV))
135
+ decryptor = cipher.decryptor()
136
+ padded_plain = decryptor.update(raw_ciphertext) + decryptor.finalize()
137
+
138
+ unpadder = padding.PKCS7(128).unpadder()
139
+ plain_bytes = unpadder.update(padded_plain) + unpadder.finalize()
140
+ plain_text = plain_bytes.decode("utf-8")
141
+
142
+ try:
143
+ return json.loads(plain_text)
144
+ except json.JSONDecodeError:
145
+ return plain_text
146
+ except Exception as exc:
147
+ raise CryptoError(f"AES decryption failed: {exc}") from exc
148
+
149
+ @staticmethod
150
+ def sign_rsa_sha256(data_enc_b64: str, private_key_pem: str) -> str:
151
+ """
152
+ Signs the data_enc string using RSA-SHA256 with the client's private key.
153
+ Returns the Base64-encoded signature.
154
+ """
155
+ try:
156
+ pem_bytes = (
157
+ private_key_pem.encode("utf-8")
158
+ if isinstance(private_key_pem, str)
159
+ else private_key_pem
160
+ )
161
+ private_key = load_pem_private_key(pem_bytes, password=None)
162
+ signature = private_key.sign(
163
+ data_enc_b64.encode("utf-8"),
164
+ asym_padding.PKCS1v15(),
165
+ hashes.SHA256(),
166
+ )
167
+ return base64.b64encode(signature).decode("utf-8")
168
+ except Exception as exc:
169
+ raise CryptoError(f"RSA signature failed: {exc}") from exc
rubiflow/exceptions.py ADDED
@@ -0,0 +1,64 @@
1
+ """
2
+ Custom exceptions for the RubiFlow framework.
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class RubiFlowError(Exception):
9
+ """Base exception for all RubiFlow errors."""
10
+
11
+ def __init__(self, message: str, status_det: Optional[str] = None):
12
+ super().__init__(message)
13
+ self.message = message
14
+ self.status_det = status_det
15
+
16
+ def __str__(self) -> str:
17
+ if self.status_det:
18
+ return f"{self.message} (status_det: {self.status_det})"
19
+ return self.message
20
+
21
+
22
+ class ApiError(RubiFlowError):
23
+ """Raised when the Rubika API returns an error status."""
24
+
25
+
26
+ class InvalidAuthError(ApiError):
27
+ """Raised when the session authentication token is invalid or expired."""
28
+
29
+
30
+ class NetworkError(RubiFlowError):
31
+ """Raised when a network or transport issue occurs."""
32
+
33
+
34
+ class CryptoError(RubiFlowError):
35
+ """Raised when an encryption or decryption error occurs."""
36
+
37
+
38
+ class NotFoundError(ApiError):
39
+ """Raised when a requested resource (user, chat, message) is not found."""
40
+
41
+
42
+ class FileError(RubiFlowError):
43
+ """Raised when file uploading or downloading fails."""
44
+
45
+
46
+ def check_api_status(response_data: dict) -> None:
47
+ """
48
+ Checks the status and status_det fields of a Rubika response and raises
49
+ the appropriate typed exception if an error is present.
50
+ """
51
+ status = response_data.get("status")
52
+ status_det = response_data.get("status_det")
53
+
54
+ if status == "OK":
55
+ return
56
+
57
+ if status_det == "INVALID_AUTH":
58
+ raise InvalidAuthError(
59
+ "The session authentication token is invalid or has expired.", status_det
60
+ )
61
+ elif status_det in ("NOT_FOUND", "USER_NOT_FOUND", "CHAT_NOT_FOUND"):
62
+ raise NotFoundError(f"Requested resource was not found ({status_det}).", status_det)
63
+ else:
64
+ raise ApiError(f"Rubika API returned error: {status}", status_det)
@@ -0,0 +1,52 @@
1
+ """
2
+ Unified Methods Mixin combining all specialized API submodules.
3
+ """
4
+
5
+ from .auth import AuthMethods
6
+ from .channels import ChannelsMethods
7
+ from .chats import ChatsMethods
8
+ from .files import FilesMethods
9
+ from .folders import FoldersMethods
10
+ from .groups import GroupsMethods
11
+ from .messages import MessagesMethods
12
+ from .rubino import RubinoMethods
13
+ from .settings import SettingsMethods
14
+ from .users import UsersMethods
15
+ from .vod import VodMethods
16
+ from .wallet import WalletMethods
17
+
18
+
19
+ class Methods(
20
+ AuthMethods,
21
+ MessagesMethods,
22
+ ChatsMethods,
23
+ GroupsMethods,
24
+ ChannelsMethods,
25
+ UsersMethods,
26
+ FilesMethods,
27
+ FoldersMethods,
28
+ SettingsMethods,
29
+ RubinoMethods,
30
+ VodMethods,
31
+ WalletMethods,
32
+ ):
33
+ """
34
+ Combined Mixin providing direct access to 300+ Rubika RPC methods.
35
+ """
36
+
37
+
38
+ __all__ = [
39
+ "Methods",
40
+ "AuthMethods",
41
+ "MessagesMethods",
42
+ "ChatsMethods",
43
+ "GroupsMethods",
44
+ "ChannelsMethods",
45
+ "UsersMethods",
46
+ "FilesMethods",
47
+ "FoldersMethods",
48
+ "SettingsMethods",
49
+ "RubinoMethods",
50
+ "VodMethods",
51
+ "WalletMethods",
52
+ ]
@@ -0,0 +1,158 @@
1
+ """
2
+ Authentication, 2-Step Verification, and Session management methods.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ class AuthMethods:
9
+ """Methods for login, registration, 2FA, and active sessions."""
10
+
11
+ async def execute(self, method: str, input_data: Optional[Dict[str, Any]] = None, api_version: str = "6") -> Dict[str, Any]:
12
+ raise NotImplementedError
13
+
14
+ async def send_code(
15
+ self,
16
+ phone_number: str,
17
+ send_type: str = "SMS",
18
+ ) -> Dict[str, Any]:
19
+ """Sends an activation SMS or internal code to a phone number."""
20
+ clean_phone = phone_number.replace("+", "").replace(" ", "").strip()
21
+ payload = {
22
+ "phone_number": clean_phone,
23
+ "send_type": send_type,
24
+ }
25
+ return await self.execute("sendCode", payload)
26
+
27
+ async def sign_in(
28
+ self,
29
+ phone_number: str,
30
+ phone_code_hash: str,
31
+ phone_code: str,
32
+ ) -> Dict[str, Any]:
33
+ """Verifies activation code and authenticates the user."""
34
+ clean_phone = phone_number.replace("+", "").replace(" ", "").strip()
35
+ payload = {
36
+ "phone_number": clean_phone,
37
+ "phone_code_hash": phone_code_hash,
38
+ "phone_code": phone_code,
39
+ }
40
+ return await self.execute("signIn", payload)
41
+
42
+ async def logout(self) -> Dict[str, Any]:
43
+ """Logs out and invalidates the current session token."""
44
+ return await self.execute("logout", {})
45
+
46
+ async def register_device(
47
+ self,
48
+ device_model: str = "RubiFlow Framework",
49
+ system_version: str = "Linux",
50
+ token: str = "",
51
+ token_type: str = "Web",
52
+ ) -> Dict[str, Any]:
53
+ """Registers device identification for the current account."""
54
+ payload = {
55
+ "app_version": "2.5.9",
56
+ "device_hash": "RubiFlow-" + getattr(self, "session", None).auth[:8] if hasattr(self, "session") else "RubiFlow",
57
+ "device_model": device_model,
58
+ "is_扶_mode": False,
59
+ "lang_code": "fa",
60
+ "system_version": system_version,
61
+ "token": token,
62
+ "token_type": token_type,
63
+ }
64
+ return await self.execute("registerDevice", payload)
65
+
66
+ async def get_dcs(self) -> Dict[str, Any]:
67
+ """Fetches the list of active Data Center (DC) URLs."""
68
+ return await self.execute("getDCs", {}, api_version="4")
69
+
70
+ async def get_base_info(self) -> Dict[str, Any]:
71
+ """Retrieves global platform configurations and server timestamps."""
72
+ return await self.execute("getBaseInfo", {}, api_version="0")
73
+
74
+ # --- Two-Step Verification (2FA) ---
75
+
76
+ async def setup_two_step_verification(
77
+ self,
78
+ password: str,
79
+ hint: Optional[str] = None,
80
+ recovery_email: Optional[str] = None,
81
+ ) -> Dict[str, Any]:
82
+ """Sets up a two-step verification password."""
83
+ payload: Dict[str, Any] = {"password": password}
84
+ if hint:
85
+ payload["hint"] = hint
86
+ if recovery_email:
87
+ payload["recovery_email"] = recovery_email
88
+ return await self.execute("setupTwoStepVerification", payload)
89
+
90
+ async def check_two_step_passcode(self, password: str) -> Dict[str, Any]:
91
+ """Validates 2FA passcode during login."""
92
+ return await self.execute("checkTwoStepPasscode", {"password": password})
93
+
94
+ async def change_password(
95
+ self,
96
+ old_password: str,
97
+ new_password: str,
98
+ hint: Optional[str] = None,
99
+ ) -> Dict[str, Any]:
100
+ """Changes the current two-step verification password."""
101
+ payload: Dict[str, Any] = {
102
+ "old_password": old_password,
103
+ "new_password": new_password,
104
+ }
105
+ if hint:
106
+ payload["hint"] = hint
107
+ return await self.execute("changePassword", payload)
108
+
109
+ async def abort_two_step_setup(self) -> Dict[str, Any]:
110
+ """Cancels an in-progress 2FA setup."""
111
+ return await self.execute("abortTwoStepSetup", {})
112
+
113
+ async def login_two_step_forget_password(self, phone_code_hash: str) -> Dict[str, Any]:
114
+ """Requests password recovery code sent to recovery email."""
115
+ return await self.execute("loginTwoStepForgetPassword", {"phone_code_hash": phone_code_hash})
116
+
117
+ async def login_disable_two_step(self, phone_code_hash: str, email_code: str) -> Dict[str, Any]:
118
+ """Disables 2FA using email confirmation code."""
119
+ return await self.execute(
120
+ "loginDisableTwoStep",
121
+ {"phone_code_hash": phone_code_hash, "email_code": email_code},
122
+ )
123
+
124
+ async def set_recovery_email(self, email: str) -> Dict[str, Any]:
125
+ """Sets or updates the account recovery email."""
126
+ return await self.execute("setRecoveryEmail", {"email": email})
127
+
128
+ async def verify_recovery_email(self, email_code: str) -> Dict[str, Any]:
129
+ """Confirms the recovery email verification code."""
130
+ return await self.execute("verifyRecoveryEmail", {"email_code": email_code})
131
+
132
+ async def abort_set_recovery_email(self) -> Dict[str, Any]:
133
+ """Aborts pending recovery email verification."""
134
+ return await self.execute("abortSetRecoveryEmail", {})
135
+
136
+ # --- Session Management ---
137
+
138
+ async def get_my_sessions(self) -> List[Dict[str, Any]]:
139
+ """Retrieves list of active logged-in sessions and devices."""
140
+ res = await self.execute("getMySessions", {})
141
+ return res.get("data", {}).get("sessions", [])
142
+
143
+ async def terminate_session(self, session_key: str) -> Dict[str, Any]:
144
+ """Terminates a specific active session by its session key."""
145
+ return await self.execute("terminateSession", {"session_key": session_key})
146
+
147
+ async def terminate_user_sessions(self) -> Dict[str, Any]:
148
+ """Terminates all other logged-in sessions except the current one."""
149
+ return await self.execute("terminateUserSessions", {})
150
+
151
+ async def action_on_unconfirmed_session(
152
+ self, session_key: str, action: str = "Accept"
153
+ ) -> Dict[str, Any]:
154
+ """Accepts or rejects an unconfirmed session login attempt."""
155
+ return await self.execute(
156
+ "actionOnUnconfirmedSession",
157
+ {"session_key": session_key, "action": action},
158
+ )