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
pararamio_aio/client.py
ADDED
@@ -0,0 +1,989 @@
|
|
1
|
+
"""Async client for Pararamio API."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import datetime
|
6
|
+
import json
|
7
|
+
import logging
|
8
|
+
import os
|
9
|
+
from http.cookiejar import Cookie
|
10
|
+
from http.cookies import SimpleCookie
|
11
|
+
from io import BytesIO
|
12
|
+
from typing import (
|
13
|
+
Any,
|
14
|
+
BinaryIO,
|
15
|
+
Dict,
|
16
|
+
Sequence,
|
17
|
+
)
|
18
|
+
from urllib.parse import quote, quote_plus
|
19
|
+
|
20
|
+
import aiofiles
|
21
|
+
import aiohttp
|
22
|
+
|
23
|
+
# Import from core
|
24
|
+
from pararamio_aio._core import (
|
25
|
+
XSRF_HEADER_NAME,
|
26
|
+
AsyncCookieManager,
|
27
|
+
AsyncInMemoryCookieManager,
|
28
|
+
PararamioAuthenticationException,
|
29
|
+
PararamioHTTPRequestException,
|
30
|
+
PararamioValidationException,
|
31
|
+
)
|
32
|
+
from yarl import URL
|
33
|
+
|
34
|
+
from .file_operations import async_delete_file, async_download_file, async_xupload_file
|
35
|
+
from .models import Chat, File, Group, Post, User
|
36
|
+
from .utils import async_authenticate, async_do_second_step_with_code, get_async_xsrf_token
|
37
|
+
|
38
|
+
ProfileTypeT = Dict[str, Any]
|
39
|
+
|
40
|
+
__all__ = ("AsyncPararamio",)
|
41
|
+
|
42
|
+
log = logging.getLogger("pararamio_aio.client")
|
43
|
+
|
44
|
+
|
45
|
+
class AsyncPararamio:
|
46
|
+
"""Async Pararamio client class.
|
47
|
+
|
48
|
+
This class provides an async client interface for interacting with the Pararamio API.
|
49
|
+
Unlike the sync version, this client uses explicit loading instead of lazy loading.
|
50
|
+
"""
|
51
|
+
|
52
|
+
def __init__(
|
53
|
+
self,
|
54
|
+
login: str | None = None,
|
55
|
+
password: str | None = None,
|
56
|
+
key: str | None = None,
|
57
|
+
cookie_manager: AsyncCookieManager | None = None,
|
58
|
+
session: aiohttp.ClientSession | None = None,
|
59
|
+
wait_auth_limit: bool = False,
|
60
|
+
):
|
61
|
+
"""Initialize async Pararamio client.
|
62
|
+
|
63
|
+
Args:
|
64
|
+
login: Optional string for the login name
|
65
|
+
password: Optional string for the password
|
66
|
+
key: Optional string for an authentication key
|
67
|
+
cookie_manager: Optional AsyncCookieManager instance for cookie persistence
|
68
|
+
session: Optional aiohttp.ClientSession to use
|
69
|
+
wait_auth_limit: Boolean flag to wait for rate limits instead of raising
|
70
|
+
exception (default False)
|
71
|
+
"""
|
72
|
+
self._login = login
|
73
|
+
self._password = password
|
74
|
+
self._key = key
|
75
|
+
self._wait_auth_limit = wait_auth_limit
|
76
|
+
self._authenticated = False
|
77
|
+
self._session = session
|
78
|
+
self._cookie_jar = aiohttp.CookieJar()
|
79
|
+
self._cookie_manager = (
|
80
|
+
cookie_manager if cookie_manager is not None else AsyncInMemoryCookieManager()
|
81
|
+
)
|
82
|
+
self._headers: dict[str, str] = {}
|
83
|
+
self._profile: ProfileTypeT | None = None
|
84
|
+
|
85
|
+
def _create_simple_cookie(self, cookie) -> tuple[SimpleCookie, URL]:
|
86
|
+
"""Create SimpleCookie from cookie object.
|
87
|
+
|
88
|
+
Returns:
|
89
|
+
Tuple of (SimpleCookie, URL for the cookie domain)
|
90
|
+
"""
|
91
|
+
# Create proper URL for the cookie domain
|
92
|
+
domain = cookie.domain
|
93
|
+
# Remove leading dot for URL creation
|
94
|
+
if domain.startswith("."):
|
95
|
+
url_domain = domain[1:]
|
96
|
+
else:
|
97
|
+
url_domain = domain
|
98
|
+
if not url_domain.startswith("http"):
|
99
|
+
url_domain = f"https://{url_domain}"
|
100
|
+
url = URL(url_domain).with_path(cookie.path)
|
101
|
+
|
102
|
+
# Create SimpleCookie with all attributes preserved
|
103
|
+
simple_cookie = SimpleCookie()
|
104
|
+
# Remove quotes from cookie value if present
|
105
|
+
cookie_value = cookie.value
|
106
|
+
if cookie_value.startswith('"') and cookie_value.endswith('"'):
|
107
|
+
cookie_value = cookie_value[1:-1]
|
108
|
+
simple_cookie[cookie.name] = cookie_value
|
109
|
+
|
110
|
+
# Set all cookie attributes
|
111
|
+
if cookie.domain:
|
112
|
+
simple_cookie[cookie.name]["domain"] = cookie.domain
|
113
|
+
if cookie.path:
|
114
|
+
simple_cookie[cookie.name]["path"] = cookie.path
|
115
|
+
if cookie.secure:
|
116
|
+
simple_cookie[cookie.name]["secure"] = True
|
117
|
+
if cookie.expires is not None:
|
118
|
+
# Convert expires timestamp to formatted string
|
119
|
+
expires_dt = datetime.datetime.fromtimestamp(cookie.expires, tz=datetime.timezone.utc)
|
120
|
+
simple_cookie[cookie.name]["expires"] = expires_dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
121
|
+
|
122
|
+
return simple_cookie, url
|
123
|
+
|
124
|
+
async def _load_cookies_to_session(self) -> None:
|
125
|
+
"""Load cookies from cookie manager to session."""
|
126
|
+
cookies = self._cookie_manager.get_all_cookies()
|
127
|
+
if not cookies:
|
128
|
+
# Try to load if no cookies yet
|
129
|
+
await self._cookie_manager.load_cookies()
|
130
|
+
cookies = self._cookie_manager.get_all_cookies()
|
131
|
+
|
132
|
+
if cookies:
|
133
|
+
# Convert cookies to aiohttp format
|
134
|
+
for cookie in cookies:
|
135
|
+
# Skip cookies with empty or None value
|
136
|
+
if not cookie.value:
|
137
|
+
continue
|
138
|
+
|
139
|
+
simple_cookie, url = self._create_simple_cookie(cookie)
|
140
|
+
|
141
|
+
# Update session cookies
|
142
|
+
if self._session:
|
143
|
+
self._session.cookie_jar.update_cookies(simple_cookie, url)
|
144
|
+
|
145
|
+
async def _ensure_session(self) -> None:
|
146
|
+
"""Ensure session is created."""
|
147
|
+
if self._session is None:
|
148
|
+
connector = aiohttp.TCPConnector(limit=30, limit_per_host=10)
|
149
|
+
self._session = aiohttp.ClientSession(
|
150
|
+
connector=connector,
|
151
|
+
cookie_jar=self._cookie_jar,
|
152
|
+
timeout=aiohttp.ClientTimeout(total=30),
|
153
|
+
)
|
154
|
+
|
155
|
+
async def _check_xsrf_token(self) -> None:
|
156
|
+
"""Check for XSRF token in cookies and set authentication status."""
|
157
|
+
cookies = self._cookie_manager.get_all_cookies()
|
158
|
+
for cookie in cookies:
|
159
|
+
if cookie.name == "_xsrf":
|
160
|
+
self._headers[XSRF_HEADER_NAME] = cookie.value
|
161
|
+
self._authenticated = True
|
162
|
+
break
|
163
|
+
|
164
|
+
async def __aenter__(self):
|
165
|
+
"""Async context manager entry."""
|
166
|
+
# Create session with cookie jar
|
167
|
+
await self._ensure_session()
|
168
|
+
|
169
|
+
# Load cookies from cookie manager to session
|
170
|
+
await self._load_cookies_to_session()
|
171
|
+
|
172
|
+
# Check for XSRF token in cookies
|
173
|
+
await self._check_xsrf_token()
|
174
|
+
|
175
|
+
return self
|
176
|
+
|
177
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
178
|
+
"""Async context manager exit."""
|
179
|
+
# Save cookies if we have a cookie manager
|
180
|
+
await self._save_cookies_to_manager()
|
181
|
+
|
182
|
+
if self._session:
|
183
|
+
await self._session.close()
|
184
|
+
|
185
|
+
@property
|
186
|
+
def session(self) -> aiohttp.ClientSession:
|
187
|
+
"""Get the aiohttp session."""
|
188
|
+
if self._session is None:
|
189
|
+
raise RuntimeError("Client session not initialized. Use async context manager.")
|
190
|
+
return self._session
|
191
|
+
|
192
|
+
async def _save_cookies_to_manager(self) -> None:
|
193
|
+
"""Save cookies from aiohttp cookie jar to cookie manager."""
|
194
|
+
if self._cookie_manager and self._cookie_jar:
|
195
|
+
# Convert aiohttp cookies to standard Cookie objects
|
196
|
+
for aiohttp_cookie in self._cookie_jar:
|
197
|
+
expires = aiohttp_cookie.get("expires")
|
198
|
+
if expires == "":
|
199
|
+
expires = None
|
200
|
+
elif expires is not None:
|
201
|
+
try:
|
202
|
+
expires = int(float(expires))
|
203
|
+
except (ValueError, TypeError):
|
204
|
+
expires = None
|
205
|
+
|
206
|
+
# Remove quotes from cookie value before saving
|
207
|
+
cookie_value = aiohttp_cookie.value
|
208
|
+
if cookie_value.startswith('"') and cookie_value.endswith('"'):
|
209
|
+
cookie_value = cookie_value[1:-1]
|
210
|
+
|
211
|
+
cookie = Cookie(
|
212
|
+
version=0,
|
213
|
+
name=aiohttp_cookie.key,
|
214
|
+
value=cookie_value,
|
215
|
+
port=None,
|
216
|
+
port_specified=False,
|
217
|
+
domain=aiohttp_cookie["domain"],
|
218
|
+
domain_specified=bool(aiohttp_cookie["domain"]),
|
219
|
+
domain_initial_dot=aiohttp_cookie["domain"].startswith("."),
|
220
|
+
path=aiohttp_cookie["path"],
|
221
|
+
path_specified=bool(aiohttp_cookie["path"]),
|
222
|
+
secure=aiohttp_cookie.get("secure", False),
|
223
|
+
expires=expires,
|
224
|
+
discard=False,
|
225
|
+
comment=None,
|
226
|
+
comment_url=None,
|
227
|
+
rest={},
|
228
|
+
rfc2109=False,
|
229
|
+
)
|
230
|
+
self._cookie_manager.add_cookie(cookie)
|
231
|
+
await self._cookie_manager.save_cookies()
|
232
|
+
|
233
|
+
async def authenticate(
|
234
|
+
self,
|
235
|
+
login: str | None = None,
|
236
|
+
password: str | None = None,
|
237
|
+
key: str | None = None,
|
238
|
+
) -> bool:
|
239
|
+
"""Authenticate with the Pararamio API.
|
240
|
+
|
241
|
+
Args:
|
242
|
+
login: Optional login override
|
243
|
+
password: Optional password override
|
244
|
+
key: Optional key override
|
245
|
+
|
246
|
+
Returns:
|
247
|
+
True if authentication successful
|
248
|
+
"""
|
249
|
+
login = login or self._login or ""
|
250
|
+
password = password or self._password or ""
|
251
|
+
key = key or self._key or ""
|
252
|
+
|
253
|
+
if not key:
|
254
|
+
raise PararamioAuthenticationException("key must be set and not empty")
|
255
|
+
|
256
|
+
self._authenticated, xsrf_token = await async_authenticate(
|
257
|
+
self.session, login, password, key, self._wait_auth_limit
|
258
|
+
)
|
259
|
+
|
260
|
+
if self._authenticated:
|
261
|
+
self._headers[XSRF_HEADER_NAME] = xsrf_token
|
262
|
+
# Save cookies through cookie manager
|
263
|
+
await self._save_cookies_to_manager()
|
264
|
+
|
265
|
+
return self._authenticated
|
266
|
+
|
267
|
+
async def authenticate_with_code(
|
268
|
+
self,
|
269
|
+
code: str,
|
270
|
+
login: str | None = None,
|
271
|
+
password: str | None = None,
|
272
|
+
) -> bool:
|
273
|
+
"""Authenticate with a TOTP code directly.
|
274
|
+
|
275
|
+
Args:
|
276
|
+
code: The 6-digit authentication code. Must be set and not empty.
|
277
|
+
login: Optional login override
|
278
|
+
password: Optional password override
|
279
|
+
|
280
|
+
Returns:
|
281
|
+
True if authentication successful
|
282
|
+
|
283
|
+
Raises:
|
284
|
+
PararamioAuthenticationException: If the code is not provided or is empty.
|
285
|
+
"""
|
286
|
+
login = login or self._login or ""
|
287
|
+
password = password or self._password or ""
|
288
|
+
|
289
|
+
if not code:
|
290
|
+
raise PararamioAuthenticationException("code must be set and not empty")
|
291
|
+
|
292
|
+
self._authenticated, xsrf_token = await async_authenticate(
|
293
|
+
self.session,
|
294
|
+
login,
|
295
|
+
password,
|
296
|
+
key=None,
|
297
|
+
wait_auth_limit=self._wait_auth_limit,
|
298
|
+
second_step_fn=async_do_second_step_with_code,
|
299
|
+
second_step_arg=code,
|
300
|
+
)
|
301
|
+
|
302
|
+
if self._authenticated:
|
303
|
+
self._headers[XSRF_HEADER_NAME] = xsrf_token
|
304
|
+
# Save cookies through cookie manager
|
305
|
+
await self._save_cookies_to_manager()
|
306
|
+
|
307
|
+
return self._authenticated
|
308
|
+
|
309
|
+
async def _ensure_authenticated(self):
|
310
|
+
"""Ensure the client is authenticated."""
|
311
|
+
if not self._authenticated:
|
312
|
+
success = await self.authenticate()
|
313
|
+
if not success:
|
314
|
+
raise PararamioAuthenticationException("Failed to authenticate")
|
315
|
+
|
316
|
+
async def _api_request_with_retry(self, method: str, url: str, **kwargs) -> dict[str, Any]:
|
317
|
+
"""Make API request with automatic retry on auth errors."""
|
318
|
+
try:
|
319
|
+
return await self._api_request(method, url, **kwargs)
|
320
|
+
except PararamioHTTPRequestException as e:
|
321
|
+
if e.code == 401:
|
322
|
+
# Check if it's an XSRF token error by examining the response
|
323
|
+
try:
|
324
|
+
if e.fp and hasattr(e.fp, "read"):
|
325
|
+
response_text = e.fp.read()
|
326
|
+
if isinstance(response_text, bytes):
|
327
|
+
response_text = response_text.decode("utf-8")
|
328
|
+
elif e.response:
|
329
|
+
response_text = e.response
|
330
|
+
if isinstance(response_text, bytes):
|
331
|
+
response_text = response_text.decode("utf-8")
|
332
|
+
else:
|
333
|
+
response_text = ""
|
334
|
+
|
335
|
+
# Check if it's an XSRF error
|
336
|
+
if response_text and "xsrf" in response_text.lower():
|
337
|
+
log.info(
|
338
|
+
"XSRF token is expired, invalid or was not set, trying to get new one"
|
339
|
+
)
|
340
|
+
self._headers[XSRF_HEADER_NAME] = ""
|
341
|
+
return await self._api_request(method, url, **kwargs)
|
342
|
+
|
343
|
+
except (json.JSONDecodeError, ValueError) as parse_error:
|
344
|
+
log.debug("Failed to parse error response: %s", parse_error)
|
345
|
+
|
346
|
+
# Regular authentication error - use cookie manager only if we have credentials
|
347
|
+
if self._cookie_manager and self._key:
|
348
|
+
|
349
|
+
async def retry():
|
350
|
+
self._authenticated = False
|
351
|
+
await self.authenticate()
|
352
|
+
return await self._api_request(method, url, **kwargs)
|
353
|
+
|
354
|
+
return await self._cookie_manager.handle_auth_error(retry)
|
355
|
+
raise
|
356
|
+
|
357
|
+
async def _api_request(self, method: str, url: str, **kwargs) -> dict[str, Any]:
|
358
|
+
"""Make raw API request."""
|
359
|
+
await self._ensure_authenticated()
|
360
|
+
|
361
|
+
# Ensure XSRF token is present in headers
|
362
|
+
if not self._headers.get(XSRF_HEADER_NAME):
|
363
|
+
try:
|
364
|
+
xsrf_token = await get_async_xsrf_token(self.session)
|
365
|
+
self._headers[XSRF_HEADER_NAME] = xsrf_token
|
366
|
+
# Save cookies after getting new XSRF token
|
367
|
+
await self._save_cookies_to_manager()
|
368
|
+
except (aiohttp.ClientError, ValueError) as e:
|
369
|
+
log.warning("Failed to get XSRF token: %s", e)
|
370
|
+
|
371
|
+
full_url = f"https://api.pararam.io{url}"
|
372
|
+
async with self.session.request(
|
373
|
+
method, full_url, headers=self._headers, **kwargs
|
374
|
+
) as response:
|
375
|
+
if response.status != 200:
|
376
|
+
# Read response body for error details
|
377
|
+
try:
|
378
|
+
error_body = await response.text()
|
379
|
+
except aiohttp.ClientError:
|
380
|
+
error_body = ""
|
381
|
+
|
382
|
+
# Create a BytesIO object for the error body to match expected interface
|
383
|
+
# BytesIO already imported at top of file
|
384
|
+
error_fp = BytesIO(error_body.encode("utf-8") if error_body else b"")
|
385
|
+
|
386
|
+
raise PararamioHTTPRequestException(
|
387
|
+
full_url,
|
388
|
+
response.status,
|
389
|
+
f"HTTP {response.status}",
|
390
|
+
list(response.headers.items()),
|
391
|
+
error_fp,
|
392
|
+
)
|
393
|
+
return await response.json()
|
394
|
+
|
395
|
+
async def api_get(self, url: str) -> dict[str, Any]:
|
396
|
+
"""Make an authenticated GET request.
|
397
|
+
|
398
|
+
Args:
|
399
|
+
url: API endpoint URL
|
400
|
+
|
401
|
+
Returns:
|
402
|
+
JSON response as dict
|
403
|
+
"""
|
404
|
+
return await self._api_request_with_retry("GET", url)
|
405
|
+
|
406
|
+
async def api_post(self, url: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
|
407
|
+
"""Make an authenticated POST request.
|
408
|
+
|
409
|
+
Args:
|
410
|
+
url: API endpoint URL
|
411
|
+
data: Optional data payload
|
412
|
+
|
413
|
+
Returns:
|
414
|
+
JSON response as dict
|
415
|
+
"""
|
416
|
+
return await self._api_request_with_retry("POST", url, json=data)
|
417
|
+
|
418
|
+
async def api_put(self, url: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
|
419
|
+
"""Make an authenticated PUT request.
|
420
|
+
|
421
|
+
Args:
|
422
|
+
url: API endpoint URL
|
423
|
+
data: Optional data payload
|
424
|
+
|
425
|
+
Returns:
|
426
|
+
JSON response as dict
|
427
|
+
"""
|
428
|
+
return await self._api_request_with_retry("PUT", url, json=data)
|
429
|
+
|
430
|
+
async def api_delete(self, url: str) -> dict[str, Any]:
|
431
|
+
"""Make an authenticated DELETE request.
|
432
|
+
|
433
|
+
Args:
|
434
|
+
url: API endpoint URL
|
435
|
+
|
436
|
+
Returns:
|
437
|
+
JSON response as dict
|
438
|
+
"""
|
439
|
+
return await self._api_request_with_retry("DELETE", url)
|
440
|
+
|
441
|
+
async def get_profile(self) -> ProfileTypeT:
|
442
|
+
"""Get user profile.
|
443
|
+
|
444
|
+
Returns:
|
445
|
+
User profile data
|
446
|
+
"""
|
447
|
+
if not self._profile:
|
448
|
+
response = await self.api_get("/user/me")
|
449
|
+
self._profile = response
|
450
|
+
return self._profile
|
451
|
+
|
452
|
+
def get_cookies(self) -> aiohttp.CookieJar | None:
|
453
|
+
"""Get current cookie jar.
|
454
|
+
|
455
|
+
Note: Unlike sync version, this doesn't trigger authentication.
|
456
|
+
Use authenticate() explicitly if needed.
|
457
|
+
|
458
|
+
Returns:
|
459
|
+
Current aiohttp CookieJar or None
|
460
|
+
"""
|
461
|
+
return self._cookie_jar
|
462
|
+
|
463
|
+
def get_headers(self) -> dict[str, str]:
|
464
|
+
"""Get current request headers.
|
465
|
+
|
466
|
+
Note: Unlike sync version, this doesn't trigger authentication.
|
467
|
+
Use authenticate() explicitly if needed.
|
468
|
+
|
469
|
+
Returns:
|
470
|
+
Copy of current headers dict
|
471
|
+
"""
|
472
|
+
return self._headers.copy()
|
473
|
+
|
474
|
+
async def search_users(self, query: str, include_self: bool = False) -> list[User]:
|
475
|
+
"""Search for users.
|
476
|
+
|
477
|
+
Args:
|
478
|
+
query: Search query string
|
479
|
+
include_self: Whether to include current user in results. Default is False.
|
480
|
+
|
481
|
+
Returns:
|
482
|
+
List of found users
|
483
|
+
"""
|
484
|
+
url = f"/user/search?flt={quote(query)}"
|
485
|
+
if not include_self:
|
486
|
+
url += "&self=false"
|
487
|
+
response = await self.api_get(url)
|
488
|
+
users = []
|
489
|
+
for user_data in response.get("users", []):
|
490
|
+
user = User.from_dict(self, user_data)
|
491
|
+
users.append(user)
|
492
|
+
return users
|
493
|
+
|
494
|
+
async def get_user_by_id(self, user_id: int) -> User | None:
|
495
|
+
"""Get user by ID.
|
496
|
+
|
497
|
+
Args:
|
498
|
+
user_id: User ID
|
499
|
+
|
500
|
+
Returns:
|
501
|
+
User object or None if not found
|
502
|
+
"""
|
503
|
+
try:
|
504
|
+
users = await self.get_users_by_ids([user_id])
|
505
|
+
return users[0] if users else None
|
506
|
+
except (aiohttp.ClientError, IndexError, KeyError):
|
507
|
+
return None
|
508
|
+
|
509
|
+
async def get_users_by_ids(self, ids: Sequence[int]) -> list[User]:
|
510
|
+
"""Get multiple users by IDs.
|
511
|
+
|
512
|
+
Args:
|
513
|
+
ids: Sequence of user IDs
|
514
|
+
|
515
|
+
Returns:
|
516
|
+
List of user objects
|
517
|
+
"""
|
518
|
+
if not ids:
|
519
|
+
return []
|
520
|
+
if len(ids) > 100:
|
521
|
+
raise PararamioValidationException("too many ids, max 100")
|
522
|
+
|
523
|
+
url = "/user/list?ids=" + ",".join(map(str, ids))
|
524
|
+
response = await self.api_get(url)
|
525
|
+
users = []
|
526
|
+
for user_data in response.get("users", []):
|
527
|
+
user = User.from_dict(self, user_data)
|
528
|
+
users.append(user)
|
529
|
+
return users
|
530
|
+
|
531
|
+
async def get_chat_by_id(self, chat_id: int) -> Chat | None:
|
532
|
+
"""Get chat by ID.
|
533
|
+
|
534
|
+
Args:
|
535
|
+
chat_id: Chat ID
|
536
|
+
|
537
|
+
Returns:
|
538
|
+
Chat object or None if not found
|
539
|
+
"""
|
540
|
+
try:
|
541
|
+
chats = await self.get_chats_by_ids([chat_id])
|
542
|
+
return chats[0] if chats else None
|
543
|
+
except (aiohttp.ClientError, IndexError, KeyError):
|
544
|
+
return None
|
545
|
+
|
546
|
+
async def get_chats_by_ids(self, ids: Sequence[int]) -> list[Chat]:
|
547
|
+
"""Get multiple chats by IDs.
|
548
|
+
|
549
|
+
Args:
|
550
|
+
ids: Sequence of chat IDs
|
551
|
+
|
552
|
+
Returns:
|
553
|
+
List of chat objects
|
554
|
+
"""
|
555
|
+
if not ids:
|
556
|
+
return []
|
557
|
+
|
558
|
+
url = f"/core/chat?ids={','.join(map(str, ids))}"
|
559
|
+
response = await self.api_get(url)
|
560
|
+
chats = []
|
561
|
+
for chat_data in response.get("chats", []):
|
562
|
+
chat = Chat.from_dict(self, chat_data)
|
563
|
+
chats.append(chat)
|
564
|
+
return chats
|
565
|
+
|
566
|
+
async def list_chats(self) -> list[Chat]:
|
567
|
+
"""List all user chats.
|
568
|
+
|
569
|
+
Returns:
|
570
|
+
List of chat objects
|
571
|
+
"""
|
572
|
+
url = "/core/chat/sync"
|
573
|
+
response = await self.api_get(url)
|
574
|
+
chat_ids = response.get("chats", [])
|
575
|
+
return await self.get_chats_by_ids(chat_ids)
|
576
|
+
|
577
|
+
async def create_chat(
|
578
|
+
self,
|
579
|
+
title: str,
|
580
|
+
description: str = "",
|
581
|
+
users: list[int] | None = None,
|
582
|
+
groups: list[int] | None = None,
|
583
|
+
**kwargs,
|
584
|
+
) -> Chat:
|
585
|
+
"""Create a new chat.
|
586
|
+
|
587
|
+
Args:
|
588
|
+
title: Chat title
|
589
|
+
description: Chat description
|
590
|
+
users: List of user IDs to add
|
591
|
+
groups: List of group IDs to add
|
592
|
+
**kwargs: Additional chat parameters
|
593
|
+
|
594
|
+
Returns:
|
595
|
+
Created chat object
|
596
|
+
"""
|
597
|
+
data = {
|
598
|
+
"title": title,
|
599
|
+
"description": description,
|
600
|
+
"users": users or [],
|
601
|
+
"groups": groups or [],
|
602
|
+
**kwargs,
|
603
|
+
}
|
604
|
+
|
605
|
+
response = await self.api_post("/core/chat", data)
|
606
|
+
chat_id = response["chat_id"]
|
607
|
+
chat = await self.get_chat_by_id(chat_id)
|
608
|
+
if not chat:
|
609
|
+
raise PararamioValidationException(f"Failed to create chat with ID {chat_id}")
|
610
|
+
return chat
|
611
|
+
|
612
|
+
async def search_groups(self, query: str) -> list[Group]:
|
613
|
+
"""Search for groups.
|
614
|
+
|
615
|
+
Note: This uses the user search endpoint which also returns groups.
|
616
|
+
|
617
|
+
Args:
|
618
|
+
query: Search query string
|
619
|
+
|
620
|
+
Returns:
|
621
|
+
List of found groups
|
622
|
+
"""
|
623
|
+
return await Group.search(self, query)
|
624
|
+
|
625
|
+
async def get_group_by_id(self, group_id: int) -> Group | None:
|
626
|
+
"""Get group by ID.
|
627
|
+
|
628
|
+
Args:
|
629
|
+
group_id: Group ID
|
630
|
+
|
631
|
+
Returns:
|
632
|
+
Group object or None if not found
|
633
|
+
"""
|
634
|
+
try:
|
635
|
+
groups = await self.get_groups_by_ids([group_id])
|
636
|
+
return groups[0] if groups else None
|
637
|
+
except (aiohttp.ClientError, IndexError, KeyError):
|
638
|
+
return None
|
639
|
+
|
640
|
+
async def get_groups_by_ids(self, ids: Sequence[int]) -> list[Group]:
|
641
|
+
"""Get multiple groups by IDs.
|
642
|
+
|
643
|
+
Args:
|
644
|
+
ids: Sequence of group IDs
|
645
|
+
|
646
|
+
Returns:
|
647
|
+
List of group objects
|
648
|
+
"""
|
649
|
+
if not ids:
|
650
|
+
return []
|
651
|
+
if len(ids) > 100:
|
652
|
+
raise PararamioValidationException("too many ids, max 100")
|
653
|
+
|
654
|
+
url = "/core/group?ids=" + ",".join(map(str, ids))
|
655
|
+
response = await self.api_get(url)
|
656
|
+
groups = []
|
657
|
+
for group_data in response.get("groups", []):
|
658
|
+
group = Group.from_dict(self, group_data)
|
659
|
+
groups.append(group)
|
660
|
+
return groups
|
661
|
+
|
662
|
+
async def search_posts(
|
663
|
+
self,
|
664
|
+
query: str,
|
665
|
+
order_type: str = "time",
|
666
|
+
page: int = 1,
|
667
|
+
chat_ids: list[int] | None = None,
|
668
|
+
limit: int | None = None,
|
669
|
+
) -> tuple[int, list[Post]]:
|
670
|
+
"""Search for posts.
|
671
|
+
|
672
|
+
Note: This endpoint is not in the official documentation but works in practice.
|
673
|
+
|
674
|
+
Args:
|
675
|
+
query: Search query
|
676
|
+
order_type: Order type ('time', 'relevance')
|
677
|
+
page: Page number
|
678
|
+
chat_ids: Optional list of chat IDs to search within
|
679
|
+
limit: Optional result limit
|
680
|
+
|
681
|
+
Returns:
|
682
|
+
Tuple of (total_count, posts_list)
|
683
|
+
"""
|
684
|
+
url = self._build_search_url(query, order_type, page, chat_ids, limit)
|
685
|
+
response = await self.api_get(url)
|
686
|
+
total_count = response.get("count", 0)
|
687
|
+
|
688
|
+
posts_data = response.get("posts", [])
|
689
|
+
# Apply client-side limit if requested limit is less than API minimum (10)
|
690
|
+
if limit and limit < 10 and limit < len(posts_data):
|
691
|
+
posts_data = posts_data[:limit]
|
692
|
+
|
693
|
+
posts = await self._load_posts_from_data(posts_data)
|
694
|
+
return total_count, posts
|
695
|
+
|
696
|
+
def _build_search_url(
|
697
|
+
self,
|
698
|
+
query: str,
|
699
|
+
order_type: str,
|
700
|
+
page: int,
|
701
|
+
chat_ids: list[int] | None,
|
702
|
+
limit: int | None,
|
703
|
+
) -> str:
|
704
|
+
"""Build search URL with parameters."""
|
705
|
+
url = f"/posts/search?q={quote_plus(query)}"
|
706
|
+
if order_type:
|
707
|
+
url += f"&order_type={order_type}"
|
708
|
+
if page:
|
709
|
+
url += f"&page={page}"
|
710
|
+
|
711
|
+
# API requires limit to be at least 10
|
712
|
+
api_limit = max(limit or 100, 10) if limit else None
|
713
|
+
if api_limit:
|
714
|
+
url += f"&limit={api_limit}"
|
715
|
+
|
716
|
+
# Handle chat_ids parameter if provided
|
717
|
+
if chat_ids:
|
718
|
+
url += f"&chat_ids={','.join(map(str, chat_ids))}"
|
719
|
+
|
720
|
+
return url
|
721
|
+
|
722
|
+
async def _load_posts_from_data(self, posts_data: list[dict[str, Any]]) -> list[Post]:
|
723
|
+
"""Load full post objects from search results data."""
|
724
|
+
posts = []
|
725
|
+
for post_data in posts_data:
|
726
|
+
thread_id = post_data.get("thread_id")
|
727
|
+
post_no = post_data.get("post_no")
|
728
|
+
if thread_id and post_no:
|
729
|
+
# Load the full post data
|
730
|
+
post = await self.get_post(thread_id, post_no)
|
731
|
+
if post:
|
732
|
+
posts.append(post)
|
733
|
+
return posts
|
734
|
+
|
735
|
+
async def get_post(self, chat_id: int, post_no: int) -> Post | None:
|
736
|
+
"""Get a specific post by chat ID and post number.
|
737
|
+
|
738
|
+
Args:
|
739
|
+
chat_id: Chat ID
|
740
|
+
post_no: Post number
|
741
|
+
|
742
|
+
Returns:
|
743
|
+
Post object or None if not found
|
744
|
+
"""
|
745
|
+
try:
|
746
|
+
# Simple encoding for now - in real implementation would use core utils
|
747
|
+
url = f"/msg/post?ids={chat_id}-{post_no}"
|
748
|
+
response = await self.api_get(url)
|
749
|
+
posts_data = response.get("posts", [])
|
750
|
+
if posts_data:
|
751
|
+
chat = await self.get_chat_by_id(chat_id)
|
752
|
+
if chat:
|
753
|
+
return Post.from_dict(self, chat, posts_data[0])
|
754
|
+
return None
|
755
|
+
except (aiohttp.ClientError, IndexError, KeyError):
|
756
|
+
return None
|
757
|
+
|
758
|
+
async def _upload_file(
|
759
|
+
self,
|
760
|
+
file: BinaryIO | BytesIO,
|
761
|
+
chat_id: int,
|
762
|
+
*,
|
763
|
+
filename: str | None = None,
|
764
|
+
type_: str | None = None,
|
765
|
+
organization_id: int | None = None,
|
766
|
+
reply_no: int | None = None,
|
767
|
+
quote_range: str | None = None,
|
768
|
+
) -> tuple[dict, dict]:
|
769
|
+
"""
|
770
|
+
Internal method for uploading a file to a specified chat or organization.
|
771
|
+
|
772
|
+
Args:
|
773
|
+
file: A binary stream of the file to be uploaded.
|
774
|
+
chat_id: The ID of the chat where the file will be uploaded.
|
775
|
+
filename: An optional parameter that specifies the name of the file.
|
776
|
+
type_: An optional parameter that specifies the type of file being uploaded.
|
777
|
+
If not provided, it will be inferred from the filename.
|
778
|
+
organization_id: An optional parameter that specifies the ID of the organization
|
779
|
+
if the file is an organization avatar.
|
780
|
+
reply_no: An optional parameter that specifies the reply number
|
781
|
+
associated with the file.
|
782
|
+
quote_range: An optional parameter that specifies the range
|
783
|
+
of quotes associated with the file.
|
784
|
+
|
785
|
+
Returns:
|
786
|
+
A tuple containing a dictionary with the response from the xupload_file function
|
787
|
+
and a dictionary of the fields used during the upload.
|
788
|
+
|
789
|
+
Raises:
|
790
|
+
PararamioValidationException: If filename is not set when type is None,
|
791
|
+
or if organization_id is not set when type is organization_avatar,
|
792
|
+
or if chat_id is not set when type is chat_avatar.
|
793
|
+
"""
|
794
|
+
if type_ is None and not filename:
|
795
|
+
raise PararamioValidationException("filename must be set when type is None")
|
796
|
+
|
797
|
+
await self._ensure_authenticated()
|
798
|
+
|
799
|
+
if type_ == "organization_avatar" and organization_id is None:
|
800
|
+
raise PararamioValidationException(
|
801
|
+
"organization_id must be set when type is organization_avatar"
|
802
|
+
)
|
803
|
+
if type_ == "chat_avatar" and chat_id is None:
|
804
|
+
raise PararamioValidationException("chat_id must be set when type is chat_avatar")
|
805
|
+
|
806
|
+
content_type = None
|
807
|
+
if type_ not in ("organization_avatar", "chat_avatar"):
|
808
|
+
content_type = type_
|
809
|
+
|
810
|
+
file.seek(0, os.SEEK_END)
|
811
|
+
file_size = file.tell()
|
812
|
+
file.seek(0, 0)
|
813
|
+
|
814
|
+
fields: list[tuple[str, str | int | None]] = [
|
815
|
+
("type", type_),
|
816
|
+
("filename", filename),
|
817
|
+
("size", file_size),
|
818
|
+
("chat_id", chat_id),
|
819
|
+
("organization_id", organization_id),
|
820
|
+
("reply_no", reply_no),
|
821
|
+
("quote_range", quote_range),
|
822
|
+
]
|
823
|
+
|
824
|
+
result = await async_xupload_file(
|
825
|
+
self.session,
|
826
|
+
fp=file,
|
827
|
+
fields=fields,
|
828
|
+
filename=filename,
|
829
|
+
content_type=content_type,
|
830
|
+
headers=self._headers,
|
831
|
+
)
|
832
|
+
|
833
|
+
return result, dict(fields)
|
834
|
+
|
835
|
+
async def upload_file(
|
836
|
+
self,
|
837
|
+
file: str | BytesIO | BinaryIO | os.PathLike,
|
838
|
+
chat_id: int,
|
839
|
+
*,
|
840
|
+
filename: str | None = None,
|
841
|
+
content_type: str | None = None,
|
842
|
+
reply_no: int | None = None,
|
843
|
+
quote_range: str | None = None,
|
844
|
+
) -> File:
|
845
|
+
"""
|
846
|
+
Upload a file to a specified chat.
|
847
|
+
|
848
|
+
Args:
|
849
|
+
file: The file to be uploaded. It can be a file path,
|
850
|
+
a BytesIO object, or an os.PathLike object.
|
851
|
+
chat_id: The ID of the chat where the file should be uploaded.
|
852
|
+
filename: The name of the file.
|
853
|
+
If not specified and the file is a path, the basename of the file
|
854
|
+
path will be used.
|
855
|
+
content_type: The MIME type of the file.
|
856
|
+
reply_no: The reply number in the chat to which this file is in response.
|
857
|
+
quote_range: The range of messages being quoted.
|
858
|
+
|
859
|
+
Returns:
|
860
|
+
File: An instance of the File class representing the uploaded file.
|
861
|
+
"""
|
862
|
+
if isinstance(file, (str, os.PathLike)):
|
863
|
+
filename = filename or os.path.basename(str(file))
|
864
|
+
async with aiofiles.open(file, "rb") as f:
|
865
|
+
content = await f.read()
|
866
|
+
bio = BytesIO(content)
|
867
|
+
res, extra = await self._upload_file(
|
868
|
+
bio,
|
869
|
+
chat_id,
|
870
|
+
filename=filename,
|
871
|
+
type_=content_type,
|
872
|
+
reply_no=reply_no,
|
873
|
+
quote_range=quote_range,
|
874
|
+
)
|
875
|
+
else:
|
876
|
+
res, extra = await self._upload_file(
|
877
|
+
file,
|
878
|
+
chat_id,
|
879
|
+
filename=filename,
|
880
|
+
type_=content_type,
|
881
|
+
reply_no=reply_no,
|
882
|
+
quote_range=quote_range,
|
883
|
+
)
|
884
|
+
|
885
|
+
return File(self, guid=res["guid"], mime_type=extra.get("type"), **extra)
|
886
|
+
|
887
|
+
async def delete_file(self, guid: str) -> dict:
|
888
|
+
"""
|
889
|
+
Delete a file identified by the provided GUID.
|
890
|
+
|
891
|
+
Args:
|
892
|
+
guid: The globally unique identifier of the file to be deleted.
|
893
|
+
|
894
|
+
Returns:
|
895
|
+
dict: The result of the deletion operation.
|
896
|
+
"""
|
897
|
+
return await async_delete_file(self.session, guid, headers=self._headers)
|
898
|
+
|
899
|
+
async def download_file(self, guid: str, filename: str) -> BytesIO:
|
900
|
+
"""
|
901
|
+
Download and return a file as a BytesIO object given its unique identifier and filename.
|
902
|
+
|
903
|
+
Args:
|
904
|
+
guid: The unique identifier of the file to be downloaded.
|
905
|
+
filename: The name of the file to be downloaded.
|
906
|
+
|
907
|
+
Returns:
|
908
|
+
BytesIO: A BytesIO object containing the downloaded file content.
|
909
|
+
"""
|
910
|
+
return await async_download_file(self.session, guid, filename, headers=self._headers)
|
911
|
+
|
912
|
+
async def post_private_message_by_user_email(self, email: str, text: str) -> Post:
|
913
|
+
"""Post a private message to a user identified by their email address.
|
914
|
+
|
915
|
+
Args:
|
916
|
+
email: The email address of the user to whom the message will be sent.
|
917
|
+
text: The content of the message to be posted.
|
918
|
+
|
919
|
+
Returns:
|
920
|
+
A Post object representing the posted message.
|
921
|
+
"""
|
922
|
+
url = "/msg/post/private"
|
923
|
+
resp = await self.api_post(url, data={"text": text, "user_email": email})
|
924
|
+
|
925
|
+
# Get the created post
|
926
|
+
post = await self.get_post(resp["chat_id"], resp["post_no"])
|
927
|
+
if post is None:
|
928
|
+
raise ValueError(
|
929
|
+
f"Failed to retrieve created post {resp['post_no']} from chat {resp['chat_id']}"
|
930
|
+
)
|
931
|
+
return post
|
932
|
+
|
933
|
+
async def post_private_message_by_user_id(self, user_id: int, text: str) -> Post:
|
934
|
+
"""Send a private message to a specific user.
|
935
|
+
|
936
|
+
Args:
|
937
|
+
user_id: The ID of the user to whom the message will be sent.
|
938
|
+
text: The content of the message to be sent.
|
939
|
+
|
940
|
+
Returns:
|
941
|
+
The Post object containing information about the sent message.
|
942
|
+
"""
|
943
|
+
url = "/msg/post/private"
|
944
|
+
resp = await self.api_post(url, data={"text": text, "user_id": user_id})
|
945
|
+
|
946
|
+
# Get the created post
|
947
|
+
post = await self.get_post(resp["chat_id"], resp["post_no"])
|
948
|
+
if post is None:
|
949
|
+
raise ValueError(
|
950
|
+
f"Failed to retrieve created post {resp['post_no']} from chat {resp['chat_id']}"
|
951
|
+
)
|
952
|
+
return post
|
953
|
+
|
954
|
+
async def post_private_message_by_user_unique_name(self, unique_name: str, text: str) -> Post:
|
955
|
+
"""Post a private message to a user identified by their unique name.
|
956
|
+
|
957
|
+
Args:
|
958
|
+
unique_name: The unique name of the user to whom the private message is to be sent.
|
959
|
+
text: The content of the private message.
|
960
|
+
|
961
|
+
Returns:
|
962
|
+
An instance of the Post class representing the posted message.
|
963
|
+
"""
|
964
|
+
url = "/msg/post/private"
|
965
|
+
resp = await self.api_post(url, data={"text": text, "user_unique_name": unique_name})
|
966
|
+
|
967
|
+
# Get the created post
|
968
|
+
post = await self.get_post(resp["chat_id"], resp["post_no"])
|
969
|
+
if post is None:
|
970
|
+
raise ValueError(
|
971
|
+
f"Failed to retrieve created post {resp['post_no']} from chat {resp['chat_id']}"
|
972
|
+
)
|
973
|
+
return post
|
974
|
+
|
975
|
+
async def mark_all_messages_as_read(self, org_id: int | None = None) -> bool:
|
976
|
+
"""Mark all messages as read for the organization or everywhere if org_id is None.
|
977
|
+
|
978
|
+
Args:
|
979
|
+
org_id: The ID of the organization. This parameter is optional.
|
980
|
+
|
981
|
+
Returns:
|
982
|
+
True if the operation was successful, False otherwise.
|
983
|
+
"""
|
984
|
+
url = "/msg/lastread/all"
|
985
|
+
data = {}
|
986
|
+
if org_id is not None:
|
987
|
+
data["org_id"] = org_id
|
988
|
+
response = await self.api_post(url, data=data)
|
989
|
+
return response.get("result", None) == "OK"
|