diff --git a/itd/client.py b/itd/client.py index d42861c..f2d4414 100644 --- a/itd/client.py +++ b/itd/client.py @@ -6,7 +6,7 @@ from requests.exceptions import HTTPError from itd.routes.users import get_user, update_profile, follow, unfollow, get_followers, get_following, update_privacy from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status -from itd.routes.comments import get_comments, add_comment, delete_comment, like_comment, unlike_comment +from itd.routes.comments import get_comments, add_comment, delete_comment, like_comment, unlike_comment, add_reply_comment from itd.routes.hashtags import get_hastags, get_posts_by_hastag from itd.routes.notifications import get_notifications, mark_as_read, mark_all_as_read, get_unread_notifications_count from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts @@ -16,13 +16,14 @@ from itd.routes.files import upload_file from itd.routes.auth import refresh_token, change_password, logout from itd.routes.verification import verificate, get_verification_status +from itd.models.comment import Comment from itd.models.clan import Clan from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow from itd.models.pagination import Pagination from itd.models.verification import Verification, VerificationStatus from itd.request import set_cookies -from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, UserNotFound, InvalidProfileData, UserBanned, PendingRequestExists +from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists def refresh_on_error(func): @@ -122,7 +123,7 @@ class Client: username (str): username или "me" Raises: - UserNotFound: Пользователь не найден + NotFound: Пользователь не найден UserBanned: Пользователь заблокирован Returns: @@ -130,7 +131,7 @@ class Client: """ res = get_user(self.token, username) if res.json().get('error', {}).get('code') == 'NOT_FOUND': - raise UserNotFound() + raise NotFound('User') if res.json().get('error', {}).get('code') == 'USER_BLOCKED': raise UserBanned() res.raise_for_status() @@ -157,14 +158,14 @@ class Client: banner_id (UUID | None, optional): UUID баннера. Defaults to None. Raises: - InvalidProfileData: Неправильные данные (валидация не прошла) + ValidationError: Ошибка валидации Returns: UserProfileUpdate: Обновленный профиль """ res = update_profile(self.token, bio, display_name, username, banner_id) if res.status_code == 422 and 'found' in res.json(): - raise InvalidProfileData(*list(res.json()['found'].items())[0]) + raise ValidationError(*list(res.json()['found'].items())[0]) res.raise_for_status() return UserProfileUpdate.model_validate(res.json()) @@ -193,14 +194,14 @@ class Client: username (str): username Raises: - UserNotFound: Пользователь не найден + NotFound: Пользователь не найден Returns: int: Число подписчиков после подписки """ res = follow(self.token, username) if res.json().get('error', {}).get('code') == 'NOT_FOUND': - raise UserNotFound() + raise NotFound('User') res.raise_for_status() return res.json()['followersCount'] @@ -213,14 +214,14 @@ class Client: username (str): username Raises: - UserNotFound: Пользователь не найден + NotFound: Пользователь не найден Returns: int: Число подписчиков после отписки """ res = unfollow(self.token, username) if res.json().get('error', {}).get('code') == 'NOT_FOUND': - raise UserNotFound() + raise NotFound('User') res.raise_for_status() return res.json()['followersCount'] @@ -235,7 +236,7 @@ class Client: page (int, optional): Страница (при дозагрузке, увеличивайте на 1). Defaults to 1. Raises: - UserNotFound: Пользователь не найден + NotFound: Пользователь не найден Returns: list[UserFollower]: Список подписчиков @@ -243,7 +244,7 @@ class Client: """ res = get_followers(self.token, username, limit, page) if res.json().get('error', {}).get('code') == 'NOT_FOUND': - raise UserNotFound() + raise NotFound('User') res.raise_for_status() return [UserFollower.model_validate(user) for user in res.json()['data']['users']], Pagination.model_validate(res.json()['data']['pagination']) @@ -258,7 +259,7 @@ class Client: page (int, optional): Страница (при дозагрузке, увеличивайте на 1). Defaults to 1. Raises: - UserNotFound: Пользователь не найден + NotFound: Пользователь не найден Returns: list[UserFollower]: Список подписок @@ -266,7 +267,7 @@ class Client: """ res = get_following(self.token, username, limit, page) if res.json().get('error', {}).get('code') == 'NOT_FOUND': - raise UserNotFound() + raise NotFound('User') res.raise_for_status() return [UserFollower.model_validate(user) for user in res.json()['data']['users']], Pagination.model_validate(res.json()['data']['pagination']) @@ -274,6 +275,17 @@ class Client: @refresh_on_error def verificate(self, file_url: str) -> Verification: + """Отправить запрос на верификацию + + Args: + file_url (str): Ссылка на видео + + Raises: + PendingRequestExists: Запрос уже отправлен + + Returns: + Verification: Верификация + """ res = verificate(self.token, file_url) if res.json().get('error', {}).get('code') == 'PENDING_REQUEST_EXISTS': raise PendingRequestExists() @@ -283,6 +295,11 @@ class Client: @refresh_on_error def get_verification_status(self) -> VerificationStatus: + """Получить статус верификации + + Returns: + VerificationStatus: Верификация + """ res = get_verification_status(self.token) res.raise_for_status() @@ -291,6 +308,11 @@ class Client: @refresh_on_error def get_who_to_follow(self) -> list[UserWhoToFollow]: + """Получить список популярнык пользователей (кого читать) + + Returns: + list[UserWhoToFollow]: Список пользователей + """ res = get_who_to_follow(self.token) res.raise_for_status() @@ -298,34 +320,101 @@ class Client: @refresh_on_error def get_top_clans(self) -> list[Clan]: + """Получить топ кланов + + Returns: + list[Clan]: Топ кланов + """ res = get_top_clans(self.token) res.raise_for_status() return [Clan.model_validate(clan) for clan in res.json()['clans']] @refresh_on_error - def get_platform_status(self) -> dict: - return get_platform_status(self.token) + def get_platform_status(self) -> bool: + """Получить статус платформы + + Returns: + bool: read only + """ + res = get_platform_status(self.token) + res.raise_for_status() + + return res.json()['readOnly'] @refresh_on_error - def add_comment(self, post_id: str, content: str, reply_comment_id: str | None = None): - return add_comment(self.token, post_id, content, reply_comment_id) + def add_comment(self, post_id: UUID, content: str) -> Comment: + """Добавить комментарий + + Args: + post_id (str): UUID поста + content (str): Содержание + reply_comment_id (UUID | None, optional): ID коммента для ответа. Defaults to None. + + Raises: + ValidationError: Ошибка валидации + + Returns: + Comment: Комментарий + """ + res = add_comment(self.token, post_id, content) + if res.status_code == 422 and 'found' in res.json(): + raise ValidationError(*list(res.json()['found'].items())[0]) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + res.raise_for_status() + + return Comment.model_validate(res.json()) + @refresh_on_error - def get_comments(self, post_id: str, limit: int = 20, cursor: int = 0, sort: str = 'popular'): - return get_comments(self.token, post_id, limit, cursor, sort) + def add_reply_comment(self, comment_id: UUID, content: str, author_id: UUID) -> Comment: + """Добавить ответный комментарий + + Args: + comment_id (str): UUID комментария + content (str): Содержание + author_id (UUID | None, optional): ID пользователя, отправившего комментарий. Defaults to None. + + Raises: + ValidationError: Ошибка валидации + + Returns: + Comment: Комментарий + """ + res = add_reply_comment(self.token, comment_id, content, author_id) + if res.status_code == 500 and 'Failed query' in res.text: + raise NotFound('User') + if res.status_code == 422 and 'found' in res.json(): + raise ValidationError(*list(res.json()['found'].items())[0]) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Comment') + res.raise_for_status() + + return Comment.model_validate(res.json()) + @refresh_on_error - def like_comment(self, id: str): + def get_comments(self, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular') -> tuple[list[Comment], Pagination]: + res = get_comments(self.token, post_id, limit, cursor, sort) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + res.raise_for_status() + data = res.json()['data'] + + return [Comment.model_validate(comment) for comment in data['comments']], Pagination(page=(cursor // limit) or 1, limit=limit, total=data['total'], hasMore=data['hasMore']) + + @refresh_on_error + def like_comment(self, id: UUID): return like_comment(self.token, id) @refresh_on_error - def unlike_comment(self, id: str): + def unlike_comment(self, id: UUID): return unlike_comment(self.token, id) @refresh_on_error - def delete_comment(self, id: str): + def delete_comment(self, id: UUID): return delete_comment(self.token, id) @@ -426,6 +515,6 @@ class Client: def upload_file(self, name: str, data: BufferedReader): return upload_file(self.token, name, data) - def update_banner(self, name: str): + def update_banner(self, name: str) -> UserProfileUpdate: id = self.upload_file(name, cast(BufferedReader, open(name, 'rb')))['id'] return self.update_profile(banner_id=id) \ No newline at end of file diff --git a/itd/enums.py b/itd/enums.py index 3d88e59..c37fdf5 100644 --- a/itd/enums.py +++ b/itd/enums.py @@ -22,4 +22,8 @@ class ReportTargetReason(Enum): HATE = 'hate' # ненависть ADULT = 'adult' # 18+ FRAUD = 'fraud' # обман\мошенничество - OTHER = 'other' # другое \ No newline at end of file + OTHER = 'other' # другое + +class AttachType(Enum): + AUDIO = 'audio' + IMAGE = 'image' diff --git a/itd/exceptions.py b/itd/exceptions.py index a3e39ca..68c4d8b 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -23,21 +23,29 @@ class InvalidOldPassword(Exception): def __str__(self): return 'Old password is incorrect' -class UserNotFound(Exception): +class NotFound(Exception): + def __init__(self, obj): + self.obj = obj def __str__(self): - return 'User not found' + return f'{self.obj} not found' class UserBanned(Exception): def __str__(self): return 'User banned' -class InvalidProfileData(Exception): +class ValidationError(Exception): def __init__(self, name: str, value: str): self.name = name self.value = value def __str__(self): - return f'Invalid update profile data {self.name}: "{self.value}"' + return f'Failed validation on {self.name}: "{self.value}"' class PendingRequestExists(Exception): def __str__(self): - return 'Pending verifiaction request already exists' \ No newline at end of file + return 'Pending verifiaction request already exists' + +class RateLimitExceeded(Exception): + def __init__(self, retry_after: int): + self.retry_after = retry_after + def __str__(self): + return f'Rate limit exceeded - too much requests. Retry after {self.retry_after} seconds' \ No newline at end of file diff --git a/itd/models/_text.py b/itd/models/_text.py index 70944a0..49238a7 100644 --- a/itd/models/_text.py +++ b/itd/models/_text.py @@ -1,17 +1,26 @@ from uuid import UUID from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from itd.models.user import UserPost +from itd.models.file import Attach class TextObject(BaseModel): id: UUID content: str author: UserPost - attachments: list[UUID] + attachments: list[Attach] = [] created_at: datetime = Field(alias='createdAt') - model_config = {'populate_by_name': True} \ No newline at end of file + model_config = {'populate_by_name': True} + + @field_validator('created_at', mode='plain') + @classmethod + def validate_created_at(cls, v: str): + try: + return datetime.strptime(v + '00', '%Y-%m-%d %H:%M:%S.%f%z') + except ValueError: + return datetime.strptime(v, '%Y-%m-%dT%H:%M:%S.%fZ') \ No newline at end of file diff --git a/itd/models/comment.py b/itd/models/comment.py index 2187242..efcd499 100644 --- a/itd/models/comment.py +++ b/itd/models/comment.py @@ -1,11 +1,13 @@ from pydantic import Field from itd.models._text import TextObject +from itd.models.user import UserPost -class CommentShort(TextObject): +class Comment(TextObject): likes_count: int = Field(0, alias='likesCount') replies_count: int = Field(0, alias='repliesCount') is_liked: bool = Field(False, alias='isLiked') - replies: list['CommentShort'] = [] \ No newline at end of file + replies: list['Comment'] = [] + reply_to: UserPost | None = None # author of replied comment, if this comment is reply \ No newline at end of file diff --git a/itd/models/file.py b/itd/models/file.py index 63fe4b4..b732b7e 100644 --- a/itd/models/file.py +++ b/itd/models/file.py @@ -2,9 +2,25 @@ from uuid import UUID from pydantic import BaseModel, Field +from itd.enums import AttachType + class File(BaseModel): id: UUID url: str filename: str mime_type: str = Field('image/png', alias='mimeType') - size: int \ No newline at end of file + size: int + + +class Attach(BaseModel): + id: UUID + type: AttachType = AttachType.IMAGE + url: str + thumbnail_url: str | None = Field(None, alias='thumbnailUrl') + filename: str + mime_type: str = Field(alias='mimeType') + size: int + width: int | None = None + height: int | None = None + duration: int | None = None + order: int = 0 \ No newline at end of file diff --git a/itd/models/user.py b/itd/models/user.py index a388647..0c717ae 100644 --- a/itd/models/user.py +++ b/itd/models/user.py @@ -13,7 +13,7 @@ class UserPrivacy(BaseModel): class UserProfileUpdate(BaseModel): id: UUID - username: str + username: str | None = None display_name: str = Field(alias='displayName') bio: str | None = None @@ -24,7 +24,7 @@ class UserProfileUpdate(BaseModel): class UserNotification(BaseModel): id: UUID - username: str + username: str | None = None display_name: str = Field(alias='displayName') avatar: str diff --git a/itd/request.py b/itd/request.py index ed02f31..a63cf10 100644 --- a/itd/request.py +++ b/itd/request.py @@ -2,7 +2,7 @@ from _io import BufferedReader from requests import Session -from itd.exceptions import InvalidToken, InvalidCookie +from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded s = Session() @@ -31,6 +31,9 @@ def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str, else: res = s.request(method.upper(), base, timeout=20, json=params, headers=headers, files=files) + if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': + raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) + print(res.text) return res @@ -72,6 +75,8 @@ def auth_fetch(cookies: str, method: str, url: str, params: dict = {}, token: st # print(res.text) if res.text == 'UNAUTHORIZED': raise InvalidToken() + if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': + raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING'): raise InvalidCookie() diff --git a/itd/routes/comments.py b/itd/routes/comments.py index e0e7395..d98ddf2 100644 --- a/itd/routes/comments.py +++ b/itd/routes/comments.py @@ -1,19 +1,21 @@ +from uuid import UUID + from itd.request import fetch -def add_comment(token: str, post_id: str, content: str, reply_comment_id: str | None = None): - data = {'content': content} - if reply_comment_id: - data['replyTo'] = str(reply_comment_id) - return fetch(token, 'post', f'posts/{post_id}/comments', data) +def add_comment(token: str, post_id: UUID, content: str): + return fetch(token, 'post', f'posts/{post_id}/comments', {'content': content}) -def get_comments(token: str, post_id: str, limit: int = 20, cursor: int = 0, sort: str = 'popular'): +def add_reply_comment(token: str, comment_id: UUID, content: str, author_id: UUID): + return fetch(token, 'post', f'comments/{comment_id}/replies', {'content': content, 'replyToUserId': str(author_id)}) + +def get_comments(token: str, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular'): return fetch(token, 'get', f'posts/{post_id}/comments', {'limit': limit, 'sort': sort, 'cursor': cursor}) -def like_comment(token: str, comment_id: str): +def like_comment(token: str, comment_id: UUID): return fetch(token, 'post', f'comments/{comment_id}/like') -def unlike_comment(token: str, comment_id: str): +def unlike_comment(token: str, comment_id: UUID): return fetch(token, 'delete', f'comments/{comment_id}/like') -def delete_comment(token: str, comment_id: str): +def delete_comment(token: str, comment_id: UUID): return fetch(token, 'delete', f'comments/{comment_id}')