pararamio-aio 3.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.
- pararamio_aio/__init__.py +26 -0
- pararamio_aio/_core/__init__.py +125 -0
- pararamio_aio/_core/_types.py +120 -0
- pararamio_aio/_core/base.py +143 -0
- pararamio_aio/_core/client_protocol.py +90 -0
- pararamio_aio/_core/constants/__init__.py +7 -0
- pararamio_aio/_core/constants/base.py +9 -0
- pararamio_aio/_core/constants/endpoints.py +84 -0
- pararamio_aio/_core/cookie_decorator.py +208 -0
- pararamio_aio/_core/cookie_manager.py +1222 -0
- pararamio_aio/_core/endpoints.py +67 -0
- pararamio_aio/_core/exceptions/__init__.py +6 -0
- pararamio_aio/_core/exceptions/auth.py +91 -0
- pararamio_aio/_core/exceptions/base.py +124 -0
- pararamio_aio/_core/models/__init__.py +17 -0
- pararamio_aio/_core/models/base.py +66 -0
- pararamio_aio/_core/models/chat.py +92 -0
- pararamio_aio/_core/models/post.py +65 -0
- pararamio_aio/_core/models/user.py +54 -0
- pararamio_aio/_core/py.typed +2 -0
- pararamio_aio/_core/utils/__init__.py +73 -0
- pararamio_aio/_core/utils/async_requests.py +417 -0
- pararamio_aio/_core/utils/auth_flow.py +202 -0
- pararamio_aio/_core/utils/authentication.py +235 -0
- pararamio_aio/_core/utils/captcha.py +92 -0
- pararamio_aio/_core/utils/helpers.py +336 -0
- pararamio_aio/_core/utils/http_client.py +199 -0
- pararamio_aio/_core/utils/requests.py +424 -0
- pararamio_aio/_core/validators.py +78 -0
- pararamio_aio/_types.py +29 -0
- pararamio_aio/client.py +989 -0
- pararamio_aio/constants/__init__.py +16 -0
- pararamio_aio/exceptions/__init__.py +31 -0
- pararamio_aio/exceptions/base.py +1 -0
- pararamio_aio/file_operations.py +232 -0
- pararamio_aio/models/__init__.py +31 -0
- pararamio_aio/models/activity.py +127 -0
- pararamio_aio/models/attachment.py +141 -0
- pararamio_aio/models/base.py +83 -0
- pararamio_aio/models/bot.py +274 -0
- pararamio_aio/models/chat.py +722 -0
- pararamio_aio/models/deferred_post.py +174 -0
- pararamio_aio/models/file.py +103 -0
- pararamio_aio/models/group.py +361 -0
- pararamio_aio/models/poll.py +275 -0
- pararamio_aio/models/post.py +643 -0
- pararamio_aio/models/team.py +403 -0
- pararamio_aio/models/user.py +239 -0
- pararamio_aio/py.typed +2 -0
- pararamio_aio/utils/__init__.py +18 -0
- pararamio_aio/utils/authentication.py +383 -0
- pararamio_aio/utils/requests.py +75 -0
- pararamio_aio-3.0.0.dist-info/METADATA +269 -0
- pararamio_aio-3.0.0.dist-info/RECORD +56 -0
- pararamio_aio-3.0.0.dist-info/WHEEL +5 -0
- pararamio_aio-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,417 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import json
|
5
|
+
import logging
|
6
|
+
import mimetypes
|
7
|
+
from io import BytesIO
|
8
|
+
from json import JSONDecodeError
|
9
|
+
from pathlib import Path
|
10
|
+
from typing import TYPE_CHECKING, Any, BinaryIO
|
11
|
+
from urllib.parse import quote
|
12
|
+
|
13
|
+
import aiohttp
|
14
|
+
from aiohttp import FormData
|
15
|
+
|
16
|
+
from ..constants import (
|
17
|
+
BASE_API_URL,
|
18
|
+
FILE_UPLOAD_URL,
|
19
|
+
UPLOAD_TIMEOUT,
|
20
|
+
VERSION,
|
21
|
+
)
|
22
|
+
from ..constants import (
|
23
|
+
REQUEST_TIMEOUT as TIMEOUT,
|
24
|
+
)
|
25
|
+
from ..exceptions import PararamioHTTPRequestException
|
26
|
+
|
27
|
+
if TYPE_CHECKING:
|
28
|
+
from .._types import HeaderLikeT
|
29
|
+
|
30
|
+
__all__ = (
|
31
|
+
'api_request',
|
32
|
+
'bot_request',
|
33
|
+
'delete_file',
|
34
|
+
'download_file',
|
35
|
+
'raw_api_request',
|
36
|
+
'upload_file',
|
37
|
+
'xupload_file',
|
38
|
+
)
|
39
|
+
log = logging.getLogger('pararamio')
|
40
|
+
UA_HEADER = f'pararamio lib version {VERSION}'
|
41
|
+
DEFAULT_HEADERS = {
|
42
|
+
'Content-type': 'application/json',
|
43
|
+
'Accept': 'application/json',
|
44
|
+
'User-agent': UA_HEADER,
|
45
|
+
}
|
46
|
+
|
47
|
+
|
48
|
+
async def bot_request(
|
49
|
+
session: aiohttp.ClientSession,
|
50
|
+
url: str,
|
51
|
+
key: str,
|
52
|
+
method: str = 'GET',
|
53
|
+
data: dict | None = None,
|
54
|
+
headers: dict | None = None,
|
55
|
+
timeout: int = TIMEOUT,
|
56
|
+
):
|
57
|
+
"""
|
58
|
+
Sends an async request to a bot API endpoint with the specified parameters.
|
59
|
+
|
60
|
+
Parameters:
|
61
|
+
session (aiohttp.ClientSession): The aiohttp session to use for the request.
|
62
|
+
url (str): The endpoint URL of the bot API.
|
63
|
+
key (str): The API token for authentication.
|
64
|
+
method (str): The HTTP method to use for the request. Defaults to 'GET'.
|
65
|
+
data (Optional[dict]): The data payload for the request. Defaults to None.
|
66
|
+
headers (Optional[dict]): Additional headers to include in the request. Defaults to None.
|
67
|
+
timeout (int): The timeout setting for the request. Defaults to TIMEOUT.
|
68
|
+
|
69
|
+
Returns:
|
70
|
+
Response object from the API request.
|
71
|
+
"""
|
72
|
+
_headers = {'X-APIToken': key, **DEFAULT_HEADERS}
|
73
|
+
if headers:
|
74
|
+
_headers = {**_headers, **headers}
|
75
|
+
return await api_request(
|
76
|
+
session=session, url=url, method=method, data=data, headers=_headers, timeout=timeout
|
77
|
+
)
|
78
|
+
|
79
|
+
|
80
|
+
async def _base_request(
|
81
|
+
session: aiohttp.ClientSession,
|
82
|
+
url: str,
|
83
|
+
method: str = 'GET',
|
84
|
+
data: bytes | None = None,
|
85
|
+
headers: dict | None = None,
|
86
|
+
timeout: int = TIMEOUT,
|
87
|
+
) -> aiohttp.ClientResponse:
|
88
|
+
"""
|
89
|
+
Sends an async HTTP request and returns a ClientResponse object.
|
90
|
+
|
91
|
+
Parameters:
|
92
|
+
session (aiohttp.ClientSession): The aiohttp session to use for the request.
|
93
|
+
url (str): The URL endpoint to which the request is sent.
|
94
|
+
method (str): The HTTP method to use for the request (default is 'GET').
|
95
|
+
data (Optional[bytes]): The payload to include in the request body (default is None).
|
96
|
+
headers (Optional[dict]): A dictionary of headers to include in the request
|
97
|
+
(default is None).
|
98
|
+
timeout (int): The timeout for the request in seconds (TIMEOUT defines default).
|
99
|
+
|
100
|
+
Returns:
|
101
|
+
aiohttp.ClientResponse: The response object containing the server's response to the HTTP
|
102
|
+
request.
|
103
|
+
|
104
|
+
Raises:
|
105
|
+
PararamioHTTPRequestException: An exception is raised if there is
|
106
|
+
an issue with the HTTP request.
|
107
|
+
"""
|
108
|
+
_url = f'{BASE_API_URL}{url}'
|
109
|
+
_headers = DEFAULT_HEADERS.copy()
|
110
|
+
if headers:
|
111
|
+
_headers.update(headers)
|
112
|
+
|
113
|
+
return await _make_request(session, _url, method, data, _headers, timeout)
|
114
|
+
|
115
|
+
|
116
|
+
async def _base_file_request(
|
117
|
+
session: aiohttp.ClientSession,
|
118
|
+
url: str,
|
119
|
+
method='GET',
|
120
|
+
data: bytes | FormData | None = None,
|
121
|
+
headers: HeaderLikeT | None = None,
|
122
|
+
timeout: int = TIMEOUT,
|
123
|
+
) -> aiohttp.ClientResponse:
|
124
|
+
"""
|
125
|
+
Performs an async file request to the specified URL with the given parameters.
|
126
|
+
|
127
|
+
Arguments:
|
128
|
+
session (aiohttp.ClientSession): The aiohttp session to use for the request.
|
129
|
+
url (str): The URL endpoint to send the request to.
|
130
|
+
method (str, optional): The HTTP method to use for the request (default is 'GET').
|
131
|
+
data (Optional[Union[bytes, FormData]], optional): The data to send in the request body
|
132
|
+
(default is None).
|
133
|
+
headers (Optional[HeaderLikeT], optional): The headers to include in the request
|
134
|
+
(default is None).
|
135
|
+
timeout (int, optional): The timeout duration for the request (default value is TIMEOUT).
|
136
|
+
|
137
|
+
Returns:
|
138
|
+
aiohttp.ClientResponse: The response object from the file request.
|
139
|
+
|
140
|
+
Raises:
|
141
|
+
PararamioHTTPRequestException: If the request fails with an HTTP error code.
|
142
|
+
"""
|
143
|
+
_url = f'{FILE_UPLOAD_URL}{url}'
|
144
|
+
_headers = headers or {}
|
145
|
+
|
146
|
+
return await _make_request(session, _url, method, data, _headers, timeout)
|
147
|
+
|
148
|
+
|
149
|
+
async def _read_json_response(response: aiohttp.ClientResponse) -> dict:
|
150
|
+
"""Read response content and parse as JSON."""
|
151
|
+
content = await response.read()
|
152
|
+
return json.loads(content)
|
153
|
+
|
154
|
+
|
155
|
+
async def _upload_with_form_data(
|
156
|
+
session: aiohttp.ClientSession,
|
157
|
+
url: str,
|
158
|
+
data: FormData,
|
159
|
+
headers: HeaderLikeT | None = None,
|
160
|
+
timeout: int = TIMEOUT,
|
161
|
+
) -> dict:
|
162
|
+
"""Upload form data and return JSON response."""
|
163
|
+
_headers = {
|
164
|
+
'User-agent': UA_HEADER,
|
165
|
+
'Accept': 'application/json',
|
166
|
+
**(headers or {}),
|
167
|
+
}
|
168
|
+
|
169
|
+
response = await _base_file_request(
|
170
|
+
session,
|
171
|
+
url,
|
172
|
+
method='POST',
|
173
|
+
data=data,
|
174
|
+
headers=_headers,
|
175
|
+
timeout=timeout,
|
176
|
+
)
|
177
|
+
|
178
|
+
return await _read_json_response(response)
|
179
|
+
|
180
|
+
|
181
|
+
async def upload_file(
|
182
|
+
session: aiohttp.ClientSession,
|
183
|
+
fp: BinaryIO,
|
184
|
+
perm: str,
|
185
|
+
filename: str | None = None,
|
186
|
+
file_type=None,
|
187
|
+
headers: HeaderLikeT | None = None,
|
188
|
+
timeout: int = UPLOAD_TIMEOUT,
|
189
|
+
):
|
190
|
+
"""
|
191
|
+
Upload a file to a pararam server asynchronously with specified permissions and optional
|
192
|
+
parameters.
|
193
|
+
|
194
|
+
Args:
|
195
|
+
session (aiohttp.ClientSession): The aiohttp session to use for the request.
|
196
|
+
fp (BinaryIO): A file-like object to be uploaded.
|
197
|
+
perm (str): The permission level for the uploaded file.
|
198
|
+
filename (Optional[str], optional): Optional filename used during upload. Defaults to None.
|
199
|
+
file_type (optional): Optional MIME type of the file. Defaults to None.
|
200
|
+
headers (Optional[HeaderLikeT], optional): Optional headers to include in the request.
|
201
|
+
Defaults to None.
|
202
|
+
timeout (int, optional): Timeout duration for the upload request.
|
203
|
+
Defaults to UPLOAD_TIMEOUT.
|
204
|
+
|
205
|
+
Returns:
|
206
|
+
dict: A dictionary containing the server's response to the file upload.
|
207
|
+
"""
|
208
|
+
url = f'/upload/{perm}'
|
209
|
+
|
210
|
+
if not filename:
|
211
|
+
filename = Path(getattr(fp, 'name', 'file')).name
|
212
|
+
if not file_type:
|
213
|
+
file_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
214
|
+
|
215
|
+
data = FormData()
|
216
|
+
fp.seek(0)
|
217
|
+
data.add_field('file', fp, filename=filename, content_type=file_type)
|
218
|
+
|
219
|
+
return await _upload_with_form_data(session, url, data, headers, timeout)
|
220
|
+
|
221
|
+
|
222
|
+
async def xupload_file(
|
223
|
+
session: aiohttp.ClientSession,
|
224
|
+
fp: BinaryIO,
|
225
|
+
fields: list[tuple[str, str | None | int]],
|
226
|
+
filename: str | None = None,
|
227
|
+
content_type: str | None = None,
|
228
|
+
headers: HeaderLikeT | None = None,
|
229
|
+
timeout: int = UPLOAD_TIMEOUT,
|
230
|
+
) -> dict:
|
231
|
+
"""
|
232
|
+
Uploads a file asynchronously to a predefined URL using a multipart/form-data request.
|
233
|
+
|
234
|
+
Arguments:
|
235
|
+
- session: The aiohttp session to use for the request.
|
236
|
+
- fp: A binary file-like object to upload.
|
237
|
+
- fields: A list of tuples where each tuple contains a field name and a value which can be
|
238
|
+
a string, None, or an integer.
|
239
|
+
- filename: Optional; The name of the file being uploaded.
|
240
|
+
If not provided, it defaults to None.
|
241
|
+
- content_type: Optional; The MIME type of the file being uploaded.
|
242
|
+
If not provided, it defaults to None.
|
243
|
+
- headers: Optional; Additional headers to include in the upload request.
|
244
|
+
If not provided, defaults to None.
|
245
|
+
- timeout: Optional; The timeout in seconds for the request.
|
246
|
+
Defaults to UPLOAD_TIMEOUT.
|
247
|
+
|
248
|
+
Returns:
|
249
|
+
- A dictionary parsed from the JSON response of the upload request.
|
250
|
+
"""
|
251
|
+
url = '/upload'
|
252
|
+
|
253
|
+
if not filename:
|
254
|
+
filename = Path(getattr(fp, 'name', 'file')).name
|
255
|
+
if not content_type:
|
256
|
+
content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
257
|
+
|
258
|
+
data = FormData()
|
259
|
+
|
260
|
+
# Add form fields
|
261
|
+
for key, value in fields:
|
262
|
+
if value is not None:
|
263
|
+
data.add_field(key, str(value))
|
264
|
+
|
265
|
+
# Add file
|
266
|
+
fp.seek(0)
|
267
|
+
data.add_field('data', fp, filename=filename, content_type=content_type)
|
268
|
+
|
269
|
+
return await _upload_with_form_data(session, url, data, headers, timeout)
|
270
|
+
|
271
|
+
|
272
|
+
async def delete_file(
|
273
|
+
session: aiohttp.ClientSession,
|
274
|
+
guid: str,
|
275
|
+
headers: HeaderLikeT | None = None,
|
276
|
+
timeout: int = TIMEOUT,
|
277
|
+
) -> dict:
|
278
|
+
url = f'/delete/{guid}'
|
279
|
+
response = await _base_file_request(
|
280
|
+
session, url, method='DELETE', headers=headers, timeout=timeout
|
281
|
+
)
|
282
|
+
return await _read_json_response(response)
|
283
|
+
|
284
|
+
|
285
|
+
async def download_file(
|
286
|
+
session: aiohttp.ClientSession,
|
287
|
+
guid: str,
|
288
|
+
filename: str,
|
289
|
+
headers: HeaderLikeT | None = None,
|
290
|
+
timeout: int = TIMEOUT,
|
291
|
+
) -> BytesIO:
|
292
|
+
url = f'/download/{guid}/{quote(filename)}'
|
293
|
+
response = await file_request(session, url, method='GET', headers=headers, timeout=timeout)
|
294
|
+
content = await response.read()
|
295
|
+
return BytesIO(content)
|
296
|
+
|
297
|
+
|
298
|
+
async def file_request(
|
299
|
+
session: aiohttp.ClientSession,
|
300
|
+
url: str,
|
301
|
+
method='GET',
|
302
|
+
data: bytes | None = None,
|
303
|
+
headers: HeaderLikeT | None = None,
|
304
|
+
timeout: int = TIMEOUT,
|
305
|
+
) -> aiohttp.ClientResponse:
|
306
|
+
_headers = DEFAULT_HEADERS.copy()
|
307
|
+
if headers:
|
308
|
+
_headers.update(headers)
|
309
|
+
return await _base_file_request(
|
310
|
+
session,
|
311
|
+
url,
|
312
|
+
method=method,
|
313
|
+
data=data,
|
314
|
+
headers=_headers,
|
315
|
+
timeout=timeout,
|
316
|
+
)
|
317
|
+
|
318
|
+
|
319
|
+
async def _make_request(
|
320
|
+
session: aiohttp.ClientSession,
|
321
|
+
url: str,
|
322
|
+
method: str = 'GET',
|
323
|
+
data: Any = None,
|
324
|
+
headers: HeaderLikeT | None = None,
|
325
|
+
timeout: int = TIMEOUT,
|
326
|
+
) -> aiohttp.ClientResponse:
|
327
|
+
"""
|
328
|
+
Common request handler for both API and file requests.
|
329
|
+
|
330
|
+
Args:
|
331
|
+
session: The aiohttp session to use for the request
|
332
|
+
url: The full URL to send the request to
|
333
|
+
method: The HTTP method to use
|
334
|
+
data: The data to send in the request body
|
335
|
+
headers: Headers to include in the request
|
336
|
+
timeout: Request timeout in seconds
|
337
|
+
|
338
|
+
Returns:
|
339
|
+
The response object from the request
|
340
|
+
|
341
|
+
Raises:
|
342
|
+
PararamioHTTPRequestException: If the request fails
|
343
|
+
"""
|
344
|
+
log.debug('%s - %s - %s - %s', url, method, data, headers)
|
345
|
+
|
346
|
+
try:
|
347
|
+
timeout_obj = aiohttp.ClientTimeout(total=timeout)
|
348
|
+
async with session.request(
|
349
|
+
method, url, data=data, headers=headers or {}, timeout=timeout_obj
|
350
|
+
) as response:
|
351
|
+
if response.status >= 400:
|
352
|
+
content = await response.read()
|
353
|
+
headers_list = list(response.headers.items())
|
354
|
+
raise PararamioHTTPRequestException(
|
355
|
+
url,
|
356
|
+
response.status,
|
357
|
+
response.reason or 'Unknown error',
|
358
|
+
headers_list,
|
359
|
+
BytesIO(content),
|
360
|
+
)
|
361
|
+
return response
|
362
|
+
except asyncio.TimeoutError as e:
|
363
|
+
log.exception('%s - %s - timeout', url, method)
|
364
|
+
raise PararamioHTTPRequestException(url, 408, 'Request Timeout', [], BytesIO()) from e
|
365
|
+
except aiohttp.ClientError as e:
|
366
|
+
log.exception('%s - %s', url, method)
|
367
|
+
raise PararamioHTTPRequestException(url, 0, str(e), [], BytesIO()) from e
|
368
|
+
|
369
|
+
|
370
|
+
async def raw_api_request(
|
371
|
+
session: aiohttp.ClientSession,
|
372
|
+
url: str,
|
373
|
+
method: str = 'GET',
|
374
|
+
data: bytes | None = None,
|
375
|
+
headers: HeaderLikeT | None = None,
|
376
|
+
timeout: int = TIMEOUT,
|
377
|
+
) -> tuple[dict, list]:
|
378
|
+
response = await _base_request(session, url, method, data, headers, timeout)
|
379
|
+
if 200 <= response.status < 300:
|
380
|
+
content = await response.read()
|
381
|
+
headers_list = list(response.headers.items())
|
382
|
+
return json.loads(content), headers_list
|
383
|
+
return {}, []
|
384
|
+
|
385
|
+
|
386
|
+
async def api_request(
|
387
|
+
session: aiohttp.ClientSession,
|
388
|
+
url: str,
|
389
|
+
method: str = 'GET',
|
390
|
+
data: dict | None = None,
|
391
|
+
headers: HeaderLikeT | None = None,
|
392
|
+
timeout: int = TIMEOUT,
|
393
|
+
) -> dict:
|
394
|
+
_data = None
|
395
|
+
if data is not None:
|
396
|
+
_data = json.dumps(data).encode('utf-8')
|
397
|
+
|
398
|
+
response = await _base_request(session, url, method, _data, headers, timeout)
|
399
|
+
|
400
|
+
if response.status == 204:
|
401
|
+
return {}
|
402
|
+
|
403
|
+
if 200 <= response.status < 500:
|
404
|
+
content = await response.read()
|
405
|
+
try:
|
406
|
+
return json.loads(content)
|
407
|
+
except JSONDecodeError as e:
|
408
|
+
log.exception('%s - %s', url, method)
|
409
|
+
headers_list = list(response.headers.items())
|
410
|
+
raise PararamioHTTPRequestException(
|
411
|
+
url,
|
412
|
+
response.status,
|
413
|
+
'JSONDecodeError',
|
414
|
+
headers_list,
|
415
|
+
BytesIO(content),
|
416
|
+
) from e
|
417
|
+
return {}
|
@@ -0,0 +1,202 @@
|
|
1
|
+
"""Common authentication flow logic for sync and async clients."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import base64
|
6
|
+
import binascii
|
7
|
+
import contextlib
|
8
|
+
import hashlib
|
9
|
+
import hmac
|
10
|
+
import time
|
11
|
+
from datetime import datetime
|
12
|
+
|
13
|
+
__all__ = (
|
14
|
+
'AuthenticationFlow',
|
15
|
+
'AuthenticationResult',
|
16
|
+
'generate_otp',
|
17
|
+
)
|
18
|
+
|
19
|
+
|
20
|
+
def generate_otp(key: str) -> str:
|
21
|
+
"""Generate one-time password from TOTP key.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
key: TOTP secret key
|
25
|
+
|
26
|
+
Returns:
|
27
|
+
6-digit OTP code
|
28
|
+
|
29
|
+
Raises:
|
30
|
+
binascii.Error: If key is invalid
|
31
|
+
"""
|
32
|
+
digits = 6
|
33
|
+
digest = hashlib.sha1
|
34
|
+
interval = 30
|
35
|
+
|
36
|
+
def byte_secret(s):
|
37
|
+
missing_padding = len(s) % 8
|
38
|
+
if missing_padding != 0:
|
39
|
+
s += '=' * (8 - missing_padding)
|
40
|
+
return base64.b32decode(s, casefold=True)
|
41
|
+
|
42
|
+
def int_to_byte_string(i, padding=8):
|
43
|
+
result = bytearray()
|
44
|
+
while i != 0:
|
45
|
+
result.append(i & 0xFF)
|
46
|
+
i >>= 8
|
47
|
+
return bytes(bytearray(reversed(result)).rjust(padding, b'\0'))
|
48
|
+
|
49
|
+
def time_code(for_time):
|
50
|
+
i = time.mktime(for_time.timetuple())
|
51
|
+
return int(i / interval)
|
52
|
+
|
53
|
+
hmac_hash = hmac.new(
|
54
|
+
byte_secret(key),
|
55
|
+
int_to_byte_string(time_code(datetime.now())), # noqa: DTZ005
|
56
|
+
digest,
|
57
|
+
).digest()
|
58
|
+
|
59
|
+
hmac_hash = bytearray(hmac_hash)
|
60
|
+
offset = hmac_hash[-1] & 0xF
|
61
|
+
code = (
|
62
|
+
(hmac_hash[offset] & 0x7F) << 24
|
63
|
+
| (hmac_hash[offset + 1] & 0xFF) << 16
|
64
|
+
| (hmac_hash[offset + 2] & 0xFF) << 8
|
65
|
+
| (hmac_hash[offset + 3] & 0xFF)
|
66
|
+
)
|
67
|
+
str_code = str(code % 10**digits)
|
68
|
+
while len(str_code) < digits:
|
69
|
+
str_code = '0' + str_code
|
70
|
+
|
71
|
+
return str_code
|
72
|
+
|
73
|
+
|
74
|
+
class AuthenticationResult:
|
75
|
+
"""Result of authentication attempt."""
|
76
|
+
|
77
|
+
def __init__( # pylint: disable=too-many-positional-arguments
|
78
|
+
self,
|
79
|
+
success: bool,
|
80
|
+
xsrf_token: str | None = None,
|
81
|
+
user_id: int | None = None,
|
82
|
+
error: str | None = None,
|
83
|
+
error_type: str | None = None,
|
84
|
+
requires_captcha: bool = False,
|
85
|
+
requires_totp: bool = False,
|
86
|
+
):
|
87
|
+
self.success = success
|
88
|
+
self.xsrf_token = xsrf_token
|
89
|
+
self.user_id = user_id
|
90
|
+
self.error = error
|
91
|
+
self.error_type = error_type
|
92
|
+
self.requires_captcha = requires_captcha
|
93
|
+
self.requires_totp = requires_totp
|
94
|
+
|
95
|
+
|
96
|
+
class AuthenticationFlow:
|
97
|
+
"""Common authentication flow logic.
|
98
|
+
|
99
|
+
This class provides the core authentication flow that can be used
|
100
|
+
by both sync and async implementations.
|
101
|
+
|
102
|
+
Rate limits for login attempts:
|
103
|
+
- 3 attempts per minute
|
104
|
+
- 10 attempts per 30 minutes
|
105
|
+
"""
|
106
|
+
|
107
|
+
@staticmethod
|
108
|
+
def prepare_login_data(email: str, password: str) -> dict[str, str]:
|
109
|
+
"""Prepare login data payload.
|
110
|
+
|
111
|
+
Args:
|
112
|
+
email: User email
|
113
|
+
password: User password
|
114
|
+
|
115
|
+
Returns:
|
116
|
+
Dict with login data
|
117
|
+
"""
|
118
|
+
return {'email': email, 'password': password}
|
119
|
+
|
120
|
+
@staticmethod
|
121
|
+
def prepare_totp_data(code: str) -> dict[str, str]:
|
122
|
+
"""Prepare TOTP data payload.
|
123
|
+
|
124
|
+
Args:
|
125
|
+
code: 6-digit TOTP code or key
|
126
|
+
|
127
|
+
Returns:
|
128
|
+
Dict with TOTP data
|
129
|
+
"""
|
130
|
+
# If it's longer than 6 chars, it's likely a key
|
131
|
+
if len(code) > 6:
|
132
|
+
with contextlib.suppress(ValueError, binascii.Error):
|
133
|
+
code = generate_otp(code)
|
134
|
+
|
135
|
+
return {'code': code}
|
136
|
+
|
137
|
+
@staticmethod
|
138
|
+
def parse_error_response(response: dict[str, any]) -> tuple[str, str]:
|
139
|
+
"""Parse error from API response.
|
140
|
+
|
141
|
+
Args:
|
142
|
+
response: API response dict
|
143
|
+
|
144
|
+
Returns:
|
145
|
+
Tuple of (error_type, error_message)
|
146
|
+
"""
|
147
|
+
# Check for various error formats
|
148
|
+
if 'error' in response:
|
149
|
+
return 'error', response['error']
|
150
|
+
|
151
|
+
if 'codes' in response:
|
152
|
+
codes = response['codes']
|
153
|
+
if isinstance(codes, dict):
|
154
|
+
# Check for specific error codes
|
155
|
+
if codes.get('non_field') == 'captcha_required':
|
156
|
+
return 'captcha_required', 'Captcha required'
|
157
|
+
# Return first error found
|
158
|
+
for field, error in codes.items():
|
159
|
+
if error:
|
160
|
+
return field, error
|
161
|
+
|
162
|
+
if 'message' in response:
|
163
|
+
return 'message', response['message']
|
164
|
+
|
165
|
+
return 'unknown', 'Unknown error'
|
166
|
+
|
167
|
+
@staticmethod
|
168
|
+
def should_retry_with_new_xsrf(error_type: str, error_message: str) -> bool: # noqa: ARG004 # pylint: disable=unused-argument
|
169
|
+
"""Check if we should retry with a new XSRF token.
|
170
|
+
|
171
|
+
Args:
|
172
|
+
error_type: Type of error
|
173
|
+
error_message: Error message
|
174
|
+
|
175
|
+
Returns:
|
176
|
+
True if should retry
|
177
|
+
"""
|
178
|
+
# Common XSRF-related errors
|
179
|
+
xsrf_errors = ['xsrf', 'csrf', 'token']
|
180
|
+
error_lower = error_message.lower()
|
181
|
+
|
182
|
+
return any(err in error_lower for err in xsrf_errors)
|
183
|
+
|
184
|
+
@staticmethod
|
185
|
+
def parse_rate_limit_info(headers: dict[str, str]) -> int | None:
|
186
|
+
"""Parse rate limit information from response headers.
|
187
|
+
|
188
|
+
Args:
|
189
|
+
headers: Response headers
|
190
|
+
|
191
|
+
Returns:
|
192
|
+
Retry-after seconds if rate limited, None otherwise
|
193
|
+
"""
|
194
|
+
retry_after = headers.get('Retry-After', headers.get('retry-after'))
|
195
|
+
if retry_after:
|
196
|
+
try:
|
197
|
+
return int(retry_after)
|
198
|
+
except ValueError:
|
199
|
+
# Default to 60 seconds if can't parse
|
200
|
+
return 60
|
201
|
+
# Default retry after for rate limits
|
202
|
+
return 60
|