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.
Files changed (56) hide show
  1. pararamio_aio/__init__.py +26 -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/exceptions/__init__.py +31 -0
  34. pararamio_aio/exceptions/base.py +1 -0
  35. pararamio_aio/file_operations.py +232 -0
  36. pararamio_aio/models/__init__.py +31 -0
  37. pararamio_aio/models/activity.py +127 -0
  38. pararamio_aio/models/attachment.py +141 -0
  39. pararamio_aio/models/base.py +83 -0
  40. pararamio_aio/models/bot.py +274 -0
  41. pararamio_aio/models/chat.py +722 -0
  42. pararamio_aio/models/deferred_post.py +174 -0
  43. pararamio_aio/models/file.py +103 -0
  44. pararamio_aio/models/group.py +361 -0
  45. pararamio_aio/models/poll.py +275 -0
  46. pararamio_aio/models/post.py +643 -0
  47. pararamio_aio/models/team.py +403 -0
  48. pararamio_aio/models/user.py +239 -0
  49. pararamio_aio/py.typed +2 -0
  50. pararamio_aio/utils/__init__.py +18 -0
  51. pararamio_aio/utils/authentication.py +383 -0
  52. pararamio_aio/utils/requests.py +75 -0
  53. pararamio_aio-3.0.0.dist-info/METADATA +269 -0
  54. pararamio_aio-3.0.0.dist-info/RECORD +56 -0
  55. pararamio_aio-3.0.0.dist-info/WHEEL +5 -0
  56. pararamio_aio-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,643 @@
1
+ """Async Post model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING, Any, cast
8
+ from uuid import uuid4
9
+
10
+ # Imports from core
11
+ from pararamio_aio._core import PararamioRequestException, PararamModelNotLoaded
12
+ from pararamio_aio._core.utils.helpers import encode_digit
13
+
14
+ from .base import BaseModel
15
+ from .file import File
16
+
17
+ if TYPE_CHECKING:
18
+ from ..client import AsyncPararamio
19
+ from .chat import Chat
20
+ from .user import User
21
+
22
+ __all__ = ("Post", "get_post_mention")
23
+
24
+
25
+ def get_post_mention(data: dict[str, Any]) -> dict[str, Any] | None:
26
+ """Extract mention data from parsed text item.
27
+
28
+ Args:
29
+ data: Parsed text item data
30
+
31
+ Returns:
32
+ PostMention dict or None if not a mention
33
+ """
34
+ id_val = data.get("id")
35
+ name = data.get("name")
36
+ value = data.get("value")
37
+
38
+ if id_val is None and name is None and value is None:
39
+ return None
40
+
41
+ return cast(dict[str, Any], {"id": id_val, "name": name, "value": value})
42
+
43
+
44
+ class Post(BaseModel): # pylint: disable=too-many-public-methods
45
+ """Async Post model with explicit loading."""
46
+
47
+ def __init__(self, client: AsyncPararamio, chat: Chat, post_no: int, **kwargs):
48
+ """Initialize async post.
49
+
50
+ Args:
51
+ client: AsyncPararamio client
52
+ chat: Parent chat object
53
+ post_no: Post number
54
+ **kwargs: Additional post data
55
+ """
56
+ super().__init__(client, post_no=post_no, **kwargs)
57
+ self._chat = chat
58
+ self.post_no = post_no
59
+
60
+ @property
61
+ def chat(self) -> Chat:
62
+ """Get parent chat."""
63
+ return self._chat
64
+
65
+ @property
66
+ def chat_id(self) -> int:
67
+ """Get chat ID."""
68
+ return self._chat.id
69
+
70
+ def is_loaded(self) -> bool:
71
+ """Check if post data has been loaded.
72
+
73
+ Returns:
74
+ True if post data has been loaded, False otherwise
75
+ """
76
+ # Check for essential fields that are only present after loading
77
+ return "text" in self._data and "user_id" in self._data and "time_created" in self._data
78
+
79
+ @property
80
+ def text(self) -> str:
81
+ """Get post text."""
82
+ if not self.is_loaded() and "text" not in self._data:
83
+ raise PararamModelNotLoaded(
84
+ "Post data has not been loaded. Use load() to fetch post data first."
85
+ )
86
+ return self._data.get("text", "")
87
+
88
+ @property
89
+ def user_id(self) -> int | None:
90
+ """Get post author user ID."""
91
+ if not self.is_loaded() and "user_id" not in self._data:
92
+ raise PararamModelNotLoaded(
93
+ "Post data has not been loaded. Use load() to fetch post data first."
94
+ )
95
+ return self._data.get("user_id")
96
+
97
+ @property
98
+ def time_created(self) -> datetime | None:
99
+ """Get post creation time."""
100
+ if not self.is_loaded() and "time_created" not in self._data:
101
+ raise PararamModelNotLoaded(
102
+ "Post data has not been loaded. Use load() to fetch post data first."
103
+ )
104
+ return self._data.get("time_created")
105
+
106
+ @property
107
+ def time_edited(self) -> datetime | None:
108
+ """Get post edit time."""
109
+ if not self.is_loaded() and "time_edited" not in self._data:
110
+ raise PararamModelNotLoaded(
111
+ "Post data has not been loaded. Use load() to fetch post data first."
112
+ )
113
+ return self._data.get("time_edited")
114
+
115
+ @property
116
+ def reply_no(self) -> int | None:
117
+ """Get reply post number if this is a reply."""
118
+ if not self.is_loaded() and "reply_no" not in self._data:
119
+ raise PararamModelNotLoaded(
120
+ "Post data has not been loaded. Use load() to fetch post data first."
121
+ )
122
+ return self._data.get("reply_no")
123
+
124
+ @property
125
+ def is_reply(self) -> bool:
126
+ """Check if this post is a reply."""
127
+ return self.reply_no is not None
128
+
129
+ @property
130
+ def is_deleted(self) -> bool:
131
+ """Check if post is deleted."""
132
+ if not self.is_loaded() and "is_deleted" not in self._data:
133
+ raise PararamModelNotLoaded(
134
+ "Post data has not been loaded. Use load() to fetch post data first."
135
+ )
136
+ return self._data.get("is_deleted", False)
137
+
138
+ @property
139
+ def meta(self) -> dict[str, Any]:
140
+ """Get post metadata."""
141
+ if not self.is_loaded() and "meta" not in self._data:
142
+ raise PararamModelNotLoaded(
143
+ "Post data has not been loaded. Use load() to fetch post data first."
144
+ )
145
+ return self._data.get("meta", {})
146
+
147
+ @property
148
+ def event(self) -> dict[str, Any] | None:
149
+ """Get post event data."""
150
+ if not self.is_loaded() and "event" not in self._data:
151
+ raise PararamModelNotLoaded(
152
+ "Post data has not been loaded. Use load() to fetch post data first."
153
+ )
154
+ return self._data.get("event")
155
+
156
+ @property
157
+ def is_event(self) -> bool:
158
+ """Check if this is an event post."""
159
+ return bool(self.event)
160
+
161
+ @property
162
+ def uuid(self) -> str | None:
163
+ """Get post UUID."""
164
+ if not self.is_loaded() and "uuid" not in self._data:
165
+ raise PararamModelNotLoaded(
166
+ "Post data has not been loaded. Use load() to fetch post data first."
167
+ )
168
+ return self._data.get("uuid")
169
+
170
+ @property
171
+ def text_parsed(self) -> list[dict[str, Any]]:
172
+ """Get parsed text data."""
173
+ if not self.is_loaded() and "text_parsed" not in self._data:
174
+ raise PararamModelNotLoaded(
175
+ "Post data has not been loaded. Use load() to fetch post data first."
176
+ )
177
+ return self._data.get("text_parsed", [])
178
+
179
+ @property
180
+ def mentions(self) -> list[dict[str, Any]]:
181
+ """Get post mentions."""
182
+ mentions = []
183
+ for item in self.text_parsed:
184
+ if item.get("type") == "mention":
185
+ mentions.append(
186
+ {
187
+ "id": item.get("id"),
188
+ "name": item.get("name"),
189
+ "value": item.get("value"),
190
+ }
191
+ )
192
+ return mentions
193
+
194
+ @property
195
+ def user_links(self) -> list[dict[str, Any]]:
196
+ """Get user links in post."""
197
+ links = []
198
+ for item in self.text_parsed:
199
+ if item.get("type") == "user_link":
200
+ links.append(
201
+ {
202
+ "id": item.get("id"),
203
+ "name": item.get("name"),
204
+ "value": item.get("value"),
205
+ }
206
+ )
207
+ return links
208
+
209
+ async def get_author(self) -> User | None:
210
+ """Get post author.
211
+
212
+ Returns:
213
+ User object or None if not found
214
+ """
215
+ if not self.user_id:
216
+ return None
217
+
218
+ return await self.client.get_user_by_id(self.user_id)
219
+
220
+ async def load(self) -> Post:
221
+ """Load full post data from API.
222
+
223
+ Returns:
224
+ Self with updated data
225
+ """
226
+ url = f"/msg/post?ids={encode_digit(self.chat_id)}-{encode_digit(self.post_no)}"
227
+ response = await self.client.api_get(url)
228
+ posts_data = response.get("posts", [])
229
+
230
+ if len(posts_data) != 1:
231
+ raise PararamioRequestException(
232
+ f"Failed to load data for post_no {self.post_no} in chat {self.chat_id}"
233
+ )
234
+
235
+ # Update our data with loaded data
236
+ self._data.update(posts_data[0])
237
+ return self
238
+
239
+ async def get_replies(self) -> list[int]:
240
+ """Get list of reply post numbers.
241
+
242
+ Returns:
243
+ List of post numbers that reply to this post
244
+ """
245
+ url = f"/msg/post/{self.chat_id}/{self.post_no}/replies"
246
+ response = await self.client.api_get(url)
247
+ return response.get("data", [])
248
+
249
+ async def load_reply_posts(self) -> list[Post]:
250
+ """Load all posts that reply to this post.
251
+
252
+ Returns:
253
+ List of reply posts
254
+ """
255
+ reply_numbers = await self.get_replies()
256
+
257
+ posts = []
258
+ for post_no in reply_numbers:
259
+ post = await self.client.get_post(self.chat_id, post_no)
260
+ if post:
261
+ posts.append(post)
262
+
263
+ return posts
264
+
265
+ async def get_reply_to_post(self) -> Post | None:
266
+ """Get the post this post replies to.
267
+
268
+ Returns:
269
+ Parent post or None if not a reply
270
+ """
271
+ if not self.is_reply or self.reply_no is None:
272
+ return None
273
+
274
+ return await self.client.get_post(self.chat_id, self.reply_no)
275
+
276
+ async def reply(self, text: str, quote: str | None = None) -> Post:
277
+ """Reply to this post.
278
+
279
+ Args:
280
+ text: Reply text
281
+ quote: Optional quote text
282
+
283
+ Returns:
284
+ Created reply post
285
+ """
286
+ url = f"/msg/post/{self.chat_id}"
287
+ data = {
288
+ "uuid": str(uuid4().hex),
289
+ "text": text,
290
+ "reply_no": self.post_no,
291
+ }
292
+
293
+ if quote:
294
+ data["quote"] = quote
295
+
296
+ response = await self.client.api_post(url, data)
297
+ post_no = response["post_no"]
298
+
299
+ post = await self.client.get_post(self.chat_id, post_no)
300
+ if post is None:
301
+ raise ValueError(f"Failed to retrieve reply post {post_no} from chat {self.chat_id}")
302
+ return post
303
+
304
+ async def edit(self, text: str, quote: str | None = None, reply_no: int | None = None) -> bool:
305
+ """Edit this post.
306
+
307
+ Args:
308
+ text: New post text
309
+ quote: Optional new quote
310
+ reply_no: Optional new reply number
311
+
312
+ Returns:
313
+ True if successful
314
+ """
315
+ url = f"/msg/post/{self.chat_id}/{self.post_no}"
316
+ data: dict[str, Any] = {
317
+ "uuid": self.uuid or str(uuid4().hex),
318
+ "text": text,
319
+ }
320
+
321
+ if quote is not None:
322
+ data["quote"] = quote
323
+ if reply_no is not None:
324
+ data["reply_no"] = reply_no
325
+
326
+ response = await self.client.api_put(url, data)
327
+
328
+ if response.get("ver"):
329
+ # Reload the post data
330
+ await self.load()
331
+ return True
332
+
333
+ return False
334
+
335
+ async def delete(self) -> bool:
336
+ """Delete this post.
337
+
338
+ Returns:
339
+ True if successful
340
+ """
341
+ url = f"/msg/post/{self.chat_id}/{self.post_no}"
342
+ response = await self.client.api_delete(url)
343
+
344
+ if response.get("ver"):
345
+ # Update local data to reflect deletion
346
+ self._data["is_deleted"] = True
347
+ return True
348
+
349
+ return False
350
+
351
+ async def who_read(self) -> list[int]:
352
+ """Get list of user IDs who read this post.
353
+
354
+ Returns:
355
+ List of user IDs
356
+ """
357
+ url = f"/activity/who-read?thread_id={self.chat_id}&post_no={self.post_no}"
358
+ response = await self.client.api_get(url)
359
+ return response.get("users", [])
360
+
361
+ async def mark_read(self) -> bool:
362
+ """Mark this post as read.
363
+
364
+ Returns:
365
+ True if successful
366
+ """
367
+ return await self.chat.mark_read(self.post_no)
368
+
369
+ async def get_file(self) -> File | None:
370
+ """Get attached file if any.
371
+
372
+ Returns:
373
+ File object or None if no file
374
+ """
375
+ file_data = self.meta.get("file")
376
+ if not file_data:
377
+ return None
378
+
379
+ return File.from_dict(self.client, file_data)
380
+
381
+ async def load_attachments(self) -> list[File]:
382
+ """Load all file attachments for this post.
383
+
384
+ Returns:
385
+ List of attached files
386
+ """
387
+ attachment_uuids = self.meta.get("attachments", [])
388
+ if not attachment_uuids:
389
+ return []
390
+
391
+ # This is a simplified implementation
392
+ # In reality, you'd need to search through nearby posts to find the files
393
+ files = []
394
+ main_file = await self.get_file()
395
+ if main_file:
396
+ files.append(main_file)
397
+
398
+ return files
399
+
400
+ @classmethod
401
+ def from_dict( # type: ignore[override] # pylint: disable=arguments-renamed
402
+ cls,
403
+ client: AsyncPararamio,
404
+ chat_or_data: Chat | dict[str, Any],
405
+ data: dict[str, Any] | None = None,
406
+ ) -> Post:
407
+ """Create post from dict data.
408
+
409
+ Args:
410
+ client: AsyncPararamio client
411
+ chat_or_data: Parent chat object or data dict (for base class compatibility)
412
+ data: Raw post data (when chat is provided)
413
+
414
+ Returns:
415
+ Post instance
416
+ """
417
+ if isinstance(chat_or_data, dict):
418
+ # Called with base signature: from_dict(client, data)
419
+ raise NotImplementedError("Post.from_dict requires a Chat object")
420
+
421
+ chat = chat_or_data
422
+ if data is None:
423
+ raise ValueError("data parameter is required when chat is provided")
424
+
425
+ post_no = data.pop("post_no", None) or data.pop("in_thread_no", None)
426
+ return cls(client, chat, post_no, **data)
427
+
428
+ def __eq__(self, other) -> bool:
429
+ """Check equality with another post."""
430
+ if not isinstance(other, Post):
431
+ return False
432
+ return self.chat_id == other.chat_id and self.post_no == other.post_no
433
+
434
+ @property
435
+ def is_bot(self) -> bool:
436
+ """Check if post is from a bot.
437
+
438
+ Returns:
439
+ True if post is from bot
440
+ """
441
+ if not self.is_loaded():
442
+ raise PararamModelNotLoaded(
443
+ "Post data has not been loaded. Use load() to fetch post data first."
444
+ )
445
+ return self._data.get("meta", {}).get("user", {}).get("is_bot", False)
446
+
447
+ @property
448
+ def is_file(self) -> bool:
449
+ """Check if post contains file attachment.
450
+
451
+ Returns:
452
+ True if post has file
453
+ """
454
+ if not self.is_loaded():
455
+ raise PararamModelNotLoaded(
456
+ "Post data has not been loaded. Use load() to fetch post data first."
457
+ )
458
+ return "file" in self._data.get("meta", {})
459
+
460
+ @property
461
+ def is_mention(self) -> bool:
462
+ """Check if post contains mentions.
463
+
464
+ Returns:
465
+ True if post has mentions
466
+ """
467
+ if not self.is_loaded():
468
+ raise PararamModelNotLoaded(
469
+ "Post data has not been loaded. Use load() to fetch post data first."
470
+ )
471
+ text_parsed = self._data.get("text_parsed", [])
472
+ if not text_parsed:
473
+ return False
474
+
475
+ for item in text_parsed:
476
+ if isinstance(item, dict) and item.get("type") == "mention":
477
+ return True
478
+ return False
479
+
480
+ async def next(self) -> Post | None:
481
+ """Get next post in thread.
482
+
483
+ Returns:
484
+ Next post or None
485
+ """
486
+ try:
487
+ # Get posts after this one
488
+ posts = await self.chat.load_posts(
489
+ start_post_no=self.post_no + 1, end_post_no=self.post_no + 2, limit=1
490
+ )
491
+ return posts[0] if posts else None
492
+ except (IndexError, KeyError, AttributeError):
493
+ return None
494
+
495
+ async def prev(self) -> Post | None:
496
+ """Get previous post in thread.
497
+
498
+ Returns:
499
+ Previous post or None
500
+ """
501
+ try:
502
+ # Get posts before this one
503
+ if self.post_no <= 1:
504
+ return None
505
+ posts = await self.chat.load_posts(
506
+ start_post_no=self.post_no - 1, end_post_no=self.post_no, limit=1
507
+ )
508
+ return posts[0] if posts else None
509
+ except (IndexError, KeyError, AttributeError):
510
+ return None
511
+
512
+ def attachments(self) -> list[dict[str, Any]]:
513
+ """Get post attachments.
514
+
515
+ Returns:
516
+ List of attachment data
517
+ """
518
+ return self._data.get("attachments", [])
519
+
520
+ def file(self) -> dict[str, Any] | None:
521
+ """Get file attachment data.
522
+
523
+ Returns:
524
+ File data or None
525
+ """
526
+ return self._data.get("meta", {}).get("file")
527
+
528
+ def in_thread_no(self) -> int | None:
529
+ """Get thread number if post is in a thread.
530
+
531
+ Returns:
532
+ Thread number or None
533
+ """
534
+ return self._data.get("in_thread_no")
535
+
536
+ def __str__(self) -> str:
537
+ """String representation."""
538
+ return self.text
539
+
540
+ def __repr__(self) -> str:
541
+ """Detailed representation."""
542
+ return (
543
+ f"<Post(client={hex(id(self.client))}, chat_id={self.chat_id}, post_no={self.post_no})>"
544
+ )
545
+
546
+ async def rerere(self) -> list[Post]:
547
+ """Get all replies in a thread recursively.
548
+
549
+ Returns:
550
+ List of all posts in the reply chain
551
+ """
552
+ url = f"/msg/post/{self.chat_id}/{self.post_no}/rerere"
553
+ response = await self.client.api_get(url)
554
+
555
+ posts = []
556
+ for post_no in response.get("data", []):
557
+ post = Post(self.client, self._chat, post_no)
558
+ await post.load()
559
+ posts.append(post)
560
+
561
+ return posts
562
+
563
+ async def get_tree(self, load_limit: int = 1000) -> OrderedDict[int, Post]:
564
+ """Get post hierarchy as an ordered dictionary.
565
+
566
+ Args:
567
+ load_limit: Maximum number of posts to load between first and current
568
+
569
+ Returns:
570
+ OrderedDict mapping post numbers to Post objects
571
+ """
572
+ posts: dict[int, Post] = {self.post_no: self}
573
+
574
+ # Get all replies recursively
575
+ for post in await self.rerere():
576
+ posts[post.post_no] = post
577
+
578
+ # Find the first post in thread
579
+ first = posts[min(posts.keys())]
580
+ tree = OrderedDict(sorted(posts.items()))
581
+
582
+ # Calculate load range
583
+ load_start = first.post_no + 1
584
+ if self.post_no - first.post_no > load_limit:
585
+ load_start = self.post_no - load_limit
586
+
587
+ # Load posts in range if needed
588
+ if load_start < self.post_no - 1:
589
+ loaded_posts = await self._chat.load_posts(
590
+ start_post_no=load_start, end_post_no=self.post_no - 1
591
+ )
592
+ for post in loaded_posts:
593
+ posts[post.post_no] = post
594
+
595
+ # Build final tree with only connected posts
596
+ for post in sorted(posts.values(), key=lambda p: p.post_no):
597
+ if post.reply_no is None or post.reply_no not in tree:
598
+ continue
599
+ tree[post.post_no] = post
600
+
601
+ return OrderedDict(sorted(tree.items()))
602
+
603
+ @classmethod
604
+ async def create(
605
+ cls,
606
+ chat: Chat,
607
+ text: str,
608
+ *,
609
+ reply_no: int | None = None,
610
+ quote: str | None = None,
611
+ uuid: str | None = None,
612
+ attachments: list[str] | None = None,
613
+ ) -> Post:
614
+ """Create a new post.
615
+
616
+ Args:
617
+ chat: Parent chat
618
+ text: Post text
619
+ reply_no: Optional post number to reply to
620
+ quote: Optional quote text
621
+ uuid: Optional UUID (generated if not provided)
622
+ attachments: Optional list of attachment UUIDs
623
+
624
+ Returns:
625
+ Created Post object
626
+ """
627
+ url = f"/msg/post/{chat.id}"
628
+ data: dict[str, Any] = {
629
+ "uuid": uuid or str(uuid4().hex),
630
+ "text": text,
631
+ "quote": quote,
632
+ "reply_no": reply_no,
633
+ }
634
+ if attachments:
635
+ data["attachments"] = attachments
636
+
637
+ response = await chat._client.api_post(url, data)
638
+ if not response:
639
+ raise PararamioRequestException("Failed to create post")
640
+
641
+ post = cls(chat._client, chat, post_no=response["post_no"])
642
+ await post.load()
643
+ return post