diff --git a/pybotx/__init__.py b/pybotx/__init__.py index e14e9439..456feb01 100644 --- a/pybotx/__init__.py +++ b/pybotx/__init__.py @@ -39,6 +39,8 @@ ChatCreationError, ChatCreationProhibitedError, InvalidUsersListError, + ThreadCreationError, + ThreadCreationProhibitedError, ) from pybotx.client.exceptions.common import ( ChatNotFoundError, @@ -260,6 +262,9 @@ "SyncSmartAppEventHandlerFunc", "SyncSmartAppEventHandlerNotFoundError", "SyncSourceTypes", + "ThreadCreationError", + "ThreadCreationEventNotFoundError", + "ThreadCreationProhibitedError", "UnknownBotAccountError", "UnknownSystemEventError", "UnsupportedBotAPIVersionError", diff --git a/pybotx/bot/bot.py b/pybotx/bot/bot.py index 0cd21e17..887a0ee7 100644 --- a/pybotx/bot/bot.py +++ b/pybotx/bot/bot.py @@ -1,17 +1,13 @@ from asyncio import Task +from collections.abc import AsyncIterable, AsyncIterator, Iterator, Mapping, Sequence from contextlib import asynccontextmanager from datetime import datetime from types import SimpleNamespace from typing import ( Any, - AsyncIterable, - AsyncIterator, Dict, - Iterator, List, - Mapping, Optional, - Sequence, Set, Tuple, Union, @@ -57,6 +53,10 @@ BotXAPICreateChatRequestPayload, CreateChatMethod, ) +from pybotx.client.chats_api.create_thread import ( + BotXAPICreateThreadRequestPayload, + CreateThreadMethod, +) from pybotx.client.chats_api.disable_stealth import ( BotXAPIDisableStealthRequestPayload, DisableStealthMethod, @@ -1176,6 +1176,27 @@ async def create_chat( return botx_api_chat_id.to_domain() + async def create_thread(self, bot_id: UUID, sync_id: UUID) -> UUID: + """ + Create thread. + + :param bot_id: Bot which should perform the request. + :param sync_id: Message for which thread should be created + + :return: Created thread uuid. + """ + + method = CreateThreadMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + payload = BotXAPICreateThreadRequestPayload.from_domain(sync_id=sync_id) + botx_api_thread_id = await method.execute(payload) + + return botx_api_thread_id.to_domain() + async def pin_message( self, *, diff --git a/pybotx/client/chats_api/create_thread.py b/pybotx/client/chats_api/create_thread.py new file mode 100644 index 00000000..f7370b41 --- /dev/null +++ b/pybotx/client/chats_api/create_thread.py @@ -0,0 +1,57 @@ +from typing import Literal +from uuid import UUID + +from pybotx.client.authorized_botx_method import AuthorizedBotXMethod +from pybotx.client.botx_method import response_exception_thrower +from pybotx.client.exceptions.chats import ( + ThreadCreationError, + ThreadCreationProhibitedError, +) +from pybotx.client.exceptions.event import EventNotFoundError +from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPICreateThreadRequestPayload(UnverifiedPayloadBaseModel): + sync_id: UUID + + @classmethod + def from_domain(cls, sync_id: UUID) -> "BotXAPICreateThreadRequestPayload": + return cls(sync_id=sync_id) + + +class BotXAPIThreadIdResult(VerifiedPayloadBaseModel): + thread_id: UUID + + +class BotXAPICreateThreadResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIThreadIdResult + + def to_domain(self) -> UUID: + return self.result.thread_id + + +class CreateThreadMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 403: response_exception_thrower(ThreadCreationProhibitedError), + 404: response_exception_thrower(EventNotFoundError), + 422: response_exception_thrower(ThreadCreationError), + } + + async def execute( + self, + payload: BotXAPICreateThreadRequestPayload, + ) -> BotXAPICreateThreadResponsePayload: + path = "/api/v3/botx/chats/create_thread" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPICreateThreadResponsePayload, + response, + ) diff --git a/pybotx/client/exceptions/chats.py b/pybotx/client/exceptions/chats.py index 4134b5a0..50a2d3a1 100644 --- a/pybotx/client/exceptions/chats.py +++ b/pybotx/client/exceptions/chats.py @@ -15,3 +15,24 @@ class ChatCreationProhibitedError(BaseClientError): class ChatCreationError(BaseClientError): """Error while chat creation.""" + + +class ThreadCreationError(BaseClientError): + """Error while thread creation (invalid scheme).""" + + +class ThreadCreationProhibitedError(BaseClientError): + """ + Error while permission checks. + + 1. Bot has no permissions to create thread + 2. Threads are not allowed for that message + 3. Bot is not a chat member where message is located + 4. Message is located in personal chat + 5. Usupported event type + 6. Unsuppoerted chat type + 7. Thread is already created + 8. No access for message + 9. Message in stealth mode + 10. Message is deleted + """ diff --git a/pybotx/models/enums.py b/pybotx/models/enums.py index 05bc35fa..d56f61d7 100644 --- a/pybotx/models/enums.py +++ b/pybotx/models/enums.py @@ -59,6 +59,7 @@ class ChatTypes(AutoName): PERSONAL_CHAT = auto() GROUP_CHAT = auto() CHANNEL = auto() + THREAD = auto() class SyncSourceTypes(AutoName): @@ -92,6 +93,7 @@ class APIChatTypes(Enum): CHAT = "chat" GROUP_CHAT = "group_chat" CHANNEL = "channel" + THREAD = "thread" class BotAPICommandTypes(StrEnum): @@ -295,6 +297,7 @@ def convert_chat_type_from_domain(chat_type: ChatTypes) -> APIChatTypes: ChatTypes.PERSONAL_CHAT: APIChatTypes.CHAT, ChatTypes.GROUP_CHAT: APIChatTypes.GROUP_CHAT, ChatTypes.CHANNEL: APIChatTypes.CHANNEL, + ChatTypes.THREAD: APIChatTypes.THREAD, } converted_type = chat_types_mapping.get(chat_type) @@ -323,6 +326,7 @@ def convert_chat_type_to_domain( APIChatTypes.CHAT: ChatTypes.PERSONAL_CHAT, APIChatTypes.GROUP_CHAT: ChatTypes.GROUP_CHAT, APIChatTypes.CHANNEL: ChatTypes.CHANNEL, + APIChatTypes.THREAD: ChatTypes.THREAD, } converted_type: Optional[IncomingChatTypes] diff --git a/pyproject.toml b/pyproject.toml index 6bb84255..bfdfe41e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [tool.poetry] name = "pybotx" -version = "0.75.1" +version = "0.75.2" description = "A python library for interacting with eXpress BotX API" authors = [ "Sidnev Nikolay ", "Maxim Gorbachev ", "Alexander Samoylenko ", - "Arseniy Zhiltsov " + "Arseniy Zhiltsov ", ] readme = "README.md" repository = "https://github.com/ExpressApp/pybotx" diff --git a/tests/client/chats_api/test_create_thread.py b/tests/client/chats_api/test_create_thread.py new file mode 100644 index 00000000..bfb56fbd --- /dev/null +++ b/tests/client/chats_api/test_create_thread.py @@ -0,0 +1,261 @@ +from http import HTTPStatus +from typing import Any, Callable +from uuid import UUID + +import httpx +import pytest +from respx import Route +from respx.router import MockRouter + +from pybotx import ( + Bot, + BotAccountWithSecret, + EventNotFoundError, + HandlerCollector, + ThreadCreationError, + ThreadCreationProhibitedError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + +ENDPOINT = "api/v3/botx/chats/create_thread" + + +@pytest.fixture +def sync_id() -> str: + return "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" + + +@pytest.fixture +def create_mocked_endpoint( + respx_mock: MockRouter, + host: str, + sync_id: str, +) -> Callable[[dict[str, Any], int], Route]: + def mocked_endpoint(json_response: dict[str, Any], status_code: int) -> Route: + return respx_mock.post( + f"https://{host}/{ENDPOINT}", + headers={ + "Authorization": "Bearer token", + "Content-Type": "application/json", + }, + json={"sync_id": sync_id}, + ).mock(return_value=httpx.Response(status_code, json=json_response)) + + return mocked_endpoint + + +async def test__create_thread__succeed( + create_mocked_endpoint: Callable[[dict[str, Any], int], Route], + sync_id: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + thread_id = "2a8c0d1e-c4d1-4308-b024-6e1a9f4a4b6d" + endpoint = create_mocked_endpoint( + { + "status": "ok", + "result": {"thread_id": thread_id}, + }, + HTTPStatus.OK, + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + created_thread_id = await bot.create_thread( + bot_id=bot_id, + sync_id=UUID(sync_id), + ) + + # - Assert - + assert str(created_thread_id) == thread_id + assert endpoint.called + + +@pytest.mark.parametrize( + "return_json, response_status, expected_exc_type", + ( + ( + { + "status": "error", + "reason": "thread_creation_is_prohibited", + "errors": ["This bot is not allowed to create thread"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "threads_not_enabled", + "errors": ["Threads not enabled for this chat"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "bot_is_not_a_chat_member", + "errors": ["This bot is not a chat member"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "can_not_create_for_personal_chat", + "errors": ["This is personal chat"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "unsupported_event_type", + "errors": ["This event type is unsupported"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "unsupported_chat_type", + "errors": ["This chat type is unsupported"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "thread_already_created", + "errors": ["Thread already created"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "no_access_for_message", + "errors": ["There is no access for this message"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "event_in_stealth_mode", + "errors": ["This event is in stealth mode"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "event_already_deleted", + "errors": ["This event already deleted"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "event_not_found", + "errors": ["Event not found"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.NOT_FOUND, + EventNotFoundError, + ), + ( + { + "status": "error", + "reason": "|specified reason|", + "errors": ["|specified errors|"], + "error_data": {}, + }, + HTTPStatus.UNPROCESSABLE_ENTITY, + ThreadCreationError, + ), + ( + { + "status": "error", + "reason": None, + "errors": [], + "error_data": {}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "errors": [], + "error_data": {}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "unexpected reason", + "errors": [], + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ), +) +async def test__create_thread__botx_error_raised( + create_mocked_endpoint: Callable[[dict[str, Any], int], Route], + sync_id: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + return_json: dict[str, Any], + response_status: int, + expected_exc_type: type[BaseException], +) -> None: + # - Arrange - + endpoint = create_mocked_endpoint(return_json, response_status) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(expected_exc_type) as exc: + await bot.create_thread( + bot_id=bot_id, + sync_id=UUID(sync_id), + ) + + # - Assert - + assert endpoint.called + + if return_json.get("reason"): + assert return_json["reason"] in str(exc.value)