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 +34 -0
- rubiflow/client.py +203 -0
- rubiflow/crypto.py +169 -0
- rubiflow/exceptions.py +64 -0
- rubiflow/methods/__init__.py +52 -0
- rubiflow/methods/auth.py +158 -0
- rubiflow/methods/channels.py +167 -0
- rubiflow/methods/chats.py +81 -0
- rubiflow/methods/files.py +100 -0
- rubiflow/methods/folders.py +62 -0
- rubiflow/methods/groups.py +176 -0
- rubiflow/methods/messages.py +313 -0
- rubiflow/methods/rubino.py +255 -0
- rubiflow/methods/settings.py +62 -0
- rubiflow/methods/users.py +110 -0
- rubiflow/methods/vod.py +59 -0
- rubiflow/methods/wallet.py +51 -0
- rubiflow/models/__init__.py +9 -0
- rubiflow/models/chat.py +42 -0
- rubiflow/models/message.py +68 -0
- rubiflow/models/user.py +50 -0
- rubiflow/network.py +50 -0
- rubiflow/session.py +162 -0
- rubiflow-1.0.0.dist-info/METADATA +136 -0
- rubiflow-1.0.0.dist-info/RECORD +27 -0
- rubiflow-1.0.0.dist-info/WHEEL +5 -0
- rubiflow-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
rubiflow/methods/auth.py
ADDED
|
@@ -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
|
+
)
|