pararamio-aio 2.1.1__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.
Files changed (57) hide show
  1. pararamio_aio/__init__.py +78 -0
  2. pararamio_aio/_core/__init__.py +125 -0
  3. pararamio_aio/_core/_types.py +120 -0
  4. pararamio_aio/_core/base.py +143 -0
  5. pararamio_aio/_core/client_protocol.py +90 -0
  6. pararamio_aio/_core/constants/__init__.py +7 -0
  7. pararamio_aio/_core/constants/base.py +9 -0
  8. pararamio_aio/_core/constants/endpoints.py +84 -0
  9. pararamio_aio/_core/cookie_decorator.py +208 -0
  10. pararamio_aio/_core/cookie_manager.py +1222 -0
  11. pararamio_aio/_core/endpoints.py +67 -0
  12. pararamio_aio/_core/exceptions/__init__.py +6 -0
  13. pararamio_aio/_core/exceptions/auth.py +91 -0
  14. pararamio_aio/_core/exceptions/base.py +124 -0
  15. pararamio_aio/_core/models/__init__.py +17 -0
  16. pararamio_aio/_core/models/base.py +66 -0
  17. pararamio_aio/_core/models/chat.py +92 -0
  18. pararamio_aio/_core/models/post.py +65 -0
  19. pararamio_aio/_core/models/user.py +54 -0
  20. pararamio_aio/_core/py.typed +2 -0
  21. pararamio_aio/_core/utils/__init__.py +73 -0
  22. pararamio_aio/_core/utils/async_requests.py +417 -0
  23. pararamio_aio/_core/utils/auth_flow.py +202 -0
  24. pararamio_aio/_core/utils/authentication.py +235 -0
  25. pararamio_aio/_core/utils/captcha.py +92 -0
  26. pararamio_aio/_core/utils/helpers.py +336 -0
  27. pararamio_aio/_core/utils/http_client.py +199 -0
  28. pararamio_aio/_core/utils/requests.py +424 -0
  29. pararamio_aio/_core/validators.py +78 -0
  30. pararamio_aio/_types.py +29 -0
  31. pararamio_aio/client.py +989 -0
  32. pararamio_aio/constants/__init__.py +16 -0
  33. pararamio_aio/cookie_manager.py +15 -0
  34. pararamio_aio/exceptions/__init__.py +31 -0
  35. pararamio_aio/exceptions/base.py +1 -0
  36. pararamio_aio/file_operations.py +232 -0
  37. pararamio_aio/models/__init__.py +32 -0
  38. pararamio_aio/models/activity.py +127 -0
  39. pararamio_aio/models/attachment.py +141 -0
  40. pararamio_aio/models/base.py +83 -0
  41. pararamio_aio/models/bot.py +274 -0
  42. pararamio_aio/models/chat.py +722 -0
  43. pararamio_aio/models/deferred_post.py +174 -0
  44. pararamio_aio/models/file.py +103 -0
  45. pararamio_aio/models/group.py +361 -0
  46. pararamio_aio/models/poll.py +275 -0
  47. pararamio_aio/models/post.py +643 -0
  48. pararamio_aio/models/team.py +403 -0
  49. pararamio_aio/models/user.py +239 -0
  50. pararamio_aio/py.typed +2 -0
  51. pararamio_aio/utils/__init__.py +18 -0
  52. pararamio_aio/utils/authentication.py +383 -0
  53. pararamio_aio/utils/requests.py +75 -0
  54. pararamio_aio-2.1.1.dist-info/METADATA +269 -0
  55. pararamio_aio-2.1.1.dist-info/RECORD +57 -0
  56. pararamio_aio-2.1.1.dist-info/WHEEL +5 -0
  57. pararamio_aio-2.1.1.dist-info/top_level.txt +1 -0
@@ -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"