pararamio-aio 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pararamio_aio/__init__.py +26 -0
- pararamio_aio/_core/__init__.py +125 -0
- pararamio_aio/_core/_types.py +120 -0
- pararamio_aio/_core/base.py +143 -0
- pararamio_aio/_core/client_protocol.py +90 -0
- pararamio_aio/_core/constants/__init__.py +7 -0
- pararamio_aio/_core/constants/base.py +9 -0
- pararamio_aio/_core/constants/endpoints.py +84 -0
- pararamio_aio/_core/cookie_decorator.py +208 -0
- pararamio_aio/_core/cookie_manager.py +1222 -0
- pararamio_aio/_core/endpoints.py +67 -0
- pararamio_aio/_core/exceptions/__init__.py +6 -0
- pararamio_aio/_core/exceptions/auth.py +91 -0
- pararamio_aio/_core/exceptions/base.py +124 -0
- pararamio_aio/_core/models/__init__.py +17 -0
- pararamio_aio/_core/models/base.py +66 -0
- pararamio_aio/_core/models/chat.py +92 -0
- pararamio_aio/_core/models/post.py +65 -0
- pararamio_aio/_core/models/user.py +54 -0
- pararamio_aio/_core/py.typed +2 -0
- pararamio_aio/_core/utils/__init__.py +73 -0
- pararamio_aio/_core/utils/async_requests.py +417 -0
- pararamio_aio/_core/utils/auth_flow.py +202 -0
- pararamio_aio/_core/utils/authentication.py +235 -0
- pararamio_aio/_core/utils/captcha.py +92 -0
- pararamio_aio/_core/utils/helpers.py +336 -0
- pararamio_aio/_core/utils/http_client.py +199 -0
- pararamio_aio/_core/utils/requests.py +424 -0
- pararamio_aio/_core/validators.py +78 -0
- pararamio_aio/_types.py +29 -0
- pararamio_aio/client.py +989 -0
- pararamio_aio/constants/__init__.py +16 -0
- pararamio_aio/exceptions/__init__.py +31 -0
- pararamio_aio/exceptions/base.py +1 -0
- pararamio_aio/file_operations.py +232 -0
- pararamio_aio/models/__init__.py +31 -0
- pararamio_aio/models/activity.py +127 -0
- pararamio_aio/models/attachment.py +141 -0
- pararamio_aio/models/base.py +83 -0
- pararamio_aio/models/bot.py +274 -0
- pararamio_aio/models/chat.py +722 -0
- pararamio_aio/models/deferred_post.py +174 -0
- pararamio_aio/models/file.py +103 -0
- pararamio_aio/models/group.py +361 -0
- pararamio_aio/models/poll.py +275 -0
- pararamio_aio/models/post.py +643 -0
- pararamio_aio/models/team.py +403 -0
- pararamio_aio/models/user.py +239 -0
- pararamio_aio/py.typed +2 -0
- pararamio_aio/utils/__init__.py +18 -0
- pararamio_aio/utils/authentication.py +383 -0
- pararamio_aio/utils/requests.py +75 -0
- pararamio_aio-3.0.0.dist-info/METADATA +269 -0
- pararamio_aio-3.0.0.dist-info/RECORD +56 -0
- pararamio_aio-3.0.0.dist-info/WHEEL +5 -0
- pararamio_aio-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
|