diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
deleted file mode 100644
index 3802c718..00000000
--- a/.github/CODEOWNERS
+++ /dev/null
@@ -1 +0,0 @@
-* @Nimond @Opti213 @YunusovSamat @lxmnk @vonabarak
diff --git a/.github/workflows/deploy-docs-gh-pages.yml b/.github/workflows/deploy-docs-gh-pages.yml
deleted file mode 100644
index 7a6b1e07..00000000
--- a/.github/workflows/deploy-docs-gh-pages.yml
+++ /dev/null
@@ -1,49 +0,0 @@
-name: Build and Deploy to GitHub Pages
-
-on:
- push:
- branches:
- - master
-
-jobs:
- build:
- runs-on: ubuntu-18.04
- steps:
- - uses: actions/checkout@v2
-
- - name: Set up Python
- uses: actions/setup-python@v1
- with:
- python-version: 3.8
-
- - name: Install poetry
- run: |
- pip install poetry==1.0
- poetry config virtualenvs.in-project true
-
- - name: Set up cache
- uses: actions/cache@v1
- id: cache
- with:
- path: .venv
- key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }}
-
- - name: Ensure cache is healthy
- if: steps.cache.outputs.cache-hit == 'true'
- shell: bash
- run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv
-
- - name: Install dependencies
- shell: bash
- run: poetry install --extras tests
-
- - name: Build MkDocs for GitHub Pages
- run: |
- poetry run nox -s build-docs
-
- - name: Deploy to GitHub Pages
- uses: JamesIves/github-pages-deploy-action@releases/v3
- with:
- ACCESS_TOKEN: ${{ secrets.GH_PAGES_TOKEN }}
- BRANCH: gh-pages
- FOLDER: site
\ No newline at end of file
diff --git a/.github/workflows/deploy-docs-netlify.yml b/.github/workflows/deploy-docs-netlify.yml
index 613ee0ba..4db3f0ab 100644
--- a/.github/workflows/deploy-docs-netlify.yml
+++ b/.github/workflows/deploy-docs-netlify.yml
@@ -1,13 +1,7 @@
name: Build and Deploy to Netlify
on:
- push:
- branches:
- - master
- pull_request:
- types:
- - opened
- - synchronize
+ push
env:
SITE_URL: https://pybotx.netlify.com
@@ -23,31 +17,13 @@ jobs:
with:
python-version: 3.8
- - name: Install poetry
- run: |
- pip install poetry==1.0
- poetry config virtualenvs.in-project true
-
- - name: Set up cache
- uses: actions/cache@v1
- id: cache
- with:
- path: .venv
- key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }}
-
- - name: Ensure cache is healthy
- if: steps.cache.outputs.cache-hit == 'true'
- shell: bash
- run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv
-
- - name: Install dependencies
- shell: bash
- run: poetry install --extras tests
+ - name: Setup dependencies
+ uses: ExpressApp/github-actions-poetry@v0.1
- name: Build MkDocs for Netlify
run: |
- poetry run nox -s build-docs
-
+ source .venv/bin/activate
+ mkdocs build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@v1
with:
@@ -56,4 +32,4 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
\ No newline at end of file
+ NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
diff --git a/.github/workflows/python_app.yml b/.github/workflows/python_app.yml
new file mode 100644
index 00000000..3a61c2a7
--- /dev/null
+++ b/.github/workflows/python_app.yml
@@ -0,0 +1,54 @@
+name: Python application
+on: push
+jobs:
+
+ test:
+ name: Test
+ runs-on: ubuntu-20.04
+ strategy:
+ matrix:
+ python-version: ["3.8", "3.9", "3.10"]
+
+ steps:
+ - name: Setup dependencies
+ uses: ExpressApp/github-actions-poetry@v0.2
+ with:
+ python-version: ${{ matrix.python-version }}
+ poetry-version: "1.1.12"
+
+ - name: Run tests
+ env:
+ BOT_CREDENTIALS: ${{ secrets.END_TO_END_TESTS_BOT_CREDENTIALS }}
+ run: |
+ poetry run ./scripts/test --cov-report=xml
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v2
+ with:
+ fail_ci_if_error: true
+ files: ./coverage.xml
+ flags: unittests
+
+ lint:
+ name: Lint
+ runs-on: ubuntu-20.04
+
+ steps:
+ - name: Setup dependencies
+ uses: ExpressApp/github-actions-poetry@v0.2
+
+ - name: Run linters
+ run: |
+ poetry run ./scripts/lint
+
+ docs-lint:
+ name: Docs lint
+ runs-on: ubuntu-20.04
+
+ steps:
+ - name: Setup dependencies
+ uses: ExpressApp/github-actions-poetry@v0.2
+
+ - name: Run linters
+ run: |
+ poetry run ./scripts/docs-lint
diff --git a/.github/workflows/styles.yml b/.github/workflows/styles.yml
deleted file mode 100644
index ac0807cf..00000000
--- a/.github/workflows/styles.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-name: Styles
-
-on: push
-
-jobs:
- styles:
- name: Styles
- runs-on: ubuntu-18.04
- strategy:
- matrix:
- python-version: [3.8]
- steps:
- - uses: actions/checkout@v1
-
- - name: Set up Python
- uses: actions/setup-python@v1
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install poetry
- run: |
- pip install poetry==1.0
- poetry config virtualenvs.in-project true
-
- - name: Set up cache
- uses: actions/cache@v1
- id: cache
- with:
- path: .venv
- key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }}
-
- - name: Ensure cache is healthy
- if: steps.cache.outputs.cache-hit == 'true'
- shell: bash
- run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv
-
- - name: Install dependencies
- shell: bash
- run: poetry install --extras tests
-
- - name: Run linters
- run: |
- poetry run nox -s lint
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
deleted file mode 100644
index 577c321e..00000000
--- a/.github/workflows/tests.yml
+++ /dev/null
@@ -1,48 +0,0 @@
-name: Tests
-
-on: push
-
-jobs:
- tests:
- name: Tests
- runs-on: ubuntu-18.04
- strategy:
- matrix:
- python-version: [3.7, 3.8, 3.9]
- steps:
- - uses: actions/checkout@master
-
- - name: Set up Python
- uses: actions/setup-python@v1
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install poetry
- run: |
- pip install poetry==1.0
- poetry config virtualenvs.in-project true
-
- - name: Set up cache
- uses: actions/cache@v1
- id: cache
- with:
- path: .venv
- key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }}
-
- - name: Ensure cache is healthy
- if: steps.cache.outputs.cache-hit == 'true'
- shell: bash
- run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv
-
- - name: Install dependencies
- shell: bash
- run: poetry install --extras tests
-
- - name: Run tests
- run: |
- poetry run nox -s test
-
- - uses: codecov/codecov-action@v1
- with:
- file: ./coverage.xml
- fail_ci_if_error: true
diff --git a/.gitignore b/.gitignore
index 1a1155c1..d91383cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,111 +1,8 @@
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-.hypothesis/
-.pytest_cache/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# pyenv
-.python-version
-
-# celery beat schedule file
-celerybeat-schedule
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
.env
-.venv
-env/
+.venv/
venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-
+__pycache__
+htmlcov
+site
.idea/
-.vscode/
-
-static/
-
-**/.DS_Store
diff --git a/README.md b/README.md
index 4cac9024..ac2a8dd7 100644
--- a/README.md
+++ b/README.md
@@ -1,126 +1,37 @@
-
pybotx
-
- A little python framework for building bots for eXpress messenger.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+# pybotx
+*A python library for building bots and smartapps for eXpress messenger.*
----
+[![PyPI version](https://badge.fury.io/py/botx.svg)](https://badge.fury.io/py/botx)
+![PyPI - Python Version](https://img.shields.io/pypi/pyversions/botx)
+[![Coverage](https://codecov.io/gh/ExpressApp/pybotx/branch/master/graph/badge.svg)](https://codecov.io/gh/ExpressApp/pybotx/branch/master)
+[![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black)
-# Introduction
-`pybotx` is a framework for building bots for eXpress providing a mechanism for simple
-integration with your favourite web frameworks.
+## Features
-Main features:
+* Designed to be easy to use
+* Simple integration with async web-frameworks
+* Support middlewares for command, command-collector and bot
+* 100% test coverage
+* 100% type annotated codebase
- * Simple integration with your web apps.
- * Asynchronous API with synchronous as a fallback option.
- * 100% test coverage.
- * 100% type annotated codebase.
+## Documentation
-**NOTE**: *This library is under active development and its API may be unstable. Please lock the version you are using at the minor update level. For example, like this in `poetry`.*
+Documentation will be here:
+For now, pls contact eXpress team, we'll help you.
-```toml
-[tool.poetry.dependencies]
-botx = "^0.15.0"
-```
-
----
-
-## Requirements
-
-Python 3.7+
+**Note:** Available only in Russian language
-`pybotx` use the following libraries:
-
-* pydantic for the data parts.
-* httpx for making HTTP calls to BotX API.
-* loguru for beautiful and powerful logs.
-* **Optional**. Starlette for tests.
## Installation
-```bash
-$ pip install botx
-```
-
-Or if you are going to write tests:
-
-```bash
-$ pip install botx[tests]
-```
-
-You will also need a web framework to create bots as the current BotX API only works with webhooks.
-This documentation will use FastAPI for the examples bellow.
-```bash
-$ pip install fastapi uvicorn
-```
-
-## Example
-Let's create a simple echo bot.
+Install pybotx using `pip`:
-* Create a file `main.py` with following content:
-
-```python3
-from botx import Bot, BotXCredentials, IncomingMessage, Message, Status
-from fastapi import FastAPI
-from starlette.status import HTTP_202_ACCEPTED
-from uuid import UUID
-
-
-bot_accounts=[
- BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))
-]
-bot = Bot(bot_accounts=bot_accounts)
-
-
-@bot.default(include_in_status=False)
-async def echo_handler(message: Message) -> None:
- await bot.answer_message(message.body, message)
-
-
-app = FastAPI()
-app.add_event_handler("shutdown", bot.shutdown)
-
-
-@app.get("/status", response_model=Status)
-async def bot_status() -> Status:
- return await bot.status()
-
-
-@app.post("/command", status_code=HTTP_202_ACCEPTED)
-async def bot_command(message: IncomingMessage) -> None:
- await bot.execute_command(message.dict())
-```
-
-* Deploy a bot on your server using uvicorn and set the url for the webhook in Express.
```bash
-$ uvicorn main:app --host=0.0.0.0
+pip install git+https://github.com/ExpressApp/pybotx.git
```
-This bot will send back every your message.
-
-## License
-
-This project is licensed under the terms of the MIT license.
+**Note:** This project is under active development (`0.y.z`) and its API may be
+unstable.
diff --git a/botx/__init__.py b/botx/__init__.py
index 0bdf1196..ecd2d420 100644
--- a/botx/__init__.py
+++ b/botx/__init__.py
@@ -1,157 +1,173 @@
-"""A little python framework for building bots for Express."""
-
-from loguru import logger
-
-from botx.bots.bots import Bot
-from botx.clients.clients.async_client import AsyncClient
-from botx.clients.clients.sync_client import Client
-from botx.clients.types.message_payload import InternalBotNotificationPayload
-from botx.collecting.collectors.collector import Collector
-from botx.dependencies.injection_params import Depends
-from botx.exceptions import BotXAPIError, DependencyFailure, TokenError, UnknownBotError
-from botx.models.attachments import (
- AttachList,
- Attachment,
- Contact,
- Document,
- Image,
- Link,
- Location,
- Video,
- Voice,
+from botx.bot.api.exceptions import UnsupportedBotAPIVersionError
+from botx.bot.api.responses.bot_disabled import (
+ BotAPIBotDisabledResponse,
+ build_bot_disabled_response,
+)
+from botx.bot.api.responses.command_accepted import build_command_accepted_response
+from botx.bot.bot import Bot
+from botx.bot.exceptions import (
+ AnswerDestinationLookupError,
+ BotShuttingDownError,
+ BotXMethodCallbackNotFoundError,
+ UnknownBotAccountError,
+)
+from botx.bot.handler import IncomingMessageHandlerFunc, Middleware
+from botx.bot.handler_collector import HandlerCollector
+from botx.bot.testing import lifespan_wrapper
+from botx.client.exceptions.callbacks import (
+ BotXMethodFailedCallbackReceivedError,
+ CallbackNotReceivedError,
+)
+from botx.client.exceptions.chats import (
+ CantUpdatePersonalChatError,
+ ChatCreationError,
+ ChatCreationProhibitedError,
+ InvalidUsersListError,
+)
+from botx.client.exceptions.common import (
+ ChatNotFoundError,
+ InvalidBotAccountError,
+ PermissionDeniedError,
+ RateLimitReachedError,
+)
+from botx.client.exceptions.event import EventNotFoundError
+from botx.client.exceptions.files import FileDeletedError, FileMetadataNotFound
+from botx.client.exceptions.http import (
+ InvalidBotXResponsePayloadError,
+ InvalidBotXStatusCodeError,
+)
+from botx.client.exceptions.notifications import (
+ BotIsNotChatMemberError,
+ FinalRecipientsListEmptyError,
+ StealthModeDisabledError,
)
-from botx.models.buttons import BubbleElement, KeyboardElement
-from botx.models.credentials import BotXCredentials
-from botx.models.entities import (
- ChatMention,
- Entity,
- EntityList,
- Forward,
- Mention,
- Reply,
- UserMention,
+from botx.client.exceptions.users import UserNotFoundError
+from botx.client.stickers_api.exceptions import (
+ InvalidEmojiError,
+ InvalidImageError,
+ StickerPackOrStickerNotFoundError,
)
+from botx.logger import logger
+from botx.models.async_files import Document, File, Image, Video, Voice
+from botx.models.attachments import OutgoingAttachment
+from botx.models.bot_account import BotAccount, BotAccountWithSecret
+from botx.models.bot_sender import BotSender
+from botx.models.chats import Chat, ChatInfo, ChatInfoMember, ChatListItem
from botx.models.enums import (
+ AttachmentTypes,
ChatTypes,
- CommandTypes,
- EntityTypes,
+ ClientPlatforms,
MentionTypes,
- Statuses,
- SystemEvents,
UserKinds,
)
-from botx.models.errors import BotDisabledErrorData, BotDisabledResponse
-from botx.models.events import ChatCreatedEvent, InternalBotNotificationEvent
-from botx.models.files import File
-from botx.models.menu import Status
-from botx.models.messages.incoming_message import IncomingMessage
-from botx.models.messages.message import Message
-from botx.models.messages.sending.credentials import SendingCredentials
-from botx.models.messages.sending.markup import MessageMarkup
-from botx.models.messages.sending.message import SendingMessage
-from botx.models.messages.sending.options import MessageOptions, NotificationOptions
-from botx.models.messages.sending.payload import MessagePayload, UpdatePayload
-from botx.models.smartapps import SendingSmartAppEvent, SendingSmartAppNotification
-from botx.models.status import StatusRecipient
-from botx.models.stickers import (
- Pagination,
- Sticker,
- StickerFromPack,
- StickerPack,
- StickerPackList,
- StickerPackPreview,
+from botx.models.message.edit_message import EditMessage
+from botx.models.message.forward import Forward
+from botx.models.message.incoming_message import IncomingMessage, UserDevice, UserSender
+from botx.models.message.markup import BubbleMarkup, Button, KeyboardMarkup
+from botx.models.message.mentions import Mention, MentionList
+from botx.models.message.message_status import MessageStatus
+from botx.models.message.outgoing_message import OutgoingMessage
+from botx.models.message.reply import Reply
+from botx.models.message.reply_message import ReplyMessage
+from botx.models.method_callbacks import BotAPIMethodFailedCallback
+from botx.models.status import BotMenu, StatusRecipient
+from botx.models.stickers import Sticker, StickerPack
+from botx.models.system_events.added_to_chat import AddedToChatEvent
+from botx.models.system_events.chat_created import ChatCreatedEvent, ChatCreatedMember
+from botx.models.system_events.cts_login import CTSLoginEvent
+from botx.models.system_events.cts_logout import CTSLogoutEvent
+from botx.models.system_events.deleted_from_chat import DeletedFromChatEvent
+from botx.models.system_events.internal_bot_notification import (
+ InternalBotNotificationEvent,
)
-from botx.testing.building.builder import MessageBuilder
-
-try:
- from botx.testing.testing_client.client import TestClient # noqa: WPS433
-except ImportError:
- TestClient = None # type: ignore # noqa: WPS440
+from botx.models.system_events.left_from_chat import LeftFromChatEvent
+from botx.models.system_events.smartapp_event import SmartAppEvent
+from botx.models.users import UserFromSearch
__all__ = (
- # bots
+ "AddedToChatEvent",
+ "AnswerDestinationLookupError",
+ "AttachmentTypes",
"Bot",
- # collecting
- "Collector",
- # clients
- "AsyncClient",
- "Client",
- # exceptions
- "BotXAPIError",
- "DependencyFailure",
- "UnknownBotError",
- "TokenError",
- # DI
- "Depends",
- # models
- # markup
- "BubbleElement",
- "KeyboardElement",
- # credentials
- "BotXCredentials",
- # enums
- "Statuses",
- "UserKinds",
- "ChatTypes",
- "CommandTypes",
- "SystemEvents",
- "EntityTypes",
- "MentionTypes",
- # errors
- "BotDisabledErrorData",
- "BotDisabledResponse",
- # events
+ "BotAccountWithSecret",
+ "BotAPIBotDisabledResponse",
+ "BotAPIMethodFailedCallback",
+ "BotIsNotChatMemberError",
+ "BotMenu",
+ "BotAccount",
+ "BotSender",
+ "BotShuttingDownError",
+ "BotXMethodCallbackNotFoundError",
+ "BotXMethodFailedCallbackReceivedError",
+ "BubbleMarkup",
+ "Button",
+ "CallbackNotReceivedError",
+ "CantUpdatePersonalChatError",
+ "Chat",
"ChatCreatedEvent",
- "InternalBotNotificationEvent",
- # files
+ "ChatCreatedMember",
+ "ChatCreationError",
+ "ChatCreationProhibitedError",
+ "ChatInfo",
+ "ChatInfoMember",
+ "ChatListItem",
+ "ChatNotFoundError",
+ "ChatTypes",
+ "ClientPlatforms",
+ "CTSLoginEvent",
+ "CTSLogoutEvent",
+ "DeletedFromChatEvent",
+ "Document",
+ "EditMessage",
+ "MessageStatus",
"File",
- # attachments
+ "FileDeletedError",
+ "FileMetadataNotFound",
+ "FinalRecipientsListEmptyError",
+ "Forward",
+ "HandlerCollector",
"Image",
- "Video",
- "Document",
- "Voice",
- "Location",
- "Contact",
- "Link",
- "Attachment",
- "AttachList",
- # mentions
- "Mention",
- "ChatMention",
- "UserMention",
- # status
- "Status",
- "StatusRecipient",
- # messages
- # handler message
- "Message",
- # incoming
"IncomingMessage",
- "Entity",
- "EntityList",
- "Forward",
+ "IncomingMessageHandlerFunc",
+ "InternalBotNotificationEvent",
+ "InvalidBotAccountError",
+ "InvalidBotXResponsePayloadError",
+ "InvalidBotXStatusCodeError",
+ "InvalidEmojiError",
+ "InvalidImageError",
+ "InvalidUsersListError",
+ "EventNotFoundError",
+ "KeyboardMarkup",
+ "LeftFromChatEvent",
+ "Mention",
+ "MentionList",
+ "MentionTypes",
+ "Middleware",
+ "OutgoingAttachment",
+ "OutgoingMessage",
+ "PermissionDeniedError",
+ "RateLimitReachedError",
"Reply",
- # sending
- "SendingCredentials",
- "SendingMessage",
- "MessageMarkup",
- "MessageOptions",
- "NotificationOptions",
- "MessagePayload",
- "UpdatePayload",
- "InternalBotNotificationPayload",
- # Stickers
- "Pagination",
+ "ReplyMessage",
+ "SmartAppEvent",
+ "SmartAppEvent",
+ "StatusRecipient",
+ "StealthModeDisabledError",
"Sticker",
"StickerPack",
- "StickerPackList",
- "StickerFromPack",
- "StickerPackPreview",
- "SendingSmartAppEvent",
- "SendingSmartAppNotification",
- # testing
- "TestClient",
- "MessageBuilder",
+ "StickerPackOrStickerNotFoundError",
+ "UnknownBotAccountError",
+ "UnsupportedBotAPIVersionError",
+ "UserDevice",
+ "UserFromSearch",
+ "UserKinds",
+ "UserNotFoundError",
+ "UserSender",
+ "Video",
+ "Voice",
+ "build_bot_disabled_response",
+ "build_command_accepted_response",
+ "lifespan_wrapper",
)
logger.disable("botx")
diff --git a/botx/async_buffer.py b/botx/async_buffer.py
new file mode 100644
index 00000000..d5e8a8a5
--- /dev/null
+++ b/botx/async_buffer.py
@@ -0,0 +1,33 @@
+import os
+from typing import Optional
+
+try:
+ from typing import Protocol
+except ImportError:
+ from typing_extensions import Protocol # type: ignore # noqa: WPS440
+
+
+class AsyncBufferBase(Protocol):
+ async def seek(self, cursor: int, whence: int = os.SEEK_SET) -> int:
+ ... # noqa: WPS428
+
+ async def tell(self) -> int:
+ ... # noqa: WPS428
+
+
+class AsyncBufferWritable(AsyncBufferBase):
+ async def write(self, content: bytes) -> int:
+ ... # noqa: WPS428
+
+
+class AsyncBufferReadable(AsyncBufferBase):
+ async def read(self, bytes_to_read: Optional[int] = None) -> bytes:
+ ... # noqa: WPS428
+
+
+async def get_file_size(async_buffer: AsyncBufferReadable) -> int:
+ await async_buffer.seek(0, os.SEEK_END)
+ file_size = await async_buffer.tell()
+ await async_buffer.seek(0)
+
+ return file_size
diff --git a/docs/src/__init__.py b/botx/bot/__init__.py
similarity index 100%
rename from docs/src/__init__.py
rename to botx/bot/__init__.py
diff --git a/docs/src/development/__init__.py b/botx/bot/api/__init__.py
similarity index 100%
rename from docs/src/development/__init__.py
rename to botx/bot/api/__init__.py
diff --git a/botx/bot/api/exceptions.py b/botx/bot/api/exceptions.py
new file mode 100644
index 00000000..47cd0a5d
--- /dev/null
+++ b/botx/bot/api/exceptions.py
@@ -0,0 +1,10 @@
+from botx.constants import BOT_API_VERSION
+
+
+class UnsupportedBotAPIVersionError(Exception):
+ def __init__(self, version: int) -> None:
+ self.version = version
+ self.message = (
+ f"Unsupported Bot API version: `{version}`, expected `{BOT_API_VERSION}`"
+ )
+ super().__init__(self.message)
diff --git a/docs/src/development/collector/__init__.py b/botx/bot/api/responses/__init__.py
similarity index 100%
rename from docs/src/development/collector/__init__.py
rename to botx/bot/api/responses/__init__.py
diff --git a/botx/bot/api/responses/bot_disabled.py b/botx/bot/api/responses/bot_disabled.py
new file mode 100644
index 00000000..c8a2f3af
--- /dev/null
+++ b/botx/bot/api/responses/bot_disabled.py
@@ -0,0 +1,41 @@
+from dataclasses import asdict, dataclass, field
+from typing import Any, Dict, List, Literal
+
+
+@dataclass
+class BotAPIBotDisabledErrorData:
+ status_message: str
+
+
+@dataclass
+class BotAPIBotDisabledResponse:
+ """Disabled bot response model.
+
+ Only `.error_data.status_message` attribute will be displayed to
+ user. Other attributes will be visible only in BotX logs.
+ """
+
+ error_data: BotAPIBotDisabledErrorData
+ errors: List[str] = field(default_factory=list)
+ reason: Literal["bot_disabled"] = "bot_disabled"
+
+
+def build_bot_disabled_response(status_message: str) -> Dict[str, Any]:
+ """Build bot disabled response for BotX.
+
+ It should be send if the bot can't process the command.
+
+ If you would like to build complex response, see
+ [BotAPIBotDisabledResponse]
+ [botx.bot.api.commands.bot_disabled_response.BotAPIBotDisabledResponse].
+
+ :param status_message: Status message.
+
+ :return: Built bot disabled response.
+ """
+
+ response = BotAPIBotDisabledResponse(
+ error_data=BotAPIBotDisabledErrorData(status_message=status_message),
+ )
+
+ return asdict(response)
diff --git a/botx/bot/api/responses/command_accepted.py b/botx/bot/api/responses/command_accepted.py
new file mode 100644
index 00000000..a0713734
--- /dev/null
+++ b/botx/bot/api/responses/command_accepted.py
@@ -0,0 +1,12 @@
+from typing import Any, Dict
+
+
+def build_command_accepted_response() -> Dict[str, Any]:
+ """Build accepted response for BotX.
+
+ It should be sent if the bot started processing a command.
+
+ :return: Built accepted response.
+ """
+
+ return {"result": "accepted"}
diff --git a/botx/bot/bot.py b/botx/bot/bot.py
new file mode 100644
index 00000000..9d3d8c56
--- /dev/null
+++ b/botx/bot/bot.py
@@ -0,0 +1,1466 @@
+from types import SimpleNamespace
+from typing import Any, AsyncIterable, Dict, Iterator, List, Optional, Sequence, Union
+from uuid import UUID
+
+import httpx
+from pydantic import ValidationError, parse_obj_as
+
+from botx.async_buffer import AsyncBufferReadable, AsyncBufferWritable
+from botx.bot.bot_accounts_storage import BotAccountsStorage
+from botx.bot.callbacks_manager import CallbacksManager
+from botx.bot.contextvars import bot_id_var, chat_id_var
+from botx.bot.exceptions import AnswerDestinationLookupError
+from botx.bot.handler import Middleware
+from botx.bot.handler_collector import HandlerCollector
+from botx.bot.middlewares.exception_middleware import ExceptionHandlersDict
+from botx.client.chats_api.add_admin import (
+ AddAdminMethod,
+ BotXAPIAddAdminRequestPayload,
+)
+from botx.client.chats_api.add_user import AddUserMethod, BotXAPIAddUserRequestPayload
+from botx.client.chats_api.chat_info import (
+ BotXAPIChatInfoRequestPayload,
+ ChatInfoMethod,
+)
+from botx.client.chats_api.create_chat import (
+ BotXAPICreateChatRequestPayload,
+ CreateChatMethod,
+)
+from botx.client.chats_api.disable_stealth import (
+ BotXAPIDisableStealthRequestPayload,
+ DisableStealthMethod,
+)
+from botx.client.chats_api.list_chats import ListChatsMethod
+from botx.client.chats_api.pin_message import (
+ BotXAPIPinMessageRequestPayload,
+ PinMessageMethod,
+)
+from botx.client.chats_api.remove_user import (
+ BotXAPIRemoveUserRequestPayload,
+ RemoveUserMethod,
+)
+from botx.client.chats_api.set_stealth import (
+ BotXAPISetStealthRequestPayload,
+ SetStealthMethod,
+)
+from botx.client.chats_api.unpin_message import (
+ BotXAPIUnpinMessageRequestPayload,
+ UnpinMessageMethod,
+)
+from botx.client.events_api.edit_event import (
+ BotXAPIEditEventRequestPayload,
+ EditEventMethod,
+)
+from botx.client.events_api.message_status_event import (
+ BotXAPIMessageStatusRequestPayload,
+ MessageStatusMethod,
+)
+from botx.client.events_api.reply_event import (
+ BotXAPIReplyEventRequestPayload,
+ ReplyEventMethod,
+)
+from botx.client.events_api.stop_typing_event import (
+ BotXAPIStopTypingEventRequestPayload,
+ StopTypingEventMethod,
+)
+from botx.client.events_api.typing_event import (
+ BotXAPITypingEventRequestPayload,
+ TypingEventMethod,
+)
+from botx.client.exceptions.common import InvalidBotAccountError
+from botx.client.files_api.download_file import (
+ BotXAPIDownloadFileRequestPayload,
+ DownloadFileMethod,
+)
+from botx.client.files_api.upload_file import (
+ BotXAPIUploadFileRequestPayload,
+ UploadFileMethod,
+)
+from botx.client.get_token import get_token
+from botx.client.notifications_api.direct_notification import (
+ BotXAPIDirectNotificationRequestPayload,
+ DirectNotificationMethod,
+)
+from botx.client.notifications_api.internal_bot_notification import (
+ BotXAPIInternalBotNotificationRequestPayload,
+ InternalBotNotificationMethod,
+)
+from botx.client.smartapps_api.smartapp_event import (
+ BotXAPISmartAppEventRequestPayload,
+ SmartAppEventMethod,
+)
+from botx.client.smartapps_api.smartapp_notification import (
+ BotXAPISmartAppNotificationRequestPayload,
+ SmartAppNotificationMethod,
+)
+from botx.client.stickers_api.add_sticker import (
+ AddStickerMethod,
+ BotXAPIAddStickerRequestPayload,
+)
+from botx.client.stickers_api.create_sticker_pack import (
+ BotXAPICreateStickerPackRequestPayload,
+ CreateStickerPackMethod,
+)
+from botx.client.stickers_api.delete_sticker import (
+ BotXAPIDeleteStickerRequestPayload,
+ DeleteStickerMethod,
+)
+from botx.client.stickers_api.delete_sticker_pack import (
+ BotXAPIDeleteStickerPackRequestPayload,
+ DeleteStickerPackMethod,
+)
+from botx.client.stickers_api.edit_sticker_pack import (
+ BotXAPIEditStickerPackRequestPayload,
+ EditStickerPackMethod,
+)
+from botx.client.stickers_api.get_sticker import (
+ BotXAPIGetStickerRequestPayload,
+ GetStickerMethod,
+)
+from botx.client.stickers_api.get_sticker_pack import (
+ BotXAPIGetStickerPackRequestPayload,
+ GetStickerPackMethod,
+)
+from botx.client.stickers_api.get_sticker_packs import (
+ BotXAPIGetStickerPacksRequestPayload,
+ GetStickerPacksMethod,
+)
+from botx.client.users_api.search_user_by_email import (
+ BotXAPISearchUserByEmailRequestPayload,
+ SearchUserByEmailMethod,
+)
+from botx.client.users_api.search_user_by_huid import (
+ BotXAPISearchUserByHUIDRequestPayload,
+ SearchUserByHUIDMethod,
+)
+from botx.client.users_api.search_user_by_login import (
+ BotXAPISearchUserByLoginRequestPayload,
+ SearchUserByLoginMethod,
+)
+from botx.constants import STICKER_PACKS_PER_PAGE
+from botx.converters import optional_sequence_to_list
+from botx.image_validators import (
+ ensure_file_content_is_png,
+ ensure_sticker_image_size_valid,
+)
+from botx.logger import logger, pformat_jsonable_obj, trim_file_data_in_incoming_json
+from botx.missing import Missing, MissingOptional, Undefined, not_undefined
+from botx.models.async_files import File
+from botx.models.attachments import IncomingFileAttachment, OutgoingAttachment
+from botx.models.bot_account import BotAccount, BotAccountWithSecret
+from botx.models.chats import ChatInfo, ChatListItem
+from botx.models.commands import BotAPICommand, BotCommand
+from botx.models.enums import ChatTypes
+from botx.models.message.edit_message import EditMessage
+from botx.models.message.markup import BubbleMarkup, KeyboardMarkup
+from botx.models.message.message_status import MessageStatus
+from botx.models.message.outgoing_message import OutgoingMessage
+from botx.models.message.reply_message import ReplyMessage
+from botx.models.method_callbacks import BotXMethodCallback
+from botx.models.status import (
+ BotAPIStatusRecipient,
+ BotMenu,
+ StatusRecipient,
+ build_bot_status_response,
+)
+from botx.models.stickers import Sticker, StickerPack, StickerPackFromList
+from botx.models.users import UserFromSearch
+
+MissingOptionalAttachment = MissingOptional[
+ Union[IncomingFileAttachment, OutgoingAttachment]
+]
+
+
+class Bot:
+ def __init__(
+ self,
+ *,
+ collectors: Sequence[HandlerCollector],
+ bot_accounts: Sequence[BotAccountWithSecret],
+ middlewares: Optional[Sequence[Middleware]] = None,
+ httpx_client: Optional[httpx.AsyncClient] = None,
+ exception_handlers: Optional[ExceptionHandlersDict] = None,
+ default_callback_timeout: Optional[int] = None,
+ ) -> None:
+ if not collectors:
+ logger.warning("Bot has no connected collectors")
+ if not bot_accounts:
+ logger.warning("Bot has no bot accounts")
+
+ self.state: SimpleNamespace = SimpleNamespace()
+
+ self.default_callback_timeout = default_callback_timeout
+
+ middlewares = optional_sequence_to_list(middlewares)
+
+ self._handler_collector = self._build_main_collector(
+ collectors,
+ middlewares,
+ exception_handlers,
+ )
+
+ self._bot_accounts_storage = BotAccountsStorage(list(bot_accounts))
+ self._httpx_client = httpx_client or httpx.AsyncClient()
+
+ self._callback_manager = CallbacksManager()
+
+ def async_execute_raw_bot_command(self, raw_bot_command: Dict[str, Any]) -> None:
+ try:
+ bot_api_command: BotAPICommand = parse_obj_as(
+ # Same ignore as in pydantic
+ BotAPICommand, # type: ignore[arg-type]
+ raw_bot_command,
+ )
+ except ValidationError as validation_exc:
+ raise ValueError("Bot command validation error") from validation_exc
+
+ logger.opt(lazy=True).debug(
+ "Got command: {command}",
+ command=lambda: pformat_jsonable_obj(
+ trim_file_data_in_incoming_json(raw_bot_command),
+ ),
+ )
+
+ bot_command = bot_api_command.to_domain(raw_bot_command)
+ self.async_execute_bot_command(bot_command)
+
+ def async_execute_bot_command(self, bot_command: BotCommand) -> None:
+ # raise UnknownBotAccountError if no bot account with this bot_id.
+ self._bot_accounts_storage.ensure_bot_id_exists(bot_command.bot.id)
+
+ self._handler_collector.async_handle_bot_command(self, bot_command)
+
+ async def raw_get_status(self, query_params: Dict[str, str]) -> Dict[str, Any]:
+ logger.opt(lazy=True).debug(
+ "Got status: {status}",
+ status=lambda: pformat_jsonable_obj(query_params),
+ )
+
+ try:
+ bot_api_status_recipient = BotAPIStatusRecipient.parse_obj(query_params)
+ except ValidationError as exc:
+ raise ValueError("Status request validation error") from exc
+
+ status_recipient = bot_api_status_recipient.to_domain()
+
+ bot_menu = await self.get_status(status_recipient)
+ return build_bot_status_response(bot_menu)
+
+ async def get_status(self, status_recipient: StatusRecipient) -> BotMenu:
+ # raise UnknownBotAccountError if no bot account with this bot_id.
+ self._bot_accounts_storage.ensure_bot_id_exists(status_recipient.bot_id)
+
+ return await self._handler_collector.get_bot_menu(status_recipient, self)
+
+ def set_raw_botx_method_result(
+ self,
+ raw_botx_method_result: Dict[str, Any],
+ ) -> None:
+ logger.debug("Got callback: {callback}", callback=raw_botx_method_result)
+
+ callback: BotXMethodCallback = parse_obj_as(
+ # Same ignore as in pydantic
+ BotXMethodCallback, # type: ignore[arg-type]
+ raw_botx_method_result,
+ )
+
+ self._callback_manager.set_botx_method_callback_result(callback)
+
+ async def wait_botx_method_callback(
+ self,
+ sync_id: UUID,
+ timeout: Optional[int],
+ ) -> BotXMethodCallback:
+ return await self._callback_manager.wait_botx_method_callback(sync_id, timeout)
+
+ @property
+ def bot_accounts(self) -> Iterator[BotAccount]:
+ yield from self._bot_accounts_storage.iter_bot_accounts()
+
+ async def startup(self) -> None:
+ for bot_account in self.bot_accounts:
+ try:
+ token = await self.get_token(bot_id=bot_account.id)
+ except (InvalidBotAccountError, httpx.HTTPError):
+ logger.warning(
+ "Can't get token for bot account: "
+ f"host - {bot_account.host}, bot_id - {bot_account.id}",
+ )
+ continue
+
+ self._bot_accounts_storage.set_token(bot_account.id, token)
+
+ async def shutdown(self) -> None:
+ self._callback_manager.stop_callbacks_waiting()
+ await self._handler_collector.wait_active_tasks()
+ await self._httpx_client.aclose()
+
+ # - Bots API -
+ async def get_token(
+ self,
+ *,
+ bot_id: UUID,
+ ) -> str:
+ """Get bot auth token.
+
+ :param bot_id: Bot which should perform the request.
+
+ :return: Auth token.
+ """
+
+ return await get_token(bot_id, self._httpx_client, self._bot_accounts_storage)
+
+ # - Notifications API -
+ async def answer_message(
+ self,
+ body: str,
+ *,
+ metadata: Missing[Dict[str, Any]] = Undefined,
+ bubbles: Missing[BubbleMarkup] = Undefined,
+ keyboard: Missing[KeyboardMarkup] = Undefined,
+ file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined,
+ recipients: Missing[List[UUID]] = Undefined,
+ silent_response: Missing[bool] = Undefined,
+ markup_auto_adjust: Missing[bool] = Undefined,
+ stealth_mode: Missing[bool] = Undefined,
+ send_push: Missing[bool] = Undefined,
+ ignore_mute: Missing[bool] = Undefined,
+ wait_callback: bool = True,
+ callback_timeout: MissingOptional[int] = Undefined,
+ ) -> UUID:
+ """Answer to incoming message.
+
+ Works just like `Bot.send`, but `bot_id` and `chat_id` are
+ taken from the incoming message.
+
+ :param body: Message body.
+ :param metadata: Notification options.
+ :param bubbles: Bubbles (buttons attached to message) markup.
+ :param keyboard: Keyboard (buttons below message input) markup.
+ :param file: Attachment.
+ :param recipients: List of recipients, empty for all in chat.
+ :param silent_response: (BotX default: False) Exclude next user
+ messages from history.
+ :param markup_auto_adjust: (BotX default: False) Move button to next
+ row, if its text doesn't fit.
+ :param stealth_mode: (BotX default: False) Enable stealth mode.
+ :param send_push: (BotX default: True) Send push notification on
+ devices.
+ :param ignore_mute: (BotX default: False) Ignore mute or dnd (do not
+ disturb).
+ :param wait_callback: Block method call until callback received.
+ :param callback_timeout: Callback timeout in seconds (or `None` for
+ endless waiting).
+
+ :raises AnswerDestinationLookupError: If you try to answer without
+ receiving incoming message.
+
+ :return: Notification sync_id.
+ """
+
+ try: # noqa: WPS229
+ bot_id = bot_id_var.get()
+ chat_id = chat_id_var.get()
+ except LookupError as exc:
+ raise AnswerDestinationLookupError from exc
+
+ return await self.send_message(
+ bot_id=bot_id,
+ chat_id=chat_id,
+ body=body,
+ metadata=metadata,
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ recipients=recipients,
+ silent_response=silent_response,
+ markup_auto_adjust=markup_auto_adjust,
+ stealth_mode=stealth_mode,
+ send_push=send_push,
+ ignore_mute=ignore_mute,
+ wait_callback=wait_callback,
+ callback_timeout=callback_timeout,
+ )
+
+ async def send(
+ self,
+ *,
+ message: OutgoingMessage,
+ wait_callback: bool = True,
+ callback_timeout: MissingOptional[int] = Undefined,
+ ) -> UUID:
+ """Send internal notification.
+
+ :param message: Built outgoing message.
+ :param wait_callback: Wait for callback.
+ :param callback_timeout: Timeout for waiting for callback.
+
+ :return: Notification sync_id.
+ """
+
+ return await self.send_message(
+ bot_id=message.bot_id,
+ chat_id=message.chat_id,
+ body=message.body,
+ metadata=message.metadata,
+ bubbles=message.bubbles,
+ keyboard=message.keyboard,
+ file=message.file,
+ recipients=message.recipients,
+ silent_response=message.silent_response,
+ markup_auto_adjust=message.markup_auto_adjust,
+ stealth_mode=message.stealth_mode,
+ send_push=message.send_push,
+ ignore_mute=message.ignore_mute,
+ wait_callback=wait_callback,
+ callback_timeout=callback_timeout,
+ )
+
+ async def send_message(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ body: str,
+ metadata: Missing[Dict[str, Any]] = Undefined,
+ bubbles: Missing[BubbleMarkup] = Undefined,
+ keyboard: Missing[KeyboardMarkup] = Undefined,
+ file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined,
+ silent_response: Missing[bool] = Undefined,
+ markup_auto_adjust: Missing[bool] = Undefined,
+ recipients: Missing[List[UUID]] = Undefined,
+ stealth_mode: Missing[bool] = Undefined,
+ send_push: Missing[bool] = Undefined,
+ ignore_mute: Missing[bool] = Undefined,
+ wait_callback: bool = True,
+ callback_timeout: MissingOptional[int] = Undefined,
+ ) -> UUID:
+ """Send message to chat.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param body: Message body.
+ :param metadata: Notification options.
+ :param bubbles: Bubbles (buttons attached to message) markup.
+ :param keyboard: Keyboard (buttons below message input) markup.
+ :param file: Attachment.
+ :param recipients: List of recipients, empty for all in chat.
+ :param silent_response: (BotX default: False) Exclude next user
+ messages from history.
+ :param markup_auto_adjust: (BotX default: False) Move button to next
+ row, if its text doesn't fit.
+ :param stealth_mode: (BotX default: False) Enable stealth mode.
+ :param send_push: (BotX default: True) Send push notification on
+ devices.
+ :param ignore_mute: (BotX default: False) Ignore mute or dnd (do not
+ disturb).
+ :param wait_callback: Block method call until callback received.
+ :param callback_timeout: Callback timeout in seconds (or `None` for
+ endless waiting).
+
+ :return: Notification sync_id.
+ """
+
+ method = DirectNotificationMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ self._callback_manager,
+ )
+
+ payload = BotXAPIDirectNotificationRequestPayload.from_domain(
+ chat_id=chat_id,
+ body=body,
+ metadata=metadata,
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ recipients=recipients,
+ silent_response=silent_response,
+ markup_auto_adjust=markup_auto_adjust,
+ stealth_mode=stealth_mode,
+ send_push=send_push,
+ ignore_mute=ignore_mute,
+ )
+ botx_api_sync_id = await method.execute(
+ payload,
+ wait_callback,
+ not_undefined(callback_timeout, self.default_callback_timeout),
+ )
+
+ return botx_api_sync_id.to_domain()
+
+ async def send_internal_bot_notification(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ data: Dict[str, Any],
+ opts: Missing[Dict[str, Any]] = Undefined,
+ recipients: Missing[List[UUID]] = Undefined,
+ wait_callback: bool = True,
+ callback_timeout: MissingOptional[int] = Undefined,
+ ) -> UUID:
+ """Send internal notification.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param data: Notification payload.
+ :param opts: Notification options.
+ :param recipients: List of bot uuids, empty for all in chat.
+ :param wait_callback: Wait for callback.
+ :param callback_timeout: Timeout for waiting for callback.
+
+ :return: Notification sync_id.
+ """
+
+ method = InternalBotNotificationMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ self._callback_manager,
+ )
+
+ payload = BotXAPIInternalBotNotificationRequestPayload.from_domain(
+ chat_id=chat_id,
+ data=data,
+ opts=opts,
+ recipients=recipients,
+ )
+ botx_api_sync_id = await method.execute(
+ payload,
+ wait_callback,
+ not_undefined(callback_timeout, self.default_callback_timeout),
+ )
+
+ return botx_api_sync_id.to_domain()
+
+ # - Events API -
+ async def edit(
+ self,
+ *,
+ message: EditMessage,
+ ) -> None:
+ """Edit message.
+
+ :param message: Built outgoing edit message.
+ """
+
+ await self.edit_message(
+ bot_id=message.bot_id,
+ sync_id=message.sync_id,
+ body=message.body,
+ metadata=message.metadata,
+ bubbles=message.bubbles,
+ keyboard=message.keyboard,
+ file=message.file,
+ markup_auto_adjust=message.markup_auto_adjust,
+ )
+
+ async def edit_message(
+ self,
+ *,
+ bot_id: UUID,
+ sync_id: UUID,
+ body: Missing[str] = Undefined,
+ metadata: Missing[Dict[str, Any]] = Undefined,
+ bubbles: Missing[BubbleMarkup] = Undefined,
+ keyboard: Missing[KeyboardMarkup] = Undefined,
+ file: MissingOptionalAttachment = Undefined,
+ markup_auto_adjust: Missing[bool] = Undefined,
+ ) -> None:
+ """Edit message.
+
+ :param bot_id: Bot which should perform the request.
+ :param sync_id: `sync_id` of message to update.
+ :param body: New message body. Skip to leave previous body or pass
+ empty string to clean it.
+ :param metadata: Notification options. Skip to leave previous metadata.
+ :param bubbles: Bubbles (buttons attached to message) markup. Skip to
+ leave previous bubbles.
+ :param keyboard: Keyboard (buttons below message input) markup. Skip
+ to leave previous keyboard.
+ :param file: Attachment. Skip to leave previous file or pass `None`
+ to clean it.
+ :param markup_auto_adjust: (BotX default: False) Move button to next
+ row, if its text doesn't fit.
+ """
+
+ method = EditEventMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPIEditEventRequestPayload.from_domain(
+ sync_id=sync_id,
+ body=body,
+ metadata=metadata,
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ markup_auto_adjust=markup_auto_adjust,
+ )
+
+ await method.execute(payload)
+
+ async def reply(
+ self,
+ *,
+ message: ReplyMessage,
+ ) -> None:
+ """Reply message.
+
+ :param message: Built outgoing reply message.
+ """
+
+ await self.reply_message(
+ bot_id=message.bot_id,
+ sync_id=message.sync_id,
+ body=message.body,
+ metadata=message.metadata,
+ bubbles=message.bubbles,
+ keyboard=message.keyboard,
+ file=message.file,
+ silent_response=message.silent_response,
+ markup_auto_adjust=message.markup_auto_adjust,
+ stealth_mode=message.stealth_mode,
+ send_push=message.send_push,
+ ignore_mute=message.ignore_mute,
+ )
+
+ async def reply_message(
+ self,
+ *,
+ bot_id: UUID,
+ sync_id: UUID,
+ body: str,
+ metadata: Missing[Dict[str, Any]] = Undefined,
+ bubbles: Missing[BubbleMarkup] = Undefined,
+ keyboard: Missing[KeyboardMarkup] = Undefined,
+ file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined,
+ silent_response: Missing[bool] = Undefined,
+ markup_auto_adjust: Missing[bool] = Undefined,
+ stealth_mode: Missing[bool] = Undefined,
+ send_push: Missing[bool] = Undefined,
+ ignore_mute: Missing[bool] = Undefined,
+ ) -> None:
+ """Reply on message by `sync_id`.
+
+ :param bot_id: Bot which should perform the request.
+ :param sync_id: `sync_id` of message to reply on.
+ :param body: Reply body.
+ :param metadata: Notification options.
+ :param bubbles: Bubbles (buttons attached to message) markup.
+ :param keyboard: Keyboard (buttons below message input) markup.
+ :param file: Attachment.
+ :param silent_response: (BotX default: False) Exclude next user
+ messages from history.
+ :param markup_auto_adjust: (BotX default: False) Move button to next
+ row, if its text doesn't fit.
+ :param stealth_mode: (BotX default: False) Enable stealth mode.
+ :param send_push: (BotX default: True) Send push notification on
+ devices.
+ :param ignore_mute: (BotX default: False) Ignore mute or dnd (do not
+ disturb).
+ """
+
+ payload = BotXAPIReplyEventRequestPayload.from_domain(
+ sync_id=sync_id,
+ body=body,
+ metadata=metadata,
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ silent_response=silent_response,
+ markup_auto_adjust=markup_auto_adjust,
+ stealth_mode=stealth_mode,
+ send_push=send_push,
+ ignore_mute=ignore_mute,
+ )
+ method = ReplyEventMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ await method.execute(payload)
+
+ async def get_message_status(self, *, bot_id: UUID, sync_id: UUID) -> MessageStatus:
+ """
+ Get status of message by `sync_id`.
+
+ :param bot_id: Bot which should perform the request.
+ :param sync_id: `sync_id` of message to get its status.
+
+ :returns: Message status object.
+ """
+ payload = BotXAPIMessageStatusRequestPayload.from_domain(sync_id=sync_id)
+ method = MessageStatusMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+
+ botx_api_message_status = await method.execute(payload)
+ return botx_api_message_status.to_domain()
+
+ async def start_typing(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ ) -> None:
+ """Send `typing` event.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ """
+
+ payload = BotXAPITypingEventRequestPayload.from_domain(
+ chat_id=chat_id,
+ )
+ method = TypingEventMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ await method.execute(payload)
+
+ async def stop_typing(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ ) -> None:
+ """Send `stop_typing` event.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ """
+
+ payload = BotXAPIStopTypingEventRequestPayload.from_domain(
+ chat_id=chat_id,
+ )
+ method = StopTypingEventMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ await method.execute(payload)
+
+ # - Chats API -
+ async def list_chats(
+ self,
+ *,
+ bot_id: UUID,
+ ) -> List[ChatListItem]:
+ """Get all bot chats.
+
+ :param bot_id: Bot which should perform the request.
+
+ :returns: List of chats info.
+ """
+
+ method = ListChatsMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+
+ botx_api_list_chat = await method.execute()
+
+ return botx_api_list_chat.to_domain()
+
+ async def chat_info(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ ) -> ChatInfo:
+ """Get chat information.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+
+ :return: Chat information.
+ """
+
+ method = ChatInfoMethod(bot_id, self._httpx_client, self._bot_accounts_storage)
+
+ payload = BotXAPIChatInfoRequestPayload.from_domain(chat_id=chat_id)
+ botx_api_chat_info = await method.execute(payload)
+
+ return botx_api_chat_info.to_domain()
+
+ async def add_users_to_chat(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ huids: List[UUID],
+ ) -> None:
+ """Add user to chat.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param huids: List of eXpress account ids.
+ """
+
+ method = AddUserMethod(bot_id, self._httpx_client, self._bot_accounts_storage)
+
+ payload = BotXAPIAddUserRequestPayload.from_domain(chat_id=chat_id, huids=huids)
+ await method.execute(payload)
+
+ async def remove_users_from_chat(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ huids: List[UUID],
+ ) -> None:
+ """Remove eXpress accounts from chat.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param huids: List of eXpress account ids.
+ """
+
+ method = RemoveUserMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+
+ payload = BotXAPIRemoveUserRequestPayload.from_domain(
+ chat_id=chat_id,
+ huids=huids,
+ )
+ await method.execute(payload)
+
+ async def promote_to_chat_admins(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ huids: List[UUID],
+ ) -> None:
+ """Promote users in chat to admins.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param huids: List of eXpress account ids.
+ """
+
+ method = AddAdminMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+
+ payload = BotXAPIAddAdminRequestPayload.from_domain(
+ chat_id=chat_id,
+ huids=huids,
+ )
+ await method.execute(payload)
+
+ async def enable_stealth(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ disable_web_client: Missing[bool] = Undefined,
+ ttl_after_read: Missing[int] = Undefined,
+ total_ttl: Missing[int] = Undefined,
+ ) -> None:
+ """Enable stealth mode.
+
+ After the expiration of the time all messages will be hidden.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param disable_web_client: (BotX default: False) Should messages
+ be shown in web.
+ :param ttl_after_read: (BotX default: OFF) Time of messages burning
+ after read.
+ :param total_ttl: (BotX default: OFF) Time of messages burning after
+ send.
+ """
+
+ method = SetStealthMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPISetStealthRequestPayload.from_domain(
+ chat_id=chat_id,
+ disable_web_client=disable_web_client,
+ ttl_after_read=ttl_after_read,
+ total_ttl=total_ttl,
+ )
+
+ await method.execute(payload)
+
+ async def disable_stealth(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ ) -> None:
+ """Disable stealth model. Hides all messages that were in stealth.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ """
+
+ method = DisableStealthMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPIDisableStealthRequestPayload.from_domain(chat_id=chat_id)
+
+ await method.execute(payload)
+
+ async def create_chat(
+ self,
+ *,
+ bot_id: UUID,
+ name: str,
+ chat_type: ChatTypes,
+ huids: List[UUID],
+ description: Optional[str] = None,
+ shared_history: Missing[bool] = Undefined,
+ ) -> UUID:
+ """Create chat.
+
+ :param bot_id: Bot which should perform the request.
+ :param name: Chat visible name.
+ :param chat_type: Chat type.
+ :param huids: List of eXpress account ids.
+ :param description: Chat description.
+ :param shared_history: (BotX default: False) Open old chat history for
+ new added users.
+
+ :return: Created chat uuid.
+ """
+
+ method = CreateChatMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+
+ payload = BotXAPICreateChatRequestPayload.from_domain(
+ name=name,
+ chat_type=chat_type,
+ huids=huids,
+ shared_history=shared_history,
+ description=description,
+ )
+ botx_api_chat_id = await method.execute(payload)
+
+ return botx_api_chat_id.to_domain()
+
+ async def pin_message(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ sync_id: UUID,
+ ) -> None:
+ """Pin message in chat.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param sync_id: Target sync id.
+ """
+
+ method = PinMessageMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPIPinMessageRequestPayload.from_domain(
+ chat_id=chat_id,
+ sync_id=sync_id,
+ )
+
+ await method.execute(payload)
+
+ async def unpin_message(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ ) -> None:
+ """Unpin message in chat.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ """
+
+ method = UnpinMessageMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPIUnpinMessageRequestPayload.from_domain(chat_id=chat_id)
+
+ await method.execute(payload)
+
+ # - Users API -
+ async def search_user_by_email(
+ self,
+ *,
+ bot_id: UUID,
+ email: str,
+ ) -> UserFromSearch:
+ """Search user by email for search.
+
+ :param bot_id: Bot which should perform the request.
+ :param email: User email.
+
+ :return: User information.
+ """
+
+ method = SearchUserByEmailMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPISearchUserByEmailRequestPayload.from_domain(email=email)
+
+ botx_api_user_from_search = await method.execute(payload)
+
+ return botx_api_user_from_search.to_domain()
+
+ async def search_user_by_huid(
+ self,
+ *,
+ bot_id: UUID,
+ huid: UUID,
+ ) -> UserFromSearch:
+ """Search user by huid for search.
+
+ :param bot_id: Bot which should perform the request.
+ :param huid: User huid.
+
+ :return: User information.
+ """
+
+ method = SearchUserByHUIDMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPISearchUserByHUIDRequestPayload.from_domain(huid=huid)
+
+ botx_api_user_from_search = await method.execute(payload)
+
+ return botx_api_user_from_search.to_domain()
+
+ async def search_user_by_ad(
+ self,
+ *,
+ bot_id: UUID,
+ ad_login: str,
+ ad_domain: str,
+ ) -> UserFromSearch:
+ """Search user by AD login and AD domain for search.
+
+ :param bot_id: Bot which should perform the request.
+ :param ad_login: User AD login.
+ :param ad_domain: User AD domain.
+
+ :return: User information.
+ """
+
+ method = SearchUserByLoginMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPISearchUserByLoginRequestPayload.from_domain(
+ ad_login=ad_login,
+ ad_domain=ad_domain,
+ )
+
+ botx_api_user_from_search = await method.execute(payload)
+
+ return botx_api_user_from_search.to_domain()
+
+ # - SmartApps API -
+ async def send_smartapp_event(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ data: Dict[str, Any],
+ ref: MissingOptional[UUID] = Undefined,
+ opts: Missing[Dict[str, Any]] = Undefined,
+ files: Missing[List[File]] = Undefined,
+ ) -> None:
+ """Send SmartApp event.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param data: Event payload.
+ :param ref: Request identifier.
+ :param opts: Event options.
+ :param files: Files.
+ """
+
+ method = SmartAppEventMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPISmartAppEventRequestPayload.from_domain(
+ ref=ref,
+ smartapp_id=bot_id,
+ chat_id=chat_id,
+ data=data,
+ opts=opts,
+ files=files,
+ )
+
+ await method.execute(payload)
+
+ async def send_smartapp_notification(
+ self,
+ bot_id: UUID,
+ chat_id: UUID,
+ smartapp_counter: int,
+ opts: Missing[Dict[str, Any]] = Undefined,
+ ) -> None:
+ """Send SmartApp notification.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param smartapp_counter: Value app's counter.
+ :param opts: Vvent options.
+ """
+
+ method = SmartAppNotificationMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPISmartAppNotificationRequestPayload.from_domain(
+ chat_id=chat_id,
+ smartapp_counter=smartapp_counter,
+ opts=opts,
+ )
+
+ await method.execute(payload)
+
+ # - Stickers API -
+ async def create_sticker_pack(self, *, bot_id: UUID, name: str) -> StickerPack:
+ """Create empty sticker pack.
+
+ :param bot_id: Bot which should perform the request.
+ :param name: Sticker pack name.
+
+ :return: Created sticker pack.
+ """
+
+ method = CreateStickerPackMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPICreateStickerPackRequestPayload.from_domain(name=name)
+
+ botx_api_sticker_pack = await method.execute(payload)
+
+ return botx_api_sticker_pack.to_domain()
+
+ async def add_sticker(
+ self,
+ *,
+ bot_id: UUID,
+ sticker_pack_id: UUID,
+ emoji: str,
+ async_buffer: AsyncBufferReadable,
+ ) -> Sticker:
+ """Add sticker in sticker pack.
+
+ :param bot_id: Bot which should perform the request.
+ :param sticker_pack_id: Sticker pack id to indicate where to add.
+ :param emoji: Sticker emoji.
+ :param async_buffer: Sticker image file. Only PNG.
+
+ :return: Added sticker.
+ """
+
+ await ensure_file_content_is_png(async_buffer)
+ await ensure_sticker_image_size_valid(async_buffer)
+
+ method = AddStickerMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = await BotXAPIAddStickerRequestPayload.from_domain(
+ sticker_pack_id=sticker_pack_id,
+ emoji=emoji,
+ async_buffer=async_buffer,
+ )
+
+ botx_api_sticker = await method.execute(payload)
+
+ return botx_api_sticker.to_domain()
+
+ async def delete_sticker(
+ self,
+ *,
+ bot_id: UUID,
+ sticker_pack_id: UUID,
+ sticker_id: UUID,
+ ) -> None:
+ """Delete sticker from sticker pack.
+
+ :param bot_id: Bot which should perform the request.
+ :param sticker_pack_id: Target sticker pack id.
+ :param sticker_id: Sticker id which should be deleted.
+ """
+
+ method = DeleteStickerMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = await BotXAPIDeleteStickerRequestPayload.from_domain(
+ sticker_id=sticker_id,
+ sticker_pack_id=sticker_pack_id,
+ )
+
+ await method.execute(payload)
+
+ async def iterate_by_sticker_packs(
+ self,
+ *,
+ bot_id: UUID,
+ user_huid: UUID,
+ ) -> AsyncIterable[StickerPackFromList]:
+ """Iterate by user sticker packs.
+
+ :param bot_id: Bot which should perform the request.
+ :param user_huid: User huid.
+
+ :yield: Sticker pack.
+ """
+
+ after = None
+
+ method = GetStickerPacksMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+
+ while True:
+ payload = BotXAPIGetStickerPacksRequestPayload.from_domain(
+ huid=user_huid,
+ limit=STICKER_PACKS_PER_PAGE,
+ after=after,
+ )
+ botx_api_sticker_pack_list = await method.execute(payload)
+
+ sticker_pack_page = botx_api_sticker_pack_list.to_domain()
+ after = sticker_pack_page.after
+
+ for sticker_pack in sticker_pack_page.sticker_packs:
+ yield sticker_pack
+
+ if not after:
+ break
+
+ async def get_sticker_pack(
+ self,
+ *,
+ bot_id: UUID,
+ sticker_pack_id: UUID,
+ ) -> StickerPack:
+ """Get sticker pack.
+
+ :param bot_id: Bot which should perform the request.
+ :param sticker_pack_id: Sticker pack id.
+
+ :return: Sticker pack.
+ """
+
+ method = GetStickerPackMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPIGetStickerPackRequestPayload.from_domain(
+ sticker_pack_id=sticker_pack_id,
+ )
+
+ botx_api_sticker_pack = await method.execute(payload)
+
+ return botx_api_sticker_pack.to_domain()
+
+ async def delete_sticker_pack(self, *, bot_id: UUID, sticker_pack_id: UUID) -> None:
+ """Delete existing sticker pack.
+
+ :param bot_id: Bot which should perform the request.
+ :param sticker_pack_id: Target sticker pack.
+ """
+
+ method = DeleteStickerPackMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+
+ payload = BotXAPIDeleteStickerPackRequestPayload.from_domain(
+ sticker_pack_id=sticker_pack_id,
+ )
+
+ await method.execute(payload)
+
+ async def get_sticker(
+ self,
+ *,
+ bot_id: UUID,
+ sticker_pack_id: UUID,
+ sticker_id: UUID,
+ ) -> Sticker:
+ """Get sticker.
+
+ :param bot_id: Bot which should perform the request.
+ :param sticker_pack_id: Sticker pack id.
+ :param sticker_id: Sticker id.
+
+ :return: Sticker.
+ """
+
+ method = GetStickerMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPIGetStickerRequestPayload.from_domain(
+ sticker_pack_id=sticker_pack_id,
+ sticker_id=sticker_id,
+ )
+
+ botx_api_sticker = await method.execute(payload)
+
+ return botx_api_sticker.to_domain()
+
+ async def edit_sticker_pack(
+ self,
+ *,
+ bot_id: UUID,
+ sticker_pack_id: UUID,
+ name: str,
+ preview: UUID,
+ stickers_order: List[UUID],
+ ) -> StickerPack:
+ """Edit Sticker pack.
+
+ :param bot_id: Bot which should perform the request.
+ :param sticker_pack_id: Sticker pack id.
+ :param name: Sticker pack name.
+ :param preview: Sticker from the set selected as a preview.
+ :param stickers_order: Sticker IDs in order they are displayed.
+
+ :return: Edited sticker pack.
+ """
+
+ method = EditStickerPackMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPIEditStickerPackRequestPayload.from_domain(
+ sticker_pack_id=sticker_pack_id,
+ name=name,
+ preview=preview,
+ stickers_order=stickers_order,
+ )
+
+ botx_api_sticker_pack = await method.execute(payload)
+
+ return botx_api_sticker_pack.to_domain()
+
+ # - Files API -
+ async def download_file(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ file_id: UUID,
+ async_buffer: AsyncBufferWritable,
+ ) -> None:
+ """Download file form file service.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param file_id: Async file id.
+ :param async_buffer: Buffer to write downloaded file.
+ """
+
+ method = DownloadFileMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPIDownloadFileRequestPayload.from_domain(
+ chat_id=chat_id,
+ file_id=file_id,
+ )
+
+ await method.execute(payload, async_buffer)
+
+ async def upload_file(
+ self,
+ *,
+ bot_id: UUID,
+ chat_id: UUID,
+ async_buffer: AsyncBufferReadable,
+ filename: str,
+ duration: Missing[int] = Undefined,
+ caption: Missing[str] = Undefined,
+ ) -> File:
+ """Upload file to file service.
+
+ :param bot_id: Bot which should perform the request.
+ :param chat_id: Target chat id.
+ :param async_buffer: Buffer to write downloaded file.
+ :param filename: File name.
+ :param duration: Video duration.
+ :param caption: Text under file.
+
+ :return: Meta info of uploaded file.
+ """
+
+ method = UploadFileMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ payload = BotXAPIUploadFileRequestPayload.from_domain(
+ chat_id=chat_id,
+ duration=duration,
+ caption=caption,
+ )
+
+ botx_api_async_file = await method.execute(payload, async_buffer, filename)
+
+ return botx_api_async_file.to_domain()
+
+ @staticmethod
+ def _build_main_collector(
+ collectors: Sequence[HandlerCollector],
+ middlewares: List[Middleware],
+ exception_handlers: Optional[ExceptionHandlersDict] = None,
+ ) -> HandlerCollector:
+ main_collector = HandlerCollector(middlewares=middlewares)
+ main_collector.insert_exception_middleware(exception_handlers)
+ main_collector.include(*collectors)
+
+ return main_collector
diff --git a/botx/bot/bot_accounts_storage.py b/botx/bot/bot_accounts_storage.py
new file mode 100644
index 00000000..d97cbcdd
--- /dev/null
+++ b/botx/bot/bot_accounts_storage.py
@@ -0,0 +1,48 @@
+import base64
+import hashlib
+import hmac
+from typing import Dict, Iterator, List, Optional
+from uuid import UUID
+
+from botx.bot.exceptions import UnknownBotAccountError
+from botx.models.bot_account import BotAccount, BotAccountWithSecret
+
+
+class BotAccountsStorage:
+ def __init__(self, bot_accounts: List[BotAccountWithSecret]) -> None:
+ self._bot_accounts = bot_accounts
+ self._auth_tokens: Dict[UUID, str] = {}
+
+ def iter_bot_accounts(self) -> Iterator[BotAccount]:
+ yield from self._bot_accounts
+
+ def get_host(self, bot_id: UUID) -> str:
+ bot_account = self._get_bot_account(bot_id)
+ return bot_account.host
+
+ def set_token(self, bot_id: UUID, token: str) -> None:
+ self._auth_tokens[bot_id] = token
+
+ def get_token_or_none(self, bot_id: UUID) -> Optional[str]:
+ return self._auth_tokens.get(bot_id)
+
+ def build_signature(self, bot_id: UUID) -> str:
+ bot_account = self._get_bot_account(bot_id)
+
+ signed_bot_id = hmac.new(
+ key=bot_account.secret_key.encode(),
+ msg=str(bot_account.id).encode(),
+ digestmod=hashlib.sha256,
+ ).digest()
+
+ return base64.b16encode(signed_bot_id).decode()
+
+ def ensure_bot_id_exists(self, bot_id: UUID) -> None:
+ self._get_bot_account(bot_id)
+
+ def _get_bot_account(self, bot_id: UUID) -> BotAccountWithSecret:
+ for bot_account in self._bot_accounts:
+ if bot_account.id == bot_id:
+ return bot_account
+
+ raise UnknownBotAccountError(bot_id)
diff --git a/botx/bot/callbacks_manager.py b/botx/bot/callbacks_manager.py
new file mode 100644
index 00000000..c4a3f9fc
--- /dev/null
+++ b/botx/bot/callbacks_manager.py
@@ -0,0 +1,63 @@
+import asyncio
+from typing import TYPE_CHECKING, Dict, Optional
+from uuid import UUID
+
+from botx.bot.exceptions import BotShuttingDownError, BotXMethodCallbackNotFoundError
+from botx.client.exceptions.callbacks import CallbackNotReceivedError
+from botx.logger import logger
+from botx.models.method_callbacks import BotXMethodCallback
+
+if TYPE_CHECKING:
+ from asyncio import Future # noqa: WPS458
+
+
+class CallbacksManager:
+ def __init__(self) -> None:
+ self._callback_futures: Dict[UUID, "Future[BotXMethodCallback]"] = {}
+
+ def create_botx_method_callback(self, sync_id: UUID) -> None:
+ self._callback_futures[sync_id] = asyncio.Future()
+
+ def set_botx_method_callback_result(
+ self,
+ callback: BotXMethodCallback,
+ ) -> None:
+ sync_id = callback.sync_id
+ future = self._pop_future(sync_id)
+
+ if future.cancelled():
+ logger.warning(
+ f"BotX method with sync_id `{sync_id!s}` don't wait callback",
+ )
+ return
+
+ future.set_result(callback)
+
+ async def wait_botx_method_callback(
+ self,
+ sync_id: UUID,
+ timeout: Optional[int],
+ ) -> BotXMethodCallback:
+ future = self._callback_futures[sync_id]
+
+ try:
+ return await asyncio.wait_for(future, timeout=timeout)
+ except asyncio.TimeoutError as exc:
+ raise CallbackNotReceivedError(sync_id) from exc
+
+ def stop_callbacks_waiting(self) -> None:
+ for sync_id, future in self._callback_futures.items():
+ if not future.done():
+ future.set_exception(
+ BotShuttingDownError(
+ f"Callback with sync_id `{sync_id!s}` can't be received",
+ ),
+ )
+
+ def _pop_future(self, sync_id: UUID) -> "Future[BotXMethodCallback]":
+ try:
+ future = self._callback_futures.pop(sync_id)
+ except KeyError:
+ raise BotXMethodCallbackNotFoundError(sync_id) from None
+
+ return future
diff --git a/botx/bot/contextvars.py b/botx/bot/contextvars.py
new file mode 100644
index 00000000..030a50c7
--- /dev/null
+++ b/botx/bot/contextvars.py
@@ -0,0 +1,10 @@
+from contextvars import ContextVar
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+if TYPE_CHECKING: # To avoid circular import
+ from botx.bot.bot import Bot
+
+bot_var: ContextVar["Bot"] = ContextVar("bot_var")
+bot_id_var: ContextVar[UUID] = ContextVar("bot_id")
+chat_id_var: ContextVar[UUID] = ContextVar("chat_id")
diff --git a/botx/bot/exceptions.py b/botx/bot/exceptions.py
new file mode 100644
index 00000000..b6313189
--- /dev/null
+++ b/botx/bot/exceptions.py
@@ -0,0 +1,29 @@
+from typing import Any
+from uuid import UUID
+
+
+class UnknownBotAccountError(Exception):
+ def __init__(self, bot_id: UUID) -> None:
+ self.bot_id = bot_id
+ self.message = f"No bot account with bot_id: `{bot_id!s}`"
+ super().__init__(self.message)
+
+
+class BotXMethodCallbackNotFoundError(Exception):
+ def __init__(self, sync_id: UUID) -> None:
+ self.sync_id = sync_id
+ self.message = f"No callback found with sync_id: `{sync_id!s}`"
+ super().__init__(self.message)
+
+
+class BotShuttingDownError(Exception):
+ def __init__(self, context: Any) -> None:
+ self.context = context
+ self.message = f"Bot is shutting down: {context}"
+ super().__init__(self.message)
+
+
+class AnswerDestinationLookupError(Exception):
+ def __init__(self) -> None:
+ self.message = "No IncomingMessage received. Use `Bot.send` instead"
+ super().__init__(self.message)
diff --git a/botx/bot/handler.py b/botx/bot/handler.py
new file mode 100644
index 00000000..b5ac9ffc
--- /dev/null
+++ b/botx/bot/handler.py
@@ -0,0 +1,79 @@
+from dataclasses import dataclass
+from functools import partial
+from typing import TYPE_CHECKING, Awaitable, Callable, List, Literal, TypeVar, Union
+
+from botx.models.commands import BotCommand
+from botx.models.message.incoming_message import IncomingMessage
+from botx.models.status import StatusRecipient
+from botx.models.system_events.added_to_chat import AddedToChatEvent
+from botx.models.system_events.chat_created import ChatCreatedEvent
+from botx.models.system_events.cts_login import CTSLoginEvent
+from botx.models.system_events.cts_logout import CTSLogoutEvent
+from botx.models.system_events.deleted_from_chat import DeletedFromChatEvent
+from botx.models.system_events.internal_bot_notification import (
+ InternalBotNotificationEvent,
+)
+from botx.models.system_events.left_from_chat import LeftFromChatEvent
+from botx.models.system_events.smartapp_event import SmartAppEvent
+
+if TYPE_CHECKING: # To avoid circular import
+ from botx.bot.bot import Bot
+
+TBotCommand = TypeVar("TBotCommand", bound=BotCommand)
+HandlerFunc = Callable[[TBotCommand, "Bot"], Awaitable[None]]
+
+IncomingMessageHandlerFunc = HandlerFunc[IncomingMessage]
+SystemEventHandlerFunc = Union[
+ HandlerFunc[AddedToChatEvent],
+ HandlerFunc[ChatCreatedEvent],
+ HandlerFunc[DeletedFromChatEvent],
+ HandlerFunc[LeftFromChatEvent],
+ HandlerFunc[CTSLoginEvent],
+ HandlerFunc[CTSLogoutEvent],
+ HandlerFunc[InternalBotNotificationEvent],
+ HandlerFunc[SmartAppEvent],
+]
+
+VisibleFunc = Callable[[StatusRecipient, "Bot"], Awaitable[bool]]
+
+Middleware = Callable[
+ [IncomingMessage, "Bot", IncomingMessageHandlerFunc],
+ Awaitable[None],
+]
+
+
+@dataclass
+class BaseIncomingMessageHandler:
+ handler_func: IncomingMessageHandlerFunc
+ middlewares: List[Middleware]
+
+ async def __call__(self, message: IncomingMessage, bot: "Bot") -> None:
+ handler_func = self.handler_func
+
+ for middleware in self.middlewares[::-1]:
+ handler_func = partial(middleware, call_next=handler_func)
+
+ await handler_func(message, bot)
+
+ def add_middlewares(self, middlewares: List[Middleware]) -> None:
+ self.middlewares = middlewares + self.middlewares
+
+
+@dataclass
+class HiddenCommandHandler(BaseIncomingMessageHandler):
+ # Default should be here, see: https://github.com/python/mypy/issues/6113
+ visible: Literal[False] = False
+
+
+@dataclass
+class VisibleCommandHandler(BaseIncomingMessageHandler):
+ description: str
+ visible: Union[Literal[True], VisibleFunc] = True
+
+
+@dataclass
+class DefaultMessageHandler(BaseIncomingMessageHandler):
+ """Just for separate type."""
+
+
+CommandHandler = Union[HiddenCommandHandler, VisibleCommandHandler]
diff --git a/botx/bot/handler_collector.py b/botx/bot/handler_collector.py
new file mode 100644
index 00000000..438c4fb3
--- /dev/null
+++ b/botx/bot/handler_collector.py
@@ -0,0 +1,438 @@
+import asyncio
+import re
+from typing import (
+ TYPE_CHECKING,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ Sequence,
+ Type,
+ Union,
+ overload,
+)
+from weakref import WeakSet
+
+from botx.bot.contextvars import bot_id_var, bot_var, chat_id_var
+from botx.bot.handler import (
+ CommandHandler,
+ DefaultMessageHandler,
+ HandlerFunc,
+ HiddenCommandHandler,
+ IncomingMessageHandlerFunc,
+ Middleware,
+ SystemEventHandlerFunc,
+ VisibleCommandHandler,
+ VisibleFunc,
+)
+from botx.bot.middlewares.exception_middleware import (
+ ExceptionHandlersDict,
+ ExceptionMiddleware,
+)
+from botx.converters import optional_sequence_to_list
+from botx.logger import logger
+from botx.models.commands import BotCommand, SystemEvent
+from botx.models.message.incoming_message import IncomingMessage
+from botx.models.status import BotMenu, StatusRecipient
+from botx.models.system_events.added_to_chat import AddedToChatEvent
+from botx.models.system_events.chat_created import ChatCreatedEvent
+from botx.models.system_events.cts_login import CTSLoginEvent
+from botx.models.system_events.cts_logout import CTSLogoutEvent
+from botx.models.system_events.deleted_from_chat import DeletedFromChatEvent
+from botx.models.system_events.internal_bot_notification import (
+ InternalBotNotificationEvent,
+)
+from botx.models.system_events.left_from_chat import LeftFromChatEvent
+from botx.models.system_events.smartapp_event import SmartAppEvent
+
+if TYPE_CHECKING: # To avoid circular import
+ from botx.bot.bot import Bot
+
+
+class HandlerCollector:
+ VALID_COMMAND_NAME_RE = re.compile(r"^\/[^\s\/]+$", flags=re.UNICODE)
+
+ def __init__(self, middlewares: Optional[Sequence[Middleware]] = None) -> None:
+ self._user_commands_handlers: Dict[str, CommandHandler] = {}
+ self._default_message_handler: Optional[DefaultMessageHandler] = None
+ self._system_events_handlers: Dict[
+ Type[BotCommand],
+ SystemEventHandlerFunc,
+ ] = {}
+ self._middlewares = optional_sequence_to_list(middlewares)
+ self._tasks: "WeakSet[asyncio.Task[None]]" = WeakSet()
+
+ def include(self, *others: "HandlerCollector") -> None:
+ """Include other `HandlerCollector`."""
+
+ for collector in others:
+ self._include_collector(collector)
+
+ def async_handle_bot_command(
+ self,
+ bot: "Bot",
+ bot_command: BotCommand,
+ ) -> None:
+ task = asyncio.create_task(
+ self.handle_bot_command(bot_command, bot),
+ )
+ self._tasks.add(task)
+
+ async def handle_incoming_message_by_command(
+ self,
+ message: IncomingMessage,
+ bot: "Bot",
+ command: str,
+ ) -> None:
+ message_handler = self._get_command_handler(command)
+ if message_handler:
+ self._fill_contextvars(message, bot)
+ await message_handler(message, bot)
+
+ async def handle_bot_command(self, bot_command: BotCommand, bot: "Bot") -> None:
+ if isinstance(bot_command, IncomingMessage):
+ message_handler = self._get_incoming_message_handler(bot_command)
+ if message_handler:
+ self._fill_contextvars(bot_command, bot)
+ await message_handler(bot_command, bot)
+
+ elif isinstance(
+ bot_command,
+ # TODO: Replace `__args__` with `typing.get_origin` on python 3.7 drop.
+ SystemEvent.__args__, # type: ignore [attr-defined] # noqa: WPS609
+ ):
+ event_handler = self._get_system_event_handler_or_none(bot_command)
+ if event_handler:
+ self._fill_contextvars(bot_command, bot)
+ await event_handler(bot_command, bot)
+
+ else:
+ raise NotImplementedError(f"Unsupported event type: `{bot_command}`")
+
+ async def get_bot_menu(
+ self,
+ status_recipient: StatusRecipient,
+ bot: "Bot",
+ ) -> BotMenu:
+ bot_menu = {}
+
+ for command_name, handler in self._user_commands_handlers.items():
+ if handler.visible is True or (
+ callable(handler.visible)
+ and await handler.visible(status_recipient, bot)
+ ):
+ bot_menu[command_name] = handler.description
+
+ return BotMenu(bot_menu)
+
+ def command(
+ self,
+ command_name: str,
+ visible: Union[bool, VisibleFunc] = True,
+ description: Optional[str] = None,
+ middlewares: Optional[Sequence[Middleware]] = None,
+ ) -> Callable[[IncomingMessageHandlerFunc], IncomingMessageHandlerFunc]:
+ """Decorate command handler."""
+
+ if not self.VALID_COMMAND_NAME_RE.match(command_name):
+ raise ValueError("Command should start with '/' and doesn't include spaces")
+
+ def decorator(
+ handler_func: IncomingMessageHandlerFunc,
+ ) -> IncomingMessageHandlerFunc:
+ if command_name in self._user_commands_handlers:
+ raise ValueError(
+ f"Handler for command `{command_name}` already registered",
+ )
+
+ self._user_commands_handlers[command_name] = self._build_command_handler(
+ handler_func,
+ visible,
+ description,
+ self._middlewares + optional_sequence_to_list(middlewares),
+ )
+
+ return handler_func
+
+ return decorator
+
+ @overload
+ def default_message_handler(
+ self,
+ handler_func: IncomingMessageHandlerFunc,
+ ) -> IncomingMessageHandlerFunc:
+ ... # noqa: WPS428
+
+ @overload
+ def default_message_handler(
+ self,
+ *,
+ middlewares: Optional[Sequence[Middleware]] = None,
+ ) -> Callable[[IncomingMessageHandlerFunc], IncomingMessageHandlerFunc]:
+ ... # noqa: WPS428
+
+ def default_message_handler( # noqa: WPS320
+ self,
+ handler_func: Optional[IncomingMessageHandlerFunc] = None,
+ *,
+ middlewares: Optional[Sequence[Middleware]] = None,
+ ) -> Union[
+ IncomingMessageHandlerFunc,
+ Callable[[IncomingMessageHandlerFunc], IncomingMessageHandlerFunc],
+ ]:
+ """Decorate fallback messages handler."""
+ if self._default_message_handler:
+ raise ValueError("Default command handler already registered")
+
+ def decorator(
+ handler_func: IncomingMessageHandlerFunc, # noqa: WPS442
+ ) -> IncomingMessageHandlerFunc:
+ self._default_message_handler = DefaultMessageHandler(
+ handler_func=handler_func,
+ middlewares=self._middlewares + optional_sequence_to_list(middlewares),
+ )
+
+ return handler_func
+
+ if callable(handler_func) and not middlewares:
+ return decorator(handler_func)
+
+ return decorator
+
+ def chat_created(
+ self,
+ handler_func: HandlerFunc[ChatCreatedEvent],
+ ) -> HandlerFunc[ChatCreatedEvent]:
+ """Decorate `chat_created` event handler."""
+
+ self._system_event(ChatCreatedEvent, handler_func)
+
+ return handler_func
+
+ def added_to_chat(
+ self,
+ handler_func: HandlerFunc[AddedToChatEvent],
+ ) -> HandlerFunc[AddedToChatEvent]:
+ """Decorate `added_to_chat` event handler."""
+
+ self._system_event(AddedToChatEvent, handler_func)
+
+ return handler_func
+
+ def deleted_from_chat(
+ self,
+ handler_func: HandlerFunc[DeletedFromChatEvent],
+ ) -> HandlerFunc[DeletedFromChatEvent]:
+ """Decorate `deleted_from_chat` event handler."""
+
+ self._system_event(DeletedFromChatEvent, handler_func)
+
+ return handler_func
+
+ def left_from_chat(
+ self,
+ handler_func: HandlerFunc[LeftFromChatEvent],
+ ) -> HandlerFunc[LeftFromChatEvent]:
+ """Decorate `left_from_chat` event handler."""
+
+ self._system_event(LeftFromChatEvent, handler_func)
+
+ return handler_func
+
+ def internal_bot_notification(
+ self,
+ handler_func: HandlerFunc[InternalBotNotificationEvent],
+ ) -> HandlerFunc[InternalBotNotificationEvent]:
+ """Decorate `internal_bot_notification` event handler."""
+
+ self._system_event(InternalBotNotificationEvent, handler_func)
+
+ return handler_func
+
+ def cts_login(
+ self,
+ handler_func: HandlerFunc[CTSLoginEvent],
+ ) -> HandlerFunc[CTSLoginEvent]:
+ """Decorate `cts_login` event handler."""
+
+ self._system_event(CTSLoginEvent, handler_func)
+
+ return handler_func
+
+ def cts_logout(
+ self,
+ handler_func: HandlerFunc[CTSLogoutEvent],
+ ) -> HandlerFunc[CTSLogoutEvent]:
+ """Decorate `cts_logout` event handler."""
+
+ self._system_event(CTSLogoutEvent, handler_func)
+
+ return handler_func
+
+ def smartapp_event(
+ self,
+ handler_func: HandlerFunc[SmartAppEvent],
+ ) -> HandlerFunc[SmartAppEvent]:
+ """Decorate `smartapp` event handler."""
+
+ self._system_event(SmartAppEvent, handler_func)
+
+ return handler_func
+
+ def insert_exception_middleware(
+ self,
+ exception_handlers: Optional[ExceptionHandlersDict] = None,
+ ) -> None:
+ exception_middleware = ExceptionMiddleware(exception_handlers or {})
+ self._middlewares.insert(0, exception_middleware.dispatch)
+
+ async def wait_active_tasks(self) -> None:
+ if self._tasks:
+ await asyncio.wait(
+ self._tasks,
+ return_when=asyncio.ALL_COMPLETED,
+ )
+
+ def _include_collector(self, other: "HandlerCollector") -> None:
+ # - Message handlers -
+ command_duplicates = set(self._user_commands_handlers) & set(
+ other._user_commands_handlers,
+ )
+ if command_duplicates:
+ raise ValueError(
+ f"Handlers for {command_duplicates} commands already registered",
+ )
+
+ other_handlers = other._user_commands_handlers
+ for handler in other_handlers.values():
+ handler.add_middlewares(self._middlewares)
+
+ self._user_commands_handlers.update(other_handlers)
+
+ # - Default message handler -
+ if self._default_message_handler and other._default_message_handler:
+ raise ValueError("Default message handler already registered")
+
+ if not self._default_message_handler and other._default_message_handler:
+ other._default_message_handler.add_middlewares(self._middlewares)
+ self._default_message_handler = other._default_message_handler
+
+ # - System events -
+ events_duplicates = set(self._system_events_handlers) & set(
+ other._system_events_handlers,
+ )
+ if events_duplicates:
+ raise ValueError(
+ f"Handlers for {events_duplicates} events already registered",
+ )
+
+ self._system_events_handlers.update(other._system_events_handlers)
+
+ def _get_incoming_message_handler(
+ self,
+ message: IncomingMessage,
+ ) -> Union[CommandHandler, DefaultMessageHandler, None]:
+ return self._get_command_handler(message.body)
+
+ def _get_command_handler(
+ self,
+ command: str,
+ ) -> Union[CommandHandler, DefaultMessageHandler, None]:
+ handler: Optional[Union[CommandHandler, DefaultMessageHandler]] = None
+
+ command_name = self._get_command_name(command)
+ if command_name:
+ handler = self._user_commands_handlers.get(command_name)
+ if handler:
+ logger.info(f"Found handler for command `{command_name}`")
+ return handler
+
+ if self._default_message_handler:
+ self._log_default_handler_call(command_name)
+ return self._default_message_handler
+
+ logger.warning(f"Handler for message text `{command}` not found")
+ return None
+
+ def _get_system_event_handler_or_none(
+ self,
+ event: SystemEvent,
+ ) -> Optional[SystemEventHandlerFunc]:
+ event_cls = event.__class__
+
+ handler = self._system_events_handlers.get(event_cls)
+ self._log_system_event_handler_call(event_cls.__name__, handler)
+
+ return handler
+
+ def _get_command_name(self, body: str) -> Optional[str]:
+ if not body:
+ return None
+
+ command_name = body.split(maxsplit=1)[0]
+ if self.VALID_COMMAND_NAME_RE.match(command_name):
+ return command_name
+
+ return None
+
+ def _build_command_handler(
+ self,
+ handler_func: IncomingMessageHandlerFunc,
+ visible: Union[bool, VisibleFunc],
+ description: Optional[str],
+ middlewares: List[Middleware],
+ ) -> CommandHandler:
+ if visible is True or callable(visible):
+ if not description:
+ raise ValueError("Description is required for visible command")
+
+ return VisibleCommandHandler(
+ handler_func=handler_func,
+ visible=visible,
+ description=description,
+ middlewares=middlewares,
+ )
+
+ return HiddenCommandHandler(
+ handler_func=handler_func,
+ middlewares=middlewares,
+ )
+
+ def _system_event(
+ self,
+ event_cls_name: Type[BotCommand],
+ handler_func: SystemEventHandlerFunc,
+ ) -> SystemEventHandlerFunc:
+ if event_cls_name in self._system_events_handlers:
+ raise ValueError(f"Handler for {event_cls_name} already registered")
+
+ self._system_events_handlers[event_cls_name] = handler_func
+
+ return handler_func
+
+ def _fill_contextvars(self, bot_command: BotCommand, bot: "Bot") -> None:
+ bot_var.set(bot)
+ bot_id_var.set(bot_command.bot.id)
+
+ chat = getattr(bot_command, "chat", None)
+ if chat:
+ chat_id_var.set(chat.id)
+
+ def _log_system_event_handler_call(
+ self,
+ event_cls_name: str,
+ handler: Optional[SystemEventHandlerFunc],
+ ) -> None:
+ if handler:
+ logger.info(f"Found handler for `{event_cls_name}`")
+ else:
+ logger.info(f"Handler for `{event_cls_name}` not found")
+
+ def _log_default_handler_call(self, command_name: Optional[str]) -> None:
+ if command_name:
+ logger.info(
+ f"Handler for command `{command_name}` not found, "
+ "using default handler",
+ )
+ else:
+ logger.info("No command found, using default handler")
diff --git a/docs/src/development/collector/collector0/__init__.py b/botx/bot/middlewares/__init__.py
similarity index 100%
rename from docs/src/development/collector/collector0/__init__.py
rename to botx/bot/middlewares/__init__.py
diff --git a/botx/bot/middlewares/exception_middleware.py b/botx/bot/middlewares/exception_middleware.py
new file mode 100644
index 00000000..ae1a0055
--- /dev/null
+++ b/botx/bot/middlewares/exception_middleware.py
@@ -0,0 +1,56 @@
+from typing import TYPE_CHECKING, Awaitable, Callable, Dict, Optional, Type
+
+from botx.bot.handler import IncomingMessageHandlerFunc
+from botx.logger import logger
+from botx.models.message.incoming_message import IncomingMessage
+
+if TYPE_CHECKING: # To avoid circular import
+ from botx.bot.bot import Bot
+
+ExceptionHandler = Callable[
+ [IncomingMessage, "Bot", Exception],
+ Awaitable[None],
+]
+ExceptionHandlersDict = Dict[Type[Exception], ExceptionHandler]
+
+
+class ExceptionMiddleware:
+ """Exception handling middleware."""
+
+ def __init__(self, exception_handlers: ExceptionHandlersDict) -> None:
+ self._exception_handlers = exception_handlers
+
+ async def dispatch(
+ self,
+ message: IncomingMessage,
+ bot: "Bot",
+ call_next: IncomingMessageHandlerFunc,
+ ) -> None:
+ try:
+ await call_next(message, bot)
+ except Exception as message_handler_exc:
+ exception_handler = self._get_exception_handler(message_handler_exc)
+ if exception_handler is None:
+ exc_name = type(message_handler_exc).__name__
+ logger.exception(
+ f"Uncaught exception {exc_name}:",
+ message_handler_exc,
+ )
+ return
+
+ try: # noqa: WPS505
+ await exception_handler(message, bot, message_handler_exc)
+ except Exception as error_handler_exc:
+ exc_name = type(message_handler_exc).__name__
+ logger.exception(
+ f"Uncaught exception {exc_name} in exception handler:",
+ error_handler_exc,
+ )
+
+ def _get_exception_handler(self, exc: Exception) -> Optional[ExceptionHandler]:
+ for exc_cls in type(exc).mro():
+ handler = self._exception_handlers.get(exc_cls)
+ if handler:
+ return handler
+
+ return None
diff --git a/botx/bot/testing.py b/botx/bot/testing.py
new file mode 100644
index 00000000..59427a27
--- /dev/null
+++ b/botx/bot/testing.py
@@ -0,0 +1,14 @@
+from contextlib import asynccontextmanager
+from typing import AsyncGenerator
+
+from botx.bot.bot import Bot
+
+
+@asynccontextmanager
+async def lifespan_wrapper(bot: Bot) -> AsyncGenerator[Bot, None]:
+ await bot.startup()
+
+ try:
+ yield bot
+ finally:
+ await bot.shutdown()
diff --git a/botx/bots/__init__.py b/botx/bots/__init__.py
deleted file mode 100644
index 39810ec5..00000000
--- a/botx/bots/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for Bot and it's components."""
diff --git a/botx/bots/bots.py b/botx/bots/bots.py
deleted file mode 100644
index 35c4595b..00000000
--- a/botx/bots/bots.py
+++ /dev/null
@@ -1,153 +0,0 @@
-"""Implementation for bot classes."""
-
-import asyncio
-from dataclasses import InitVar, field
-from typing import Any, Callable, Dict, List
-from weakref import WeakSet
-
-from loguru import logger
-from pydantic.dataclasses import dataclass
-
-from botx import concurrency, exception_handlers, exceptions, shared, typing
-from botx.bots.mixins import (
- clients,
- collectors,
- exceptions as exception_mixin,
- lifespan,
- middlewares,
-)
-from botx.clients.clients import async_client, sync_client as synchronous_client
-from botx.collecting.collectors.collector import Collector
-from botx.dependencies.models import Depends
-from botx.middlewares.authorization import AuthorizationMiddleware
-from botx.middlewares.exceptions import ExceptionMiddleware
-from botx.models import credentials, datastructures, menu
-from botx.models.messages.message import Message
-
-
-@dataclass(config=shared.BotXDataclassConfig)
-class Bot( # noqa: WPS215
- collectors.BotCollectingMixin,
- clients.ClientsMixin,
- lifespan.LifespanMixin,
- middlewares.MiddlewareMixin,
- exception_mixin.ExceptionHandlersMixin,
-):
- """Class that implements bot behaviour."""
-
- dependencies: InitVar[List[Depends]] = field(default=None)
- bot_accounts: List[credentials.BotXCredentials] = field(default_factory=list)
- startup_events: List[typing.BotLifespanEvent] = field(default_factory=list)
- shutdown_events: List[typing.BotLifespanEvent] = field(default_factory=list)
-
- client: async_client.AsyncClient = field(init=False)
- sync_client: synchronous_client.Client = field(init=False)
- collector: Collector = field(init=False)
- exception_middleware: ExceptionMiddleware = field(init=False)
- state: datastructures.State = field(init=False)
- dependency_overrides: Dict[Callable, Callable] = field(
- init=False,
- default_factory=dict,
- )
-
- tasks: WeakSet = field(init=False, default_factory=WeakSet)
-
- async def __call__(self, message: Message) -> None:
- """Iterate through collector, find handler and execute it, running middlewares.
-
- Arguments:
- message: message that will be proceed by handler.
- """
- self.tasks.add(asyncio.ensure_future(self.exception_middleware(message)))
-
- def __post_init__(self, dependencies: List[Depends]) -> None:
- """Initialize special fields.
-
- Arguments:
- dependencies: initial background dependencies for inner collector.
- """
- self.state = datastructures.State()
- self.client = async_client.AsyncClient()
- self.sync_client = synchronous_client.Client()
- self.collector = Collector(
- dependencies=dependencies,
- dependency_overrides_provider=self,
- )
- self.exception_middleware = ExceptionMiddleware(self.collector)
-
- self.add_exception_handler(
- exceptions.DependencyFailure,
- exception_handlers.dependency_failure_exception_handler,
- )
- self.add_exception_handler(
- exceptions.NoMatchFound,
- exception_handlers.no_match_found_exception_handler,
- )
- self.add_middleware(AuthorizationMiddleware)
-
- async def status(self, *args: Any, **kwargs: Any) -> menu.Status:
- """Generate status object that could be return to BotX API on `/status`.
-
- Arguments:
- args: additional positional arguments that will be passed to callable
- status function.
- kwargs: additional key arguments that will be passed to callable
- status function.
-
- Returns:
- Built status for returning to BotX API.
- """
- status = menu.Status()
- for handler in self.handlers:
- if callable(handler.include_in_status):
- include_in_status = await concurrency.callable_to_coroutine(
- handler.include_in_status,
- *args,
- **kwargs,
- )
- else:
- include_in_status = handler.include_in_status
-
- if include_in_status:
- status.result.commands.append(
- menu.MenuCommand(
- description=handler.description or "",
- body=handler.body,
- name=handler.name,
- ),
- )
-
- return status
-
- async def execute_command(self, message: dict) -> None:
- """Process data with incoming message and handle command inside.
-
- Arguments:
- message: incoming message to bot.
- """
- logger.bind(botx_bot=True, payload=message).debug("process incoming message")
- msg = Message.from_dict(message, self)
-
- # raise UnknownBotError if not registered.
- self.get_account_by_bot_id(msg.bot_id)
-
- await self(msg)
-
- async def authorize(self, *args: Any) -> None:
- """Process auth for each bot account."""
- for account in self.bot_accounts:
- try:
- token = await self.get_token(
- account.host,
- account.bot_id,
- account.signature,
- )
- except (exceptions.BotXAPIError, exceptions.BotXConnectError) as exc:
- logger.bind(botx_bot=True).warning(
- f"Credentials `host - {account.host}, " # noqa: WPS305
- f"bot_id - {account.bot_id}` are invalid. "
- f"Reason - {exc.message_template}",
- )
- continue
-
- account.token = token
diff --git a/botx/bots/mixins/__init__.py b/botx/bots/mixins/__init__.py
deleted file mode 100644
index bffd8c59..00000000
--- a/botx/bots/mixins/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for mixins for bot."""
diff --git a/botx/bots/mixins/clients.py b/botx/bots/mixins/clients.py
deleted file mode 100644
index 506694a1..00000000
--- a/botx/bots/mixins/clients.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""Define Mixin that combines API and sending mixins and will be used in Bot."""
-from typing import List
-from uuid import UUID
-
-from botx.bots.mixins.requests.mixin import BotXRequestsMixin
-from botx.bots.mixins.sending import SendingMixin
-from botx.exceptions import TokenError, UnknownBotError
-from botx.models.credentials import BotXCredentials
-
-
-class ClientsMixin(SendingMixin, BotXRequestsMixin):
- """Mixin that defines methods that are used for communicating with BotX API."""
-
- bot_accounts: List[BotXCredentials]
-
- def get_account_by_bot_id(self, bot_id: UUID) -> BotXCredentials:
- """Find BotCredentials in bot registered bot.
-
- Arguments:
- bot_id: UUID of bot for which server should be searched.
-
- Returns:
- Found instance of registered server.
-
- Raises:
- UnknownBotError: raised if account was not found.
- """
- for bot in self.bot_accounts:
- if bot.bot_id == bot_id:
- return bot
-
- raise UnknownBotError(bot_id=bot_id)
-
- def get_token_for_bot(self, bot_id: UUID) -> str:
- """Search token in bot saved tokens by bot_id.
-
- Arguments:
- bot_id: UUID of bot for which token should be searched.
-
- Returns:
- Found bot's token.
-
- Raises:
- TokenError: raised of there is not token for bot.
- """
- account = self.get_account_by_bot_id(bot_id)
- if account.token is not None:
- return account.token
-
- raise TokenError(message_template="Token is empty")
diff --git a/botx/bots/mixins/collecting/__init__.py b/botx/bots/mixins/collecting/__init__.py
deleted file mode 100644
index a309329c..00000000
--- a/botx/bots/mixins/collecting/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for collecting behaviour mixins."""
diff --git a/botx/bots/mixins/collecting/add_handler.py b/botx/bots/mixins/collecting/add_handler.py
deleted file mode 100644
index 4e61e862..00000000
--- a/botx/bots/mixins/collecting/add_handler.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""Mixin that defines handler decorator."""
-
-from typing import Callable, Optional, Sequence, Union
-
-from botx.collecting.collectors.collector import Collector
-from botx.dependencies.models import Depends
-
-
-class AddHandlerMixin:
- """Mixin that defines handler decorator."""
-
- collector: Collector
-
- def add_handler( # noqa: WPS211
- self,
- handler: Callable,
- *,
- body: Optional[str] = None,
- name: Optional[str] = None,
- description: Optional[str] = None,
- full_description: Optional[str] = None,
- include_in_status: Union[bool, Callable] = True,
- dependencies: Optional[Sequence[Depends]] = None,
- ) -> None:
- """Create new handler from passed arguments and store it inside.
-
- !!! info
- If `include_in_status` is a function, then `body` argument will be checked
- for matching public commands style, like `/command`.
-
- Arguments:
- handler: callable that will be used for executing handler.
- body: body template that will trigger this handler.
- name: optional name for handler that will be used in generating body.
- description: description for command that will be shown in bot's menu.
- full_description: full description that can be used for example in `/help`
- command.
- include_in_status: should this handler be shown in bot's menu, can be
- callable function with no arguments *(for now)*.
- dependencies: sequence of dependencies that should be executed before
- handler.
- """
- self.collector.add_handler(
- body=body,
- handler=handler,
- name=name,
- description=description,
- full_description=full_description,
- include_in_status=include_in_status,
- dependencies=dependencies,
- )
diff --git a/botx/bots/mixins/collecting/default.py b/botx/bots/mixins/collecting/default.py
deleted file mode 100644
index 60899dac..00000000
--- a/botx/bots/mixins/collecting/default.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""Mixin that defines handler decorator."""
-
-from typing import Any, Callable, Optional, Sequence, Union
-
-from botx.collecting.collectors.collector import Collector
-from botx.dependencies.models import Depends
-
-
-class DefaultHandlerMixin:
- """Mixin that defines handler decorator."""
-
- collector: Collector
-
- def default( # noqa: WPS211
- self,
- handler: Optional[Callable] = None,
- *,
- command: Optional[str] = None,
- commands: Optional[Sequence[str]] = None,
- name: Optional[str] = None,
- description: Optional[str] = None,
- full_description: Optional[str] = None,
- include_in_status: Union[bool, Callable] = False,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Add new handler to bot and register it as default handler.
-
- !!! info
- If `include_in_status` is a function, then `body` argument will be checked
- for matching public commands style, like `/command`.
-
- Arguments:
- handler: callable that will be used for executing handler.
- command: body template that will trigger this handler.
- commands: list of body templates that will trigger this handler.
- name: optional name for handler that will be used in generating body.
- description: description for command that will be shown in bot's menu.
- full_description: full description that can be used for example in `/help`
- command.
- include_in_status: should this handler be shown in bot's menu, can be
- callable function with no arguments *(for now)*.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.default(
- handler=handler,
- command=command,
- commands=commands,
- name=name,
- description=description,
- full_description=full_description,
- include_in_status=include_in_status,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
diff --git a/botx/bots/mixins/collecting/handler.py b/botx/bots/mixins/collecting/handler.py
deleted file mode 100644
index c793646d..00000000
--- a/botx/bots/mixins/collecting/handler.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""Mixin that defines handler decorator."""
-
-from typing import Any, Callable, Optional, Sequence, Union
-
-from botx.collecting.collectors.collector import Collector
-from botx.dependencies.models import Depends
-
-
-class HandlerMixin:
- """Mixin that defines handler decorator."""
-
- collector: Collector
-
- def handler( # noqa: WPS211
- self,
- handler: Optional[Callable] = None,
- *,
- command: Optional[str] = None,
- commands: Optional[Sequence[str]] = None,
- name: Optional[str] = None,
- description: Optional[str] = None,
- full_description: Optional[str] = None,
- include_in_status: Union[bool, Callable] = True,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Add new handler to bot.
-
- !!! info
- If `include_in_status` is a function, then `body` argument will be checked
- for matching public commands style, like `/command`.
-
- Arguments:
- handler: callable that will be used for executing handler.
- command: body template that will trigger this handler.
- commands: list of body templates that will trigger this handler.
- name: optional name for handler that will be used in generating body.
- description: description for command that will be shown in bot's menu.
- full_description: full description that can be used for example in `/help`
- command.
- include_in_status: should this handler be shown in bot's menu, can be
- callable function with no arguments *(for now)*.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.handler(
- handler=handler,
- command=command,
- commands=commands,
- name=name,
- description=description,
- full_description=full_description,
- include_in_status=include_in_status,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
diff --git a/botx/bots/mixins/collecting/hidden.py b/botx/bots/mixins/collecting/hidden.py
deleted file mode 100644
index bc38b489..00000000
--- a/botx/bots/mixins/collecting/hidden.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Mixin that defines handler decorator."""
-
-from typing import Any, Callable, Optional, Sequence
-
-from botx.collecting.collectors.collector import Collector
-from botx.dependencies.models import Depends
-
-
-class HiddenHandlerMixin:
- """Mixin that defines handler decorator."""
-
- collector: Collector
-
- def hidden( # noqa: WPS211
- self,
- handler: Optional[Callable] = None,
- *,
- command: Optional[str] = None,
- commands: Optional[Sequence[str]] = None,
- name: Optional[str] = None,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register hidden handler that won't be showed in menu.
-
- Arguments:
- handler: callable that will be used for executing handler.
- command: body template that will trigger this handler.
- commands: list of body templates that will trigger this handler.
- name: optional name for handler that will be used in generating body.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.hidden(
- handler=handler,
- command=command,
- commands=commands,
- name=name,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
diff --git a/botx/bots/mixins/collecting/system_events.py b/botx/bots/mixins/collecting/system_events.py
deleted file mode 100644
index 59c8b3af..00000000
--- a/botx/bots/mixins/collecting/system_events.py
+++ /dev/null
@@ -1,238 +0,0 @@
-"""Mixin that defines handler decorator."""
-
-from typing import Any, Callable, Optional, Sequence
-
-from botx.collecting.collectors.collector import Collector
-from botx.dependencies.models import Depends
-from botx.models.enums import SystemEvents
-
-
-class SystemEventsHandlerMixin: # noqa: WPS214
- """Mixin that defines handler decorator."""
-
- collector: Collector
-
- def system_event( # noqa: WPS211
- self,
- handler: Optional[Callable] = None,
- *,
- event: Optional[SystemEvents] = None,
- events: Optional[Sequence[SystemEvents]] = None,
- name: Optional[str] = None,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for system event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- event: event for triggering this handler.
- events: a sequence of events that will trigger handler.
- name: optional name for handler that will be used in generating body.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.system_event(
- handler=handler,
- event=event,
- events=events,
- name=name,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def chat_created(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `system:chat_created` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.chat_created(
- handler=handler,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def file_transfer(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `file_transfer` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.file_transfer(
- handler=handler,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def added_to_chat(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `added_to_chat` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.added_to_chat(
- handler=handler,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def deleted_from_chat(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `deleted_from_chat` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.deleted_from_chat(
- handler=handler,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def left_from_chat(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `left_from_chat` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.left_from_chat(
- handler=handler,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def cts_login(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `cts_login` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.cts_login(
- handler=handler,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def cts_logout(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `cts_logout` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.cts_logout(
- handler=handler,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def smartapp_event(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `smartapp_event` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.collector.smartapp_event(
- handler=handler,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
diff --git a/botx/bots/mixins/collectors.py b/botx/bots/mixins/collectors.py
deleted file mode 100644
index 9cd14774..00000000
--- a/botx/bots/mixins/collectors.py
+++ /dev/null
@@ -1,76 +0,0 @@
-"""Definition for bot's collecting component.
-
-All of this methods are just wrappers around inner collector.
-"""
-
-from typing import Any, List, Optional, Sequence
-
-from botx.bots.mixins.collecting.add_handler import AddHandlerMixin
-from botx.bots.mixins.collecting.default import DefaultHandlerMixin
-from botx.bots.mixins.collecting.handler import HandlerMixin
-from botx.bots.mixins.collecting.hidden import HiddenHandlerMixin
-from botx.bots.mixins.collecting.system_events import SystemEventsHandlerMixin
-from botx.collecting.collectors.collector import Collector
-from botx.collecting.handlers.handler import Handler
-from botx.dependencies import models as deps
-
-
-class BotCollectingMixin( # noqa: WPS215
- AddHandlerMixin,
- HandlerMixin,
- DefaultHandlerMixin,
- HiddenHandlerMixin,
- SystemEventsHandlerMixin,
-):
- """Mixin that defines collector-like behaviour."""
-
- collector: Collector
-
- @property
- def handlers(self) -> List[Handler]:
- """Get handlers registered on this bot.
-
- Returns:
- Registered handlers of bot.
- """
- return self.collector.handlers
-
- def include_collector(
- self,
- collector: Collector,
- *,
- dependencies: Optional[Sequence[deps.Depends]] = None,
- ) -> None:
- """Include handlers from collector into bot.
-
- Arguments:
- collector: collector from which handlers should be copied.
- dependencies: optional sequence of dependencies for handlers for this
- collector.
- """
- self.collector.include_collector(collector, dependencies=dependencies)
-
- def command_for(self, *args: Any) -> str:
- """Find handler and build a command string using passed body query_params.
-
- Arguments:
- args: sequence of elements where first element should be name of handler.
-
- Returns:
- Command string.
- """
- return self.collector.command_for(*args)
-
- def handler_for(self, name: str) -> Handler:
- """Find handler in handlers of this bot.
-
- Find registered handler using using [botx.collector.Collector.handler_for] of
- inner collector.
-
- Arguments:
- name: name of handler that should be found.
-
- Returns:
- Handler that was found by name.
- """
- return self.collector.handler_for(name)
diff --git a/botx/bots/mixins/exceptions.py b/botx/bots/mixins/exceptions.py
deleted file mode 100644
index 9be3f9d1..00000000
--- a/botx/bots/mixins/exceptions.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""Exception mixin for bot."""
-
-from typing import Callable, Type
-
-from botx import typing
-from botx.middlewares.exceptions import ExceptionMiddleware
-
-try:
- from typing import Protocol # noqa: WPS433
-except ImportError:
- from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401
-
-
-class ExceptionHandlersMixin:
- """Mixin that defines functions for exception handlers registration."""
-
- exception_middleware: ExceptionMiddleware
-
- def add_exception_handler(
- self,
- exc_class: Type[Exception],
- handler: typing.ExceptionHandler,
- ) -> None:
- """Register new handler for exception.
-
- Arguments:
- exc_class: exception type that should be handled.
- handler: handler for exception.
- """
- self.exception_middleware.add_exception_handler(exc_class, handler)
-
- def exception_handler(self, exc_class: Type[Exception]) -> Callable:
- """Register callable as handler for exception.
-
- Arguments:
- exc_class: exception type that should be handled.
-
- Returns:
- Decorator that will register exception and return passed function.
- """
-
- def decorator(handler: typing.ExceptionHandler) -> Callable:
- self.add_exception_handler(exc_class, handler)
- return handler
-
- return decorator
diff --git a/botx/bots/mixins/lifespan.py b/botx/bots/mixins/lifespan.py
deleted file mode 100644
index 66db16c5..00000000
--- a/botx/bots/mixins/lifespan.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""Lifespan mixin for bot."""
-
-import asyncio
-from typing import List
-from weakref import WeakSet
-
-from botx.concurrency import callable_to_coroutine
-from botx.typing import BotLifespanEvent
-
-try:
- from typing import Protocol # noqa: WPS433
-except ImportError:
- from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401
-
-
-class LifespanMixin:
- """Lifespan events mixin for bot."""
-
- #: currently running tasks.
- tasks: WeakSet
-
- #: startup events.
- startup_events: List[BotLifespanEvent]
-
- #: shutdown events.
- shutdown_events: List[BotLifespanEvent]
-
- async def start(self) -> None:
- """Run all startup events and other initialization stuff."""
- for event in self.startup_events:
- await callable_to_coroutine(event, self)
-
- async def shutdown(self) -> None:
- """Wait for all running handlers shutdown."""
- await self.wait_current_handlers()
-
- for event in self.shutdown_events:
- await callable_to_coroutine(event, self)
-
- async def wait_current_handlers(self) -> None:
- """Wait until all current tasks are done."""
- if self.tasks:
- tasks, _ = await asyncio.wait(
- self.tasks,
- return_when=asyncio.ALL_COMPLETED,
- )
- for task in tasks:
- task.result()
-
- self.tasks.clear()
diff --git a/botx/bots/mixins/middlewares.py b/botx/bots/mixins/middlewares.py
deleted file mode 100644
index a8cb317d..00000000
--- a/botx/bots/mixins/middlewares.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""Implementation for bot classes."""
-
-from typing import Any, Callable, Type
-
-from botx import typing
-from botx.middlewares.base import BaseMiddleware
-from botx.middlewares.exceptions import ExceptionMiddleware
-
-
-class MiddlewareMixin:
- """Middleware mixin for bot."""
-
- exception_middleware: ExceptionMiddleware
-
- def add_middleware(
- self,
- middleware_class: Type[BaseMiddleware],
- **kwargs: Any,
- ) -> None:
- """Register new middleware for execution before handler.
-
- Arguments:
- middleware_class: middleware that should be registered.
- kwargs: arguments that are required for middleware initialization.
- """
- self.exception_middleware.executor = middleware_class(
- self.exception_middleware.executor,
- **kwargs,
- )
-
- def middleware(self, handler: typing.Executor) -> Callable:
- """Register callable as middleware for request.
-
- Arguments:
- handler: handler for middleware logic.
-
- Returns:
- Passed `handler` callable.
- """
- self.add_middleware(BaseMiddleware, dispatch=handler)
- return handler
diff --git a/botx/bots/mixins/requests/__init__.py b/botx/bots/mixins/requests/__init__.py
deleted file mode 100644
index bf1d0b23..00000000
--- a/botx/bots/mixins/requests/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Mixins for shortcuts for requests to BotX API."""
diff --git a/botx/bots/mixins/requests/bots.py b/botx/bots/mixins/requests/bots.py
deleted file mode 100644
index 2bfc5987..00000000
--- a/botx/bots/mixins/requests/bots.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Mixin for shortcut for bots resource requests."""
-
-from uuid import UUID
-
-from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol
-from botx.clients.methods.v2.bots.token import Token
-
-
-class BotsRequestsMixin:
- """Mixin for shortcut for bots resource requests."""
-
- async def get_token(
- self: BotXMethodCallProtocol,
- host: str,
- bot_id: UUID,
- signature: str,
- ) -> str:
- """Obtain token for bot.
-
- Arguments:
- host: host on which request should be made.
- bot_id: ID of bot for which token should be obtained.
- signature: calculated signature of bot.
-
- Returns:
- Obtained token.
- """
- return await self.call_method(
- Token(bot_id=bot_id, signature=signature),
- host=host,
- bot_id=bot_id,
- )
diff --git a/botx/bots/mixins/requests/call_protocol.py b/botx/bots/mixins/requests/call_protocol.py
deleted file mode 100644
index 54d7a296..00000000
--- a/botx/bots/mixins/requests/call_protocol.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""Protocol for using in mixins to make mypy and typing happy."""
-from typing import Any, Optional
-from uuid import UUID
-
-from botx.clients.methods.base import BotXMethod
-from botx.models.messages.sending.credentials import SendingCredentials
-
-try:
- from typing import Protocol # noqa: WPS433
-except ImportError:
- from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401
-
-
-class BotXMethodCallProtocol(Protocol):
- """Protocol for using in mixins to make mypy and typing happy."""
-
- async def call_method( # noqa: WPS211
- self,
- method: BotXMethod[Any],
- *,
- host: Optional[str] = None,
- token: Optional[str] = None,
- bot_id: Optional[UUID] = None,
- credentials: Optional[SendingCredentials] = None,
- ) -> Any:
- """Send request to BotX API through bot's async client."""
diff --git a/botx/bots/mixins/requests/chats.py b/botx/bots/mixins/requests/chats.py
deleted file mode 100644
index 4fa3bf0f..00000000
--- a/botx/bots/mixins/requests/chats.py
+++ /dev/null
@@ -1,226 +0,0 @@
-"""Mixin for shortcut for chats resource requests."""
-
-from typing import List, Optional
-from uuid import UUID
-
-from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol
-from botx.clients.methods.v3.chats.add_admin_role import AddAdminRole
-from botx.clients.methods.v3.chats.add_user import AddUser
-from botx.clients.methods.v3.chats.chat_list import ChatList
-from botx.clients.methods.v3.chats.create import Create
-from botx.clients.methods.v3.chats.info import Info
-from botx.clients.methods.v3.chats.pin_message import PinMessage
-from botx.clients.methods.v3.chats.remove_user import RemoveUser
-from botx.clients.methods.v3.chats.stealth_disable import StealthDisable
-from botx.clients.methods.v3.chats.stealth_set import StealthSet
-from botx.clients.methods.v3.chats.unpin_message import UnpinMessage
-from botx.models.chats import BotChatList, ChatFromSearch
-from botx.models.enums import ChatTypes
-from botx.models.messages.sending.credentials import SendingCredentials
-
-
-class ChatsRequestsMixin:
- """Mixin for shortcut for chats resource requests."""
-
- async def create_chat( # noqa: WPS211
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- name: str,
- members: List[UUID],
- chat_type: ChatTypes,
- shared_history: bool = False,
- description: Optional[str] = None,
- avatar: Optional[str] = None,
- ) -> UUID:
- """Create new chat.
-
- Arguments:
- credentials: credentials for making request.
- name: name of chat that should be created.
- members: HUIDs of users that should be added into chat.
- chat_type: chat type.
- description: description of new chat.
- avatar: logo image of chat.
- shared_history: chat history is available to newcomers.
-
- Returns:
- ID of created chat.
- """
- return await self.call_method(
- Create(
- name=name,
- description=description,
- members=members,
- avatar=avatar,
- chat_type=chat_type,
- shared_history=shared_history,
- ),
- credentials=credentials,
- )
-
- async def get_chat_info(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- chat_id: UUID,
- ) -> ChatFromSearch:
- """Return chat's info.
-
- Arguments:
- credentials: credentials for making request.
- chat_id: ID of chat for about which information should be retrieving.
-
- Returns:
- Information about chat.
- """
- return await self.call_method(
- Info(group_chat_id=chat_id),
- credentials=credentials,
- )
-
- async def get_bot_chats(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- ) -> BotChatList:
- """Return list of bot's chats.
-
- Arguments:
- credentials: credentials for making request.
-
- Returns:
- List of bot's chats.
- """
- return await self.call_method(
- ChatList(),
- credentials=credentials,
- )
-
- async def enable_stealth_mode( # noqa: WPS211
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- chat_id: UUID,
- disable_web: bool = False,
- burn_in: Optional[int] = None,
- expire_in: Optional[int] = None,
- ) -> None:
- """Enable stealth mode.
-
- Arguments:
- credentials: credentials for making request.
- chat_id: ID of chat where stealth should be enabled.
- disable_web: should messages be shown in web.
- burn_in: time of messages burning after read.
- expire_in: time of messages burning after send.
- """
- await self.call_method(
- StealthSet(
- group_chat_id=chat_id,
- disable_web=disable_web,
- burn_in=burn_in,
- expire_in=expire_in,
- ),
- credentials=credentials,
- )
-
- async def disable_stealth_mode(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- chat_id: UUID,
- ) -> None:
- """Disable stealth mode.
-
- Arguments:
- credentials: credentials for making request.
- chat_id: ID of chat where stealth should be disabled.
- """
- await self.call_method(
- StealthDisable(group_chat_id=chat_id),
- credentials=credentials,
- )
-
- async def add_users(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- chat_id: UUID,
- user_huids: List[UUID],
- ) -> None:
- """Add users to chat.
-
- Arguments:
- credentials: credentials for making request.
- chat_id: ID of chat into which users should be added.
- user_huids: IDs of users that should be added into chat.
- """
- await self.call_method(
- AddUser(group_chat_id=chat_id, user_huids=user_huids),
- credentials=credentials,
- )
-
- async def remove_users(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- chat_id: UUID,
- user_huids: List[UUID],
- ) -> None:
- """Remove users from chat.
-
- Arguments:
- credentials: credentials for making request.
- chat_id: ID of chat from which users should be removed.
- user_huids: HUID of users that should be removed.
- """
- await self.call_method(
- RemoveUser(group_chat_id=chat_id, user_huids=user_huids),
- credentials=credentials,
- )
-
- async def add_admin_roles(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- chat_id: UUID,
- user_huids: List[UUID],
- ) -> None:
- """Promote users in chat to admins.
-
- Arguments:
- credentials: credentials for making request.
- chat_id: UUID of chat where action should be performed.
- user_huids: HUIDs of users that should be promoted to admins.
- """
- await self.call_method(
- AddAdminRole(group_chat_id=chat_id, user_huids=user_huids),
- credentials=credentials,
- )
-
- async def pin_message(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- chat_id: UUID,
- sync_id: UUID,
- ) -> None:
- """Pin message in chat.
-
- Arguments:
- credentials: credentials for making request.
- chat_id: ID of chat where message should be pinned.
- sync_id: ID of message that should be pinned.
- """
- await self.call_method(
- PinMessage(chat_id=chat_id, sync_id=sync_id),
- credentials=credentials,
- )
-
- async def unpin_message(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- chat_id: UUID,
- ) -> None:
- """Unpin message in chat.
-
- Arguments:
- credentials: credentials for making request.
- chat_id: ID of chat where message should be unpinned.
- """
- await self.call_method(
- UnpinMessage(chat_id=chat_id),
- credentials=credentials,
- )
diff --git a/botx/bots/mixins/requests/command.py b/botx/bots/mixins/requests/command.py
deleted file mode 100644
index 3f28cc12..00000000
--- a/botx/bots/mixins/requests/command.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""Mixin for shortcut for command resource requests."""
-
-from typing import cast
-from uuid import UUID
-
-from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol
-from botx.clients.methods.v3.command.command_result import CommandResult
-from botx.clients.types.message_payload import ResultPayload
-from botx.clients.types.options import ResultOptions
-from botx.models.messages.sending.credentials import SendingCredentials
-from botx.models.messages.sending.options import ResultPayloadOptions
-from botx.models.messages.sending.payload import MessagePayload
-
-
-class CommandRequestsMixin:
- """Mixin for shortcut for command resource requests."""
-
- async def send_command_result(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- payload: MessagePayload,
- ) -> UUID:
- """Send command result into chat.
-
- Arguments:
- credentials: credentials for making request.
- payload: payload for command result.
-
- Returns:
- ID sent message.
- """
- return await self.call_method(
- CommandResult(
- sync_id=cast(UUID, credentials.sync_id),
- event_sync_id=credentials.message_id,
- result=ResultPayload(
- body=payload.text,
- metadata=payload.metadata,
- bubble=payload.markup.bubbles,
- keyboard=payload.markup.keyboard,
- mentions=payload.options.mentions,
- opts=ResultPayloadOptions(
- silent_response=payload.options.silent_response,
- ),
- ),
- recipients=payload.options.recipients,
- file=payload.file,
- opts=ResultOptions(
- stealth_mode=payload.options.stealth_mode,
- notification_opts=payload.options.notifications,
- ),
- ),
- credentials=credentials,
- )
diff --git a/botx/bots/mixins/requests/events.py b/botx/bots/mixins/requests/events.py
deleted file mode 100644
index a56ff789..00000000
--- a/botx/bots/mixins/requests/events.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""Mixin for shortcut for events resource requests."""
-from typing import List, Optional
-from uuid import UUID
-
-from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol
-from botx.clients.methods.v3.events.edit_event import EditEvent
-from botx.clients.methods.v3.events.reply_event import ReplyEvent
-from botx.clients.types.message_payload import ResultPayload, UpdatePayload
-from botx.clients.types.options import ResultOptions
-from botx.models.entities import Mention
-from botx.models.messages.message import File
-from botx.models.messages.sending.credentials import SendingCredentials
-from botx.models.messages.sending.markup import MessageMarkup
-from botx.models.messages.sending.payload import UpdatePayload as SendingUpdatePayload
-
-
-class EventsRequestsMixin:
- """Mixin that defines methods for communicating with BotX API."""
-
- async def update_message(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- update: SendingUpdatePayload,
- ) -> None:
- """Change message by it's event id.
-
- Arguments:
- credentials: credentials that are used for sending message. *sync_id* is
- required for credentials.
- update: update of message content.
-
- Raises:
- ValueError: raised if sync_id wasn't provided
- """
- if not credentials.sync_id:
- raise ValueError("sync_id is required for message update")
-
- await self.call_method(
- EditEvent(
- sync_id=credentials.sync_id,
- result=UpdatePayload(
- body=update.text,
- metadata=update.metadata,
- keyboard=update.keyboard,
- bubble=update.bubbles,
- mentions=update.mentions,
- ),
- file=update.file,
- ),
- credentials=credentials,
- )
-
- async def reply( # noqa: WPS211
- self: BotXMethodCallProtocol,
- source_sync_id: UUID,
- credentials: SendingCredentials,
- text: str = "",
- *,
- file: Optional[File] = None,
- markup: Optional[MessageMarkup] = None,
- mentions: Optional[List[Mention]] = None,
- opts: Optional[ResultOptions] = None,
- ) -> None:
- """Reply on message by source_sync_id.
-
- Arguments:
- text: text of message.
- source_sync_id: source message uuid that will replied.
- file: attachment file.
- markup: markup of sending message.
- opts: options of sending message.
- credentials: credentials for making request.
- mentions: mentions in message.
-
- Raises:
- ValueError: empty text.
- """
- if not (text or file or mentions):
- raise ValueError("text or file or mention required")
-
- await self.call_method(
- ReplyEvent(
- source_sync_id=source_sync_id,
- result=ResultPayload(
- body=text,
- keyboard=markup.keyboard if markup else [],
- bubble=markup.bubbles if markup else [],
- mentions=mentions or [],
- ),
- file=file,
- opts=opts or ResultOptions(),
- ),
- credentials=credentials,
- )
diff --git a/botx/bots/mixins/requests/files.py b/botx/bots/mixins/requests/files.py
deleted file mode 100644
index 50c1678e..00000000
--- a/botx/bots/mixins/requests/files.py
+++ /dev/null
@@ -1,87 +0,0 @@
-"""Mixin for shortcut for files resource requests."""
-
-from typing import Optional
-from uuid import UUID
-
-from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol
-from botx.clients.clients.async_client import AsyncClient
-from botx.clients.methods.v3.files.download import DownloadFile
-from botx.clients.methods.v3.files.upload import UploadFile
-from botx.clients.types.upload_file import UploadingFileMeta
-from botx.models.files import File, MetaFile
-from botx.models.messages.sending.credentials import SendingCredentials
-
-
-class FilesRequestsMixin:
- """Mixin for shortcut for files resource requests."""
-
- client: AsyncClient
-
- async def upload_file( # noqa: WPS211
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- sending_file: File,
- group_chat_id: UUID,
- *,
- duration: Optional[int] = None,
- caption: Optional[str] = None,
- ) -> MetaFile:
- """Upload file to the chat.
-
- Arguments:
- credentials: credentials for making request.
- sending_file: file to upload.
- group_chat_id: ID of the chat that accepts the file.
- duration: duration of the voice or the video.
- caption: file caption.
-
- Returns:
- File metadata.
- """
- return await self.call_method(
- UploadFile(
- group_chat_id=group_chat_id,
- file=sending_file,
- meta=UploadingFileMeta(
- duration=duration,
- caption=caption,
- ),
- ),
- credentials=credentials,
- )
-
- async def download_file( # noqa: WPS211
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- file_id: UUID,
- group_chat_id: UUID,
- *,
- file_name: Optional[str] = None,
- is_preview: bool = False,
- ) -> File:
- """Download file from the chat.
-
- Arguments:
- credentials: credentials for making request.
- file_id: ID of the file.
- group_chat_id: ID of the chat that accepts the file.
- file_name: file name to be assigned instead of default name.
- is_preview: get preview or file.
-
- Returns:
- Downloaded file.
- """
- file = await self.call_method(
- DownloadFile(
- file_id=file_id,
- group_chat_id=group_chat_id,
- is_preview=is_preview,
- ),
- credentials=credentials,
- )
-
- if file_name:
- ext = file.file_name.split(".", maxsplit=1)[1]
- file.file_name = "{name}.{ext}".format(name=file_name, ext=ext)
-
- return file
diff --git a/botx/bots/mixins/requests/internal_bot_notification.py b/botx/bots/mixins/requests/internal_bot_notification.py
deleted file mode 100644
index 9a78f112..00000000
--- a/botx/bots/mixins/requests/internal_bot_notification.py
+++ /dev/null
@@ -1,47 +0,0 @@
-"""Mixin for shortcut for internal bot notification resource requests."""
-
-from typing import Any, Dict, List, Optional
-from uuid import UUID
-
-from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol
-from botx.clients.methods.v4.notifications.internal_bot_notification import (
- InternalBotNotification,
-)
-from botx.clients.types.message_payload import InternalBotNotificationPayload
-from botx.models.messages.sending.credentials import SendingCredentials
-
-
-class InternalBotNotificationRequestsMixin:
- """Mixin for shortcut for internal bot notification resource requests."""
-
- async def internal_bot_notification( # noqa: WPS211
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- group_chat_id: UUID,
- text: str,
- sender: Optional[str] = None,
- recipients: Optional[List[UUID]] = None,
- opts: Optional[Dict[str, Any]] = None,
- ) -> UUID:
- """Send internal bot notifications into chat.
-
- Arguments:
- credentials: credentials for making request.
- group_chat_id: ID of chats into which message should be sent.
- text: notification text.
- sender: information about notification sender.
- recipients: List of recipients' UUIDs (send to all if None)
- opts: additional user-defined data to send
-
- Returns:
- Sync ID of sent notification.
- """
- return await self.call_method(
- InternalBotNotification(
- group_chat_id=group_chat_id,
- recipients=recipients,
- data=InternalBotNotificationPayload(message=text, sender=sender),
- opts=opts or {},
- ),
- credentials=credentials,
- )
diff --git a/botx/bots/mixins/requests/mixin.py b/botx/bots/mixins/requests/mixin.py
deleted file mode 100644
index 95d2015e..00000000
--- a/botx/bots/mixins/requests/mixin.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""Definition for mixin that defines BotX API methods."""
-from typing import Optional, TypeVar, cast
-from uuid import UUID
-
-from loguru import logger
-
-from botx.bots.mixins.requests import bots # noqa: WPS235
-from botx.bots.mixins.requests import (
- chats,
- command,
- events,
- files,
- internal_bot_notification,
- notification,
- smartapps,
- stickers,
- users,
-)
-from botx.clients.clients.async_client import AsyncClient
-from botx.clients.methods.base import BotXMethod
-from botx.models.credentials import BotXCredentials
-from botx.models.messages.sending.credentials import SendingCredentials
-
-ResponseT = TypeVar("ResponseT")
-
-try:
- from typing import Protocol # noqa: WPS433
-except ImportError:
- from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401
-
-
-class CredentialsSearchProtocol(Protocol):
- """Protocol for search token in local credentials."""
-
- def get_token_for_bot(self, bot_id: UUID) -> str:
- """Search token in local credentials."""
-
- def get_account_by_bot_id(self, bot_id: UUID) -> BotXCredentials:
- """Get bot credentials by bot id."""
-
-
-# A lot of base classes since it's mixin for all shorthands for BotX API requests
-class BotXRequestsMixin( # noqa: WPS215
- bots.BotsRequestsMixin,
- chats.ChatsRequestsMixin,
- command.CommandRequestsMixin,
- events.EventsRequestsMixin,
- notification.NotificationRequestsMixin,
- users.UsersRequestsMixin,
- internal_bot_notification.InternalBotNotificationRequestsMixin,
- files.FilesRequestsMixin,
- smartapps.SmartAppMixin,
- stickers.StickersMixin,
-):
- """Mixin that defines methods for communicating with BotX API."""
-
- client: AsyncClient
-
- # TODO: remove SendingCredential from client module.
- async def call_method( # noqa: WPS211
- self,
- method: BotXMethod[ResponseT],
- *,
- host: Optional[str] = None,
- token: Optional[str] = None,
- bot_id: Optional[UUID] = None,
- credentials: Optional[SendingCredentials] = None,
- ) -> ResponseT:
- """Call method with async client.
-
- Arguments:
- method: method that should be user for request.
- host: host where request should be sent.
- token: token for method.
- bot_id: ID of bot that send request.
- credentials: credentials for making request.
-
- Returns:
- Response for method.
- """
- if credentials is not None:
- debug_bot_id = credentials.bot_id
- host = cast(str, credentials.host)
- bot_id = cast(UUID, credentials.bot_id)
- method.configure(
- host=host,
- token=cast(CredentialsSearchProtocol, self).get_token_for_bot(bot_id),
- )
- else:
- debug_bot_id = bot_id
- method.configure(host=host or method.host, token=token or method.token)
-
- request = self.client.build_request(method)
-
- logger.bind(
- botx_client=True,
- payload=request.dict(exclude={"expected_type"}),
- ).debug(
- "send {0} request to bot {1}",
- method.__repr_name__(), # noqa: WPS609
- debug_bot_id,
- )
-
- response = await self.client.execute(request)
- return await self.client.process_response(method, response)
diff --git a/botx/bots/mixins/requests/notification.py b/botx/bots/mixins/requests/notification.py
deleted file mode 100644
index a4dfe1e9..00000000
--- a/botx/bots/mixins/requests/notification.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""Mixin for shortcut for notification resource requests."""
-
-from typing import Optional, Sequence
-from uuid import UUID
-
-from botx import converters
-from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol
-from botx.clients.methods.v3.notification.direct_notification import NotificationDirect
-from botx.clients.methods.v3.notification.notification import Notification
-from botx.clients.types.message_payload import ResultPayload
-from botx.clients.types.options import ResultOptions
-from botx.models.messages.sending.credentials import SendingCredentials
-from botx.models.messages.sending.options import ResultPayloadOptions
-from botx.models.messages.sending.payload import MessagePayload
-
-
-class NotificationRequestsMixin:
- """Mixin for shortcut for notification resource requests."""
-
- async def send_notification(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- payload: MessagePayload,
- group_chat_ids: Optional[Sequence[UUID]] = None,
- ) -> None:
- """Send notifications into chat.
-
- Arguments:
- credentials: credentials for making request.
- payload: payload for notification.
- group_chat_ids: IDS of chats into which message should be sent.
- """
- if group_chat_ids is not None:
- chat_ids = converters.optional_sequence_to_list(group_chat_ids)
- elif credentials.chat_id:
- chat_ids = [credentials.chat_id]
- else:
- chat_ids = []
-
- await self.call_method(
- Notification(
- group_chat_ids=chat_ids,
- result=ResultPayload(
- body=payload.text,
- metadata=payload.metadata,
- bubble=payload.markup.bubbles,
- keyboard=payload.markup.keyboard,
- mentions=payload.options.mentions,
- opts=ResultPayloadOptions(
- silent_response=payload.options.silent_response,
- ),
- ),
- recipients=payload.options.recipients,
- file=payload.file,
- opts=ResultOptions(
- stealth_mode=payload.options.stealth_mode,
- raw_mentions=payload.options.raw_mentions,
- notification_opts=payload.options.notifications,
- ),
- ),
- credentials=credentials,
- )
-
- async def send_direct_notification(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- payload: MessagePayload,
- ) -> UUID:
- """Send notification into chat.
-
- Arguments:
- credentials: credentials for making request.
- payload: payload for notification.
-
- Returns:
- ID sent message.
-
- Raises:
- ValueError: raised if chat_id wasn't provided
- """
- if not credentials.chat_id:
- raise ValueError("chat_id is required to send direct notification")
-
- return await self.call_method(
- NotificationDirect(
- group_chat_id=credentials.chat_id,
- event_sync_id=credentials.message_id,
- result=ResultPayload(
- body=payload.text,
- metadata=payload.metadata,
- bubble=payload.markup.bubbles,
- keyboard=payload.markup.keyboard,
- mentions=payload.options.mentions,
- opts=ResultPayloadOptions(
- silent_response=payload.options.silent_response,
- ),
- ),
- recipients=payload.options.recipients,
- file=payload.file,
- opts=ResultOptions(
- stealth_mode=payload.options.stealth_mode,
- raw_mentions=payload.options.raw_mentions,
- notification_opts=payload.options.notifications,
- ),
- ),
- credentials=credentials,
- )
diff --git a/botx/bots/mixins/requests/smartapps.py b/botx/bots/mixins/requests/smartapps.py
deleted file mode 100644
index 69e2ac27..00000000
--- a/botx/bots/mixins/requests/smartapps.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""Mixin for shortcut for smartapp."""
-
-from uuid import UUID
-
-from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol
-from botx.clients.methods.v3.smartapps.smartapp_event import SmartAppEvent
-from botx.clients.methods.v3.smartapps.smartapp_notification import SmartAppNotification
-from botx.models.messages.sending.credentials import SendingCredentials
-from botx.models.smartapps import SendingSmartAppEvent, SendingSmartAppNotification
-
-
-class SmartAppMixin:
- """Mixin for shortcut for smartapp methods."""
-
- async def send_smartapp_event(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- smartapp_event: SendingSmartAppEvent,
- ) -> UUID:
- """Send smartapp event into chat.
-
- Arguments:
- credentials: credentials for making request.
- smartapp_event: SmartpApp event.
-
- Returns:
- ID sent message.
- """
- return await self.call_method(
- SmartAppEvent(
- ref=smartapp_event.ref,
- smartapp_id=smartapp_event.smartapp_id,
- data=smartapp_event.data,
- opts=smartapp_event.opts,
- smartapp_api_version=smartapp_event.smartapp_api_version,
- group_chat_id=smartapp_event.group_chat_id,
- files=smartapp_event.files,
- async_files=smartapp_event.async_files,
- ),
- credentials=credentials,
- )
-
- async def send_smartapp_notification(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- smartapp_notification: SendingSmartAppNotification,
- ) -> None:
- """Send smartapp notification into chat.
-
- Arguments:
- credentials: credentials for making request.
- smartapp_notification: Smartapp notification.
- """
- await self.call_method(
- SmartAppNotification(
- group_chat_id=smartapp_notification.group_chat_id,
- smartapp_counter=smartapp_notification.smartapp_counter,
- opts=smartapp_notification.opts,
- smartapp_api_version=smartapp_notification.smartapp_api_version,
- ),
- credentials=credentials,
- )
diff --git a/botx/bots/mixins/requests/stickers.py b/botx/bots/mixins/requests/stickers.py
deleted file mode 100644
index 8ca02581..00000000
--- a/botx/bots/mixins/requests/stickers.py
+++ /dev/null
@@ -1,199 +0,0 @@
-"""Mixin for shortcut for users resource requests."""
-
-from typing import List, Optional, Tuple
-from uuid import UUID
-
-from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol
-from botx.clients.methods.v3.stickers.add_sticker import AddSticker
-from botx.clients.methods.v3.stickers.create_sticker_pack import CreateStickerPack
-from botx.clients.methods.v3.stickers.delete_sticker import DeleteSticker
-from botx.clients.methods.v3.stickers.delete_sticker_pack import DeleteStickerPack
-from botx.clients.methods.v3.stickers.edit_sticker_pack import EditStickerPack
-from botx.clients.methods.v3.stickers.sticker import GetSticker
-from botx.clients.methods.v3.stickers.sticker_pack import GetStickerPack
-from botx.clients.methods.v3.stickers.sticker_pack_list import GetStickerPackList
-from botx.models.messages.sending.credentials import SendingCredentials
-from botx.models.stickers import (
- Sticker,
- StickerFromPack,
- StickerPack,
- StickerPackList,
- StickerPackPreview,
-)
-
-
-class StickersMixin: # noqa: WPS214
- """Mixin for shortcut for users resource requests."""
-
- async def get_sticker_pack_list(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- user_huid: Optional[UUID] = None,
- limit: int = 1,
- after: Optional[str] = None,
- ) -> Tuple[List[StickerPackPreview], Optional[str]]:
- """Get sticker pack list.
-
- Arguments:
- credentials: credentials for making request.
- user_huid: author HUID.
- limit: returning value count.
- after: cursor hash for pagination.
-
- Returns:
- Sticker packs list and cursor.
- """
- response: StickerPackList = await self.call_method(
- GetStickerPackList(user_huid=user_huid, limit=limit, after=after),
- credentials=credentials,
- )
-
- return response.packs, response.pagination.after
-
- async def get_sticker_pack(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- pack_id: UUID,
- ) -> StickerPack:
- """Get sticker pack.
-
- Arguments:
- credentials: credentials for making request.
- pack_id: sticker pack ID.
-
- Returns:
- StickerPack entity.
- """
- return await self.call_method(
- GetStickerPack(pack_id=pack_id),
- credentials=credentials,
- )
-
- async def get_sticker_from_pack(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- pack_id: UUID,
- sticker_id: UUID,
- ) -> StickerFromPack:
- """Get sticker from pack.
-
- Arguments:
- credentials: credentials for making request.
- pack_id: sticker pack ID.
- sticker_id: sticker ID.
-
- Returns:
- StickerFromPack entity.
- """
- return await self.call_method(
- GetSticker(pack_id=pack_id, sticker_id=sticker_id),
- credentials=credentials,
- )
-
- async def create_sticker_pack(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- name: str,
- user_huid: UUID,
- ) -> StickerPack:
- """Create sticker pack.
-
- Arguments:
- credentials: credentials for making request.
- name: sticker pack name.
- user_huid: author HUID.
-
- Returns:
- StickerPackPreview entity.
- """
- return await self.call_method(
- CreateStickerPack(name=name, user_huid=user_huid),
- credentials=credentials,
- )
-
- async def add_sticker(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- pack_id: UUID,
- emoji: str,
- image: str,
- ) -> Sticker:
- """Add sticker.
-
- Arguments:
- credentials: credentials for making request.
- pack_id: sticker pack ID.
- emoji: emoji that the sticker will be associated with.
- image: sticker image.
-
- Returns:
- Sticker entity.
- """
- return await self.call_method(
- AddSticker(pack_id=pack_id, emoji=emoji, image=image),
- credentials=credentials,
- )
-
- async def edit_sticker_pack( # noqa: WPS211
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- pack_id: UUID,
- name: str,
- preview: Optional[UUID] = None,
- stickers_order: Optional[List[UUID]] = None,
- ) -> StickerPack:
- """Edit sticker pack.
-
- Arguments:
- credentials: credentials for making request.
- pack_id: sticker pack ID.
- name: sticker pack name.
- preview: sticker pack preview.
- stickers_order: stickers order in sticker pack.
-
- Returns:
- StickerPack entity.
- """
- return await self.call_method(
- EditStickerPack(
- pack_id=pack_id,
- name=name,
- preview=preview,
- stickers_order=stickers_order,
- ),
- credentials=credentials,
- )
-
- async def delete_sticker_pack(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- pack_id: UUID,
- ) -> None:
- """Delete sticker pack.
-
- Arguments:
- credentials: credentials for making request.
- pack_id: sticker pack ID.
- """
- await self.call_method(
- DeleteStickerPack(pack_id=pack_id),
- credentials=credentials,
- )
-
- async def delete_sticker(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- pack_id: UUID,
- sticker_id: UUID,
- ) -> None:
- """Delete sticker.
-
- Arguments:
- credentials: credentials for making request.
- pack_id: sticker pack ID.
- sticker_id: sticker ID.
- """
- await self.call_method(
- DeleteSticker(pack_id=pack_id, sticker_id=sticker_id),
- credentials=credentials,
- )
diff --git a/botx/bots/mixins/requests/users.py b/botx/bots/mixins/requests/users.py
deleted file mode 100644
index 8cc24569..00000000
--- a/botx/bots/mixins/requests/users.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""Mixin for shortcut for users resource requests."""
-
-from typing import Optional, Tuple
-from uuid import UUID
-
-from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol
-from botx.clients.methods.v3.users.by_email import ByEmail
-from botx.clients.methods.v3.users.by_huid import ByHUID
-from botx.clients.methods.v3.users.by_login import ByLogin
-from botx.models.messages.sending.credentials import SendingCredentials
-from botx.models.users import UserFromSearch
-
-
-class UsersRequestsMixin:
- """Mixin for shortcut for users resource requests."""
-
- async def search_user(
- self: BotXMethodCallProtocol,
- credentials: SendingCredentials,
- *,
- user_huid: Optional[UUID] = None,
- email: Optional[str] = None,
- ad: Optional[Tuple[str, str]] = None,
- ) -> UserFromSearch:
- """Search user by one of provided params for search.
-
- Arguments:
- credentials: credentials for making request.
- user_huid: HUID of user.
- email: email of user.
- ad: AD login and domain of user.
-
- Returns:
- Information about user.
-
- Raises:
- ValueError: raised if none of provided params were filled.
- """
- if user_huid is not None:
- return await self.call_method(
- ByHUID(user_huid=user_huid),
- credentials=credentials,
- )
- elif email is not None:
- return await self.call_method(ByEmail(email=email), credentials=credentials)
- elif ad is not None:
- return await self.call_method(
- ByLogin(ad_login=ad[0], ad_domain=ad[1]),
- credentials=credentials,
- )
-
- raise ValueError("one of user_huid, email or ad query_params should be filled")
diff --git a/botx/bots/mixins/sending.py b/botx/bots/mixins/sending.py
deleted file mode 100644
index da2d93ff..00000000
--- a/botx/bots/mixins/sending.py
+++ /dev/null
@@ -1,175 +0,0 @@
-"""Definition for mixin that defines helpers for sending message."""
-
-from typing import Any, BinaryIO, Dict, Optional, TextIO, Union
-from uuid import UUID
-
-from botx.models.files import File
-from botx.models.messages.message import Message
-from botx.models.messages.sending.credentials import SendingCredentials
-from botx.models.messages.sending.markup import MessageMarkup
-from botx.models.messages.sending.message import SendingMessage
-from botx.models.messages.sending.options import MessageOptions
-from botx.models.messages.sending.payload import MessagePayload, UpdatePayload
-
-try:
- from typing import Protocol # noqa: WPS433
-except ImportError:
- from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401
-
-
-class ResultSendProtocol(Protocol):
- """Protocol for object that can create new or update message."""
-
- async def send_command_result(
- self,
- credentials: SendingCredentials,
- payload: MessagePayload,
- ) -> UUID:
- """Send command result."""
-
- async def send_direct_notification(
- self,
- credentials: SendingCredentials,
- payload: MessagePayload,
- ) -> UUID:
- """Send notification."""
-
- async def update_message(
- self,
- credentials: SendingCredentials,
- update: UpdatePayload,
- ) -> None:
- """Update existing message."""
-
-
-class MessageSendProtocol(Protocol):
- """Protocol for object that can send complex message."""
-
- async def send(self, message: SendingMessage) -> UUID:
- """Send message."""
-
-
-class SendingMixin:
- """Mixin that defines helpers for sending messages."""
-
- async def send_message( # noqa: WPS211
- self: MessageSendProtocol,
- text: str,
- credentials: SendingCredentials,
- *,
- file: Optional[Union[BinaryIO, TextIO]] = None,
- markup: Optional[MessageMarkup] = None,
- options: Optional[MessageOptions] = None,
- ) -> UUID:
- """Send message as answer to command or notification to chat and get it id.
-
- Arguments:
- text: text that should be sent to client.
- credentials: credentials that are used for sending message.
- file: file that should be attached to message.
- markup: message markup that should be attached to message.
- options: extra options for message.
-
- Returns:
- `UUID` of sent event.
- """
- message = SendingMessage(
- text=text,
- markup=markup,
- options=options,
- credentials=credentials,
- )
- if file:
- message.add_file(file)
-
- return await self.send(message)
-
- async def send(
- self: ResultSendProtocol,
- message: SendingMessage,
- *,
- update: bool = False,
- ) -> UUID:
- """Send message as direct notification to chat and get it id.
-
- Arguments:
- message: message that should be sent to chat.
- update: if True then check, that `message_id` was set in credentials and
- update existing message with this ID.
-
- Returns:
- `UUID` of sent event.
- """
- if message.credentials.message_id is not None and update:
- await self.update_message(
- message.credentials.copy(
- update={"sync_id": message.credentials.message_id},
- ),
- UpdatePayload.from_sending_payload(message.payload),
- )
- return message.credentials.message_id
-
- return await self.send_direct_notification(message.credentials, message.payload)
-
- async def answer_message( # noqa: WPS211
- self: MessageSendProtocol,
- text: str,
- message: Message,
- *,
- metadata: Optional[Dict[str, Any]] = None,
- file: Optional[Union[BinaryIO, TextIO, File]] = None,
- markup: Optional[MessageMarkup] = None,
- options: Optional[MessageOptions] = None,
- embed_mentions: bool = False,
- ) -> UUID:
- """Answer on incoming message and return id of new message..
-
- !!! warning
- This method should be used only in handlers.
-
- Arguments:
- text: text that should be sent in message.
- message: incoming message.
- file: file that can be attached to the message.
- markup: bubbles and keyboard that can be attached to the message.
- options: additional message options, like mentions or notifications
- configuration.
- metadata: dict of message metadata.
- embed_mentions: get mentions from text.
-
- Returns:
- `UUID` of sent event.
- """
- sending_message = SendingMessage(
- text=text,
- credentials=message.credentials,
- markup=markup,
- options=options,
- metadata=metadata,
- embed_mentions=embed_mentions,
- )
- if file:
- sending_message.add_file(file)
-
- return await self.send(sending_message)
-
- async def send_file(
- self: MessageSendProtocol,
- file: Union[TextIO, BinaryIO, File],
- credentials: SendingCredentials,
- filename: Optional[str] = None,
- ) -> UUID:
- """Send file in chat and return id of message.
-
- Arguments:
- file: file-like object that will be sent to chat.
- credentials: credentials of chat where file should be sent.
- filename: name for file that will be used if it can not be accessed from
- `file` argument.
-
- Returns:
- `UUID` of sent event.
- """
- message = SendingMessage(credentials=credentials)
- message.add_file(file, filename)
- return await self.send(message)
diff --git a/docs/src/development/dependencies_injection/__init__.py b/botx/client/__init__.py
similarity index 100%
rename from docs/src/development/dependencies_injection/__init__.py
rename to botx/client/__init__.py
diff --git a/botx/client/authorized_botx_method.py b/botx/client/authorized_botx_method.py
new file mode 100644
index 00000000..e574617e
--- /dev/null
+++ b/botx/client/authorized_botx_method.py
@@ -0,0 +1,50 @@
+from contextlib import asynccontextmanager
+from typing import Any, AsyncGenerator, Dict
+
+import httpx
+
+from botx.client.botx_method import BotXMethod, response_exception_thrower
+from botx.client.exceptions.common import InvalidBotAccountError
+from botx.client.get_token import get_token
+
+
+class AuthorizedBotXMethod(BotXMethod):
+ status_handlers = {401: response_exception_thrower(InvalidBotAccountError)}
+
+ async def _botx_method_call(
+ self,
+ *args: Any,
+ **kwargs: Any,
+ ) -> httpx.Response:
+ headers = kwargs.pop("headers", {})
+ await self._add_authorization_headers(headers)
+
+ return await super()._botx_method_call(*args, headers=headers, **kwargs)
+
+ @asynccontextmanager
+ async def _botx_method_stream(
+ self,
+ *args: Any,
+ **kwargs: Any,
+ ) -> AsyncGenerator[httpx.Response, None]:
+ headers = kwargs.pop("headers", {})
+ await self._add_authorization_headers(headers)
+
+ async with super()._botx_method_stream(
+ *args,
+ headers=headers,
+ **kwargs,
+ ) as response:
+ yield response
+
+ async def _add_authorization_headers(self, headers: Dict[str, Any]) -> None:
+ token = self._bot_accounts_storage.get_token_or_none(self._bot_id)
+ if not token:
+ token = await get_token(
+ self._bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ )
+ self._bot_accounts_storage.set_token(self._bot_id, token)
+
+ headers.update({"Authorization": f"Bearer {token}"})
diff --git a/docs/src/development/first_steps/__init__.py b/botx/client/bots_api/__init__.py
similarity index 100%
rename from docs/src/development/first_steps/__init__.py
rename to botx/client/bots_api/__init__.py
diff --git a/botx/client/bots_api/get_token.py b/botx/client/bots_api/get_token.py
new file mode 100644
index 00000000..2c4c2e5a
--- /dev/null
+++ b/botx/client/bots_api/get_token.py
@@ -0,0 +1,42 @@
+from typing import Literal
+
+from botx.client.botx_method import BotXMethod, response_exception_thrower
+from botx.client.exceptions.common import InvalidBotAccountError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIGetTokenRequestPayload(UnverifiedPayloadBaseModel):
+ signature: str
+
+ @classmethod
+ def from_domain(cls, signature: str) -> "BotXAPIGetTokenRequestPayload":
+ return cls(signature=signature)
+
+
+class BotXAPIGetTokenResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: str
+
+ def to_domain(self) -> str:
+ return self.result
+
+
+class GetTokenMethod(BotXMethod):
+ status_handlers = {401: response_exception_thrower(InvalidBotAccountError)}
+
+ async def execute(
+ self,
+ payload: BotXAPIGetTokenRequestPayload,
+ ) -> BotXAPIGetTokenResponsePayload:
+ path = f"/api/v2/botx/bots/{self._bot_id}/token"
+
+ response = await self._botx_method_call(
+ "GET",
+ self._build_url(path),
+ params=payload.jsonable_dict(),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIGetTokenResponsePayload,
+ response,
+ )
diff --git a/botx/client/botx_method.py b/botx/client/botx_method.py
new file mode 100644
index 00000000..53a5e173
--- /dev/null
+++ b/botx/client/botx_method.py
@@ -0,0 +1,201 @@
+import json
+from contextlib import asynccontextmanager
+from json.decoder import JSONDecodeError
+from typing import (
+ Any,
+ AsyncGenerator,
+ Awaitable,
+ Callable,
+ Mapping,
+ NoReturn,
+ Optional,
+ Type,
+ TypeVar,
+)
+from urllib.parse import urljoin
+from uuid import UUID
+
+import httpx
+from mypy_extensions import Arg
+from pydantic import ValidationError, parse_obj_as
+
+from botx.bot.bot_accounts_storage import BotAccountsStorage
+from botx.bot.callbacks_manager import CallbacksManager
+from botx.client.exceptions.base import BaseClientError
+from botx.client.exceptions.callbacks import BotXMethodFailedCallbackReceivedError
+from botx.client.exceptions.http import (
+ InvalidBotXResponsePayloadError,
+ InvalidBotXStatusCodeError,
+)
+from botx.logger import logger, pformat_jsonable_obj, trim_file_data_in_outgoing_json
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.method_callbacks import BotAPIMethodFailedCallback, BotXMethodCallback
+
+StatusHandler = Callable[[Arg(httpx.Response, "response")], NoReturn] # noqa: F821
+StatusHandlers = Mapping[int, StatusHandler]
+
+CallbackExceptionHandler = Callable[
+ [Arg(BotAPIMethodFailedCallback, "callback")], # noqa: F821
+ NoReturn,
+]
+ErrorCallbackHandlers = Mapping[str, CallbackExceptionHandler]
+TBotXAPIModel = TypeVar("TBotXAPIModel", bound=VerifiedPayloadBaseModel)
+
+
+def response_exception_thrower(
+ exc: Type[BaseClientError],
+ comment: Optional[str] = None,
+) -> StatusHandler:
+ def factory(response: httpx.Response) -> NoReturn:
+ raise exc.from_response(response, comment)
+
+ return factory
+
+
+def callback_exception_thrower(
+ exc: Type[BaseClientError],
+ comment: Optional[str] = None,
+) -> CallbackExceptionHandler: # noqa: F821
+ def factory(callback: BotAPIMethodFailedCallback) -> NoReturn:
+ raise exc.from_callback(callback, comment)
+
+ return factory
+
+
+class BotXMethod:
+ status_handlers: StatusHandlers = {}
+ error_callback_handlers: ErrorCallbackHandlers = {}
+
+ def __init__(
+ self,
+ sender_bot_id: UUID,
+ httpx_client: httpx.AsyncClient,
+ bot_accounts_storage: BotAccountsStorage,
+ callbacks_manager: Optional[CallbacksManager] = None,
+ ) -> None:
+ self._bot_id = sender_bot_id
+ self._httpx_client = httpx_client
+ self._bot_accounts_storage = bot_accounts_storage
+ self._callbacks_manager = callbacks_manager
+
+ # For MyPy checks
+ execute: Callable[..., Awaitable[Any]]
+
+ async def execute(self, *args: Any, **kwargs: Any) -> Any: # type: ignore
+ raise NotImplementedError("You should define `execute` method")
+
+ def _build_url(self, path: str) -> str:
+ host = self._bot_accounts_storage.get_host(self._bot_id)
+ return urljoin(f"https://{host}", path)
+
+ def _verify_and_extract_api_model(
+ self,
+ model_cls: Type[TBotXAPIModel],
+ response: httpx.Response,
+ ) -> TBotXAPIModel:
+ try:
+ raw_model = json.loads(response.content)
+ except JSONDecodeError as decoding_exc:
+ raise InvalidBotXResponsePayloadError(response) from decoding_exc
+
+ logger.opt(lazy=True).debug(
+ "Got response from BotX: {json}",
+ json=lambda: pformat_jsonable_obj(raw_model),
+ )
+
+ try:
+ api_model = parse_obj_as(model_cls, raw_model)
+ except ValidationError as validation_exc:
+ raise InvalidBotXResponsePayloadError(response) from validation_exc
+
+ return api_model
+
+ async def _botx_method_call(self, *args: Any, **kwargs: Any) -> httpx.Response:
+ self._log_outgoing_request(*args, **kwargs)
+
+ response = await self._httpx_client.request(*args, **kwargs)
+ await self._raise_for_status(response)
+
+ return response
+
+ @asynccontextmanager
+ async def _botx_method_stream(
+ self,
+ *args: Any,
+ **kwargs: Any,
+ ) -> AsyncGenerator[httpx.Response, None]:
+ self._log_outgoing_request(*args, **kwargs)
+
+ async with self._httpx_client.stream(*args, **kwargs) as response:
+ await self._raise_for_status(response)
+ yield response
+
+ async def _raise_for_status(self, response: httpx.Response) -> None:
+ handler = self.status_handlers.get(response.status_code)
+ if handler:
+ if not response.is_closed:
+ await response.aread()
+
+ handler(response) # Handler should raise an exception
+
+ try:
+ response.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ if not response.is_closed:
+ await response.aread()
+
+ raise InvalidBotXStatusCodeError(exc.response)
+
+ async def _process_callback(
+ self,
+ sync_id: UUID,
+ wait_callback: bool,
+ callback_timeout: Optional[int],
+ ) -> Optional[BotXMethodCallback]:
+ assert (
+ self._callbacks_manager is not None
+ ), "CallbackManager hasn't been passed to this method"
+
+ self._callbacks_manager.create_botx_method_callback(sync_id)
+
+ if not wait_callback:
+ return None
+
+ callback = await self._callbacks_manager.wait_botx_method_callback(
+ sync_id,
+ callback_timeout,
+ )
+
+ if callback.status == "error":
+ error_handler = self.error_callback_handlers.get(callback.reason)
+ if not error_handler:
+ raise BotXMethodFailedCallbackReceivedError(callback)
+
+ error_handler(callback) # Handler should raise an exception
+
+ return callback
+
+ def _log_outgoing_request(
+ self,
+ *args: Any,
+ **kwargs: Any,
+ ) -> None:
+ method, url = args
+ query_params = kwargs.get("params")
+ json_body = kwargs.get("json")
+
+ log_template = "Performing request to BotX:\n{method} {url}"
+ if query_params:
+ log_template += "\nquery: {params}"
+ if json_body is not None:
+ log_template += "\njson: {json}"
+
+ logger.opt(lazy=True).debug(
+ log_template,
+ method=lambda: method, # If `lazy` enabled, all kwargs should be callable
+ url=lambda: url, # If `lazy` enabled, all kwargs should be callable
+ params=lambda: pformat_jsonable_obj(query_params),
+ json=lambda: pformat_jsonable_obj(
+ trim_file_data_in_outgoing_json(json_body),
+ ),
+ )
diff --git a/docs/src/development/handling_errors/__init__.py b/botx/client/chats_api/__init__.py
similarity index 100%
rename from docs/src/development/handling_errors/__init__.py
rename to botx/client/chats_api/__init__.py
diff --git a/botx/client/chats_api/add_admin.py b/botx/client/chats_api/add_admin.py
new file mode 100644
index 00000000..a582598b
--- /dev/null
+++ b/botx/client/chats_api/add_admin.py
@@ -0,0 +1,71 @@
+from typing import List, Literal, NoReturn
+from uuid import UUID
+
+import httpx
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.chats import (
+ CantUpdatePersonalChatError,
+ InvalidUsersListError,
+)
+from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError
+from botx.client.exceptions.http import InvalidBotXStatusCodeError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIAddAdminRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+ user_huids: List[UUID]
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ huids: List[UUID],
+ ) -> "BotXAPIAddAdminRequestPayload":
+ return cls(group_chat_id=chat_id, user_huids=huids)
+
+
+class BotXAPIAddAdminResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+def bad_request_error_handler(response: httpx.Response) -> NoReturn:
+ reason = response.json().get("reason")
+
+ if reason == "chat_members_not_modifiable":
+ raise CantUpdatePersonalChatError.from_response(
+ response,
+ "Personal chat couldn't have admins",
+ )
+ elif reason == "admins_not_changed":
+ raise InvalidUsersListError.from_response(
+ response,
+ "Specified users are already admins or missing from chat",
+ )
+
+ raise InvalidBotXStatusCodeError(response)
+
+
+class AddAdminMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 400: bad_request_error_handler,
+ 403: response_exception_thrower(PermissionDeniedError),
+ 404: response_exception_thrower(ChatNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIAddAdminRequestPayload,
+ ) -> None:
+ path = "/api/v3/botx/chats/add_admin"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(BotXAPIAddAdminResponsePayload, response)
diff --git a/botx/client/chats_api/add_user.py b/botx/client/chats_api/add_user.py
new file mode 100644
index 00000000..bd39bcf8
--- /dev/null
+++ b/botx/client/chats_api/add_user.py
@@ -0,0 +1,48 @@
+from typing import List, Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIAddUserRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+ user_huids: List[UUID]
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ huids: List[UUID],
+ ) -> "BotXAPIAddUserRequestPayload":
+ return cls(group_chat_id=chat_id, user_huids=huids)
+
+
+class BotXAPIAddUserResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class AddUserMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 403: response_exception_thrower(PermissionDeniedError),
+ 404: response_exception_thrower(ChatNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIAddUserRequestPayload,
+ ) -> None:
+ path = "/api/v3/botx/chats/add_user"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+ self._verify_and_extract_api_model(
+ BotXAPIAddUserResponsePayload,
+ response,
+ )
diff --git a/botx/client/chats_api/chat_info.py b/botx/client/chats_api/chat_info.py
new file mode 100644
index 00000000..46135d51
--- /dev/null
+++ b/botx/client/chats_api/chat_info.py
@@ -0,0 +1,88 @@
+from datetime import datetime as dt
+from typing import List, Literal, Optional
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.common import ChatNotFoundError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.chats import ChatInfo, ChatInfoMember
+from botx.models.enums import (
+ APIChatTypes,
+ APIUserKinds,
+ convert_chat_type_to_domain,
+ convert_user_kind_to_domain,
+)
+
+
+class BotXAPIChatInfoRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+
+ @classmethod
+ def from_domain(cls, chat_id: UUID) -> "BotXAPIChatInfoRequestPayload":
+ return cls(group_chat_id=chat_id)
+
+
+class BotXAPIChatInfoMember(VerifiedPayloadBaseModel):
+ admin: bool
+ user_huid: UUID
+ user_kind: APIUserKinds
+
+
+class BotXAPIChatInfoResult(VerifiedPayloadBaseModel):
+ chat_type: APIChatTypes
+ creator: UUID
+ description: Optional[str] = None
+ group_chat_id: UUID
+ inserted_at: dt
+ members: List[BotXAPIChatInfoMember]
+ name: str
+
+
+class BotXAPIChatInfoResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPIChatInfoResult
+
+ def to_domain(self) -> ChatInfo:
+ members = [
+ ChatInfoMember(
+ is_admin=member.admin,
+ huid=member.user_huid,
+ kind=convert_user_kind_to_domain(member.user_kind),
+ )
+ for member in self.result.members
+ ]
+
+ return ChatInfo(
+ chat_type=convert_chat_type_to_domain(self.result.chat_type),
+ creator_id=self.result.creator,
+ description=self.result.description,
+ chat_id=self.result.group_chat_id,
+ created_at=self.result.inserted_at,
+ members=members,
+ name=self.result.name,
+ )
+
+
+class ChatInfoMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(ChatNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIChatInfoRequestPayload,
+ ) -> BotXAPIChatInfoResponsePayload:
+ path = "/api/v3/botx/chats/info"
+
+ response = await self._botx_method_call(
+ "GET",
+ self._build_url(path),
+ params=payload.jsonable_dict(),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIChatInfoResponsePayload,
+ response,
+ )
diff --git a/botx/client/chats_api/create_chat.py b/botx/client/chats_api/create_chat.py
new file mode 100644
index 00000000..57af0f5f
--- /dev/null
+++ b/botx/client/chats_api/create_chat.py
@@ -0,0 +1,71 @@
+from typing import List, Literal, Optional
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.chats import ChatCreationError, ChatCreationProhibitedError
+from botx.missing import Missing
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.enums import APIChatTypes, ChatTypes, convert_chat_type_from_domain
+
+
+class BotXAPICreateChatRequestPayload(UnverifiedPayloadBaseModel):
+ name: str
+ description: Optional[str]
+ chat_type: APIChatTypes
+ members: List[UUID]
+ shared_history: Missing[bool]
+
+ @classmethod
+ def from_domain(
+ cls,
+ name: str,
+ chat_type: ChatTypes,
+ huids: List[UUID],
+ shared_history: Missing[bool],
+ description: Optional[str] = None,
+ ) -> "BotXAPICreateChatRequestPayload":
+ return cls(
+ name=name,
+ chat_type=convert_chat_type_from_domain(chat_type),
+ members=huids,
+ description=description,
+ shared_history=shared_history,
+ )
+
+
+class BotXAPIChatIdResult(VerifiedPayloadBaseModel):
+ chat_id: UUID
+
+
+class BotXAPICreateChatResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPIChatIdResult
+
+ def to_domain(self) -> UUID:
+ return self.result.chat_id
+
+
+class CreateChatMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 403: response_exception_thrower(ChatCreationProhibitedError),
+ 422: response_exception_thrower(ChatCreationError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPICreateChatRequestPayload,
+ ) -> BotXAPICreateChatResponsePayload:
+ path = "/api/v3/botx/chats/create"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPICreateChatResponsePayload,
+ response,
+ )
diff --git a/botx/client/chats_api/disable_stealth.py b/botx/client/chats_api/disable_stealth.py
new file mode 100644
index 00000000..5a2a1eea
--- /dev/null
+++ b/botx/client/chats_api/disable_stealth.py
@@ -0,0 +1,46 @@
+from typing import Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIDisableStealthRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ ) -> "BotXAPIDisableStealthRequestPayload":
+ return cls(
+ group_chat_id=chat_id,
+ )
+
+
+class BotXAPIDisableStealthResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class DisableStealthMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 403: response_exception_thrower(PermissionDeniedError),
+ 404: response_exception_thrower(ChatNotFoundError),
+ }
+
+ async def execute(self, payload: BotXAPIDisableStealthRequestPayload) -> None:
+ path = "/api/v3/botx/chats/stealth_disable"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(
+ BotXAPIDisableStealthResponsePayload,
+ response,
+ )
diff --git a/botx/client/chats_api/list_chats.py b/botx/client/chats_api/list_chats.py
new file mode 100644
index 00000000..05db5e0d
--- /dev/null
+++ b/botx/client/chats_api/list_chats.py
@@ -0,0 +1,52 @@
+from datetime import datetime
+from typing import List, Literal, Optional
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.chats import ChatListItem
+from botx.models.enums import APIChatTypes, convert_chat_type_to_domain
+
+
+class BotXAPIListChatResult(VerifiedPayloadBaseModel):
+ group_chat_id: UUID
+ chat_type: APIChatTypes
+ name: str
+ description: Optional[str] = None
+ members: List[UUID]
+ inserted_at: datetime
+ updated_at: datetime
+
+
+class BotXAPIListChatResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: List[BotXAPIListChatResult]
+
+ def to_domain(self) -> List[ChatListItem]:
+ return [
+ ChatListItem(
+ chat_id=chat_item.group_chat_id,
+ chat_type=convert_chat_type_to_domain(chat_item.chat_type),
+ name=chat_item.name,
+ description=chat_item.description,
+ members=chat_item.members,
+ created_at=chat_item.inserted_at,
+ updated_at=chat_item.updated_at,
+ )
+ for chat_item in self.result
+ ]
+
+
+class ListChatsMethod(AuthorizedBotXMethod):
+ async def execute(self) -> BotXAPIListChatResponsePayload:
+ path = "/api/v3/botx/chats/list"
+
+ response = await self._botx_method_call(
+ "GET",
+ self._build_url(path),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIListChatResponsePayload,
+ response,
+ )
diff --git a/botx/client/chats_api/pin_message.py b/botx/client/chats_api/pin_message.py
new file mode 100644
index 00000000..062dc76f
--- /dev/null
+++ b/botx/client/chats_api/pin_message.py
@@ -0,0 +1,46 @@
+from typing import Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIPinMessageRequestPayload(UnverifiedPayloadBaseModel):
+ chat_id: UUID
+ sync_id: UUID
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ sync_id: UUID,
+ ) -> "BotXAPIPinMessageRequestPayload":
+ return cls(chat_id=chat_id, sync_id=sync_id)
+
+
+class BotXAPIPinMessageResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class PinMessageMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 403: response_exception_thrower(PermissionDeniedError),
+ 404: response_exception_thrower(ChatNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIPinMessageRequestPayload,
+ ) -> None:
+ path = "/api/v3/botx/chats/pin_message"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(BotXAPIPinMessageResponsePayload, response)
diff --git a/botx/client/chats_api/remove_user.py b/botx/client/chats_api/remove_user.py
new file mode 100644
index 00000000..8b0f2627
--- /dev/null
+++ b/botx/client/chats_api/remove_user.py
@@ -0,0 +1,46 @@
+from typing import List, Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIRemoveUserRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+ user_huids: List[UUID]
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ huids: List[UUID],
+ ) -> "BotXAPIRemoveUserRequestPayload":
+ return cls(group_chat_id=chat_id, user_huids=huids)
+
+
+class BotXAPIRemoveUserResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class RemoveUserMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 403: response_exception_thrower(PermissionDeniedError),
+ 404: response_exception_thrower(ChatNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIRemoveUserRequestPayload,
+ ) -> None:
+ path = "/api/v3/botx/chats/remove_user"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(BotXAPIRemoveUserResponsePayload, response)
diff --git a/botx/client/chats_api/set_stealth.py b/botx/client/chats_api/set_stealth.py
new file mode 100644
index 00000000..b0e0d012
--- /dev/null
+++ b/botx/client/chats_api/set_stealth.py
@@ -0,0 +1,53 @@
+from typing import Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError
+from botx.missing import Missing
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPISetStealthRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+ disable_web: Missing[bool]
+ burn_in: Missing[int]
+ expire_in: Missing[int]
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ disable_web_client: Missing[bool],
+ ttl_after_read: Missing[int],
+ total_ttl: Missing[int],
+ ) -> "BotXAPISetStealthRequestPayload":
+ return cls(
+ group_chat_id=chat_id,
+ disable_web=disable_web_client,
+ burn_in=ttl_after_read,
+ expire_in=total_ttl,
+ )
+
+
+class BotXAPISetStealthResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class SetStealthMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 403: response_exception_thrower(PermissionDeniedError),
+ 404: response_exception_thrower(ChatNotFoundError),
+ }
+
+ async def execute(self, payload: BotXAPISetStealthRequestPayload) -> None:
+ path = "/api/v3/botx/chats/stealth_set"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(BotXAPISetStealthResponsePayload, response)
diff --git a/botx/client/chats_api/unpin_message.py b/botx/client/chats_api/unpin_message.py
new file mode 100644
index 00000000..d1260f02
--- /dev/null
+++ b/botx/client/chats_api/unpin_message.py
@@ -0,0 +1,44 @@
+from typing import Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIUnpinMessageRequestPayload(UnverifiedPayloadBaseModel):
+ chat_id: UUID
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ ) -> "BotXAPIUnpinMessageRequestPayload":
+ return cls(chat_id=chat_id)
+
+
+class BotXAPIUnpinMessageResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class UnpinMessageMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 403: response_exception_thrower(PermissionDeniedError),
+ 404: response_exception_thrower(ChatNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIUnpinMessageRequestPayload,
+ ) -> None:
+ path = "/api/v3/botx/chats/unpin_message"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(BotXAPIUnpinMessageResponsePayload, response)
diff --git a/docs/src/development/logging/__init__.py b/botx/client/events_api/__init__.py
similarity index 100%
rename from docs/src/development/logging/__init__.py
rename to botx/client/events_api/__init__.py
diff --git a/botx/client/events_api/edit_event.py b/botx/client/events_api/edit_event.py
new file mode 100644
index 00000000..8bfeb855
--- /dev/null
+++ b/botx/client/events_api/edit_event.py
@@ -0,0 +1,98 @@
+from typing import Any, Dict, List, Literal, Union
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.missing import Missing, MissingOptional, Undefined
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.attachments import (
+ BotXAPIAttachment,
+ IncomingFileAttachment,
+ OutgoingAttachment,
+)
+from botx.models.message.markup import (
+ BotXAPIMarkup,
+ BubbleMarkup,
+ KeyboardMarkup,
+ api_markup_from_domain,
+)
+from botx.models.message.mentions import BotXAPIMention, find_and_replace_embed_mentions
+
+
+class BotXAPIEditEventOpts(UnverifiedPayloadBaseModel):
+ buttons_auto_adjust: Missing[bool]
+
+
+class BotXAPIEditEvent(UnverifiedPayloadBaseModel):
+ body: Missing[str]
+ metadata: Missing[Dict[str, Any]]
+ opts: Missing[BotXAPIEditEventOpts]
+ bubble: Missing[BotXAPIMarkup]
+ keyboard: Missing[BotXAPIMarkup]
+ mentions: Missing[List[BotXAPIMention]]
+
+
+class BotXAPIEditEventRequestPayload(UnverifiedPayloadBaseModel):
+ sync_id: UUID
+ payload: BotXAPIEditEvent
+ file: Missing[BotXAPIAttachment]
+
+ @classmethod
+ def from_domain(
+ cls,
+ sync_id: UUID,
+ body: Missing[str],
+ metadata: Missing[Dict[str, Any]],
+ bubbles: Missing[BubbleMarkup],
+ keyboard: Missing[KeyboardMarkup],
+ file: Missing[Union[IncomingFileAttachment, OutgoingAttachment, None]],
+ markup_auto_adjust: Missing[bool],
+ ) -> "BotXAPIEditEventRequestPayload":
+ api_file: MissingOptional[BotXAPIAttachment] = Undefined
+ if file:
+ assert not file.is_async_file, "async_files not supported"
+ api_file = BotXAPIAttachment.from_file_attachment(file)
+ elif file is None:
+ api_file = None
+
+ mentions: Missing[List[BotXAPIMention]] = Undefined
+ if isinstance(body, str):
+ body, mentions = find_and_replace_embed_mentions(body)
+
+ return cls(
+ sync_id=sync_id,
+ payload=BotXAPIEditEvent(
+ body=body,
+ # TODO: Metadata can be cleaned with `{}`
+ metadata=metadata,
+ opts=BotXAPIEditEventOpts(
+ buttons_auto_adjust=markup_auto_adjust,
+ ),
+ bubble=api_markup_from_domain(bubbles) if bubbles else bubbles,
+ keyboard=api_markup_from_domain(keyboard) if keyboard else keyboard,
+ mentions=mentions,
+ ),
+ file=api_file,
+ )
+
+
+class BotXAPIEditEventResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class EditEventMethod(AuthorizedBotXMethod):
+ async def execute(
+ self,
+ payload: BotXAPIEditEventRequestPayload,
+ ) -> None:
+ path = "/api/v3/botx/events/edit_event"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(
+ BotXAPIEditEventResponsePayload,
+ response,
+ )
diff --git a/botx/client/events_api/message_status_event.py b/botx/client/events_api/message_status_event.py
new file mode 100644
index 00000000..89a3071c
--- /dev/null
+++ b/botx/client/events_api/message_status_event.py
@@ -0,0 +1,79 @@
+from dataclasses import dataclass
+from datetime import datetime
+from typing import List, Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.event import EventNotFoundError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.message.message_status import MessageStatus
+
+
+class BotXAPIMessageStatusRequestPayload(UnverifiedPayloadBaseModel):
+ sync_id: UUID
+
+ @classmethod
+ def from_domain(cls, sync_id: UUID) -> "BotXAPIMessageStatusRequestPayload":
+ return cls(sync_id=sync_id)
+
+
+@dataclass
+class BotXAPIMessageStatusReadUser:
+ user_huid: UUID
+ read_at: datetime
+
+
+@dataclass
+class BotXAPIMessageStatusReceivedUser:
+ user_huid: UUID
+ received_at: datetime
+
+
+class BotXAPIMessageStatusResult(VerifiedPayloadBaseModel):
+ group_chat_id: UUID
+ sent_to: List[UUID]
+ read_by: List[BotXAPIMessageStatusReadUser]
+ received_by: List[BotXAPIMessageStatusReceivedUser]
+
+
+class BotXAPIMessageStatusResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPIMessageStatusResult
+
+ def to_domain(self) -> MessageStatus:
+
+ return MessageStatus(
+ group_chat_id=self.result.group_chat_id,
+ sent_to=self.result.sent_to,
+ read_by={
+ reader.user_huid: reader.read_at for reader in self.result.read_by
+ },
+ received_by={
+ receiver.user_huid: receiver.received_at
+ for receiver in self.result.received_by
+ },
+ )
+
+
+class MessageStatusMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(EventNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIMessageStatusRequestPayload,
+ ) -> "BotXAPIMessageStatusResponsePayload":
+ path = f"/api/v3/botx/events/{payload.sync_id}/status"
+
+ response = await self._botx_method_call(
+ "GET",
+ self._build_url(path),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIMessageStatusResponsePayload,
+ response,
+ )
diff --git a/botx/client/events_api/reply_event.py b/botx/client/events_api/reply_event.py
new file mode 100644
index 00000000..3275db25
--- /dev/null
+++ b/botx/client/events_api/reply_event.py
@@ -0,0 +1,118 @@
+from typing import Any, Dict, List, Literal, Union
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.missing import Missing, Undefined
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.attachments import (
+ BotXAPIAttachment,
+ IncomingFileAttachment,
+ OutgoingAttachment,
+)
+from botx.models.message.markup import (
+ BotXAPIMarkup,
+ BubbleMarkup,
+ KeyboardMarkup,
+ api_markup_from_domain,
+)
+from botx.models.message.mentions import BotXAPIMention, find_and_replace_embed_mentions
+
+
+class BotXAPIReplyEventMessageOpts(UnverifiedPayloadBaseModel):
+ silent_response: Missing[bool]
+ buttons_auto_adjust: Missing[bool]
+
+
+class BotXAPIReplyEvent(UnverifiedPayloadBaseModel):
+ status: Literal["ok"]
+ body: str
+ metadata: Missing[Dict[str, Any]]
+ opts: Missing[BotXAPIReplyEventMessageOpts]
+ bubble: Missing[BotXAPIMarkup]
+ keyboard: Missing[BotXAPIMarkup]
+ mentions: Missing[List[BotXAPIMention]]
+
+
+class BotXAPIReplyEventNestedOpts(UnverifiedPayloadBaseModel):
+ send: Missing[bool]
+ force_dnd: Missing[bool]
+
+
+class BotXAPIReplyEventOpts(UnverifiedPayloadBaseModel):
+ raw_mentions: Literal[True]
+ stealth_mode: Missing[bool]
+ notification_opts: Missing[BotXAPIReplyEventNestedOpts]
+
+
+class BotXAPIReplyEventRequestPayload(UnverifiedPayloadBaseModel):
+ source_sync_id: UUID
+ reply: BotXAPIReplyEvent
+ file: Missing[BotXAPIAttachment]
+ opts: BotXAPIReplyEventOpts
+
+ @classmethod
+ def from_domain(
+ cls,
+ sync_id: UUID,
+ body: str,
+ metadata: Missing[Dict[str, Any]],
+ bubbles: Missing[BubbleMarkup],
+ keyboard: Missing[KeyboardMarkup],
+ file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]],
+ silent_response: Missing[bool],
+ markup_auto_adjust: Missing[bool],
+ stealth_mode: Missing[bool],
+ send_push: Missing[bool],
+ ignore_mute: Missing[bool],
+ ) -> "BotXAPIReplyEventRequestPayload":
+ api_file: Missing[BotXAPIAttachment] = Undefined
+ if file:
+ assert not file.is_async_file, "async_files not supported"
+ api_file = BotXAPIAttachment.from_file_attachment(file)
+
+ body, mentions = find_and_replace_embed_mentions(body)
+
+ return cls(
+ source_sync_id=sync_id,
+ reply=BotXAPIReplyEvent(
+ status="ok",
+ body=body,
+ metadata=metadata,
+ opts=BotXAPIReplyEventMessageOpts(
+ buttons_auto_adjust=markup_auto_adjust,
+ silent_response=silent_response,
+ ),
+ bubble=api_markup_from_domain(bubbles) if bubbles else bubbles,
+ keyboard=api_markup_from_domain(keyboard) if keyboard else keyboard,
+ mentions=mentions or Undefined,
+ ),
+ file=api_file,
+ opts=BotXAPIReplyEventOpts(
+ raw_mentions=True,
+ stealth_mode=stealth_mode,
+ notification_opts=BotXAPIReplyEventNestedOpts(
+ send=send_push,
+ force_dnd=ignore_mute,
+ ),
+ ),
+ )
+
+
+class BotXAPIReplyEventResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class ReplyEventMethod(AuthorizedBotXMethod):
+ async def execute(self, payload: BotXAPIReplyEventRequestPayload) -> None:
+ path = "/api/v3/botx/events/reply_event"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(
+ BotXAPIReplyEventResponsePayload,
+ response,
+ )
diff --git a/botx/client/events_api/stop_typing_event.py b/botx/client/events_api/stop_typing_event.py
new file mode 100644
index 00000000..e9d27298
--- /dev/null
+++ b/botx/client/events_api/stop_typing_event.py
@@ -0,0 +1,33 @@
+from typing import Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIStopTypingEventRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+
+ @classmethod
+ def from_domain(cls, chat_id: UUID) -> "BotXAPIStopTypingEventRequestPayload":
+ return cls(group_chat_id=chat_id)
+
+
+class BotXAPIStopTypingEventResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class StopTypingEventMethod(AuthorizedBotXMethod):
+ async def execute(self, payload: BotXAPIStopTypingEventRequestPayload) -> None:
+ path = "/api/v3/botx/events/stop_typing"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(
+ BotXAPIStopTypingEventResponsePayload,
+ response,
+ )
diff --git a/botx/client/events_api/typing_event.py b/botx/client/events_api/typing_event.py
new file mode 100644
index 00000000..e7f76e5d
--- /dev/null
+++ b/botx/client/events_api/typing_event.py
@@ -0,0 +1,30 @@
+from typing import Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPITypingEventRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+
+ @classmethod
+ def from_domain(cls, chat_id: UUID) -> "BotXAPITypingEventRequestPayload":
+ return cls(group_chat_id=chat_id)
+
+
+class BotXAPITypingEventResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class TypingEventMethod(AuthorizedBotXMethod):
+ async def execute(self, payload: BotXAPITypingEventRequestPayload) -> None:
+ path = "/api/v3/botx/events/typing"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(BotXAPITypingEventResponsePayload, response)
diff --git a/docs/src/development/sending_data/__init__.py b/botx/client/exceptions/__init__.py
similarity index 100%
rename from docs/src/development/sending_data/__init__.py
rename to botx/client/exceptions/__init__.py
diff --git a/botx/client/exceptions/base.py b/botx/client/exceptions/base.py
new file mode 100644
index 00000000..68ddf2bc
--- /dev/null
+++ b/botx/client/exceptions/base.py
@@ -0,0 +1,49 @@
+from typing import Optional
+
+import httpx
+
+from botx.models.method_callbacks import BotAPIMethodFailedCallback
+
+
+class BaseClientError(Exception):
+ def __init__(self, message: str) -> None:
+ self.message = message
+ super().__init__(message)
+
+ @classmethod
+ def from_response(
+ cls,
+ response: httpx.Response,
+ comment: Optional[str] = None,
+ ) -> "BaseClientError":
+ method = response.request.method
+ url = response.request.url
+ status_code = response.status_code
+ content = response.content
+
+ message = (
+ f"{method} {url}\n" # noqa: WPS221 (Strange error on CI)
+ f"failed with code {status_code} and payload:\n"
+ f"{content!r}"
+ )
+
+ if comment is not None:
+ message = f"{message}\n\nComment: {comment}"
+
+ return cls(message)
+
+ @classmethod
+ def from_callback(
+ cls,
+ callback: BotAPIMethodFailedCallback,
+ comment: Optional[str] = None,
+ ) -> "BaseClientError":
+ message = (
+ f"BotX method call with sync_id `{callback.sync_id!s}` "
+ f"failed with: {callback}"
+ )
+
+ if comment is not None:
+ message = f"{message}\n\nComment: {comment}"
+
+ return cls(message)
diff --git a/botx/client/exceptions/callbacks.py b/botx/client/exceptions/callbacks.py
new file mode 100644
index 00000000..5a28325d
--- /dev/null
+++ b/botx/client/exceptions/callbacks.py
@@ -0,0 +1,19 @@
+from uuid import UUID
+
+from botx.client.exceptions.base import BaseClientError
+from botx.models.method_callbacks import BotAPIMethodFailedCallback
+
+
+class BotXMethodFailedCallbackReceivedError(BaseClientError):
+ """Callback with error received."""
+
+ def __init__(self, callback: BotAPIMethodFailedCallback) -> None:
+ exc = BaseClientError.from_callback(callback)
+ self.args = exc.args
+
+
+class CallbackNotReceivedError(Exception):
+ def __init__(self, sync_id: UUID) -> None:
+ self.sync_id = sync_id
+ self.message = f"Callback for sync_id `{sync_id}` hasn't been received"
+ super().__init__(self.message)
diff --git a/botx/client/exceptions/chats.py b/botx/client/exceptions/chats.py
new file mode 100644
index 00000000..cd821bd5
--- /dev/null
+++ b/botx/client/exceptions/chats.py
@@ -0,0 +1,17 @@
+from botx.client.exceptions.base import BaseClientError
+
+
+class CantUpdatePersonalChatError(BaseClientError):
+ """Can't edit a personal chat."""
+
+
+class InvalidUsersListError(BaseClientError):
+ """Users list isn't correct."""
+
+
+class ChatCreationProhibitedError(BaseClientError):
+ """Bot doesn't have permissions to create chat."""
+
+
+class ChatCreationError(BaseClientError):
+ """Error while chat creation."""
diff --git a/botx/client/exceptions/common.py b/botx/client/exceptions/common.py
new file mode 100644
index 00000000..02496b14
--- /dev/null
+++ b/botx/client/exceptions/common.py
@@ -0,0 +1,17 @@
+from botx.client.exceptions.base import BaseClientError
+
+
+class InvalidBotAccountError(BaseClientError):
+ """Can't get token with given bot account."""
+
+
+class RateLimitReachedError(BaseClientError):
+ """Too many method requests."""
+
+
+class PermissionDeniedError(BaseClientError):
+ """Bot can't perform this action."""
+
+
+class ChatNotFoundError(BaseClientError):
+ """Chat with specified group_chat_id not found."""
diff --git a/botx/client/exceptions/event.py b/botx/client/exceptions/event.py
new file mode 100644
index 00000000..9ffd7d4b
--- /dev/null
+++ b/botx/client/exceptions/event.py
@@ -0,0 +1,5 @@
+from botx.client.exceptions.base import BaseClientError
+
+
+class EventNotFoundError(BaseClientError):
+ """Event not found."""
diff --git a/botx/client/exceptions/files.py b/botx/client/exceptions/files.py
new file mode 100644
index 00000000..6002e7f5
--- /dev/null
+++ b/botx/client/exceptions/files.py
@@ -0,0 +1,9 @@
+from botx.client.exceptions.base import BaseClientError
+
+
+class FileDeletedError(BaseClientError):
+ """File deleted."""
+
+
+class FileMetadataNotFound(BaseClientError):
+ """Can't find file metadata."""
diff --git a/botx/client/exceptions/http.py b/botx/client/exceptions/http.py
new file mode 100644
index 00000000..e7bdbb41
--- /dev/null
+++ b/botx/client/exceptions/http.py
@@ -0,0 +1,19 @@
+import httpx
+
+from botx.client.exceptions.base import BaseClientError
+
+
+class InvalidBotXResponseError(BaseClientError):
+ """Received invalid response."""
+
+ def __init__(self, response: httpx.Response) -> None:
+ exc = BaseClientError.from_response(response)
+ self.args = exc.args
+
+
+class InvalidBotXStatusCodeError(InvalidBotXResponseError):
+ """Received invalid status code."""
+
+
+class InvalidBotXResponsePayloadError(InvalidBotXResponseError):
+ """Received invalid status code."""
diff --git a/botx/client/exceptions/notifications.py b/botx/client/exceptions/notifications.py
new file mode 100644
index 00000000..6795327e
--- /dev/null
+++ b/botx/client/exceptions/notifications.py
@@ -0,0 +1,13 @@
+from botx.client.exceptions.base import BaseClientError
+
+
+class BotIsNotChatMemberError(BaseClientError):
+ """Bot is not in the list of chat members."""
+
+
+class FinalRecipientsListEmptyError(BaseClientError):
+ """Resulting event recipients list is empty."""
+
+
+class StealthModeDisabledError(BaseClientError):
+ """Requested stealth mode disabled in specified chat."""
diff --git a/botx/client/exceptions/users.py b/botx/client/exceptions/users.py
new file mode 100644
index 00000000..3263bf15
--- /dev/null
+++ b/botx/client/exceptions/users.py
@@ -0,0 +1,5 @@
+from botx.client.exceptions.base import BaseClientError
+
+
+class UserNotFoundError(BaseClientError):
+ """User not found."""
diff --git a/docs/src/development/tests/__init__.py b/botx/client/files_api/__init__.py
similarity index 100%
rename from docs/src/development/tests/__init__.py
rename to botx/client/files_api/__init__.py
diff --git a/botx/client/files_api/download_file.py b/botx/client/files_api/download_file.py
new file mode 100644
index 00000000..8beaf4a4
--- /dev/null
+++ b/botx/client/files_api/download_file.py
@@ -0,0 +1,67 @@
+from typing import NoReturn
+from uuid import UUID
+
+import httpx
+
+from botx.async_buffer import AsyncBufferWritable
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.common import ChatNotFoundError
+from botx.client.exceptions.files import FileDeletedError, FileMetadataNotFound
+from botx.client.exceptions.http import InvalidBotXStatusCodeError
+from botx.models.api_base import UnverifiedPayloadBaseModel
+
+
+class BotXAPIDownloadFileRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+ file_id: UUID
+ is_preview: bool
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ file_id: UUID,
+ ) -> "BotXAPIDownloadFileRequestPayload":
+ return cls(
+ group_chat_id=chat_id,
+ file_id=file_id,
+ is_preview=False,
+ )
+
+
+def not_found_error_handler(response: httpx.Response) -> NoReturn:
+ reason = response.json().get("reason")
+
+ if reason == "file_metadata_not_found":
+ raise FileMetadataNotFound.from_response(response)
+ elif reason == "chat_not_found":
+ raise ChatNotFoundError.from_response(response)
+
+ raise InvalidBotXStatusCodeError(response)
+
+
+class DownloadFileMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 204: response_exception_thrower(FileDeletedError),
+ 404: not_found_error_handler,
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIDownloadFileRequestPayload,
+ async_buffer: AsyncBufferWritable,
+ ) -> None:
+ path = "/api/v3/botx/files/download"
+
+ async with self._botx_method_stream(
+ "GET",
+ self._build_url(path),
+ params=payload.jsonable_dict(),
+ ) as response:
+ # https://github.com/nedbat/coveragepy/issues/1223
+ async for chunk in response.aiter_bytes(): # pragma: no cover
+ await async_buffer.write(chunk)
+
+ await async_buffer.seek(0)
diff --git a/botx/client/files_api/upload_file.py b/botx/client/files_api/upload_file.py
new file mode 100644
index 00000000..2c8e7408
--- /dev/null
+++ b/botx/client/files_api/upload_file.py
@@ -0,0 +1,80 @@
+import tempfile
+from typing import Literal
+from uuid import UUID
+
+from botx.async_buffer import AsyncBufferReadable
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.common import ChatNotFoundError
+from botx.constants import CHUNK_SIZE
+from botx.missing import Missing
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.async_files import APIAsyncFile, File, convert_async_file_to_domain
+
+
+class BotXAPIUploadFileMeta(UnverifiedPayloadBaseModel):
+ duration: Missing[int]
+ caption: Missing[str]
+
+
+class BotXAPIUploadFileRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+ meta: str
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ duration: Missing[int],
+ caption: Missing[str],
+ ) -> "BotXAPIUploadFileRequestPayload":
+ return cls(
+ group_chat_id=chat_id,
+ meta=BotXAPIUploadFileMeta(
+ duration=duration,
+ caption=caption,
+ ).json(),
+ )
+
+
+class BotXAPIUploadFileResponsePayload(VerifiedPayloadBaseModel):
+ result: APIAsyncFile
+ status: Literal["ok"]
+
+ def to_domain(self) -> File:
+ return convert_async_file_to_domain(self.result)
+
+
+class UploadFileMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(ChatNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIUploadFileRequestPayload,
+ async_buffer: AsyncBufferReadable,
+ filename: str,
+ ) -> BotXAPIUploadFileResponsePayload:
+ path = "/api/v3/botx/files/upload"
+
+ with tempfile.SpooledTemporaryFile(max_size=CHUNK_SIZE) as tmp_file:
+ chunk = await async_buffer.read(CHUNK_SIZE)
+ while chunk:
+ tmp_file.write(chunk)
+ chunk = await async_buffer.read(CHUNK_SIZE)
+
+ tmp_file.seek(0)
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ data=payload.jsonable_dict(),
+ files={"content": (filename, tmp_file)},
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIUploadFileResponsePayload,
+ response,
+ )
diff --git a/botx/client/get_token.py b/botx/client/get_token.py
new file mode 100644
index 00000000..e353f2f2
--- /dev/null
+++ b/botx/client/get_token.py
@@ -0,0 +1,30 @@
+from uuid import UUID
+
+import httpx
+
+from botx.bot.bot_accounts_storage import BotAccountsStorage
+from botx.client.bots_api.get_token import BotXAPIGetTokenRequestPayload, GetTokenMethod
+
+
+async def get_token(
+ bot_id: UUID,
+ httpx_client: httpx.AsyncClient,
+ bot_accounts_storage: BotAccountsStorage,
+) -> str: # noqa: DAR101, DAR201
+ """Request token for bot.
+
+ Moved to separate file because used in `AuthorizedBotXMethod` and `Bot.get_token`.
+ """
+
+ method = GetTokenMethod(
+ bot_id,
+ httpx_client,
+ bot_accounts_storage,
+ )
+
+ signature = bot_accounts_storage.build_signature(bot_id)
+ payload = BotXAPIGetTokenRequestPayload.from_domain(signature)
+
+ botx_api_token = await method.execute(payload)
+
+ return botx_api_token.to_domain()
diff --git a/docs/src/development/tests/tests0/__init__.py b/botx/client/notifications_api/__init__.py
similarity index 100%
rename from docs/src/development/tests/tests0/__init__.py
rename to botx/client/notifications_api/__init__.py
diff --git a/botx/client/notifications_api/direct_notification.py b/botx/client/notifications_api/direct_notification.py
new file mode 100644
index 00000000..939eb32f
--- /dev/null
+++ b/botx/client/notifications_api/direct_notification.py
@@ -0,0 +1,164 @@
+from typing import Any, Dict, List, Literal, Optional, Union
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import callback_exception_thrower
+from botx.client.exceptions.common import ChatNotFoundError
+from botx.client.exceptions.notifications import (
+ BotIsNotChatMemberError,
+ FinalRecipientsListEmptyError,
+ StealthModeDisabledError,
+)
+from botx.constants import MAX_NOTIFICATION_BODY_LENGTH
+from botx.missing import Missing, Undefined
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.attachments import (
+ BotXAPIAttachment,
+ IncomingFileAttachment,
+ OutgoingAttachment,
+)
+from botx.models.message.markup import (
+ BotXAPIMarkup,
+ BubbleMarkup,
+ KeyboardMarkup,
+ api_markup_from_domain,
+)
+from botx.models.message.mentions import BotXAPIMention, find_and_replace_embed_mentions
+
+
+class BotXAPIDirectNotificationMessageOpts(UnverifiedPayloadBaseModel):
+ silent_response: Missing[bool]
+ buttons_auto_adjust: Missing[bool]
+
+
+class BotXAPIDirectNotificationNestedOpts(UnverifiedPayloadBaseModel):
+ send: Missing[bool]
+ force_dnd: Missing[bool]
+
+
+class BotXAPIDirectNotificationOpts(UnverifiedPayloadBaseModel):
+ stealth_mode: Missing[bool]
+ notification_opts: Missing[BotXAPIDirectNotificationNestedOpts]
+
+
+class BotXAPIDirectNotification(UnverifiedPayloadBaseModel):
+ status: Literal["ok"]
+ body: str
+ metadata: Missing[Dict[str, Any]]
+ opts: Missing[BotXAPIDirectNotificationMessageOpts]
+ bubble: Missing[BotXAPIMarkup]
+ keyboard: Missing[BotXAPIMarkup]
+ mentions: Missing[List[BotXAPIMention]]
+
+
+class BotXAPIDirectNotificationRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+ notification: BotXAPIDirectNotification
+ file: Missing[BotXAPIAttachment]
+ recipients: Missing[List[UUID]]
+ opts: Missing[BotXAPIDirectNotificationOpts]
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ body: str,
+ metadata: Missing[Dict[str, Any]],
+ bubbles: Missing[BubbleMarkup],
+ keyboard: Missing[KeyboardMarkup],
+ file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]],
+ recipients: Missing[List[UUID]],
+ silent_response: Missing[bool],
+ markup_auto_adjust: Missing[bool],
+ stealth_mode: Missing[bool],
+ send_push: Missing[bool],
+ ignore_mute: Missing[bool],
+ ) -> "BotXAPIDirectNotificationRequestPayload":
+ api_file: Missing[BotXAPIAttachment] = Undefined
+ if file:
+ assert not file.is_async_file, "async_files not supported"
+ api_file = BotXAPIAttachment.from_file_attachment(file)
+
+ if len(body) > MAX_NOTIFICATION_BODY_LENGTH:
+ raise ValueError(
+ f"Message body length exceeds {MAX_NOTIFICATION_BODY_LENGTH} symbols",
+ )
+
+ body, mentions = find_and_replace_embed_mentions(body)
+
+ return cls(
+ group_chat_id=chat_id,
+ notification=BotXAPIDirectNotification(
+ status="ok",
+ body=body,
+ metadata=metadata,
+ opts=BotXAPIDirectNotificationMessageOpts(
+ silent_response=silent_response,
+ buttons_auto_adjust=markup_auto_adjust,
+ ),
+ bubble=api_markup_from_domain(bubbles) if bubbles else bubbles,
+ keyboard=api_markup_from_domain(keyboard) if keyboard else keyboard,
+ mentions=mentions or Undefined,
+ ),
+ file=api_file,
+ recipients=recipients,
+ opts=BotXAPIDirectNotificationOpts(
+ stealth_mode=stealth_mode,
+ notification_opts=BotXAPIDirectNotificationNestedOpts(
+ send=send_push,
+ force_dnd=ignore_mute,
+ ),
+ ),
+ )
+
+
+class BotXAPISyncIdResult(VerifiedPayloadBaseModel):
+ sync_id: UUID
+
+
+class BotXAPIDirectNotificationResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPISyncIdResult
+
+ def to_domain(self) -> UUID:
+ return self.result.sync_id
+
+
+class DirectNotificationMethod(AuthorizedBotXMethod):
+ error_callback_handlers = {
+ **AuthorizedBotXMethod.error_callback_handlers,
+ "chat_not_found": callback_exception_thrower(ChatNotFoundError),
+ "bot_is_not_a_chat_member": callback_exception_thrower(
+ BotIsNotChatMemberError,
+ ),
+ "event_recipients_list_is_empty": callback_exception_thrower(
+ FinalRecipientsListEmptyError,
+ ),
+ "stealth_mode_disabled": callback_exception_thrower(StealthModeDisabledError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIDirectNotificationRequestPayload,
+ wait_callback: bool,
+ callback_timeout: Optional[int],
+ ) -> BotXAPIDirectNotificationResponsePayload:
+ path = "/api/v4/botx/notifications/direct"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ api_model = self._verify_and_extract_api_model(
+ BotXAPIDirectNotificationResponsePayload,
+ response,
+ )
+
+ await self._process_callback(
+ api_model.result.sync_id,
+ wait_callback,
+ callback_timeout,
+ )
+ return api_model
diff --git a/botx/client/notifications_api/internal_bot_notification.py b/botx/client/notifications_api/internal_bot_notification.py
new file mode 100644
index 00000000..97722418
--- /dev/null
+++ b/botx/client/notifications_api/internal_bot_notification.py
@@ -0,0 +1,93 @@
+from typing import Any, Dict, List, Literal, Optional
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import (
+ callback_exception_thrower,
+ response_exception_thrower,
+)
+from botx.client.exceptions.common import ChatNotFoundError, RateLimitReachedError
+from botx.client.exceptions.notifications import (
+ BotIsNotChatMemberError,
+ FinalRecipientsListEmptyError,
+)
+from botx.missing import Missing, MissingOptional
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIInternalBotNotificationRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+ data: Dict[str, Any]
+ opts: Missing[Dict[str, Any]]
+ recipients: MissingOptional[List[UUID]]
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ data: Dict[str, Any],
+ opts: Missing[Dict[str, Any]],
+ recipients: MissingOptional[List[UUID]],
+ ) -> "BotXAPIInternalBotNotificationRequestPayload":
+ return cls(
+ group_chat_id=chat_id,
+ data=data,
+ opts=opts,
+ recipients=recipients,
+ )
+
+
+class BotXAPISyncIdResult(VerifiedPayloadBaseModel):
+ sync_id: UUID
+
+
+class BotXAPIInternalBotNotificationResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPISyncIdResult
+
+ def to_domain(self) -> UUID:
+ return self.result.sync_id
+
+
+class InternalBotNotificationMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 429: response_exception_thrower(RateLimitReachedError),
+ }
+
+ error_callback_handlers = {
+ **AuthorizedBotXMethod.error_callback_handlers,
+ "chat_not_found": callback_exception_thrower(ChatNotFoundError),
+ "bot_is_not_a_chat_member": callback_exception_thrower(
+ BotIsNotChatMemberError,
+ ),
+ "event_recipients_list_is_empty": callback_exception_thrower(
+ FinalRecipientsListEmptyError,
+ ),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIInternalBotNotificationRequestPayload,
+ wait_callback: bool,
+ callback_timeout: Optional[int],
+ ) -> BotXAPIInternalBotNotificationResponsePayload:
+ path = "/api/v4/botx/notifications/internal"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+ api_model = self._verify_and_extract_api_model(
+ BotXAPIInternalBotNotificationResponsePayload,
+ response,
+ )
+
+ await self._process_callback(
+ api_model.result.sync_id,
+ wait_callback,
+ callback_timeout,
+ )
+
+ return api_model
diff --git a/docs/src/index/__init__.py b/botx/client/smartapps_api/__init__.py
similarity index 100%
rename from docs/src/index/__init__.py
rename to botx/client/smartapps_api/__init__.py
diff --git a/botx/client/smartapps_api/smartapp_event.py b/botx/client/smartapps_api/smartapp_event.py
new file mode 100644
index 00000000..e9f39b37
--- /dev/null
+++ b/botx/client/smartapps_api/smartapp_event.py
@@ -0,0 +1,70 @@
+from typing import Any, Dict, List, Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.constants import SMARTAPP_API_VERSION
+from botx.missing import Missing, MissingOptional, Undefined
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.async_files import APIAsyncFile, File, convert_async_file_from_domain
+
+
+class BotXAPISmartAppEventRequestPayload(UnverifiedPayloadBaseModel):
+ ref: MissingOptional[UUID]
+ smartapp_id: UUID
+ group_chat_id: UUID
+ data: Dict[str, Any]
+ opts: Missing[Dict[str, Any]]
+ smartapp_api_version: int
+ async_files: Missing[List[APIAsyncFile]]
+
+ @classmethod
+ def from_domain(
+ cls,
+ ref: MissingOptional[UUID],
+ smartapp_id: UUID,
+ chat_id: UUID,
+ data: Dict[str, Any],
+ opts: Missing[Dict[str, Any]],
+ files: Missing[List[File]],
+ ) -> "BotXAPISmartAppEventRequestPayload":
+ api_async_files: Missing[List[APIAsyncFile]] = Undefined
+ if files:
+ api_async_files = [convert_async_file_from_domain(file) for file in files]
+
+ return cls(
+ ref=ref,
+ smartapp_id=smartapp_id,
+ group_chat_id=chat_id,
+ data=data,
+ opts=opts,
+ smartapp_api_version=SMARTAPP_API_VERSION,
+ async_files=api_async_files,
+ )
+
+
+class BotXAPISmartAppEventResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class SmartAppEventMethod(AuthorizedBotXMethod):
+ async def execute(
+ self,
+ payload: BotXAPISmartAppEventRequestPayload,
+ ) -> None:
+ path = "/api/v3/botx/smartapps/event"
+
+ # TODO: Remove opts
+ # UnverifiedPayloadBaseModel.jsonable_dict remove empty dicts
+ json = payload.jsonable_dict()
+ json["opts"] = json.get("opts", {})
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=json,
+ )
+
+ self._verify_and_extract_api_model(
+ BotXAPISmartAppEventResponsePayload,
+ response,
+ )
diff --git a/botx/client/smartapps_api/smartapp_notification.py b/botx/client/smartapps_api/smartapp_notification.py
new file mode 100644
index 00000000..cf69f9b1
--- /dev/null
+++ b/botx/client/smartapps_api/smartapp_notification.py
@@ -0,0 +1,56 @@
+from typing import Any, Dict, Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.constants import SMARTAPP_API_VERSION
+from botx.missing import Missing
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPISmartAppNotificationRequestPayload(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+ smartapp_counter: int
+ opts: Missing[Dict[str, Any]]
+ smartapp_api_version: int
+
+ @classmethod
+ def from_domain(
+ cls,
+ chat_id: UUID,
+ smartapp_counter: int,
+ opts: Missing[Dict[str, Any]],
+ ) -> "BotXAPISmartAppNotificationRequestPayload":
+ return cls(
+ group_chat_id=chat_id,
+ smartapp_counter=smartapp_counter,
+ opts=opts,
+ smartapp_api_version=SMARTAPP_API_VERSION,
+ )
+
+
+class BotXAPISmartAppNotificationResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class SmartAppNotificationMethod(AuthorizedBotXMethod):
+ async def execute(
+ self,
+ payload: BotXAPISmartAppNotificationRequestPayload,
+ ) -> None:
+ path = "/api/v3/botx/smartapps/notification"
+
+ # TODO: Remove opts
+ # UnverifiedPayloadBaseModel.jsonable_dict remove empty dicts
+ json = payload.jsonable_dict()
+ json["opts"] = json.get("opts", {})
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=json,
+ )
+
+ self._verify_and_extract_api_model(
+ BotXAPISmartAppNotificationResponsePayload,
+ response,
+ )
diff --git a/examples/fsm/bot/__init__.py b/botx/client/stickers_api/__init__.py
similarity index 100%
rename from examples/fsm/bot/__init__.py
rename to botx/client/stickers_api/__init__.py
diff --git a/botx/client/stickers_api/add_sticker.py b/botx/client/stickers_api/add_sticker.py
new file mode 100644
index 00000000..6a2c6f26
--- /dev/null
+++ b/botx/client/stickers_api/add_sticker.py
@@ -0,0 +1,97 @@
+from typing import Literal, NoReturn
+from uuid import UUID
+
+import httpx
+
+from botx.async_buffer import AsyncBufferReadable
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.exceptions.http import InvalidBotXStatusCodeError
+from botx.client.stickers_api.exceptions import (
+ InvalidEmojiError,
+ InvalidImageError,
+ StickerPackOrStickerNotFoundError,
+)
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.attachments import encode_rfc2397
+from botx.models.stickers import Sticker
+
+
+class BotXAPIAddStickerRequestPayload(UnverifiedPayloadBaseModel):
+ sticker_pack_id: UUID
+ emoji: str
+ image: str
+
+ @classmethod
+ async def from_domain(
+ cls,
+ sticker_pack_id: UUID,
+ emoji: str,
+ async_buffer: AsyncBufferReadable,
+ ) -> "BotXAPIAddStickerRequestPayload":
+ mimetype = "image/png"
+
+ content = await async_buffer.read()
+ b64_content = encode_rfc2397(content, mimetype)
+
+ return cls(sticker_pack_id=sticker_pack_id, emoji=emoji, image=b64_content)
+
+
+class BotXAPIAddStickerResult(VerifiedPayloadBaseModel):
+ id: UUID
+ emoji: str
+ link: str
+
+
+class BotXAPIAddStickerResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPIAddStickerResult
+
+ def to_domain(self) -> Sticker:
+ return Sticker(
+ id=self.result.id,
+ emoji=self.result.emoji,
+ image_link=self.result.link,
+ )
+
+
+def bad_request_error_handler(response: httpx.Response) -> NoReturn: # noqa: WPS238
+ reason = response.json().get("reason")
+
+ if reason == "pack_not_found":
+ raise StickerPackOrStickerNotFoundError.from_response(response)
+
+ error_data = response.json().get("error_data")
+
+ if error_data.get("emoji") == "invalid":
+ raise InvalidEmojiError.from_response(response)
+ elif error_data.get("image") == "invalid":
+ raise InvalidImageError.from_response(response)
+
+ raise InvalidBotXStatusCodeError(response)
+
+
+class AddStickerMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 400: bad_request_error_handler,
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIAddStickerRequestPayload,
+ ) -> BotXAPIAddStickerResponsePayload:
+ jsonable_dict = payload.jsonable_dict()
+ sticker_pack_id = jsonable_dict.pop("sticker_pack_id")
+
+ path = f"/api/v3/botx/stickers/packs/{sticker_pack_id}/stickers"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=jsonable_dict,
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIAddStickerResponsePayload,
+ response,
+ )
diff --git a/botx/client/stickers_api/create_sticker_pack.py b/botx/client/stickers_api/create_sticker_pack.py
new file mode 100644
index 00000000..b0e04b33
--- /dev/null
+++ b/botx/client/stickers_api/create_sticker_pack.py
@@ -0,0 +1,56 @@
+from typing import Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.stickers import StickerPack
+
+
+class BotXAPICreateStickerPackRequestPayload(UnverifiedPayloadBaseModel):
+ name: str
+
+ @classmethod
+ def from_domain(cls, name: str) -> "BotXAPICreateStickerPackRequestPayload":
+ return cls(name=name)
+
+
+class BotXAPICreateStickerPackResult(VerifiedPayloadBaseModel):
+ id: UUID
+ name: str
+ public: bool
+
+
+class BotXAPICreateStickerPackResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPICreateStickerPackResult
+
+ def to_domain(self) -> StickerPack:
+ return StickerPack(
+ id=self.result.id,
+ name=self.result.name,
+ is_public=self.result.public,
+ stickers=[],
+ )
+
+
+class CreateStickerPackMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPICreateStickerPackRequestPayload,
+ ) -> BotXAPICreateStickerPackResponsePayload:
+ path = "/api/v3/botx/stickers/packs"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPICreateStickerPackResponsePayload,
+ response,
+ )
diff --git a/botx/client/stickers_api/delete_sticker.py b/botx/client/stickers_api/delete_sticker.py
new file mode 100644
index 00000000..3bcefa4f
--- /dev/null
+++ b/botx/client/stickers_api/delete_sticker.py
@@ -0,0 +1,50 @@
+from typing import Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.stickers_api.exceptions import StickerPackOrStickerNotFoundError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIDeleteStickerRequestPayload(UnverifiedPayloadBaseModel):
+ sticker_pack_id: UUID
+ sticker_id: UUID
+
+ @classmethod
+ async def from_domain(
+ cls,
+ sticker_pack_id: UUID,
+ sticker_id: UUID,
+ ) -> "BotXAPIDeleteStickerRequestPayload":
+ return cls(sticker_pack_id=sticker_pack_id, sticker_id=sticker_id)
+
+
+class BotXAPIDeleteStickerResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class DeleteStickerMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(StickerPackOrStickerNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIDeleteStickerRequestPayload,
+ ) -> None:
+ path = (
+ f"/api/v3/botx/stickers/packs/{payload.sticker_pack_id}"
+ f"/stickers/{payload.sticker_id}"
+ )
+
+ response = await self._botx_method_call(
+ "DELETE",
+ self._build_url(path),
+ )
+
+ self._verify_and_extract_api_model(
+ BotXAPIDeleteStickerResponsePayload,
+ response,
+ )
diff --git a/botx/client/stickers_api/delete_sticker_pack.py b/botx/client/stickers_api/delete_sticker_pack.py
new file mode 100644
index 00000000..1bcef0ed
--- /dev/null
+++ b/botx/client/stickers_api/delete_sticker_pack.py
@@ -0,0 +1,45 @@
+from typing import Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.stickers_api.exceptions import StickerPackOrStickerNotFoundError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIDeleteStickerPackRequestPayload(UnverifiedPayloadBaseModel):
+ sticker_pack_id: UUID
+
+ @classmethod
+ def from_domain(
+ cls,
+ sticker_pack_id: UUID,
+ ) -> "BotXAPIDeleteStickerPackRequestPayload":
+ return cls(sticker_pack_id=sticker_pack_id)
+
+
+class BotXAPIDeleteStickerPackResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class DeleteStickerPackMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(StickerPackOrStickerNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIDeleteStickerPackRequestPayload,
+ ) -> BotXAPIDeleteStickerPackResponsePayload:
+ path = f"/api/v3/botx/stickers/packs/{payload.sticker_pack_id}"
+
+ response = await self._botx_method_call(
+ "DELETE",
+ self._build_url(path),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIDeleteStickerPackResponsePayload,
+ response,
+ )
diff --git a/botx/client/stickers_api/edit_sticker_pack.py b/botx/client/stickers_api/edit_sticker_pack.py
new file mode 100644
index 00000000..191e51f6
--- /dev/null
+++ b/botx/client/stickers_api/edit_sticker_pack.py
@@ -0,0 +1,57 @@
+from typing import List, Optional
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.stickers_api.exceptions import StickerPackOrStickerNotFoundError
+from botx.client.stickers_api.sticker_pack import BotXAPIGetStickerPackResponsePayload
+from botx.models.api_base import UnverifiedPayloadBaseModel
+
+
+class BotXAPIEditStickerPackRequestPayload(UnverifiedPayloadBaseModel):
+ sticker_pack_id: UUID
+ name: str
+ preview: UUID
+ stickers_order: Optional[List[UUID]]
+
+ @classmethod
+ def from_domain(
+ cls,
+ sticker_pack_id: UUID,
+ name: str,
+ preview: UUID,
+ stickers_order: Optional[List[UUID]],
+ ) -> "BotXAPIEditStickerPackRequestPayload":
+ return cls(
+ sticker_pack_id=sticker_pack_id,
+ name=name,
+ preview=preview,
+ stickers_order=stickers_order,
+ )
+
+
+class EditStickerPackMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(StickerPackOrStickerNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIEditStickerPackRequestPayload,
+ ) -> BotXAPIGetStickerPackResponsePayload:
+ jsonable_dict = payload.jsonable_dict()
+ sticker_pack_id = jsonable_dict.pop("sticker_pack_id")
+
+ path = f"/api/v3/botx/stickers/packs/{sticker_pack_id}"
+
+ response = await self._botx_method_call(
+ "PUT",
+ self._build_url(path),
+ json=jsonable_dict,
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIGetStickerPackResponsePayload,
+ response,
+ )
diff --git a/botx/client/stickers_api/exceptions.py b/botx/client/stickers_api/exceptions.py
new file mode 100644
index 00000000..4e6af776
--- /dev/null
+++ b/botx/client/stickers_api/exceptions.py
@@ -0,0 +1,13 @@
+from botx.client.exceptions.base import BaseClientError
+
+
+class StickerPackOrStickerNotFoundError(BaseClientError):
+ """Sticker pack or sticker with specified id not found."""
+
+
+class InvalidEmojiError(BaseClientError):
+ """Bad emoji."""
+
+
+class InvalidImageError(BaseClientError):
+ """Bad image."""
diff --git a/botx/client/stickers_api/get_sticker.py b/botx/client/stickers_api/get_sticker.py
new file mode 100644
index 00000000..81a37c08
--- /dev/null
+++ b/botx/client/stickers_api/get_sticker.py
@@ -0,0 +1,66 @@
+from typing import Literal
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.stickers_api.exceptions import StickerPackOrStickerNotFoundError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.stickers import Sticker
+
+
+class BotXAPIGetStickerRequestPayload(UnverifiedPayloadBaseModel):
+ sticker_pack_id: UUID
+ sticker_id: UUID
+
+ @classmethod
+ def from_domain(
+ cls,
+ sticker_pack_id: UUID,
+ sticker_id: UUID,
+ ) -> "BotXAPIGetStickerRequestPayload":
+ return cls(sticker_pack_id=sticker_pack_id, sticker_id=sticker_id)
+
+
+class BotXAPIGetStickerResult(VerifiedPayloadBaseModel):
+ id: UUID
+ emoji: str
+ link: str
+
+
+class BotXAPIGetStickerResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPIGetStickerResult
+
+ def to_domain(self) -> Sticker:
+ return Sticker(
+ id=self.result.id,
+ emoji=self.result.emoji,
+ image_link=self.result.link,
+ )
+
+
+class GetStickerMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(StickerPackOrStickerNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIGetStickerRequestPayload,
+ ) -> BotXAPIGetStickerResponsePayload:
+ jsonable_dict = payload.jsonable_dict()
+ path = (
+ f"/api/v3/botx/stickers/packs/{jsonable_dict['sticker_pack_id']}/"
+ f"stickers/{jsonable_dict['sticker_id']}"
+ )
+
+ response = await self._botx_method_call(
+ "GET",
+ self._build_url(path),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIGetStickerResponsePayload,
+ response,
+ )
diff --git a/botx/client/stickers_api/get_sticker_pack.py b/botx/client/stickers_api/get_sticker_pack.py
new file mode 100644
index 00000000..443df163
--- /dev/null
+++ b/botx/client/stickers_api/get_sticker_pack.py
@@ -0,0 +1,42 @@
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.stickers_api.exceptions import StickerPackOrStickerNotFoundError
+from botx.client.stickers_api.sticker_pack import BotXAPIGetStickerPackResponsePayload
+from botx.models.api_base import UnverifiedPayloadBaseModel
+
+
+class BotXAPIGetStickerPackRequestPayload(UnverifiedPayloadBaseModel):
+ sticker_pack_id: UUID
+
+ @classmethod
+ def from_domain(
+ cls,
+ sticker_pack_id: UUID,
+ ) -> "BotXAPIGetStickerPackRequestPayload":
+ return cls(sticker_pack_id=sticker_pack_id)
+
+
+class GetStickerPackMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(StickerPackOrStickerNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIGetStickerPackRequestPayload,
+ ) -> BotXAPIGetStickerPackResponsePayload:
+ jsonable_dict = payload.jsonable_dict()
+ path = f"/api/v3/botx/stickers/packs/{jsonable_dict['sticker_pack_id']}"
+
+ response = await self._botx_method_call(
+ "GET",
+ self._build_url(path),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIGetStickerPackResponsePayload,
+ response,
+ )
diff --git a/botx/client/stickers_api/get_sticker_packs.py b/botx/client/stickers_api/get_sticker_packs.py
new file mode 100644
index 00000000..6800d429
--- /dev/null
+++ b/botx/client/stickers_api/get_sticker_packs.py
@@ -0,0 +1,77 @@
+from typing import List, Literal, Optional
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.stickers import StickerPackFromList, StickerPackPage
+
+
+class BotXAPIGetStickerPacksRequestPayload(UnverifiedPayloadBaseModel):
+ user_huid: UUID
+ limit: int
+ after: Optional[str]
+
+ @classmethod
+ def from_domain(
+ cls,
+ huid: UUID,
+ limit: int,
+ after: Optional[str],
+ ) -> "BotXAPIGetStickerPacksRequestPayload":
+ return cls(user_huid=huid, limit=limit, after=after)
+
+
+class BotXAPIGetPaginationResult(VerifiedPayloadBaseModel):
+ after: Optional[str]
+
+
+class BotXAPIGetStickerPackResult(VerifiedPayloadBaseModel):
+ id: UUID
+ name: str
+ public: bool
+ stickers_count: int
+ stickers_order: Optional[List[UUID]]
+
+
+class BotXAPIGetStickerPacksResult(VerifiedPayloadBaseModel):
+ packs: List[BotXAPIGetStickerPackResult]
+ pagination: BotXAPIGetPaginationResult
+
+
+class BotXAPIGetStickerPacksResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPIGetStickerPacksResult
+
+ def to_domain(self) -> StickerPackPage:
+ return StickerPackPage(
+ sticker_packs=[
+ StickerPackFromList(
+ id=sticker_pack.id,
+ name=sticker_pack.name,
+ is_public=sticker_pack.public,
+ stickers_count=sticker_pack.stickers_count,
+ sticker_ids=sticker_pack.stickers_order,
+ )
+ for sticker_pack in self.result.packs
+ ],
+ after=self.result.pagination.after,
+ )
+
+
+class GetStickerPacksMethod(AuthorizedBotXMethod):
+ async def execute(
+ self,
+ payload: BotXAPIGetStickerPacksRequestPayload,
+ ) -> BotXAPIGetStickerPacksResponsePayload:
+ path = "/api/v3/botx/stickers/packs"
+
+ response = await self._botx_method_call(
+ "GET",
+ self._build_url(path),
+ params=payload.jsonable_dict(),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIGetStickerPacksResponsePayload,
+ response,
+ )
diff --git a/botx/client/stickers_api/sticker_pack.py b/botx/client/stickers_api/sticker_pack.py
new file mode 100644
index 00000000..a03b3e45
--- /dev/null
+++ b/botx/client/stickers_api/sticker_pack.py
@@ -0,0 +1,42 @@
+from typing import List, Literal, Optional
+from uuid import UUID
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.stickers import Sticker, StickerPack
+
+
+class BotXAPIGetStickerResult(VerifiedPayloadBaseModel):
+ id: UUID
+ emoji: str
+ link: str
+
+
+class BotXAPIGetStickerPackResult(VerifiedPayloadBaseModel):
+ id: UUID
+ name: str
+ public: bool
+ stickers_order: Optional[List[UUID]]
+ stickers: List[BotXAPIGetStickerResult]
+
+
+class BotXAPIGetStickerPackResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPIGetStickerPackResult
+
+ def to_domain(self) -> StickerPack:
+ if self.result.stickers_order:
+ self.result.stickers.sort(
+ key=lambda pack: self.result.stickers_order.index( # type:ignore
+ pack.id,
+ ),
+ )
+
+ return StickerPack(
+ id=self.result.id,
+ name=self.result.name,
+ is_public=self.result.public,
+ stickers=[
+ Sticker(id=sticker.id, emoji=sticker.emoji, image_link=sticker.link)
+ for sticker in self.result.stickers
+ ],
+ )
diff --git a/tests/fixtures/__init__.py b/botx/client/users_api/__init__.py
similarity index 100%
rename from tests/fixtures/__init__.py
rename to botx/client/users_api/__init__.py
diff --git a/botx/client/users_api/search_user_by_email.py b/botx/client/users_api/search_user_by_email.py
new file mode 100644
index 00000000..0a820e79
--- /dev/null
+++ b/botx/client/users_api/search_user_by_email.py
@@ -0,0 +1,37 @@
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.users import UserNotFoundError
+from botx.client.users_api.user_from_search import BotXAPISearchUserResponsePayload
+from botx.models.api_base import UnverifiedPayloadBaseModel
+
+
+class BotXAPISearchUserByEmailRequestPayload(UnverifiedPayloadBaseModel):
+ email: str
+
+ @classmethod
+ def from_domain(cls, email: str) -> "BotXAPISearchUserByEmailRequestPayload":
+ return cls(email=email)
+
+
+class SearchUserByEmailMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(UserNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPISearchUserByEmailRequestPayload,
+ ) -> BotXAPISearchUserResponsePayload:
+ path = "/api/v3/botx/users/by_email"
+
+ response = await self._botx_method_call(
+ "GET",
+ self._build_url(path),
+ params=payload.jsonable_dict(),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPISearchUserResponsePayload,
+ response,
+ )
diff --git a/botx/client/users_api/search_user_by_huid.py b/botx/client/users_api/search_user_by_huid.py
new file mode 100644
index 00000000..5e806fbe
--- /dev/null
+++ b/botx/client/users_api/search_user_by_huid.py
@@ -0,0 +1,39 @@
+from uuid import UUID
+
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.users import UserNotFoundError
+from botx.client.users_api.user_from_search import BotXAPISearchUserResponsePayload
+from botx.models.api_base import UnverifiedPayloadBaseModel
+
+
+class BotXAPISearchUserByHUIDRequestPayload(UnverifiedPayloadBaseModel):
+ user_huid: UUID
+
+ @classmethod
+ def from_domain(cls, huid: UUID) -> "BotXAPISearchUserByHUIDRequestPayload":
+ return cls(user_huid=huid)
+
+
+class SearchUserByHUIDMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(UserNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPISearchUserByHUIDRequestPayload,
+ ) -> BotXAPISearchUserResponsePayload:
+ path = "/api/v3/botx/users/by_huid"
+
+ response = await self._botx_method_call(
+ "GET",
+ self._build_url(path),
+ params=payload.jsonable_dict(),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPISearchUserResponsePayload,
+ response,
+ )
diff --git a/botx/client/users_api/search_user_by_login.py b/botx/client/users_api/search_user_by_login.py
new file mode 100644
index 00000000..34175e64
--- /dev/null
+++ b/botx/client/users_api/search_user_by_login.py
@@ -0,0 +1,42 @@
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from botx.client.botx_method import response_exception_thrower
+from botx.client.exceptions.users import UserNotFoundError
+from botx.client.users_api.user_from_search import BotXAPISearchUserResponsePayload
+from botx.models.api_base import UnverifiedPayloadBaseModel
+
+
+class BotXAPISearchUserByLoginRequestPayload(UnverifiedPayloadBaseModel):
+ ad_login: str
+ ad_domain: str
+
+ @classmethod
+ def from_domain(
+ cls,
+ ad_login: str,
+ ad_domain: str,
+ ) -> "BotXAPISearchUserByLoginRequestPayload":
+ return cls(ad_login=ad_login, ad_domain=ad_domain)
+
+
+class SearchUserByLoginMethod(AuthorizedBotXMethod):
+ status_handlers = {
+ **AuthorizedBotXMethod.status_handlers,
+ 404: response_exception_thrower(UserNotFoundError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPISearchUserByLoginRequestPayload,
+ ) -> BotXAPISearchUserResponsePayload:
+ path = "/api/v3/botx/users/by_login"
+
+ response = await self._botx_method_call(
+ "GET",
+ self._build_url(path),
+ params=payload.jsonable_dict(),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPISearchUserResponsePayload,
+ response,
+ )
diff --git a/botx/client/users_api/user_from_search.py b/botx/client/users_api/user_from_search.py
new file mode 100644
index 00000000..448df6d4
--- /dev/null
+++ b/botx/client/users_api/user_from_search.py
@@ -0,0 +1,35 @@
+from typing import List, Literal, Optional
+from uuid import UUID
+
+from pydantic import Field
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.users import UserFromSearch
+
+
+class BotXAPISearchUserResult(VerifiedPayloadBaseModel):
+ user_huid: UUID
+ ad_login: Optional[str] = None
+ ad_domain: Optional[str] = None
+ name: str
+ company: Optional[str] = None
+ company_position: Optional[str] = None
+ department: Optional[str] = None
+ emails: List[str] = Field(default_factory=list)
+
+
+class BotXAPISearchUserResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPISearchUserResult
+
+ def to_domain(self) -> UserFromSearch:
+ return UserFromSearch(
+ huid=self.result.user_huid,
+ ad_login=self.result.ad_login,
+ ad_domain=self.result.ad_domain,
+ username=self.result.name,
+ company=self.result.company,
+ company_position=self.result.company_position,
+ department=self.result.department,
+ emails=self.result.emails,
+ )
diff --git a/botx/clients/__init__.py b/botx/clients/__init__.py
deleted file mode 100644
index 2434158d..00000000
--- a/botx/clients/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for BotX API related parts: requests, clients, types."""
diff --git a/botx/clients/clients/__init__.py b/botx/clients/clients/__init__.py
deleted file mode 100644
index 7199c19d..00000000
--- a/botx/clients/clients/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for clients that make requests to BotX API."""
diff --git a/botx/clients/clients/async_client.py b/botx/clients/clients/async_client.py
deleted file mode 100644
index 3d6bce71..00000000
--- a/botx/clients/clients/async_client.py
+++ /dev/null
@@ -1,145 +0,0 @@
-"""Definition for async client for BotX API."""
-from dataclasses import field
-from http import HTTPStatus
-from json import JSONDecodeError
-from typing import Any, List, TypeVar
-
-import httpx
-from pydantic.dataclasses import dataclass
-
-from botx.clients.clients.processing import extract_result, handle_error
-from botx.clients.methods.base import BotXMethod
-from botx.clients.types.http import ExpectedType, HTTPRequest, HTTPResponse
-from botx.converters import optional_sequence_to_list
-from botx.exceptions import (
- BotXAPIError,
- BotXAPIRouteDeprecated,
- BotXConnectError,
- BotXJSONDecodeError,
-)
-from botx.shared import BotXDataclassConfig
-
-ResponseT = TypeVar("ResponseT")
-
-
-@dataclass(config=BotXDataclassConfig)
-class AsyncClient:
- """Async client for BotX API."""
-
- http_client: httpx.AsyncClient = field(init=False)
- interceptors: List[Any] = field(default_factory=list)
-
- def __post_init__(self) -> None:
- """Init or update special fields."""
- self.http_client = httpx.AsyncClient()
- self.interceptors = optional_sequence_to_list(self.interceptors)
-
- # TODO: Use host as argument here.
- @classmethod
- def build_request(cls, method: BotXMethod[ResponseT]) -> HTTPRequest:
- """Build HTTPRequest from passed BotX method.
-
- Arguments:
- method: BotX method.
-
- Returns:
- Built request.
- """
- return method.build_http_request()
-
- async def process_response(
- self,
- method: BotXMethod[ResponseT],
- response: HTTPResponse,
- ) -> ResponseT:
- """Handle errors and extract data from BotX API response.
-
- Arguments:
- method: BotX API method.
- response: HTTPResponse that is result of method executing.
-
- Returns:
- Shape specified for method response.
-
- Raises:
- BotXAPIError: raised if handler for error status code was not found.
- BotXAPIRouteDeprecated: raised if API route was deprecated.
- """
- handlers_dict = method.error_handlers
- error_handlers = handlers_dict.get(response.status_code)
- if error_handlers is not None:
- await handle_error(method, error_handlers, response)
-
- if response.status_code == HTTPStatus.GONE:
- raise BotXAPIRouteDeprecated(
- url=method.url,
- method=method.http_method,
- status=response.status_code,
- response_content=response.json_body,
- )
-
- if response.is_error or response.is_redirect:
- raise BotXAPIError(
- url=method.url,
- method=method.http_method,
- status=response.status_code,
- response_content=response.json_body,
- )
-
- return extract_result(method, response)
-
- async def execute(self, request: HTTPRequest) -> HTTPResponse:
- """Make request to BotX API.
-
- Arguments:
- request: HTTPRequest that was built from method.
-
- Returns:
- HTTP response from API.
-
- Raises:
- BotXConnectError: raised if unable to connect to service.
- BotXJSONDecodeError: raised if service returned invalid body.
- """
- try:
- response = await self.http_client.request(
- request.method,
- request.url,
- headers=request.headers,
- params=request.query_params,
- json=request.json_body,
- data=request.data,
- files=request.files,
- )
- except httpx.HTTPError as httpx_exc:
- raise BotXConnectError(
- url=request.url,
- method=request.method,
- ) from httpx_exc
-
- headers = dict(response.headers)
-
- should_process_as_error = (
- response.status_code in request.should_process_as_error
- )
- if ( # noqa: WPS337
- not response.is_error
- and not should_process_as_error # noqa: W503
- and request.expected_type == ExpectedType.BINARY # noqa: W503
- ):
- return HTTPResponse(
- headers=headers,
- status_code=response.status_code,
- raw_data=response.read(),
- )
-
- try:
- json_body = response.json()
- except JSONDecodeError as exc:
- raise BotXJSONDecodeError(url=request.url, method=request.method) from exc
-
- return HTTPResponse(
- headers=headers,
- status_code=response.status_code,
- json_body=json_body,
- )
diff --git a/botx/clients/clients/processing.py b/botx/clients/clients/processing.py
deleted file mode 100644
index 7c98b50d..00000000
--- a/botx/clients/clients/processing.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""Logic for handling response from BotX API for real HTTP responses."""
-import collections
-import contextlib
-from io import BytesIO
-from typing import TypeVar
-
-from pydantic import ValidationError
-
-from botx import concurrency
-from botx.clients.methods.base import APIResponse, BotXMethod, ErrorHandlersInMethod
-from botx.clients.types.http import ExpectedType, HTTPResponse
-from botx.models.files import File
-
-ResponseT = TypeVar("ResponseT")
-
-
-def build_file(response: HTTPResponse) -> File:
- """Build file from response raw data.
-
- Arguments:
- response: HTTP response from BotX API.
-
- Returns:
- Built file from response.
- """
- mimetype = response.headers["content-type"].split(";", 1)[0]
- ext = File.get_ext_by_mimetype(mimetype) or ""
- file_name = "document{0}".format(ext)
- return File.from_file(BytesIO(response.raw_data), file_name) # type: ignore
-
-
-def extract_result( # noqa: WPS210
- method: BotXMethod[ResponseT],
- response: HTTPResponse,
-) -> ResponseT:
- """Extract result from successful response and convert it to right shape.
-
- Arguments:
- method: method to BotX API that was called.
- response: HTTP response from BotX API.
-
- Returns:
- Converted shape from BotX API.
- """
- if method.expected_type == ExpectedType.BINARY:
- return build_file(response) # type: ignore
-
- return_shape = method.returning
- api_response = APIResponse[return_shape].parse_obj( # type: ignore
- response.json_body,
- )
- response_result = api_response.result
- extractor = method.result_extractor
- if extractor is not None:
- # mypy does not understand that self passed here
- return extractor(response_result) # type: ignore
-
- return response_result
-
-
-async def handle_error(
- method: BotXMethod,
- error_handlers: ErrorHandlersInMethod,
- response: HTTPResponse,
-) -> None:
- """Handle error status code from BotX API.
-
- Arguments:
- method: method to BotX API that was called.
- error_handlers: registered on method handlers for different responses.
- response: HTTP response from BotX API.
- """
- if not isinstance(error_handlers, collections.Sequence):
- error_handlers = [error_handlers]
-
- for error_handler in error_handlers:
- with contextlib.suppress(ValidationError):
- await concurrency.callable_to_coroutine(error_handler, method, response)
diff --git a/botx/clients/clients/sync_client.py b/botx/clients/clients/sync_client.py
deleted file mode 100644
index a4ad37ed..00000000
--- a/botx/clients/clients/sync_client.py
+++ /dev/null
@@ -1,153 +0,0 @@
-"""Definition for sync client for BotX API."""
-from dataclasses import field
-from http import HTTPStatus
-from json import JSONDecodeError
-from typing import Any, List, TypeVar
-
-import httpx
-from pydantic.dataclasses import dataclass
-
-from botx import concurrency
-from botx.clients.clients.processing import extract_result, handle_error
-from botx.clients.methods.base import BotXMethod, ErrorHandlersInMethod
-from botx.clients.types.http import ExpectedType, HTTPRequest, HTTPResponse
-from botx.converters import optional_sequence_to_list
-from botx.exceptions import (
- BotXAPIError,
- BotXAPIRouteDeprecated,
- BotXConnectError,
- BotXJSONDecodeError,
-)
-from botx.shared import BotXDataclassConfig
-
-ResponseT = TypeVar("ResponseT")
-
-
-@dataclass(config=BotXDataclassConfig)
-class Client:
- """Sync client for BotX API."""
-
- http_client: httpx.Client = field(init=False)
- interceptors: List[Any] = field(default_factory=list)
-
- def __post_init__(self) -> None:
- """Init or update special fields."""
- self.http_client = httpx.Client()
- self.interceptors = optional_sequence_to_list(self.interceptors)
-
- @classmethod
- def build_request(cls, method: BotXMethod[ResponseT]) -> HTTPRequest:
- """Build HTTPRequest from passed BotX method.
-
- Arguments:
- method: BotX method.
-
- Returns:
- Built request.
- """
- return method.build_http_request()
-
- def process_response(
- self,
- method: BotXMethod[ResponseT],
- response: HTTPResponse,
- ) -> ResponseT:
- """Handle errors and extract data from BotX API response.
-
- Arguments:
- method: BotX API method.
- response: HTTPResponse that is result of method executing.
-
- Returns:
- Shape specified for method response.
-
- Raises:
- BotXAPIError: raised if handler for error status code was not found.
- BotXAPIRouteDeprecated: raised if API route was deprecated.
- """
- handlers_dict = method.error_handlers
- error_handlers = handlers_dict.get(response.status_code)
- if error_handlers is not None:
- _handle_error(method, error_handlers, response)
-
- if response.status_code == HTTPStatus.GONE:
- raise BotXAPIRouteDeprecated(
- url=method.url,
- method=method.http_method,
- status=response.status_code,
- response_content=response.json_body,
- )
-
- if response.is_error or response.is_redirect:
- raise BotXAPIError(
- url=method.url,
- method=method.http_method,
- status=response.status_code,
- response_content=response.json_body,
- )
-
- return extract_result(method, response)
-
- def execute(self, request: HTTPRequest) -> HTTPResponse:
- """Make request to BotX API.
-
- Arguments:
- request: HTTPRequest that was built from method.
-
- Returns:
- HTTP response from API.
-
- Raises:
- BotXConnectError: raised if unable to connect to service.
- BotXJSONDecodeError: raised if service returned invalid body.
- """
- try:
- response = self.http_client.request(
- request.method,
- request.url,
- headers=request.headers,
- params=request.query_params,
- json=request.json_body,
- data=request.data,
- files=request.files,
- )
- except httpx.HTTPError as httpx_exc:
- raise BotXConnectError(
- url=request.url,
- method=request.method,
- ) from httpx_exc
-
- headers = dict(response.headers)
-
- should_process_as_error = (
- response.status_code in request.should_process_as_error
- )
- if ( # noqa: WPS337
- not response.is_error
- and not should_process_as_error # noqa: W503
- and request.expected_type == ExpectedType.BINARY # noqa: W503
- ):
- return HTTPResponse(
- headers=headers,
- status_code=response.status_code,
- raw_data=response.read(),
- )
-
- try:
- json_body = response.json()
- except JSONDecodeError as exc:
- raise BotXJSONDecodeError(url=request.url, method=request.method) from exc
-
- return HTTPResponse(
- headers=headers,
- status_code=response.status_code,
- json_body=json_body,
- )
-
-
-def _handle_error(
- method: BotXMethod,
- error_handlers: ErrorHandlersInMethod,
- response: HTTPResponse,
-) -> None:
- concurrency.async_to_sync(handle_error)(method, error_handlers, response)
diff --git a/botx/clients/interceptors/__init__.py b/botx/clients/interceptors/__init__.py
deleted file mode 100644
index 92a142d2..00000000
--- a/botx/clients/interceptors/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for requests interceptors for clietns."""
diff --git a/botx/clients/methods/__init__.py b/botx/clients/methods/__init__.py
deleted file mode 100644
index 848781a1..00000000
--- a/botx/clients/methods/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for logic of requests to BotX API."""
diff --git a/botx/clients/methods/base.py b/botx/clients/methods/base.py
deleted file mode 100644
index 74b35fef..00000000
--- a/botx/clients/methods/base.py
+++ /dev/null
@@ -1,231 +0,0 @@
-"""Definition for base class that is responsible for request to BotX API."""
-from __future__ import annotations
-
-import json
-import typing
-from abc import ABC, abstractmethod
-from urllib.parse import urljoin
-
-from httpx import Response
-from pydantic import BaseConfig, BaseModel, Extra
-from pydantic.generics import GenericModel
-
-from botx.clients.types.http import (
- ExpectedType,
- HTTPRequest,
- HTTPResponse,
- PrimitiveDataType,
-)
-from botx.models.enums import Statuses
-
-try:
- from typing import Literal # noqa: WPS433, WPS458
-except ImportError:
- from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401
-
-ResponseT = typing.TypeVar("ResponseT")
-SyncErrorHandler = typing.Callable[["BotXMethod", HTTPResponse], typing.NoReturn]
-AsyncErrorHandler = typing.Callable[
- ["BotXMethod", Response],
- typing.Awaitable[typing.NoReturn],
-]
-ErrorHandler = typing.Union[SyncErrorHandler, AsyncErrorHandler]
-ErrorHandlersInMethod = typing.Union[typing.Sequence[ErrorHandler], ErrorHandler]
-
-
-class APIResponse(GenericModel, typing.Generic[ResponseT]):
- """Model for successful response from BotX API."""
-
- #: status of requested operation response.
- status: Literal[Statuses.ok] = Statuses.ok
-
- #: generic response shape.
- result: ResponseT
-
-
-class APIErrorResponse(GenericModel, typing.Generic[ResponseT]):
- """Model for error response from BotX API."""
-
- #: status of requested operation response.
- status: Literal[Statuses.error] = Statuses.error
-
- #: reason why operation failed
- reason: str
-
- #: errors from API.
- errors: typing.List[str]
-
- #: additional payload with more data about error.
- error_data: ResponseT
-
-
-class AbstractBotXMethod(ABC, typing.Generic[ResponseT]):
- """Abstract base class for BotX request."""
-
- @property
- @abstractmethod
- def __url__(self) -> str:
- """Path for method in BotX API."""
-
- @property
- @abstractmethod
- def __method__(self) -> str:
- """HTTP method used for method."""
-
- @property
- @abstractmethod
- def __returning__(self) -> typing.Type[typing.Any]:
- """Shape returned from method that can be parsed by pydantic."""
-
- @property
- def __errors_handlers__(self) -> typing.Mapping[int, ErrorHandlersInMethod]:
- """Error handlers for responses from BotX API by status code and handler."""
- return typing.cast(typing.Mapping[int, ErrorHandlersInMethod], {})
-
- @property
- def __result_extractor__(
- self,
- ) -> typing.Optional[typing.Callable[[BotXMethod, typing.Any], ResponseT]]:
- """Extractor for response shape from BotX API."""
- return None # noqa: WPS324
-
- @property
- def __expected_type__(self) -> ExpectedType:
- """Extractor of expected type of response body."""
- return ExpectedType.JSON
-
-
-CREDENTIALS_FIELDS = frozenset(("token", "host", "scheme"))
-
-
-class BaseBotXMethod(AbstractBotXMethod[ResponseT], ABC): # noqa: WPS214
- """Base logic that is responsible for configuration and shortcuts for fields."""
-
- #: host where request should be sent.
- host: str = ""
-
- #: token for request.
- token: str = ""
-
- #: HTTP scheme for request.
- scheme: str = "https"
-
- @property
- def url(self) -> str:
- """Full URL for request."""
- base_url = "{scheme}://{host}".format(scheme=self.scheme, host=self.host)
- return urljoin(base_url, self.__url__)
-
- @property
- def http_method(self) -> str:
- """HTTP method for request."""
- return self.__method__
-
- @property
- def headers(self) -> typing.Dict[str, str]:
- """Headers that should be used in request."""
- return {"Content-Type": "application/json"}
-
- @property
- def query_params(self) -> typing.Dict[str, PrimitiveDataType]:
- """Query string query_params for request."""
- return {}
-
- @property
- def returning(self) -> typing.Type[typing.Any]:
- """Shape returned from method that can be parsed by pydantic."""
- return self.__returning__
-
- @property
- def error_handlers(self) -> typing.Mapping[int, ErrorHandlersInMethod]:
- """Error handlers for responses from BotX API by status code and handler."""
- return self.__errors_handlers__
-
- @property
- def result_extractor(
- self,
- ) -> typing.Optional[typing.Callable[[BotXMethod, typing.Any], ResponseT]]:
- """Extractor for response shape from BotX API."""
- return self.__result_extractor__
-
- @property
- def expected_type(self) -> ExpectedType:
- """Extractor of expected type of response body."""
- return self.__expected_type__
-
-
-class BotXMethod(BaseBotXMethod[ResponseT], BaseModel, ABC):
- """Method for BotX API that should be extended by actual implementation."""
-
- class Config(BaseConfig):
- extra = Extra.allow
- allow_population_by_field_name = True
- arbitrary_types_allowed = True
- orm_mode = True
-
- def configure(self, *, host: str, token: str, scheme: str = "https") -> None:
- """Configure request with credentials and transport related stuff.
-
- Arguments:
- host: host where request should be sent.
- token: token for request.
- scheme: HTTP scheme for request.
- """
- self.token = token
- self.host = host
- self.scheme = scheme
-
- def build_serialized_dict(
- self,
- ) -> typing.Optional[typing.Dict[str, PrimitiveDataType]]:
- """Build serialized dict (with only primitive types) for request.
-
- Returns:
- Serialized dict.
- """
- # TODO: Waiting for
- serialized_dict = json.loads(
- self.json(
- by_alias=True,
- exclude=CREDENTIALS_FIELDS,
- exclude_none=True,
- ),
- )
-
- # Because exclude_none removes empty file key on message update
- if hasattr(self, "file") and self.file is None: # type: ignore # noqa: WPS421
- serialized_dict["file"] = None
-
- return serialized_dict
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- request_params = self.query_params
- request_data = self.build_serialized_dict()
-
- if self.__method__ == "GET" and request_data:
- request_params = request_data
- request_data = None
-
- return HTTPRequest(
- method=self.__method__,
- url=self.url,
- headers=self.headers,
- query_params=dict(request_params),
- json_body=request_data,
- )
-
-
-class AuthorizedBotXMethod(BotXMethod[ResponseT], ABC):
- """Method for BotX API that adds authorization token."""
-
- @property
- def headers(self) -> typing.Dict[str, str]:
- """Headers that should be used in request."""
- headers = super().headers
- headers["Authorization"] = "Bearer {token}".format(token=self.token)
- return headers
diff --git a/botx/clients/methods/errors/__init__.py b/botx/clients/methods/errors/__init__.py
deleted file mode 100644
index 86529f8e..00000000
--- a/botx/clients/methods/errors/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for built-in error handlers for responses from BotX API."""
diff --git a/botx/clients/methods/errors/bot_is_not_admin.py b/botx/clients/methods/errors/bot_is_not_admin.py
deleted file mode 100644
index 1cde3a08..00000000
--- a/botx/clients/methods/errors/bot_is_not_admin.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""Definition for "bot is not admin" error."""
-from typing import NoReturn
-from uuid import UUID
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class BotIsNotAdminError(BotXAPIError):
- """Error for raising when bot is not admin."""
-
- message_template = "bot {bot_id} is not admin of chat {group_chat_id}"
-
- #: ID of bot that sent request.
- bot_id: UUID
-
- #: ID of chat into which request was sent.
- group_chat_id: UUID
-
-
-class BotIsNotAdminData(BaseModel):
- """Data for error when bot is not admin."""
-
- #: ID of sender (bot)
- sender: UUID
-
- #: ID of chat into which request was sent.
- group_chat_id: UUID
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "bot is not admin" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- BotIsNotAdminError: raised always.
- """
- error_data = (
- APIErrorResponse[BotIsNotAdminData].parse_obj(response.json_body).error_data
- )
- raise BotIsNotAdminError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- bot_id=error_data.sender,
- group_chat_id=error_data.group_chat_id,
- )
diff --git a/botx/clients/methods/errors/bot_not_found.py b/botx/clients/methods/errors/bot_not_found.py
deleted file mode 100644
index ab08af3b..00000000
--- a/botx/clients/methods/errors/bot_not_found.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Definition for "bot not found" error."""
-from typing import NoReturn
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class BotNotFoundError(BotXAPIError):
- """Error for raising when bot not found."""
-
- message_template = "bot with id `{bot_id}` not found. "
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "bot not found" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- BotNotFoundError: raised always.
- """
- APIErrorResponse[dict].parse_obj(response.json_body)
- raise BotNotFoundError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- bot_id=method.bot_id, # type: ignore
- )
diff --git a/botx/clients/methods/errors/chat_creation_disallowed.py b/botx/clients/methods/errors/chat_creation_disallowed.py
deleted file mode 100644
index 080febc7..00000000
--- a/botx/clients/methods/errors/chat_creation_disallowed.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""Definition for "chat creating disallowed" error."""
-from typing import NoReturn
-from uuid import UUID
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class ChatCreationDisallowedError(BotXAPIError):
- """Error for raising when bot is not allowed to create chats."""
-
- message_template = "bot {bot_id} is not allowed to create chats"
-
- #: ID of bot that sent request.
- bot_id: UUID
-
-
-class ChatCreationDisallowedData(BaseModel):
- """Data for error when bot is not allowed to create chats."""
-
- #: ID of bot that sent request.
- bot_id: UUID
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "chat creating disallowed" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- ChatCreationDisallowedError: raised always.
- """
- parsed_response = APIErrorResponse[ChatCreationDisallowedData].parse_obj(
- response.json_body,
- )
- error_data = parsed_response.error_data
- raise ChatCreationDisallowedError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- bot_id=error_data.bot_id,
- )
diff --git a/botx/clients/methods/errors/chat_creation_error.py b/botx/clients/methods/errors/chat_creation_error.py
deleted file mode 100644
index 789f72eb..00000000
--- a/botx/clients/methods/errors/chat_creation_error.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Definition for "chat creation error"."""
-from typing import NoReturn
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class ChatCreationError(BotXAPIError):
- """Error for raising when there is error for chat creation."""
-
- message_template = "error while creating chat"
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "chat creation error" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- ChatCreationError: raised always.
- """
- APIErrorResponse[dict].parse_obj(response.json_body)
- raise ChatCreationError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- )
diff --git a/botx/clients/methods/errors/chat_is_not_modifiable.py b/botx/clients/methods/errors/chat_is_not_modifiable.py
deleted file mode 100644
index 8b123378..00000000
--- a/botx/clients/methods/errors/chat_is_not_modifiable.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Definition for "chat is not modifiable" error."""
-from typing import NoReturn
-from uuid import UUID
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class PersonalChatIsNotModifiableError(BotXAPIError):
- """Error for raising when chat is not modifiable."""
-
- message_template = "personal chat is not modifiable"
-
-
-class PersonalChatIsNotModifiableData(BaseModel):
- """Data for error when chat is not modifiable."""
-
- #: ID of chat that can not be modified.
- group_chat_id: UUID
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "chat creation error" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- PersonalChatIsNotModifiableError: raised always.
- """
- parsed_response = APIErrorResponse[PersonalChatIsNotModifiableData].parse_obj(
- response.json_body,
- )
- error_data = parsed_response.error_data
- raise PersonalChatIsNotModifiableError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- group_chat_id=error_data.group_chat_id,
- )
diff --git a/botx/clients/methods/errors/chat_not_found.py b/botx/clients/methods/errors/chat_not_found.py
deleted file mode 100644
index 78544d69..00000000
--- a/botx/clients/methods/errors/chat_not_found.py
+++ /dev/null
@@ -1,47 +0,0 @@
-"""Definition for "chat not found" error."""
-from typing import NoReturn
-from uuid import UUID
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class ChatNotFoundError(BotXAPIError):
- """Error for raising when chat not found."""
-
- message_template = "chat {group_chat_id} not found"
-
- #: ID of chat that was requested.
- group_chat_id: UUID
-
-
-class ChatNotFoundData(BaseModel):
- """Data for error when chat not found."""
-
- #: ID of chat that was requested.
- group_chat_id: UUID
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "chat creation error" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- ChatNotFoundError: raised always.
- """
- parsed_response = APIErrorResponse[ChatNotFoundData].parse_obj(response.json_body)
-
- error_data = parsed_response.error_data
- raise ChatNotFoundError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- group_chat_id=error_data.group_chat_id,
- )
diff --git a/botx/clients/methods/errors/files/__init__.py b/botx/clients/methods/errors/files/__init__.py
deleted file mode 100644
index 86529f8e..00000000
--- a/botx/clients/methods/errors/files/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for built-in error handlers for responses from BotX API."""
diff --git a/botx/clients/methods/errors/files/chat_not_found.py b/botx/clients/methods/errors/files/chat_not_found.py
deleted file mode 100644
index 1858b48f..00000000
--- a/botx/clients/methods/errors/files/chat_not_found.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""Definition for "chat not found" error."""
-from typing import NoReturn
-from uuid import UUID
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class ChatNotFoundError(BotXAPIError):
- """Error for raising when chat not found."""
-
- message_template = "{error_description}"
-
- #: description of error.
- error_description: str
-
-
-class ChatNotFoundData(BaseModel):
- """Data for error when chat not found."""
-
- #: ID of chat where file is from.
- group_chat_id: UUID
-
- #: description of error.
- error_description: str
-
- class Config:
- extra = "forbid"
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "chat not found" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- ChatNotFoundError: raised always.
- """
- parsed_response = APIErrorResponse[ChatNotFoundData].parse_obj(response.json_body)
-
- error_data = parsed_response.error_data
- raise ChatNotFoundError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- error_description=error_data.error_description,
- )
diff --git a/botx/clients/methods/errors/files/file_deleted.py b/botx/clients/methods/errors/files/file_deleted.py
deleted file mode 100644
index 8f3216db..00000000
--- a/botx/clients/methods/errors/files/file_deleted.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""Definition for "file deleted" error."""
-from typing import NoReturn
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class FileDeletedError(BotXAPIError):
- """Error for raising when file deleted."""
-
- message_template = "{error_description}"
-
- #: description of error.
- error_description: str
-
-
-class FileDeletedErrorData(BaseModel):
- """Data for error when file deleted."""
-
- #: link of deleted file.
- link: str
-
- #: description of error.
- error_description: str
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "file deleted" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- FileDeletedError: raised always.
- """
- parsed_response = APIErrorResponse[FileDeletedErrorData].parse_obj(
- response.json_body,
- )
-
- error_data = parsed_response.error_data
- raise FileDeletedError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- error_description=error_data.error_description,
- )
diff --git a/botx/clients/methods/errors/files/metadata_not_found.py b/botx/clients/methods/errors/files/metadata_not_found.py
deleted file mode 100644
index f1239583..00000000
--- a/botx/clients/methods/errors/files/metadata_not_found.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""Definition for "file metadata not found" error."""
-from typing import NoReturn
-from uuid import UUID
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class MetadataNotFoundError(BotXAPIError):
- """Error for raising when file metadata not found."""
-
- message_template = (
- "File with specified file_id `{file_id}` and "
- "group_chat_id `{group_chat_id}` not found in file service."
- )
-
- #: ID of file which metadata was requested.
- file_id: UUID
-
- #: ID of chat where file is from.
- group_chat_id: UUID
-
-
-class MetadataNotFoundData(BaseModel):
- """Data for error when file metadata not found."""
-
- #: ID of file which metadata was requested.
- file_id: UUID
-
- #: ID of chat where file is from.
- group_chat_id: UUID
-
- #: description of error.
- error_description: str
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "file metadata not found" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- MetadataNotFoundError: raised always.
- """
- parsed_response = APIErrorResponse[MetadataNotFoundData].parse_obj(
- response.json_body,
- )
-
- error_data = parsed_response.error_data
- raise MetadataNotFoundError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- file_id=error_data.file_id,
- group_chat_id=error_data.group_chat_id,
- )
diff --git a/botx/clients/methods/errors/files/without_preview.py b/botx/clients/methods/errors/files/without_preview.py
deleted file mode 100644
index e78c478e..00000000
--- a/botx/clients/methods/errors/files/without_preview.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""Definition for "without preview" error."""
-from typing import NoReturn
-from uuid import UUID
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class WithoutPreviewError(BotXAPIError):
- """Error for raising when there is no file preview."""
-
- message_template = "{error_description}"
-
- #: description of error.
- error_description: str
-
-
-class WithoutPreviewData(BaseModel):
- """Data for error when there is no file preview."""
-
- #: ID of file which preview was requested.
- file_id: UUID
-
- #: ID of chat where file is from.
- group_chat_id: UUID
-
- #: description of error.
- error_description: str
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "without preview" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- WithoutPreviewError: raised always.
- """
- parsed_response = APIErrorResponse[WithoutPreviewData].parse_obj(response.json_body)
-
- error_data = parsed_response.error_data
- raise WithoutPreviewError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- error_description=error_data.error_description,
- )
diff --git a/botx/clients/methods/errors/messaging.py b/botx/clients/methods/errors/messaging.py
deleted file mode 100644
index 03d806fb..00000000
--- a/botx/clients/methods/errors/messaging.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""Definition for "messaging" error."""
-from typing import NoReturn
-
-from botx.clients.methods.base import BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class MessagingError(BotXAPIError):
- """Error for raising when there is messaging error."""
-
- message_template = "error from messaging service"
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle messaging error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- MessagingError: raised always.
- """
- raise MessagingError(
- url=method.url,
- method=method.http_method,
- response=response.json_body,
- status=response.status_code,
- )
diff --git a/botx/clients/methods/errors/permissions.py b/botx/clients/methods/errors/permissions.py
deleted file mode 100644
index 0a37b99a..00000000
--- a/botx/clients/methods/errors/permissions.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""Definition for "no permission" error."""
-from typing import NoReturn
-from uuid import UUID
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class NoPermissionError(BotXAPIError):
- """Error for raising when there is no permission for operation."""
-
- message_template = (
- "Bot doesn't have permission for this operation in chat {group_chat_id}"
- )
-
- #: ID of chat that was requested.
- group_chat_id: UUID
-
-
-class NoPermissionErrorData(BaseModel):
- """Data for error when there is no permission for operation."""
-
- #: ID of chat that was requested.
- group_chat_id: UUID
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "no permission" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- NoPermissionError: raised always.
- """
- parsed_response = APIErrorResponse[NoPermissionErrorData].parse_obj(
- response.json_body,
- )
-
- error_data = parsed_response.error_data
- raise NoPermissionError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- group_chat_id=error_data.group_chat_id,
- )
diff --git a/botx/clients/methods/errors/stickers/__init__.py b/botx/clients/methods/errors/stickers/__init__.py
deleted file mode 100644
index 86529f8e..00000000
--- a/botx/clients/methods/errors/stickers/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for built-in error handlers for responses from BotX API."""
diff --git a/botx/clients/methods/errors/stickers/image_not_valid.py b/botx/clients/methods/errors/stickers/image_not_valid.py
deleted file mode 100644
index ecf6c191..00000000
--- a/botx/clients/methods/errors/stickers/image_not_valid.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""Definition for "image is not valid" error."""
-from typing import NoReturn
-
-from botx.clients.methods.base import BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class ImageNotValidError(BotXAPIError):
- """Error for raising when image is not valid."""
-
- message_template = "image is not valid"
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "image is not valid" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- ImageNotValidError: raised always.
- """
- raise ImageNotValidError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- )
diff --git a/botx/clients/methods/errors/stickers/sticker_pack_not_found.py b/botx/clients/methods/errors/stickers/sticker_pack_not_found.py
deleted file mode 100644
index 3e593eca..00000000
--- a/botx/clients/methods/errors/stickers/sticker_pack_not_found.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""Definition for "sticker pack was not found" error."""
-from typing import NoReturn
-from uuid import UUID
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class StickerPackNotFoundError(BotXAPIError):
- """Error for raising when sticker pack was not found."""
-
- message_template = "sticker pack {pack_id} not found"
-
- #: sticker pack ID.
- pack_id: UUID
-
-
-class StickerPackNotFoundData(BaseModel):
- """Data for error when sticker pack was not found."""
-
- #: sticker pack ID.
- pack_id: UUID
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "sticker pack getting error" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- StickerPackNotFoundError: raised always.
- """
- parsed_response = APIErrorResponse[StickerPackNotFoundData].parse_obj(
- response.json_body,
- )
-
- error_data = parsed_response.error_data
- raise StickerPackNotFoundError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- pack_id=error_data.pack_id,
- )
diff --git a/botx/clients/methods/errors/stickers/sticker_pack_or_sticker_not_found.py b/botx/clients/methods/errors/stickers/sticker_pack_or_sticker_not_found.py
deleted file mode 100644
index b6e354da..00000000
--- a/botx/clients/methods/errors/stickers/sticker_pack_or_sticker_not_found.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""Definition for "sticker pack or sticker was not found" error."""
-from typing import NoReturn
-from uuid import UUID
-
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class StickerPackOrStickerNotFoundError(BotXAPIError):
- """Error for raising when sticker pack or sticker was not found."""
-
- message_template = "sticker pack {pack_id} or sticker {sticker_id} was not found"
-
- #: sticker pack ID.
- pack_id: UUID
-
- #: sticker ID.
- sticker_id: UUID
-
-
-class StickerPackOrStickerNotFoundData(BaseModel):
- """Data for error when sticker pack or sticker was not found."""
-
- #: sticker pack ID.
- pack_id: UUID
-
- #: sticker ID.
- sticker_id: UUID
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "sticker getting error" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- StickerPackOrStickerNotFoundError: raised always.
- """
- parsed_response = APIErrorResponse[StickerPackOrStickerNotFoundData].parse_obj(
- response.json_body,
- )
-
- error_data = parsed_response.error_data
- raise StickerPackOrStickerNotFoundError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- pack_id=error_data.pack_id,
- sticker_id=error_data.sticker_id,
- )
diff --git a/botx/clients/methods/errors/unauthorized_bot.py b/botx/clients/methods/errors/unauthorized_bot.py
deleted file mode 100644
index a956dee6..00000000
--- a/botx/clients/methods/errors/unauthorized_bot.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""Definition for "invalid bot credentials" error."""
-from typing import NoReturn
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class InvalidBotCredentials(BotXAPIError):
- """Error for raising when got invalid bot credentials."""
-
- message_template = (
- "Can't get token for bot {bot_id}. Make sure bot credentials is correct"
- )
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "invalid bot credentials" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- InvalidBotCredentials: raised always.
- """
- APIErrorResponse[dict].parse_obj(response.json_body)
- raise InvalidBotCredentials(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- bot_id=method.bot_id, # type: ignore
- )
diff --git a/botx/clients/methods/errors/user_not_found.py b/botx/clients/methods/errors/user_not_found.py
deleted file mode 100644
index e146bae8..00000000
--- a/botx/clients/methods/errors/user_not_found.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Definition for "user not found" error."""
-from typing import NoReturn
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.clients.types.http import HTTPResponse
-from botx.exceptions import BotXAPIError
-
-
-class UserNotFoundError(BotXAPIError):
- """Error for raising when user not found."""
-
- message_template = "user not found"
-
-
-def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
- """Handle "user not found" error response.
-
- Arguments:
- method: method which was made before error.
- response: HTTP response from BotX API.
-
- Raises:
- UserNotFoundError: raised always.
- """
- APIErrorResponse[dict].parse_obj(response.json_body)
- raise UserNotFoundError(
- url=method.url,
- method=method.http_method,
- response_content=response.json_body,
- status_content=response.status_code,
- )
diff --git a/botx/clients/methods/extractors.py b/botx/clients/methods/extractors.py
deleted file mode 100644
index a7408d5a..00000000
--- a/botx/clients/methods/extractors.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Custom extractors for responses from BotX API."""
-from uuid import UUID
-
-from botx.clients.methods.base import BotXMethod
-from botx.clients.types.response_results import ChatCreatedResult, PushResult
-
-
-def extract_generated_sync_id(_method: BotXMethod, push: PushResult) -> UUID:
- """Extract generated sync ID from response.
-
- Arguments:
- _method: method that was used for making request.
- push: push response from BotX API for generated message.
-
- Returns:
- Extracted sync ID.
- """
- return push.sync_id
-
-
-def extract_generated_chat_id(_method: BotXMethod, response: ChatCreatedResult) -> UUID:
- """Extract generated sync ID from response.
-
- Arguments:
- _method: method that was used for making request.
- response: response for created chat.
-
- Returns:
- Extracted chat ID.
- """
- return response.chat_id
diff --git a/botx/clients/methods/v2/__init__.py b/botx/clients/methods/v2/__init__.py
deleted file mode 100644
index ecf8b531..00000000
--- a/botx/clients/methods/v2/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for V3 API methods for BotX API."""
diff --git a/botx/clients/methods/v2/bots/__init__.py b/botx/clients/methods/v2/bots/__init__.py
deleted file mode 100644
index 4d34b3c7..00000000
--- a/botx/clients/methods/v2/bots/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for methods for bots resource."""
diff --git a/botx/clients/methods/v2/bots/token.py b/botx/clients/methods/v2/bots/token.py
deleted file mode 100644
index 624411e8..00000000
--- a/botx/clients/methods/v2/bots/token.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Method for retrieving token for bot."""
-from http import HTTPStatus
-from typing import Dict
-from urllib.parse import urljoin
-from uuid import UUID
-
-from botx.clients.methods.base import BotXMethod, PrimitiveDataType
-from botx.clients.methods.errors import bot_not_found, unauthorized_bot
-
-
-class Token(BotXMethod[str]):
- """Method for retrieving token for bot."""
-
- __url__ = "/api/v2/botx/bots/{bot_id}/token"
- __method__ = "GET"
- __returning__ = str
- __errors_handlers__ = {
- HTTPStatus.NOT_FOUND: bot_not_found.handle_error,
- HTTPStatus.UNAUTHORIZED: unauthorized_bot.handle_error,
- }
-
- #: ID of bot which access for token.
- bot_id: UUID
-
- #: calculated signature from secret_key for bot.
- signature: str
-
- @property
- def url(self) -> str:
- """Full URL for request with filling bot_id."""
- api_url = self.__url__.format(bot_id=self.bot_id)
- return urljoin(super().url, api_url)
-
- @property
- def query_params(self) -> Dict[str, PrimitiveDataType]:
- """Query string query_params for request with signature key."""
- return {"signature": self.signature}
-
- def build_serialized_dict(self) -> None:
- """Return nothing to override dict body.
-
- Returns:
- Nothing.
- """
- return None # noqa: WPS324
diff --git a/botx/clients/methods/v3/__init__.py b/botx/clients/methods/v3/__init__.py
deleted file mode 100644
index ecf8b531..00000000
--- a/botx/clients/methods/v3/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for V3 API methods for BotX API."""
diff --git a/botx/clients/methods/v3/chats/__init__.py b/botx/clients/methods/v3/chats/__init__.py
deleted file mode 100644
index 9acbfb6f..00000000
--- a/botx/clients/methods/v3/chats/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for methods for chat resource."""
diff --git a/botx/clients/methods/v3/chats/add_admin_role.py b/botx/clients/methods/v3/chats/add_admin_role.py
deleted file mode 100644
index 1d587c42..00000000
--- a/botx/clients/methods/v3/chats/add_admin_role.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Method for promoting users to admins."""
-from http import HTTPStatus
-from typing import List
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import (
- bot_is_not_admin,
- chat_is_not_modifiable,
- chat_not_found,
-)
-
-
-class AddAdminRole(AuthorizedBotXMethod[bool]):
- """Method for promoting users to chat admins."""
-
- __url__ = "/api/v3/botx/chats/add_admin"
- __method__ = "POST"
- __returning__ = bool
- __errors_handlers__ = {
- HTTPStatus.FORBIDDEN: (
- bot_is_not_admin.handle_error,
- chat_is_not_modifiable.handle_error,
- ),
- HTTPStatus.NOT_FOUND: (chat_not_found.handle_error,),
- }
-
- #: ID of chat where action should be performed.
- group_chat_id: UUID
-
- #: IDs of users that should be promoted to admins.
- user_huids: List[UUID]
diff --git a/botx/clients/methods/v3/chats/add_user.py b/botx/clients/methods/v3/chats/add_user.py
deleted file mode 100644
index 5b60c521..00000000
--- a/botx/clients/methods/v3/chats/add_user.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Method for adding new users into chat."""
-from http import HTTPStatus
-from typing import List
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import (
- bot_is_not_admin,
- chat_is_not_modifiable,
- chat_not_found,
-)
-
-
-class AddUser(AuthorizedBotXMethod[bool]):
- """Method for adding new users into chat."""
-
- __url__ = "/api/v3/botx/chats/add_user"
- __method__ = "POST"
- __returning__ = bool
- __errors_handlers__ = {
- HTTPStatus.FORBIDDEN: (
- bot_is_not_admin.handle_error,
- chat_is_not_modifiable.handle_error,
- ),
- HTTPStatus.NOT_FOUND: (chat_not_found.handle_error,),
- }
-
- #: ID of chat into which users should be added.
- group_chat_id: UUID
-
- #: IDs of users that should be added into chat.
- user_huids: List[UUID]
diff --git a/botx/clients/methods/v3/chats/chat_list.py b/botx/clients/methods/v3/chats/chat_list.py
deleted file mode 100644
index 319ca358..00000000
--- a/botx/clients/methods/v3/chats/chat_list.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""Method for retrieving information about chat."""
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.models.chats import BotChatList
-
-
-class ChatList(AuthorizedBotXMethod[BotChatList]): # noqa: WPS110
- """Method for retrieving list of bot's chats."""
-
- __url__ = "/api/v3/botx/chats/list"
- __method__ = "GET"
- __returning__ = BotChatList
diff --git a/botx/clients/methods/v3/chats/create.py b/botx/clients/methods/v3/chats/create.py
deleted file mode 100644
index f1c562aa..00000000
--- a/botx/clients/methods/v3/chats/create.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""Method for creating new chat."""
-
-from http import HTTPStatus
-from typing import List, Optional
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import chat_creation_disallowed, chat_creation_error
-from botx.clients.methods.extractors import extract_generated_chat_id
-from botx.clients.types.response_results import ChatCreatedResult
-from botx.models.enums import ChatTypes
-
-
-class Create(AuthorizedBotXMethod[UUID]):
- """Method for creating new chat."""
-
- __url__ = "/api/v3/botx/chats/create"
- __method__ = "POST"
- __returning__ = ChatCreatedResult
- __result_extractor__ = extract_generated_chat_id
- __errors_handlers__ = {
- HTTPStatus.FORBIDDEN: chat_creation_disallowed.handle_error,
- HTTPStatus.UNPROCESSABLE_ENTITY: chat_creation_error.handle_error,
- }
-
- #: name of chat that should be created.
- name: str
-
- #: description of new chat.
- description: Optional[str] = None
-
- #: HUIDs of users that should be added into chat.
- members: List[UUID]
-
- #: logo image of chat.
- avatar: Optional[str] = None
-
- #: chat type.
- chat_type: ChatTypes
-
- #: chat history is available to newcomers.
- shared_history: bool
diff --git a/botx/clients/methods/v3/chats/info.py b/botx/clients/methods/v3/chats/info.py
deleted file mode 100644
index dd96e8f5..00000000
--- a/botx/clients/methods/v3/chats/info.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""Method for retrieving information about chat."""
-from http import HTTPStatus
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import messaging
-from botx.models.chats import ChatFromSearch
-
-
-class Info(AuthorizedBotXMethod[ChatFromSearch]): # noqa: WPS110
- """Method for retrieving information about chat."""
-
- __url__ = "/api/v3/botx/chats/info"
- __method__ = "GET"
- __returning__ = ChatFromSearch
- __errors_handlers__ = {HTTPStatus.BAD_REQUEST: messaging.handle_error}
-
- #: ID of chat for about which information should be retrieving.
- group_chat_id: UUID
diff --git a/botx/clients/methods/v3/chats/pin_message.py b/botx/clients/methods/v3/chats/pin_message.py
deleted file mode 100644
index 43ac73eb..00000000
--- a/botx/clients/methods/v3/chats/pin_message.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Method for pinning message in chat."""
-from http import HTTPStatus
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import chat_not_found, permissions
-
-
-class PinMessage(AuthorizedBotXMethod[str]):
- """Method for pinning message in chat."""
-
- __url__ = "/api/v3/botx/chats/pin_message"
- __method__ = "POST"
- __returning__ = str
- __errors_handlers__ = {
- HTTPStatus.NOT_FOUND: chat_not_found.handle_error,
- HTTPStatus.FORBIDDEN: permissions.handle_error,
- }
-
- #: ID of chat where message should be pinned.
- chat_id: UUID
-
- #: ID of message that should be pinned.
- sync_id: UUID
diff --git a/botx/clients/methods/v3/chats/remove_user.py b/botx/clients/methods/v3/chats/remove_user.py
deleted file mode 100644
index 641055ef..00000000
--- a/botx/clients/methods/v3/chats/remove_user.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Method for removing users from chat."""
-from http import HTTPStatus
-from typing import List
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import (
- bot_is_not_admin,
- chat_is_not_modifiable,
- chat_not_found,
-)
-
-
-class RemoveUser(AuthorizedBotXMethod[bool]):
- """Method for removing users from chat."""
-
- __url__ = "/api/v3/botx/chats/remove_user"
- __method__ = "POST"
- __returning__ = bool
- __errors_handlers__ = {
- HTTPStatus.FORBIDDEN: (
- bot_is_not_admin.handle_error,
- chat_is_not_modifiable.handle_error,
- ),
- HTTPStatus.NOT_FOUND: (chat_not_found.handle_error,),
- }
-
- #: ID of chat from which users should be removed.
- group_chat_id: UUID
-
- #: HUID of users that should be removed.
- user_huids: List[UUID]
diff --git a/botx/clients/methods/v3/chats/stealth_disable.py b/botx/clients/methods/v3/chats/stealth_disable.py
deleted file mode 100644
index 321eeacd..00000000
--- a/botx/clients/methods/v3/chats/stealth_disable.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""Method for disabling stealth in chat."""
-from http import HTTPStatus
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import bot_is_not_admin, chat_not_found
-
-
-class StealthDisable(AuthorizedBotXMethod[bool]):
- """Method for disabling stealth in chat."""
-
- __url__ = "/api/v3/botx/chats/stealth_disable"
- __method__ = "POST"
- __returning__ = bool
- __errors_handlers__ = {
- HTTPStatus.FORBIDDEN: bot_is_not_admin.handle_error,
- HTTPStatus.NOT_FOUND: chat_not_found.handle_error,
- }
-
- #: ID of chat where stealth should be disabled.
- group_chat_id: UUID
diff --git a/botx/clients/methods/v3/chats/stealth_set.py b/botx/clients/methods/v3/chats/stealth_set.py
deleted file mode 100644
index 93b26841..00000000
--- a/botx/clients/methods/v3/chats/stealth_set.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Method for enabling stealth in chat."""
-from http import HTTPStatus
-from typing import Optional
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import bot_is_not_admin, chat_not_found
-
-
-class StealthSet(AuthorizedBotXMethod[bool]):
- """Method for enabling stealth in chat."""
-
- __url__ = "/api/v3/botx/chats/stealth_set"
- __method__ = "POST"
- __returning__ = bool
- __errors_handlers__ = {
- HTTPStatus.FORBIDDEN: bot_is_not_admin.handle_error,
- HTTPStatus.NOT_FOUND: chat_not_found.handle_error,
- }
-
- #: ID of chat where stealth should be enabled.
- group_chat_id: UUID
-
- #: should messages be shown in web.
- disable_web: bool = False
-
- #: time of messages burning after read.
- burn_in: Optional[int] = None
-
- #: time of messages burning after send.
- expire_in: Optional[int] = None
diff --git a/botx/clients/methods/v3/chats/unpin_message.py b/botx/clients/methods/v3/chats/unpin_message.py
deleted file mode 100644
index 09ca4796..00000000
--- a/botx/clients/methods/v3/chats/unpin_message.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""Method for unpinning message in chat."""
-from http import HTTPStatus
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import chat_not_found, permissions
-
-
-class UnpinMessage(AuthorizedBotXMethod[str]):
- """Method for unpinning message in chat."""
-
- __url__ = "/api/v3/botx/chats/unpin_message"
- __method__ = "POST"
- __returning__ = str
- __errors_handlers__ = {
- HTTPStatus.NOT_FOUND: chat_not_found.handle_error,
- HTTPStatus.FORBIDDEN: permissions.handle_error,
- }
-
- #: ID of chat where message should be unpinned.
- chat_id: UUID
diff --git a/botx/clients/methods/v3/command/__init__.py b/botx/clients/methods/v3/command/__init__.py
deleted file mode 100644
index 1c917907..00000000
--- a/botx/clients/methods/v3/command/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for methods for command resource."""
diff --git a/botx/clients/methods/v3/command/command_result.py b/botx/clients/methods/v3/command/command_result.py
deleted file mode 100644
index 5132db72..00000000
--- a/botx/clients/methods/v3/command/command_result.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""Method for sending command result into chat."""
-
-from typing import Optional
-from uuid import UUID
-
-from pydantic import Field
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.extractors import extract_generated_sync_id
-from botx.clients.types.message_payload import ResultPayload
-from botx.clients.types.options import ResultOptions
-from botx.clients.types.response_results import PushResult
-from botx.models.files import File
-from botx.models.typing import AvailableRecipients
-
-
-class CommandResult(AuthorizedBotXMethod[UUID]):
- """Method for sending notification into many chats."""
-
- __url__ = "/api/v3/botx/command/callback"
- __method__ = "POST"
- __returning__ = PushResult
- __result_extractor__ = extract_generated_sync_id
-
- #: ID of event on which this answer will be sent.
- sync_id: UUID
-
- #: custom ID of new message.
- event_sync_id: Optional[UUID] = None
-
- #: users that will receive message.
- recipients: AvailableRecipients = "all"
-
- #: message payload.
- result: ResultPayload = Field(..., alias="command_result")
-
- #: attached file.
- file: Optional[File] = None
-
- #: extra options for new message.
- opts: ResultOptions = ResultOptions()
diff --git a/botx/clients/methods/v3/events/__init__.py b/botx/clients/methods/v3/events/__init__.py
deleted file mode 100644
index f7f18591..00000000
--- a/botx/clients/methods/v3/events/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for methods for events resource."""
diff --git a/botx/clients/methods/v3/events/edit_event.py b/botx/clients/methods/v3/events/edit_event.py
deleted file mode 100644
index 25ce6a3f..00000000
--- a/botx/clients/methods/v3/events/edit_event.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""Method for editing sent event."""
-from typing import Optional
-from uuid import UUID
-
-from pydantic import Field
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.types.message_payload import UpdatePayload
-from botx.clients.types.options import ResultOptions
-from botx.models.files import File
-
-
-class EditEvent(AuthorizedBotXMethod[str]):
- """Method for editing sent event."""
-
- __url__ = "/api/v3/botx/events/edit_event"
- __method__ = "POST"
- __returning__ = str
-
- #: ID of event that should be edited.
- sync_id: UUID
-
- #: data for editing.
- result: UpdatePayload = Field(..., alias="payload")
-
- #: file attached to message.
- file: Optional[File] = None
-
- #: extra options for message.
- opts: ResultOptions = ResultOptions()
diff --git a/botx/clients/methods/v3/events/reply_event.py b/botx/clients/methods/v3/events/reply_event.py
deleted file mode 100644
index b5717a99..00000000
--- a/botx/clients/methods/v3/events/reply_event.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Method for sending command result into chat."""
-
-from typing import Optional
-from uuid import UUID
-
-from pydantic import Field
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.types.message_payload import ResultPayload
-from botx.clients.types.options import ResultOptions
-from botx.models.files import File
-
-
-class ReplyEvent(AuthorizedBotXMethod[UUID]):
- """Method for sending reply message."""
-
- __url__ = "/api/v3/botx/events/reply_event"
- __method__ = "POST"
- __returning__ = str
-
- #: ID of message for reply.
- source_sync_id: Optional[UUID] = None
-
- #: message payload.
- result: ResultPayload = Field(..., alias="reply")
-
- #: attached file.
- file: Optional[File] = None
-
- #: extra options for new message.
- opts: ResultOptions = ResultOptions()
diff --git a/botx/clients/methods/v3/files/__init__.py b/botx/clients/methods/v3/files/__init__.py
deleted file mode 100644
index 2ad05f3b..00000000
--- a/botx/clients/methods/v3/files/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for methods for files resource."""
diff --git a/botx/clients/methods/v3/files/download.py b/botx/clients/methods/v3/files/download.py
deleted file mode 100644
index a9a8a0c0..00000000
--- a/botx/clients/methods/v3/files/download.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""Method for downloading file from chat."""
-from http import HTTPStatus
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors.files import (
- chat_not_found,
- file_deleted,
- metadata_not_found,
- without_preview,
-)
-from botx.clients.types.http import ExpectedType, HTTPRequest
-from botx.models.files import File
-
-
-class DownloadFile(AuthorizedBotXMethod[File]):
- """Method for downloading a file from chat."""
-
- __url__ = "/api/v3/botx/files/download"
- __method__ = "GET"
- __returning__ = File
- __expected_type__ = ExpectedType.BINARY
- __errors_handlers__ = {
- HTTPStatus.NO_CONTENT: (file_deleted.handle_error,),
- HTTPStatus.BAD_REQUEST: (without_preview.handle_error,),
- HTTPStatus.NOT_FOUND: (
- chat_not_found.handle_error,
- metadata_not_found.handle_error,
- ),
- }
-
- #: ID of the chat with file.
- group_chat_id: UUID
-
- #: ID of the file for downloading.
- file_id: UUID
-
- #: preview or file.
- is_preview: bool
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- request_params = self.build_serialized_dict()
-
- return HTTPRequest.construct(
- method=self.http_method,
- url=self.url,
- headers=self.headers,
- query_params=dict(request_params), # type: ignore
- json_body={},
- expected_type=self.expected_type,
- should_process_as_error=[HTTPStatus.NO_CONTENT],
- )
diff --git a/botx/clients/methods/v3/files/upload.py b/botx/clients/methods/v3/files/upload.py
deleted file mode 100644
index 7f5e7a41..00000000
--- a/botx/clients/methods/v3/files/upload.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""Method for uploading file to chat."""
-from http import HTTPStatus
-from typing import Dict
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors.files import chat_not_found
-from botx.clients.types.http import HTTPRequest
-from botx.clients.types.upload_file import UploadingFileMeta
-from botx.models.files import File, MetaFile
-
-
-class UploadFile(AuthorizedBotXMethod[MetaFile]):
- """Method for uploading file to a chat."""
-
- __url__ = "/api/v3/botx/files/upload"
- __method__ = "POST"
- __returning__ = MetaFile
- __errors_handlers__ = {
- HTTPStatus.NOT_FOUND: (chat_not_found.handle_error,),
- }
-
- #: ID of the chat.
- group_chat_id: UUID
-
- #: file for uploading.
- file: File
-
- #: file metadata.
- meta: UploadingFileMeta
-
- @property
- def headers(self) -> Dict[str, str]:
- """Headers that should be used in request."""
- headers = super().headers
- # used to enable the client to attach a Content-Type with a boundary
- headers.pop("Content-Type")
- return headers
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- files = {"content": (self.file.file_name, self.file.file)}
- request_data = {
- "group_chat_id": str(self.group_chat_id),
- "meta": self.meta.json(),
- }
-
- return HTTPRequest(
- method=self.http_method,
- url=self.url,
- headers=self.headers,
- query_params=self.query_params,
- json_body={},
- data=request_data,
- files=files, # type: ignore
- )
diff --git a/botx/clients/methods/v3/notification/__init__.py b/botx/clients/methods/v3/notification/__init__.py
deleted file mode 100644
index dabef1cb..00000000
--- a/botx/clients/methods/v3/notification/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for methods for notification resource."""
diff --git a/botx/clients/methods/v3/notification/direct_notification.py b/botx/clients/methods/v3/notification/direct_notification.py
deleted file mode 100644
index 6e3af91f..00000000
--- a/botx/clients/methods/v3/notification/direct_notification.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""Method for sending notification into single chat."""
-from typing import Optional
-from uuid import UUID
-
-from pydantic import Field
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.extractors import extract_generated_sync_id
-from botx.clients.types.message_payload import ResultPayload
-from botx.clients.types.options import ResultOptions
-from botx.clients.types.response_results import PushResult
-from botx.models.files import File
-from botx.models.typing import AvailableRecipients
-
-
-class NotificationDirect(AuthorizedBotXMethod[UUID]):
- """Method for sending notification into single chat."""
-
- __url__ = "/api/v3/botx/notification/callback/direct"
- __method__ = "POST"
- __returning__ = PushResult
- __result_extractor__ = extract_generated_sync_id
-
- #: ID of chat for new notification.
- group_chat_id: UUID
-
- #: custom ID for message.
- event_sync_id: Optional[UUID] = None
-
- #: HUIDs of users that should receive notifications.
- recipients: AvailableRecipients = "all"
-
- #: data for build message: body, markup, mentions.
- result: ResultPayload = Field(..., alias="notification")
-
- #: attached file for message.
- file: Optional[File] = None
-
- #: extra options for message.
- opts: ResultOptions = ResultOptions()
diff --git a/botx/clients/methods/v3/notification/notification.py b/botx/clients/methods/v3/notification/notification.py
deleted file mode 100644
index 1076d683..00000000
--- a/botx/clients/methods/v3/notification/notification.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""Method for sending notification into many chats."""
-from typing import List, Optional
-from uuid import UUID
-
-from pydantic import Field
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.types.message_payload import ResultPayload
-from botx.clients.types.options import ResultOptions
-from botx.models.files import File
-from botx.models.typing import AvailableRecipients
-
-
-class Notification(AuthorizedBotXMethod[str]):
- """Method for sending notification into many chats."""
-
- __url__ = "/api/v3/botx/notification/callback"
- __method__ = "POST"
- __returning__ = str
-
- #: IDs of chats for new notification.
- group_chat_ids: List[UUID] = []
-
- #: HUIDs of users that should receive notifications.
- recipients: AvailableRecipients = "all"
-
- #: data for build message: body, markup, mentions.
- result: ResultPayload = Field(..., alias="notification")
-
- #: attached file for message.
- file: Optional[File] = None
-
- #: extra options for message.
- opts: ResultOptions = ResultOptions()
diff --git a/botx/clients/methods/v3/smartapps/__init__.py b/botx/clients/methods/v3/smartapps/__init__.py
deleted file mode 100644
index 1e1bdd78..00000000
--- a/botx/clients/methods/v3/smartapps/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for smartapp methods."""
diff --git a/botx/clients/methods/v3/smartapps/smartapp_event.py b/botx/clients/methods/v3/smartapps/smartapp_event.py
deleted file mode 100644
index c4a0e1c0..00000000
--- a/botx/clients/methods/v3/smartapps/smartapp_event.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""Method for sending smartapp event."""
-from typing import Any, Dict, List, Optional
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.models.files import File, MetaFile
-
-
-class SmartAppEvent(AuthorizedBotXMethod[str]):
- """Method for sending smartapp events."""
-
- __url__ = "/api/v3/botx/smartapps/event"
- __method__ = "POST"
- __returning__ = str
-
- #: unique request id
- ref: Optional[UUID] = None
-
- #: smartapp id
- smartapp_id: UUID
-
- #: event data
- data: Dict[str, Any] # noqa: WPS110
-
- #: event options
- opts: Dict[str, Any] = {}
-
- #: version of protocol smartapp <-> bot
- smartapp_api_version: int
-
- #: smartapp chat
- group_chat_id: Optional[UUID]
-
- #: files
- files: List[File] = []
-
- #: file's meta to upload
- async_files: List[MetaFile] = []
diff --git a/botx/clients/methods/v3/smartapps/smartapp_notification.py b/botx/clients/methods/v3/smartapps/smartapp_notification.py
deleted file mode 100644
index 574beddb..00000000
--- a/botx/clients/methods/v3/smartapps/smartapp_notification.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""Method for sending smartapp event."""
-from typing import Any, Dict, Optional
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-
-
-class SmartAppNotification(AuthorizedBotXMethod[str]):
- """Method for sending smartapp notifications."""
-
- __url__ = "/api/v3/botx/smartapps/notification"
- __method__ = "POST"
- __returning__ = str
-
- #: smartapp chat
- group_chat_id: Optional[UUID]
-
- #: unread notifications count
- smartapp_counter: int
-
- #: event options
- opts: Dict[str, Any] = {}
-
- #: version of protocol smartapp <-> bot
- smartapp_api_version: int
diff --git a/botx/clients/methods/v3/stickers/__init__.py b/botx/clients/methods/v3/stickers/__init__.py
deleted file mode 100644
index 1ec2ca1d..00000000
--- a/botx/clients/methods/v3/stickers/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for methods for sticker resource."""
diff --git a/botx/clients/methods/v3/stickers/add_sticker.py b/botx/clients/methods/v3/stickers/add_sticker.py
deleted file mode 100644
index 4891e3a6..00000000
--- a/botx/clients/methods/v3/stickers/add_sticker.py
+++ /dev/null
@@ -1,55 +0,0 @@
-"""Method for adding stickers into sticker pack."""
-from http import HTTPStatus
-from urllib.parse import urljoin
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors.stickers import image_not_valid, sticker_pack_not_found
-from botx.clients.types.http import HTTPRequest
-from botx.models.stickers import Sticker
-
-
-class AddSticker(AuthorizedBotXMethod[Sticker]):
- """Method for adding stickers into sticker pack."""
-
- __url__ = "/api/v3/botx/stickers/packs/{pack_id}/stickers/"
- __method__ = "POST"
- __returning__ = Sticker
- __errors_handlers__ = {
- HTTPStatus.BAD_REQUEST: (
- sticker_pack_not_found.handle_error,
- image_not_valid.handle_error,
- ),
- }
-
- #: sticker pack ID.
- pack_id: UUID
-
- #: emoji that the sticker will be associated with.
- emoji: str
-
- #: sticker image.
- image: str
-
- @property
- def url(self) -> str:
- """Full URL for request with filling pack_id."""
- api_url = self.__url__.format(pack_id=self.pack_id)
- return urljoin(super().url, api_url)
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- request_params = self.build_serialized_dict()
-
- return HTTPRequest.construct(
- method=self.http_method,
- url=self.url,
- headers=self.headers,
- query_params={},
- json_body=dict(request_params), # type: ignore
- expected_type=self.expected_type,
- )
diff --git a/botx/clients/methods/v3/stickers/create_sticker_pack.py b/botx/clients/methods/v3/stickers/create_sticker_pack.py
deleted file mode 100644
index 78a20f5b..00000000
--- a/botx/clients/methods/v3/stickers/create_sticker_pack.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""Method for creating new sticker pack."""
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.types.http import HTTPRequest
-from botx.models.stickers import StickerPack
-
-
-class CreateStickerPack(AuthorizedBotXMethod[StickerPack]):
- """Method for creating sticker pack."""
-
- __url__ = "/api/v3/botx/stickers/packs"
- __method__ = "POST"
- __returning__ = StickerPack
-
- #: sticker pack name.
- name: str
-
- #: author HUID.
- user_huid: UUID
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- request_params = self.build_serialized_dict()
-
- return HTTPRequest.construct(
- method=self.http_method,
- url=self.url,
- headers=self.headers,
- query_params=dict(request_params), # type: ignore
- json_body={},
- expected_type=self.expected_type,
- )
diff --git a/botx/clients/methods/v3/stickers/delete_sticker.py b/botx/clients/methods/v3/stickers/delete_sticker.py
deleted file mode 100644
index b6e89664..00000000
--- a/botx/clients/methods/v3/stickers/delete_sticker.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""Method for deleting sticker from sticker pack."""
-from http import HTTPStatus
-from urllib.parse import urljoin
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors.stickers import sticker_pack_or_sticker_not_found
-from botx.clients.types.http import HTTPRequest
-
-
-class DeleteSticker(AuthorizedBotXMethod[str]):
- """Method for deleting sticker from sticker pack."""
-
- __url__ = "/api/v3/botx/stickers/packs/{pack_id}/stickers/{sticker_id}"
- __method__ = "DELETE"
- __returning__ = str
- __errors_handlers__ = {
- HTTPStatus.NOT_FOUND: (sticker_pack_or_sticker_not_found.handle_error,),
- }
-
- # : sticker pack ID.
- pack_id: UUID
-
- # : sticker ID.
- sticker_id: UUID
-
- @property
- def url(self) -> str:
- """Full URL for request with filling pack_id."""
- api_url = self.__url__.format(pack_id=self.pack_id, sticker_id=self.sticker_id)
- return urljoin(super().url, api_url)
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- request_params = self.build_serialized_dict()
-
- return HTTPRequest.construct(
- method=self.http_method,
- url=self.url,
- headers=self.headers,
- query_params=request_params, # type: ignore
- json_body={},
- expected_type=self.expected_type,
- )
diff --git a/botx/clients/methods/v3/stickers/delete_sticker_pack.py b/botx/clients/methods/v3/stickers/delete_sticker_pack.py
deleted file mode 100644
index 33f9a8b6..00000000
--- a/botx/clients/methods/v3/stickers/delete_sticker_pack.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Method for deleting sticker pack."""
-from http import HTTPStatus
-from urllib.parse import urljoin
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors.stickers import sticker_pack_not_found
-from botx.clients.types.http import HTTPRequest
-
-
-class DeleteStickerPack(AuthorizedBotXMethod[str]):
- """Method for deleting sticker pack."""
-
- __url__ = "/api/v3/botx/stickers/packs/{pack_id}"
- __method__ = "DELETE"
- __returning__ = str
- __errors_handlers__ = {
- HTTPStatus.NOT_FOUND: (sticker_pack_not_found.handle_error,),
- }
-
- # : sticker pack ID.
- pack_id: UUID
-
- @property
- def url(self) -> str:
- """Full URL for request with filling pack_id."""
- api_url = self.__url__.format(pack_id=self.pack_id)
- return urljoin(super().url, api_url)
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- request_params = self.build_serialized_dict()
-
- return HTTPRequest.construct(
- method=self.http_method,
- url=self.url,
- headers=self.headers,
- query_params=request_params, # type: ignore
- json_body={},
- expected_type=self.expected_type,
- )
diff --git a/botx/clients/methods/v3/stickers/edit_sticker_pack.py b/botx/clients/methods/v3/stickers/edit_sticker_pack.py
deleted file mode 100644
index c679750f..00000000
--- a/botx/clients/methods/v3/stickers/edit_sticker_pack.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""Method for editing sticker pack."""
-from http import HTTPStatus
-from typing import List, Optional
-from urllib.parse import urljoin
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors.stickers import sticker_pack_not_found
-from botx.clients.types.http import HTTPRequest
-from botx.models.stickers import StickerPack
-
-
-class EditStickerPack(AuthorizedBotXMethod[StickerPack]):
- """Method for editing sticker pack."""
-
- __url__ = "/api/v3/botx/stickers/packs/{pack_id}"
- __method__ = "PUT"
- __returning__ = StickerPack
- __errors_handlers__ = {
- HTTPStatus.NOT_FOUND: (sticker_pack_not_found.handle_error,),
- }
-
- # : sticker pack ID.
- pack_id: UUID
-
- # : sticker pack name.
- name: str
-
- #: sticker pack preview.
- preview: Optional[UUID]
-
- #: stickers order in sticker pack.
- stickers_order: Optional[List[UUID]]
-
- @property
- def url(self) -> str:
- """Full URL for request with filling pack_id."""
- api_url = self.__url__.format(pack_id=self.pack_id)
- return urljoin(super().url, api_url)
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- request_params = self.build_serialized_dict()
-
- return HTTPRequest.construct(
- method=self.http_method,
- url=self.url,
- headers=self.headers,
- query_params={},
- json_body=request_params,
- expected_type=self.expected_type,
- )
diff --git a/botx/clients/methods/v3/stickers/sticker.py b/botx/clients/methods/v3/stickers/sticker.py
deleted file mode 100644
index d50efe7d..00000000
--- a/botx/clients/methods/v3/stickers/sticker.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""Method for getting sticker from sticker pack."""
-from http import HTTPStatus
-from urllib.parse import urljoin
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors.stickers import sticker_pack_or_sticker_not_found
-from botx.clients.types.http import HTTPRequest
-from botx.models.stickers import StickerFromPack
-
-
-class GetSticker(AuthorizedBotXMethod[StickerFromPack]):
- """Method for getting sticker from sticker pack."""
-
- __url__ = "/api/v3/botx/stickers/packs/{pack_id}/stickers/{sticker_id}"
- __method__ = "GET"
- __returning__ = StickerFromPack
- __errors_handlers__ = {
- HTTPStatus.NOT_FOUND: (sticker_pack_or_sticker_not_found.handle_error,),
- }
-
- #: sticker pack ID.
- pack_id: UUID
-
- #: sticker ID.
- sticker_id: UUID
-
- @property
- def url(self) -> str:
- """Full URL for request with filling pack_id."""
- api_url = self.__url__.format(pack_id=self.pack_id, sticker_id=self.sticker_id)
- return urljoin(super().url, api_url)
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- request_params = self.build_serialized_dict()
-
- return HTTPRequest.construct(
- method=self.http_method,
- url=self.url,
- headers=self.headers,
- query_params=request_params, # type: ignore
- json_body={},
- expected_type=self.expected_type,
- )
diff --git a/botx/clients/methods/v3/stickers/sticker_pack.py b/botx/clients/methods/v3/stickers/sticker_pack.py
deleted file mode 100644
index 03ee1c5f..00000000
--- a/botx/clients/methods/v3/stickers/sticker_pack.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""Method for getting sticker pack."""
-from http import HTTPStatus
-from urllib.parse import urljoin
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors.stickers import sticker_pack_not_found
-from botx.clients.types.http import HTTPRequest
-from botx.models.stickers import StickerPack
-
-
-class GetStickerPack(AuthorizedBotXMethod[StickerPack]):
- """Method for getting sticker pack."""
-
- __url__ = "/api/v3/botx/stickers/packs/{pack_id}"
- __method__ = "GET"
- __returning__ = StickerPack
- __errors_handlers__ = {
- HTTPStatus.NOT_FOUND: (sticker_pack_not_found.handle_error,),
- }
-
- #: sticker pack ID.
- pack_id: UUID
-
- @property
- def url(self) -> str:
- """Full URL for request with filling pack_id."""
- api_url = self.__url__.format(pack_id=self.pack_id)
- return urljoin(super().url, api_url)
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- request_params = self.build_serialized_dict()
-
- return HTTPRequest.construct(
- method=self.http_method,
- url=self.url,
- headers=self.headers,
- query_params=request_params, # type: ignore
- json_body={},
- expected_type=self.expected_type,
- )
diff --git a/botx/clients/methods/v3/stickers/sticker_pack_list.py b/botx/clients/methods/v3/stickers/sticker_pack_list.py
deleted file mode 100644
index 56c44999..00000000
--- a/botx/clients/methods/v3/stickers/sticker_pack_list.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""Method for getting sticker pack list."""
-from typing import Optional
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.types.http import HTTPRequest
-from botx.models.stickers import StickerPackList
-
-
-class GetStickerPackList(AuthorizedBotXMethod[StickerPackList]):
- """Method for getting sticker pack list."""
-
- __url__ = "/api/v3/botx/stickers/packs"
- __method__ = "GET"
- __returning__ = StickerPackList
-
- #: author HUID.
- user_huid: Optional[UUID]
-
- #: returning value count.
- limit: int
-
- #: cursor hash for pagination.
- after: Optional[str] = None
-
- def build_http_request(self) -> HTTPRequest:
- """Build HTTP request that can be used by clients for making real requests.
-
- Returns:
- Built HTTP request.
- """
- request_params = self.build_serialized_dict()
-
- return HTTPRequest.construct(
- method=self.http_method,
- url=self.url,
- headers=self.headers,
- query_params=dict(request_params), # type: ignore
- json_body={},
- expected_type=self.expected_type,
- )
diff --git a/botx/clients/methods/v3/users/__init__.py b/botx/clients/methods/v3/users/__init__.py
deleted file mode 100644
index db4e74a6..00000000
--- a/botx/clients/methods/v3/users/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for methods for users resource."""
diff --git a/botx/clients/methods/v3/users/by_email.py b/botx/clients/methods/v3/users/by_email.py
deleted file mode 100644
index fed0c91b..00000000
--- a/botx/clients/methods/v3/users/by_email.py
+++ /dev/null
@@ -1,18 +0,0 @@
-"""Method for searching user by his email."""
-from http import HTTPStatus
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import user_not_found
-from botx.models.users import UserFromSearch
-
-
-class ByEmail(AuthorizedBotXMethod[UserFromSearch]):
- """Method for searching user by his email."""
-
- __url__ = "/api/v3/botx/users/by_email"
- __method__ = "GET"
- __returning__ = UserFromSearch
- __errors_handlers__ = {HTTPStatus.NOT_FOUND: user_not_found.handle_error}
-
- #: email to search
- email: str
diff --git a/botx/clients/methods/v3/users/by_huid.py b/botx/clients/methods/v3/users/by_huid.py
deleted file mode 100644
index 4cf3e404..00000000
--- a/botx/clients/methods/v3/users/by_huid.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""Method for searching user by his HUID."""
-from http import HTTPStatus
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import user_not_found
-from botx.models.users import UserFromSearch
-
-
-class ByHUID(AuthorizedBotXMethod[UserFromSearch]):
- """Method for searching user by his HUID."""
-
- __url__ = "/api/v3/botx/users/by_huid"
- __method__ = "GET"
- __returning__ = UserFromSearch
- __errors_handlers__ = {HTTPStatus.NOT_FOUND: user_not_found.handle_error}
-
- #: HUID to search
- user_huid: UUID
diff --git a/botx/clients/methods/v3/users/by_login.py b/botx/clients/methods/v3/users/by_login.py
deleted file mode 100644
index df05b731..00000000
--- a/botx/clients/methods/v3/users/by_login.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""Method for searching user by his AD credentials."""
-from http import HTTPStatus
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.errors import user_not_found
-from botx.models.users import UserFromSearch
-
-
-class ByLogin(AuthorizedBotXMethod[UserFromSearch]):
- """Method for searching user by his AD credentials."""
-
- __url__ = "/api/v3/botx/users/by_login"
- __method__ = "GET"
- __returning__ = UserFromSearch
- __errors_handlers__ = {HTTPStatus.NOT_FOUND: user_not_found.handle_error}
-
- #: AD login to search
- ad_login: str
-
- #: AD domain to search
- ad_domain: str
diff --git a/botx/clients/methods/v4/__init__.py b/botx/clients/methods/v4/__init__.py
deleted file mode 100644
index 99f9d1af..00000000
--- a/botx/clients/methods/v4/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for V4 API methods for BotX API."""
diff --git a/botx/clients/methods/v4/notifications/__init__.py b/botx/clients/methods/v4/notifications/__init__.py
deleted file mode 100644
index 1506ec03..00000000
--- a/botx/clients/methods/v4/notifications/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for methods for notifications resource."""
diff --git a/botx/clients/methods/v4/notifications/internal_bot_notification.py b/botx/clients/methods/v4/notifications/internal_bot_notification.py
deleted file mode 100644
index 1b0d5144..00000000
--- a/botx/clients/methods/v4/notifications/internal_bot_notification.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""Method for sending internal bot notification."""
-from typing import Any, Dict, List, Optional
-from uuid import UUID
-
-from botx.clients.methods.base import AuthorizedBotXMethod
-from botx.clients.methods.extractors import extract_generated_sync_id
-from botx.clients.types.message_payload import InternalBotNotificationPayload
-from botx.clients.types.response_results import InternalBotNotificationResult
-
-
-class InternalBotNotification(AuthorizedBotXMethod[UUID]):
- """Method for sending internal bot notification."""
-
- __url__ = "/api/v4/botx/notifications/internal"
- __method__ = "POST"
- __returning__ = InternalBotNotificationResult
- __result_extractor__ = extract_generated_sync_id
-
- #: IDs of chats for new notification.
- group_chat_id: UUID
-
- #: HUIDs of bots that should receive notifications (None for all bots in chat).
- recipients: Optional[List[UUID]] = None
-
- # notification payload
- data: InternalBotNotificationPayload # noqa: WPS110
-
- #: extra options for message.
- opts: Dict[str, Any] = {}
diff --git a/botx/clients/types/__init__.py b/botx/clients/types/__init__.py
deleted file mode 100644
index bdf2c69a..00000000
--- a/botx/clients/types/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Types that are used only for communicating with BotX API."""
diff --git a/botx/clients/types/http.py b/botx/clients/types/http.py
deleted file mode 100644
index aae73d5d..00000000
--- a/botx/clients/types/http.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Custom wrapper for HTTP request for BotX API."""
-from enum import Enum
-from io import BytesIO
-from typing import Any, Dict, List, Optional, Tuple, Union
-
-from pydantic import BaseModel, root_validator
-
-PrimitiveDataType = Union[None, str, int, float, bool]
-
-
-class ExpectedType(Enum):
- """Expected types of response body."""
-
- JSON = "JSON" # noqa: WPS115
- BINARY = "BINARY" # noqa: WPS115
-
-
-class HTTPRequest(BaseModel):
- """Wrapper for HTTP request."""
-
- #: HTTP method.
- method: str
-
- #: URL for request.
- url: str
-
- #: headers for request.
- headers: Dict[str, str]
-
- #: query params for request.
- query_params: Dict[str, PrimitiveDataType]
-
- #: request body.
- json_body: Optional[Dict[str, Any]]
-
- #: form data.
- data: Optional[Dict[str, Any]] = None # noqa: WPS110
-
- #: file for httpx in {field_name: (file_name, file_content)}.
- files: Optional[Dict[str, Tuple[str, BytesIO]]] = None # noqa: WPS234
-
- #: expected type of response body.
- expected_type: ExpectedType = ExpectedType.JSON
-
- # This field is used to provide handlers that are not in the range of 400 to 599.
- #: extra error codes.
- should_process_as_error: List[int] = []
-
- class Config:
- arbitrary_types_allowed = True
-
-
-class HTTPResponse(BaseModel):
- """Wrapper for HTTP response."""
-
- #: response headers
- headers: Dict[str, str]
-
- #: response status code.
- status_code: int
-
- #: response body.
- json_body: Optional[Dict[str, Any]] = None
-
- #: response raw data.
- raw_data: Optional[bytes] = None
-
- @property
- def is_redirect(self) -> bool:
- """Is redirect status code.
-
- Returns:
- Check result.
- """
- return 300 <= self.status_code < 399 # noqa: WPS432
-
- @property
- def is_error(self) -> bool:
- """Is error status code.
-
- Returns:
- Check result.
- """
- return 400 <= self.status_code < 599 # noqa: WPS432
-
- @root_validator(pre=True)
- def check_fields(cls, values: Any) -> Any: # noqa: N805, WPS110
- """Check if passed both `json_body` and `raw_data`."""
- json_body, raw_data = values.get("json_body"), values.get("raw_data")
- if (json_body is not None) and (raw_data is not None):
- raise ValueError("you cannot pass both `json_body` and `raw_data`.")
-
- return values
diff --git a/botx/clients/types/message_payload.py b/botx/clients/types/message_payload.py
deleted file mode 100644
index dd4c8c25..00000000
--- a/botx/clients/types/message_payload.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""Shape that is used for messages from bot."""
-from typing import Any, Dict, List, Optional
-
-from pydantic import BaseModel, Field
-
-from botx.models.constants import MAXIMUM_TEXT_LENGTH
-from botx.models.entities import Mention
-from botx.models.enums import Statuses
-from botx.models.messages.sending.options import ResultPayloadOptions
-from botx.models.typing import BubbleMarkup, KeyboardMarkup
-
-try:
- from typing import Literal # noqa: WPS433
-except ImportError:
- from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401
-
-
-class ResultPayload(BaseModel):
- """Data that is sent when bot answers on command or send notification."""
-
- #: status of operation.
- status: Literal[Statuses.ok] = Statuses.ok
-
- #: body for new message from bot.
- body: str = Field("", max_length=MAXIMUM_TEXT_LENGTH)
-
- #: message metadata.
- metadata: Dict[str, Any] = {}
-
- #: options for `notification` and `command_result` API entities.
- opts: ResultPayloadOptions = ResultPayloadOptions()
-
- #: keyboard that will be used for new message.
- keyboard: KeyboardMarkup = []
-
- #: bubble elements that will be showed under new message.
- bubble: BubbleMarkup = []
-
- #: mentions that BotX API will append before new message text.
- mentions: List[Mention] = []
-
-
-class UpdatePayload(BaseModel):
- """Data that is sent when bot updates message."""
-
- #: status of operation.
- status: Literal[Statuses.ok] = Statuses.ok
-
- #: new body in message.
- body: Optional[str] = Field(None, max_length=MAXIMUM_TEXT_LENGTH)
-
- #: message metadata.
- metadata: Optional[Dict[str, Any]] = None
-
- #: new keyboard that will be used for new message.
- keyboard: Optional[KeyboardMarkup] = None
-
- #: new bubble elements that will be showed under new message.
- bubble: Optional[BubbleMarkup] = None
-
- #: new mentions that BotX API will append before new message text.
- mentions: Optional[List[Mention]] = None
-
-
-class InternalBotNotificationPayload(BaseModel):
- """Data that is sent in internal bot notification."""
-
- #: message data
- message: str
-
- #: extra information about notification sender
- sender: Optional[str]
diff --git a/botx/clients/types/options.py b/botx/clients/types/options.py
deleted file mode 100644
index 5059b12e..00000000
--- a/botx/clients/types/options.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""Special options for messages from bot."""
-from pydantic import BaseModel
-
-from botx.models.messages.sending.options import NotificationOptions
-
-
-class ResultOptions(BaseModel):
- """Configuration for command result or notification that is send to BotX API."""
-
- #: send message only when stealth mode is enabled.
- stealth_mode: bool = False
-
- #: use in-text mentions
- raw_mentions: bool = False
-
- #: message options for configuring notifications.
- notification_opts: NotificationOptions = NotificationOptions()
diff --git a/botx/clients/types/response_results.py b/botx/clients/types/response_results.py
deleted file mode 100644
index 506e830b..00000000
--- a/botx/clients/types/response_results.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""Responses from BotX API."""
-from uuid import UUID
-
-from pydantic import BaseModel
-
-
-class PushResult(BaseModel):
- """Entity that contains result from notification or command result push."""
-
- #: event id of pushed message.
- sync_id: UUID
-
-
-class ChatCreatedResult(BaseModel):
- """Entity that contains result from chat creation."""
-
- #: id of created chat.
- chat_id: UUID
-
-
-class InternalBotNotificationResult(BaseModel):
- """Entity that contains result from internal bot notification."""
-
- #: event id of pushed message.
- sync_id: UUID
diff --git a/botx/clients/types/upload_file.py b/botx/clients/types/upload_file.py
deleted file mode 100644
index 1847c422..00000000
--- a/botx/clients/types/upload_file.py
+++ /dev/null
@@ -1,14 +0,0 @@
-"""Uploading file metadata."""
-from typing import Optional
-
-from pydantic import BaseModel
-
-
-class UploadingFileMeta(BaseModel):
- """Uploading file metadata."""
-
- #: duration of media file
- duration: Optional[int] = None
-
- #: caption of media file
- caption: Optional[str] = None
diff --git a/botx/collecting/__init__.py b/botx/collecting/__init__.py
deleted file mode 100644
index b20abee9..00000000
--- a/botx/collecting/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition of command handlers and routing mechanism."""
diff --git a/botx/collecting/collectors/__init__.py b/botx/collecting/collectors/__init__.py
deleted file mode 100644
index fc68d408..00000000
--- a/botx/collecting/collectors/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for collecting logic."""
diff --git a/botx/collecting/collectors/base.py b/botx/collecting/collectors/base.py
deleted file mode 100644
index 24784382..00000000
--- a/botx/collecting/collectors/base.py
+++ /dev/null
@@ -1,202 +0,0 @@
-"""Definition for base collector."""
-
-from dataclasses import InitVar, field
-from typing import Any, Callable, List, Optional, Sequence, Union
-
-from pydantic.dataclasses import dataclass
-
-from botx import converters
-from botx.collecting.handlers.handler import Handler
-from botx.collecting.handlers.name_generators import (
- get_body_from_name,
- get_name_from_callable,
-)
-from botx.dependencies import models as deps
-from botx.dependencies.models import Depends
-from botx.exceptions import NoMatchFound
-
-
-def _get_sorted_handlers(handlers: List[Handler]) -> List[Handler]:
- return sorted(handlers, key=lambda handler: len(handler.body), reverse=True)
-
-
-def _combine_dependencies(
- *dependencies: Optional[Sequence[deps.Depends]],
-) -> List[deps.Depends]:
- result_dependencies = []
- for deps_sequence in dependencies:
- result_dependencies.extend(converters.optional_sequence_to_list(deps_sequence))
-
- return result_dependencies
-
-
-def _check_new_handler_restrictions(
- body: str,
- name: Optional[str],
- handler: Callable,
- existed_handler: Handler,
-) -> None:
- handler_executor = existed_handler.handler
- handler_name = existed_handler.name
-
- if body == existed_handler.body:
- raise AssertionError("handler with body {0} already registered".format(body))
-
- handler_registered = handler == handler_executor and name == handler_name
- if name == existed_handler.name and not handler_registered:
- raise AssertionError("handler with name {0} already registered".format(name))
-
-
-@dataclass
-class BaseCollector:
- """Base collector."""
-
- default: InitVar[Handler] = None
-
- #: registered handlers on this collector handlers in order of adding.
- handlers: List[Handler] = field(default_factory=list)
-
- #: handler that will be used for handling non matched message.
- default_message_handler: Optional[Handler] = None
-
- #: background dependencies that will be executed for handlers.
- dependencies: Optional[Sequence[Depends]] = None
-
- #: overrider for dependencies.
- dependency_overrides_provider: Optional[Any] = None
-
- @property
- def sorted_handlers(self) -> List[Handler]:
- """Get added handlers sorted by bodies length.
-
- Returns:
- Sorted handlers.
- """
- return _get_sorted_handlers(self.handlers)
-
- def __post_init__(self, default: Optional[Handler]) -> None:
- """Initialize or update special fields.
-
- Arguments:
- default: callable that should be used as default handler.
- """
- handlers = self.handlers
- self.handlers = []
- self._add_handlers(converters.optional_sequence_to_list(handlers))
-
- if default is not None:
- self._add_default_handler(default)
-
- def handler_for(self, name: str) -> Handler:
- """Find handler in handlers of this bot.
-
- Arguments:
- name: name of handler that should be found.
-
- Returns:
- Handler that was found by name.
-
- Raises:
- NoMatchFound: raise if handler was not found.
- """
- for handler in self.handlers:
- if handler.name == name:
- return handler
-
- raise NoMatchFound(search_param=name)
-
- def add_handler( # noqa: WPS211
- self,
- handler: Callable,
- *,
- body: Optional[str] = None,
- name: Optional[str] = None,
- description: Optional[str] = None,
- full_description: Optional[str] = None,
- include_in_status: Union[bool, Callable] = True,
- dependencies: Optional[Sequence[deps.Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> None:
- """Create new handler from passed arguments and store it inside.
-
- !!! info
- If `include_in_status` is a function, then `body` argument will be checked
- for matching public commands style, like `/command`.
-
- Arguments:
- handler: callable that will be used for executing handler.
- body: body template that will trigger this handler.
- name: optional name for handler that will be used in generating body.
- description: description for command that will be shown in bot's menu.
- full_description: full description that can be used for example in `/help`
- command.
- include_in_status: should this handler be shown in bot's menu, can be
- callable function with no arguments *(for now)*.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
- """
- if body is None:
- name = name or get_name_from_callable(handler)
- body = get_body_from_name(name)
-
- for registered_handler in self.handlers:
- _check_new_handler_restrictions(body, name, handler, registered_handler)
-
- dep_override = (
- dependency_overrides_provider
- if dependency_overrides_provider is not None
- else self.dependency_overrides_provider
- )
- command_handler = Handler(
- body=body,
- handler=handler,
- name=name, # type: ignore
- description=description,
- full_description=full_description, # type: ignore
- include_in_status=include_in_status,
- dependencies=_combine_dependencies(self.dependencies, dependencies),
- dependency_overrides_provider=dep_override,
- )
- self.handlers.append(command_handler)
-
- def _add_handlers(
- self,
- handlers: List[Handler],
- dependencies: Optional[Sequence[Depends]] = None,
- ) -> None:
- for handler in handlers:
- combined_dependencies = _combine_dependencies(
- dependencies,
- handler.dependencies,
- )
- self.add_handler(
- body=handler.body,
- handler=handler.handler,
- name=handler.name,
- description=handler.description,
- full_description=handler.full_description,
- include_in_status=handler.include_in_status,
- dependencies=combined_dependencies,
- )
-
- def _add_default_handler(
- self,
- default: Handler,
- dependencies: Optional[Sequence[Depends]] = None,
- ) -> None:
- default_dependencies = _combine_dependencies(
- self.dependencies,
- dependencies,
- default.dependencies,
- )
- self.default_message_handler = Handler(
- body=default.body,
- handler=default.handler,
- name=default.name,
- description=default.description,
- full_description=default.full_description,
- include_in_status=default.include_in_status,
- dependencies=default_dependencies,
- dependency_overrides_provider=self.dependency_overrides_provider,
- )
diff --git a/botx/collecting/collectors/collector.py b/botx/collecting/collectors/collector.py
deleted file mode 100644
index 07ab55db..00000000
--- a/botx/collecting/collectors/collector.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""Definition for collector."""
-from typing import Any, Optional, Sequence
-
-from loguru import logger
-from pydantic.dataclasses import dataclass
-
-from botx.collecting.collectors.base import BaseCollector
-from botx.collecting.collectors.mixins.default import DefaultHandlerMixin
-from botx.collecting.collectors.mixins.handler import HandlerMixin
-from botx.collecting.collectors.mixins.hidden import HiddenHandlerMixin
-from botx.collecting.collectors.mixins.system_events import SystemEventsHandlerMixin
-from botx.dependencies.models import Depends
-from botx.exceptions import NoMatchFound
-from botx.models.messages.message import Message
-
-
-@dataclass
-class Collector( # noqa: WPS215
- HandlerMixin,
- DefaultHandlerMixin,
- HiddenHandlerMixin,
- SystemEventsHandlerMixin,
- BaseCollector,
-):
- """Collector for different handlers."""
-
- async def __call__(self, message: Message) -> None:
- """Find handler and execute it.
-
- Arguments:
- message: incoming message that will be passed to handler.
- """
- await self.handle_message(message)
-
- def include_collector(
- self,
- collector: "Collector",
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- ) -> None:
- """Include handlers from another collector into this one.
-
- Arguments:
- collector: collector from which handlers should be copied.
- dependencies: optional sequence of dependencies for handlers for this
- collector.
-
- Raises:
- AssertionError: raised if both collectors defines default handlers.
- """
- if self.default_message_handler and collector.default_message_handler:
- raise AssertionError("only one default handler can be applied")
-
- if collector.default_message_handler:
- self._add_default_handler(collector.default_message_handler, dependencies)
-
- self._add_handlers(collector.handlers, dependencies)
-
- def command_for(self, *args: Any) -> str:
- """Find handler and build a command string using passed body query_params.
-
- Arguments:
- args: sequence of elements where first element should be name of handler.
-
- Returns:
- Command string.
-
- Raises:
- TypeError: raised no arguments passed.
- """
- if not len(args):
- raise TypeError("missing handler name as the first argument")
-
- return self.handler_for(args[0]).command_for(*args)
-
- async def handle_message(self, message: Message) -> None:
- """Find handler and execute it.
-
- Arguments:
- message: incoming message that will be passed to handler.
-
- Raises:
- NoMatchFound: raised if no handler for message found.
- """
- for handler in self.sorted_handlers:
- if handler.matches(message):
- logger.bind(botx_collector=True).info(
- "botx => {0}: {1}".format(handler.name, message.command.command),
- )
- await handler(message)
- return
-
- if self.default_message_handler and not message.is_system_event:
- await self.default_message_handler(message)
- else:
- raise NoMatchFound(search_param=message.body)
diff --git a/botx/collecting/collectors/mixins/__init__.py b/botx/collecting/collectors/mixins/__init__.py
deleted file mode 100644
index f175eefa..00000000
--- a/botx/collecting/collectors/mixins/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for mixin with decorators for collector."""
diff --git a/botx/collecting/collectors/mixins/default.py b/botx/collecting/collectors/mixins/default.py
deleted file mode 100644
index cc37c988..00000000
--- a/botx/collecting/collectors/mixins/default.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""Definition for mixin with default decorator."""
-from functools import partial
-from typing import Any, Callable, Optional, Sequence, Union, cast
-
-from botx.collecting.collectors.mixins.handler import HandlerDecoratorProtocol
-from botx.collecting.handlers.handler import Handler
-from botx.collecting.handlers.name_generators import get_name_from_callable
-from botx.dependencies.models import Depends
-
-try:
- from typing import Protocol # noqa: WPS433
-except ImportError:
- from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401
-
-
-class HandlerSearchProtocol(Protocol):
- """Protocol for searching handler."""
-
- def handler_for(self, name: str) -> Handler:
- """Find handler in handlers of this bot."""
-
-
-class DefaultHandlerMixin:
- """Mixin that defines default handler decorator."""
-
- default_message_handler: Optional[Handler]
-
- def default( # noqa: WPS211
- self,
- handler: Optional[Callable] = None,
- *,
- command: Optional[str] = None,
- commands: Optional[Sequence[str]] = None,
- name: Optional[str] = None,
- description: Optional[str] = None,
- full_description: Optional[str] = None,
- include_in_status: Union[bool, Callable] = False,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Add new handler to bot and register it as default handler.
-
- !!! info
- If `include_in_status` is a function, then `body` argument will be checked
- for matching public commands style, like `/command`.
-
- Arguments:
- handler: callable that will be used for executing handler.
- command: body template that will trigger this handler.
- commands: list of body templates that will trigger this handler.
- name: optional name for handler that will be used in generating body.
- description: description for command that will be shown in bot's menu.
- full_description: full description that can be used for example in `/help`
- command.
- include_in_status: should this handler be shown in bot's menu, can be
- callable function with no arguments *(for now)*.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
-
- Raises:
- AssertionError: raised if default handler already defined on collector.
- """
- if self.default_message_handler is not None:
- raise AssertionError(
- "default handler is already registered on this collector",
- )
-
- if handler:
- registered_handler = cast(HandlerDecoratorProtocol, self).handler(
- handler=handler,
- command=command,
- commands=commands,
- name=name,
- description=description,
- full_description=full_description,
- include_in_status=include_in_status,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
- name = name or get_name_from_callable(registered_handler)
- self.default_message_handler = cast(
- HandlerSearchProtocol,
- self,
- ).handler_for(name)
-
- return handler
-
- return partial(
- self.default,
- command=command,
- commands=commands,
- name=name,
- description=description,
- full_description=full_description,
- include_in_status=include_in_status,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
diff --git a/botx/collecting/collectors/mixins/handler.py b/botx/collecting/collectors/mixins/handler.py
deleted file mode 100644
index 15eadcf4..00000000
--- a/botx/collecting/collectors/mixins/handler.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""Definition for mixin with handler decorator."""
-from functools import partial
-from typing import Any, Callable, List, Optional, Sequence, Union, cast
-
-from botx import converters
-from botx.dependencies.models import Depends
-
-try:
- from typing import Protocol # noqa: WPS433
-except ImportError:
- from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401
-
-
-class AddHandlerProtocol(Protocol):
- """Protocol for definition add_handler method."""
-
- def add_handler( # noqa: WPS211
- self,
- handler: Callable,
- *,
- body: Optional[str] = None,
- name: Optional[str] = None,
- description: Optional[str] = None,
- full_description: Optional[str] = None,
- include_in_status: Union[bool, Callable] = True,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> None:
- """Create new handler from passed arguments and store it inside."""
-
-
-class HandlerDecoratorProtocol(Protocol):
- """Protocol for definition handler decorator."""
-
- def handler( # noqa: WPS211
- self,
- handler: Optional[Callable] = None,
- *,
- command: Optional[str] = None,
- commands: Optional[Sequence[str]] = None,
- name: Optional[str] = None,
- description: Optional[str] = None,
- full_description: Optional[str] = None,
- include_in_status: Union[bool, Callable] = True,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Add new handler to collector."""
-
-
-class HandlerMixin:
- """Mixin that defines handler decorator."""
-
- def handler( # noqa: WPS211
- self: AddHandlerProtocol,
- handler: Optional[Callable] = None,
- *,
- command: Optional[str] = None,
- commands: Optional[Sequence[str]] = None,
- name: Optional[str] = None,
- description: Optional[str] = None,
- full_description: Optional[str] = None,
- include_in_status: Union[bool, Callable] = True,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Add new handler to collector.
-
- !!! info
- If `include_in_status` is a function, then `body` argument will be checked
- for matching public commands style, like `/command`.
-
- Arguments:
- handler: callable that will be used for executing handler.
- command: body template that will trigger this handler.
- commands: list of body templates that will trigger this handler.
- name: optional name for handler that will be used in generating body.
- description: description for command that will be shown in bot's menu.
- full_description: full description that can be used for example in `/help`
- command.
- include_in_status: should this handler be shown in bot's menu, can be
- callable function with no arguments *(for now)*.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- if handler:
- handler_commands: List[
- Optional[str]
- ] = converters.optional_sequence_to_list(commands)
-
- if command and commands:
- handler_commands.insert(0, command)
- elif not commands:
- handler_commands = [command]
-
- for command_body in handler_commands:
- self.add_handler(
- body=command_body,
- handler=handler,
- name=name,
- description=description,
- full_description=full_description,
- include_in_status=include_in_status,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- return handler
-
- return partial(
- cast(HandlerDecoratorProtocol, self).handler,
- command=command,
- commands=commands,
- name=name,
- description=description,
- full_description=full_description,
- include_in_status=include_in_status,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
diff --git a/botx/collecting/collectors/mixins/hidden.py b/botx/collecting/collectors/mixins/hidden.py
deleted file mode 100644
index 892ebacf..00000000
--- a/botx/collecting/collectors/mixins/hidden.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Definition for mixin with hidden decorator."""
-from typing import Any, Callable, Optional, Sequence
-
-from botx.collecting.collectors.mixins.handler import HandlerDecoratorProtocol
-from botx.dependencies.models import Depends
-
-
-class HiddenHandlerMixin:
- """Mixin that defines hidden handler decorator."""
-
- def hidden( # noqa: WPS211
- self: HandlerDecoratorProtocol,
- handler: Optional[Callable] = None,
- *,
- command: Optional[str] = None,
- commands: Optional[Sequence[str]] = None,
- name: Optional[str] = None,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register hidden handler that won't be showed in menu.
-
- Arguments:
- handler: callable that will be used for executing handler.
- command: body template that will trigger this handler.
- commands: list of body templates that will trigger this handler.
- name: optional name for handler that will be used in generating body.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.handler(
- handler=handler,
- command=command,
- commands=commands,
- name=name,
- include_in_status=False,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
diff --git a/botx/collecting/collectors/mixins/system_events.py b/botx/collecting/collectors/mixins/system_events.py
deleted file mode 100644
index d8c79e33..00000000
--- a/botx/collecting/collectors/mixins/system_events.py
+++ /dev/null
@@ -1,289 +0,0 @@
-"""Definition for mixin with system events decorator."""
-from typing import Any, Callable, Optional, Sequence, cast
-
-from botx.collecting.collectors.mixins.handler import HandlerDecoratorProtocol
-from botx.dependencies.models import Depends
-from botx.models.enums import SystemEvents
-
-try:
- from typing import Protocol # noqa: WPS433
-except ImportError:
- from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401
-
-
-class SystemEventsHandlerMixin: # noqa: WPS214
- """Mixin that defines system events handler decorator."""
-
- def system_event( # noqa: WPS211
- self,
- handler: Optional[Callable] = None,
- *,
- event: Optional[SystemEvents] = None,
- events: Optional[Sequence[SystemEvents]] = None,
- name: Optional[str] = None,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for system event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- event: event for triggering this handler.
- events: a sequence of events that will trigger handler.
- name: optional name for handler that will be used in generating body.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
-
- Raises:
- AssertionError: raised if nor event or events passed.
- """
- if not (event or events):
- raise AssertionError("at least one event should be passed")
-
- return cast(HandlerDecoratorProtocol, self).handler(
- handler=handler,
- command=event.value if event else None,
- commands=[event.value for event in events] if events else None,
- name=name,
- include_in_status=False,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def chat_created(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `system:chat_created` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.system_event(
- handler=handler,
- event=SystemEvents.chat_created,
- name=SystemEvents.chat_created.value,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def file_transfer(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `file_transfer` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.system_event(
- handler=handler,
- event=SystemEvents.file_transfer,
- name=SystemEvents.file_transfer.value,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def added_to_chat(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `added_to_chat` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.system_event(
- handler=handler,
- event=SystemEvents.added_to_chat,
- name=SystemEvents.added_to_chat.value,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def deleted_from_chat(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `deleted_from_chat` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.system_event(
- handler=handler,
- event=SystemEvents.deleted_from_chat,
- name=SystemEvents.deleted_from_chat.value,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def left_from_chat(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `left_from_chat` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.system_event(
- handler=handler,
- event=SystemEvents.left_from_chat,
- name=SystemEvents.left_from_chat.value,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def internal_bot_notification(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `internal_bot_notification` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.system_event(
- handler=handler,
- event=SystemEvents.internal_bot_notification,
- name=SystemEvents.internal_bot_notification.value,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def cts_login(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `cts_login` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.system_event(
- handler=handler,
- event=SystemEvents.cts_login,
- name=SystemEvents.cts_login.value,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def cts_logout(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `cts_logout` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.system_event(
- handler=handler,
- event=SystemEvents.cts_logout,
- name=SystemEvents.cts_logout.value,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
-
- def smartapp_event(
- self,
- handler: Optional[Callable] = None,
- *,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> Callable:
- """Register handler for `smartapp_event` event.
-
- Arguments:
- handler: callable that will be used for executing handler.
- dependencies: sequence of dependencies that should be executed before
- handler.
- dependency_overrides_provider: mock of callable for handler.
-
- Returns:
- Passed in `handler` callable.
- """
- return self.system_event(
- handler=handler,
- event=SystemEvents.smartapp_event,
- name=SystemEvents.smartapp_event.value,
- dependencies=dependencies,
- dependency_overrides_provider=dependency_overrides_provider,
- )
diff --git a/botx/collecting/handlers/__init__.py b/botx/collecting/handlers/__init__.py
deleted file mode 100644
index 8b3be030..00000000
--- a/botx/collecting/handlers/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for handler related stuff."""
diff --git a/botx/collecting/handlers/handler.py b/botx/collecting/handlers/handler.py
deleted file mode 100644
index db5ee1a0..00000000
--- a/botx/collecting/handlers/handler.py
+++ /dev/null
@@ -1,128 +0,0 @@
-"""Definition for command handler."""
-
-from __future__ import annotations
-
-import re
-from dataclasses import field
-from typing import Any, Callable, List, Optional, Union
-
-from pydantic import validator
-from pydantic.dataclasses import dataclass
-
-from botx.collecting.handlers.validators import (
- check_handler_is_function,
- retrieve_dependant,
- retrieve_executor,
- retrieve_full_description_for_handler,
- retrieve_name_for_handler,
- validate_body_for_status,
-)
-from botx.dependencies import models as deps
-from botx.models.messages.message import Message
-
-
-@dataclass
-class Handler:
- """Handler that will store body and callable."""
-
- #: callable for executing registered logic.
- handler: Callable
-
- #: command body.
- body: str
-
- #: name of handler.
- name: str = ""
-
- #: description that will be shown in bot's menu.
- description: Optional[str] = None
-
- #: custom description that can be used for another purposes.
- full_description: str = ""
-
- #: should handler be included into status.
- include_in_status: Union[bool, Callable] = True
-
- #: wrapper around handler that will be executed.
- dependant: deps.Dependant = field(init=False)
-
- #: background dependencies for handler.
- dependencies: List[deps.Depends] = field(default_factory=list)
-
- #: custom object that will override dependencies for handler.
- dependency_overrides_provider: Optional[Any] = None
-
- #: function that will be used for handling incoming message
- executor: Callable = field(init=False)
-
- _body_validator = validator("executor", always=True)(validate_body_for_status)
- _handler_is_function_validator = validator("handler", pre=True, always=True)(
- check_handler_is_function,
- )
-
- async def __call__(self, message: Message) -> None:
- """Execute handler using incoming message.
-
- Arguments:
- message: message that will be handled by handler.
- """
- await self.executor(message)
-
- def __post_init__(self) -> None:
- """Initialize or update special fields."""
- self.name = retrieve_name_for_handler(self.name, self.handler)
- self.full_description = retrieve_full_description_for_handler(
- self.full_description,
- self.handler,
- )
- self.dependant = retrieve_dependant(self.handler, self.dependencies)
- self.executor = retrieve_executor( # type: ignore
- self.dependant,
- self.dependency_overrides_provider,
- )
-
- def matches(self, message: Message) -> bool:
- """Check if message body matched to handler's body.
-
- Arguments:
- message: incoming message which body will be used to check route.
-
- Returns:
- Result of check.
- """
- return bool(re.compile(self.body).match(message.body))
-
- def command_for(self, *args: Any) -> str:
- """Build a command string using passed body query_params.
-
- Arguments:
- args: sequence of elements that are arguments for command.
-
- Returns:
- Built command.
- """
- args_str = " ".join((str(arg) for arg in args[1:]))
- return "{0} {1}".format(self.body, args_str).strip()
-
- def __eq__(self, other: object) -> bool:
- """Compare 2 handlers for equality.
-
- Arguments:
- other: handler to compare with.
-
- Returns:
- Result of comparing.
- """
- if not isinstance(other, Handler):
- return False
-
- callable_comp = self.handler == other.handler
- callable_comp = callable_comp and self.dependencies == other.dependencies
-
- export_comp = self.name == other.name
- export_comp = export_comp and self.body == other.body
- export_comp = export_comp and self.description == other.description
- export_comp = export_comp and self.full_description == other.full_description
- export_comp = export_comp and self.include_in_status == other.include_in_status
-
- return callable_comp and export_comp
diff --git a/botx/collecting/handlers/name_generators.py b/botx/collecting/handlers/name_generators.py
deleted file mode 100644
index e2210ea9..00000000
--- a/botx/collecting/handlers/name_generators.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""Generators for names for handler."""
-
-import inspect
-import re
-from typing import Callable
-
-
-def get_body_from_name(name: str) -> str:
- """Get auto body from given handler name in format `/word-word`.
-
- Examples:
- ```
- >>> get_body_from_name("HandlerFunction")
- "handler-function"
- >>> get_body_from_name("handlerFunction")
- "handler-function"
- ```
- Arguments:
- name: name of handler for which body should be generated.
- """
- splited_words = re.findall(r"^[a-z\d_\-]+|[A-Z\d_\-][^A-Z\d_\-]*", name)
- joined_body = "-".join(splited_words)
- dashed_body = joined_body.replace("_", "-")
- return "/{0}".format(re.sub("-+", "-", dashed_body).lower())
-
-
-def get_name_from_callable(handler: Callable) -> str:
- """Get auto name from given callable object.
-
- Arguments:
- handler: callable object that will be used to retrieve auto name for handler.
-
- Returns:
- Name obtained from callable.
- """
- is_function = inspect.isfunction(handler)
- is_method = inspect.ismethod(handler)
- is_class = inspect.isclass(handler)
- if is_function or is_method or is_class:
- return handler.__name__
- return handler.__class__.__name__
diff --git a/botx/collecting/handlers/validators.py b/botx/collecting/handlers/validators.py
deleted file mode 100644
index 52cbc405..00000000
--- a/botx/collecting/handlers/validators.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""Validators and extractors for Handler fields."""
-import inspect
-from typing import Any, Callable, List, Optional
-
-from botx.collecting.handlers.name_generators import get_name_from_callable
-from botx.dependencies.models import Dependant, Depends, get_dependant
-from botx.dependencies.solving import get_executor
-
-
-def validate_body_for_status(executor: Callable, values: dict) -> Callable:
- """Validate that body is acceptable for status.
-
- Arguments:
- executor: executor that will be just returned from validator.
- values: already checked validated_values.
-
- Returns:
- Passed executor.
-
- Raises:
- ValueError: raised if body is not acceptable for status.
- """
- include_in_status = values["include_in_status"]
- if not include_in_status:
- return executor
-
- body = values["body"]
-
- if not body.startswith("/"):
- raise ValueError("public commands should start with leading slash")
-
- slash_part_of_body = body[: -len(body.strip("/"))]
- if slash_part_of_body.count("/") != 1:
- raise ValueError("command body can contain only single leading slash")
-
- if len(body.split()) != 1:
- raise ValueError("public commands should contain only one word")
-
- return executor
-
-
-def retrieve_name_for_handler(name: Optional[str], handler: Callable) -> str:
- """Retrieve name for handler.
-
- Arguments:
- name: passed name for handler.
- handler: handler that will be used to generate name.
-
- Returns:
- Name for handler.
- """
- return name or get_name_from_callable(handler)
-
-
-def retrieve_full_description_for_handler(
- full_description: Optional[str],
- handler: Callable,
-) -> str:
- """Retrieve full description for handler.
-
- Arguments:
- full_description: passed name for handler.
- handler: handler from which docstring will be used.
-
- Returns:
- Full description for handler.
- """
- return full_description or inspect.cleandoc(handler.__doc__ or "")
-
-
-def check_handler_is_function(handler: Callable) -> Callable:
- """Check handler can be proceed by library.
-
- Library can handle functions and methods as handlers.
-
- Arguments:
- handler: passed handler callable.
-
- Returns:
- Passed handler.
-
- Raises:
- ValueError: raised if handler is not acceptable.
- """
- if not (inspect.isfunction(handler) or inspect.ismethod(handler)):
- raise ValueError("handler must be a function or method")
-
- return handler
-
-
-def retrieve_dependant(handler: Callable, dependencies: List[Depends]) -> Dependant:
- """Retrieve dependant for handler.
-
- Arguments:
- handler: handler for which dependant should be created.
- dependencies: passed background dependencies.
-
- Returns:
- Generated dependant object.
- """
- dependant = get_dependant(call=handler)
- for index, depends in enumerate(dependencies):
- dependant.dependencies.insert(
- index,
- get_dependant(call=depends.dependency, use_cache=depends.use_cache),
- )
-
- return dependant
-
-
-def retrieve_executor(
- dependant: Dependant,
- dependency_overrides_provider: Any,
-) -> Callable:
- """Retrieve executor for handler.
-
- Arguments:
- dependant: dependant that will be used to generate executor.
- dependency_overrides_provider: overrider for dependencies.
-
- Returns:
- Generated executor.
- """
- return get_executor(
- dependant=dependant,
- dependency_overrides_provider=dependency_overrides_provider,
- )
diff --git a/botx/concurrency.py b/botx/concurrency.py
deleted file mode 100644
index 9e15ab3b..00000000
--- a/botx/concurrency.py
+++ /dev/null
@@ -1,104 +0,0 @@
-"""Helpers for execution functions as coroutines."""
-
-import asyncio
-import contextvars
-import functools
-import inspect
-from typing import Any, Callable, Coroutine
-
-
-def is_awaitable_object(call: Callable) -> bool:
- """Check if object is an awaitable or an object which __call__ method is awaitable.
-
- Arguments:
- call: callable for checking.
-
- Returns:
- Result of check.
- """
- if is_awaitable(call):
- return True
- call = getattr(call, "__call__", None) # noqa: B004
- return asyncio.iscoroutinefunction(call)
-
-
-def is_awaitable(call: Callable) -> bool:
- """Check if function returns awaitable object.
-
- Arguments:
- call: function that should be checked.
-
- Returns:
- Result of check.
- """
- if inspect.isfunction(call) or inspect.ismethod(call):
- return asyncio.iscoroutinefunction(call)
- return False
-
-
-async def run_in_threadpool(call: Callable, *args: Any, **kwargs: Any) -> Any:
- """Run regular function (not a coroutine) as awaitable coroutine.
-
- Arguments:
- call: function that should be called as coroutine.
- args: positional arguments for the function.
- kwargs: keyword arguments for the function.
-
- Returns:
- Result of function call.
- """
- loop = asyncio.get_event_loop()
- child = functools.partial(call, *args, **kwargs)
- context = contextvars.copy_context()
- call = context.run
- args = (child,)
- return await loop.run_in_executor(None, call, *args)
-
-
-def callable_to_coroutine(func: Callable, *args: Any, **kwargs: Any) -> Coroutine:
- """Transform callable to coroutine.
-
- Arguments:
- func: function that can be sync or async and should be transformed into
- coroutine.
- args: positional arguments for this function.
- kwargs: key arguments for this function.
-
- Returns:
- Coroutine object from passed callable.
- """
- if is_awaitable_object(func):
- return func(*args, **kwargs)
-
- return run_in_threadpool(func, *args, **kwargs)
-
-
-def run_in_blocking_loop(call: Callable, *args: Any, **kwargs: Any) -> Any:
- """Run coroutine with loop blocking.
-
- Arguments:
- call: function that should be called in loop until complete.
- args: function arguments.
- kwargs: function key arguments.
-
- Returns:
- Result of function call.
- """
- try:
- loop = asyncio.get_event_loop()
- except RuntimeError:
- loop = asyncio.new_event_loop()
-
- return loop.run_until_complete(callable_to_coroutine(call, *args, **kwargs))
-
-
-def async_to_sync(func: Callable) -> Callable:
- """Convert asynchronous function to blocking.
-
- Arguments:
- func: function that should be converted.
-
- Returns:
- Converted function.
- """
- return functools.partial(run_in_blocking_loop, func)
diff --git a/botx/constants.py b/botx/constants.py
new file mode 100644
index 00000000..085fb2d0
--- /dev/null
+++ b/botx/constants.py
@@ -0,0 +1,12 @@
+try:
+ from typing import Final
+except ImportError:
+ from typing_extensions import Final # type: ignore # noqa: WPS440
+
+CHUNK_SIZE: Final = 1024 * 1024 # 1Mb
+BOT_API_VERSION: Final = 4
+SMARTAPP_API_VERSION: Final = 1
+STICKER_IMAGE_MAX_SIZE: Final = 512 * 1024 # 512Kb
+STICKER_PACKS_PER_PAGE: Final = 10
+MAX_NOTIFICATION_BODY_LENGTH: Final = 4096
+MAX_FILE_LEN_IN_LOGS: Final = 64
diff --git a/botx/converters.py b/botx/converters.py
index bb41049d..3a9147a9 100644
--- a/botx/converters.py
+++ b/botx/converters.py
@@ -1,19 +1,9 @@
-"""Converters for common operations."""
-
from typing import List, Optional, Sequence, TypeVar
-TSequenceElement = TypeVar("TSequenceElement")
+TItem = TypeVar("TItem")
def optional_sequence_to_list(
- seq: Optional[Sequence[TSequenceElement]] = None,
-) -> List[TSequenceElement]:
- """Convert optional sequence of elements to list.
-
- Arguments:
- seq: sequence that should be converted to list.
-
- Returns:
- List of passed elements.
- """
- return list(seq or [])
+ optional_sequence: Optional[Sequence[TItem]],
+) -> List[TItem]:
+ return list(optional_sequence or [])
diff --git a/botx/dependencies/__init__.py b/botx/dependencies/__init__.py
deleted file mode 100644
index 5578cd4a..00000000
--- a/botx/dependencies/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Dependency injection implementation."""
diff --git a/botx/dependencies/injection_params.py b/botx/dependencies/injection_params.py
deleted file mode 100644
index 5f7f0a47..00000000
--- a/botx/dependencies/injection_params.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""Wrappers around param classes that are used in handlers or dependencies."""
-
-from typing import Any, Callable
-
-from botx.dependencies import models
-
-
-def Depends(dependency: Callable, *, use_cache: bool = True) -> Any: # noqa: N802
- """Wrap Depends param for using in handlers.
-
- Arguments:
- dependency: callable object that will be used in handlers or other dependencies
- instances.
- use_cache: use cache for dependency.
-
- Returns:
- [Depends][botx.dependencies.models.Depends] that wraps passed callable.
- """
- return models.Depends(dependency=dependency, use_cache=use_cache)
diff --git a/botx/dependencies/inspecting.py b/botx/dependencies/inspecting.py
deleted file mode 100644
index d14fc426..00000000
--- a/botx/dependencies/inspecting.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""Functions for inspecting signatures and parameters."""
-
-import inspect
-from typing import Any, Callable, Dict
-
-from pydantic.typing import ForwardRef, evaluate_forwardref
-
-
-def get_typed_signature(call: Callable) -> inspect.Signature:
- """Get signature for callable function with solving possible annotations.
-
- Arguments:
- call: callable object that will be used to get signature with annotations.
-
- Returns:
- Callable signature obtained.
- """
- signature = inspect.signature(call)
- global_namespace = getattr(call, "__globals__", {})
- typed_params = [
- inspect.Parameter(
- name=dependency_param.name,
- kind=dependency_param.kind,
- default=dependency_param.default,
- annotation=get_typed_annotation(dependency_param, global_namespace),
- )
- for dependency_param in signature.parameters.values()
- ]
- return inspect.Signature(typed_params)
-
-
-def get_typed_annotation(
- dependency_param: inspect.Parameter,
- global_namespace: Dict[str, Any],
-) -> Any:
- """Solve forward reference annotation for instance of `inspect.Parameter`.
-
- Arguments:
- dependency_param: instance of `inspect.Parameter` for which possible forward
- annotation will be evaluated.
- global_namespace: dictionary of entities that can be used for evaluating
- forward references.
-
- Returns:
- Parameter annotation.
- """
- annotation = dependency_param.annotation
- if isinstance(annotation, str):
- annotation = ForwardRef(annotation)
- annotation = evaluate_forwardref(annotation, global_namespace, global_namespace)
- return annotation
diff --git a/botx/dependencies/models.py b/botx/dependencies/models.py
deleted file mode 100644
index 36f61b1e..00000000
--- a/botx/dependencies/models.py
+++ /dev/null
@@ -1,167 +0,0 @@
-"""Dependant model and transforming functions."""
-from __future__ import annotations
-
-import inspect
-from dataclasses import field
-from typing import Any, Callable, Dict, List, Optional
-
-from pydantic.dataclasses import dataclass
-from pydantic.utils import lenient_issubclass
-
-from botx.bots import bots
-from botx.clients.clients import async_client, sync_client
-from botx.dependencies import inspecting
-from botx.models.messages.message import Message
-
-WRONG_PARAM_TYPE_ERROR_TEXT = (
- "Param {0} of {1} can only be a dependency, message, bot or client, got: {2}"
-)
-
-CacheKey = Optional[Callable]
-DependenciesCache = Dict[CacheKey, Any]
-
-
-@dataclass
-class Depends:
- """Stores dependency callable."""
-
- #: callable object that will be used in handlers or other dependencies instances.
- dependency: Callable[..., Any]
-
- #: use cache for dependency.
- use_cache: bool = True
-
-
-@dataclass
-class Dependant:
- """Main model that contains all necessary data for solving dependencies."""
-
- #: list of sub-dependencies for this dependency.
- dependencies: List[Dependant] = field(default_factory=list)
-
- #: name of dependency.
- name: Optional[str] = None
-
- #: callable object that will solve dependency.
- call: Optional[Callable] = None
-
- #: param name for passing incoming [message][botx.models.messages.Message]
- message_param_name: Optional[str] = None
-
- #: param name for passing [bot][botx.bots.Bot] that handles command.
- bot_param_name: Optional[str] = None
-
- #: param name for passing [client][botx.clients.clients.async_client.AsyncClient].
- async_client_param_name: Optional[str] = None
-
- #: param name for passing [client][botx.clients.clients.sync_client.Client].
- sync_client_param_name: Optional[str] = None
-
- #: use cache for optimize solving performance.
- use_cache: bool = True
-
- # Save the cache key at creation to optimize performance
- #: storage for cache.
- cache_key: CacheKey = field(init=False)
-
- def __post_init__(self) -> None:
- """Init special fields."""
- self.cache_key = self.call
-
-
-Dependant.__pydantic_model__.update_forward_refs() # type: ignore # noqa: WPS609
-
-
-def get_param_sub_dependant(*, dependency_param: inspect.Parameter) -> Dependant:
- """Parse instance of parameter to get it as dependency.
-
- Arguments:
- dependency_param: param for which sub dependency should be retrieved.
-
- Returns:
- Object that will be used in solving dependency.
- """
- depends: Depends = dependency_param.default
- dependency = depends.dependency
-
- return get_dependant(
- call=dependency,
- name=dependency_param.name,
- use_cache=depends.use_cache,
- )
-
-
-def get_dependant(
- *,
- call: Callable,
- name: Optional[str] = None,
- use_cache: bool = True,
-) -> Dependant:
- """Get dependant instance from passed callable object.
-
- Arguments:
- call: callable object that will be parsed to get required parameters and
- sub dependencies.
- name: name for dependency.
- use_cache: use cache for optimize solving performance.
-
- Returns:
- Object that will be used in solving dependency.
-
- Raises:
- ValueError: raised if param is not Dependant or special type.
- """
- dependant = Dependant(call=call, name=name, use_cache=use_cache)
- for dependency_param in inspecting.get_typed_signature(call).parameters.values():
- if isinstance(dependency_param.default, Depends):
- dependant.dependencies.append(
- get_param_sub_dependant(dependency_param=dependency_param),
- )
- continue
-
- is_special_param = add_special_param_to_dependency(
- dependency_param=dependency_param,
- dependant=dependant,
- )
- if is_special_param:
- continue
-
- raise ValueError(
- WRONG_PARAM_TYPE_ERROR_TEXT.format(
- dependency_param.name,
- call,
- dependency_param.annotation,
- ),
- )
-
- return dependant
-
-
-def add_special_param_to_dependency(
- *,
- dependency_param: inspect.Parameter,
- dependant: Dependant,
-) -> bool:
- """Check if param is non field object that should be passed into callable.
-
- Arguments:
- dependency_param: param that should be checked.
- dependant: dependency which field would be filled with required param name.
-
- Returns:
- Result of check.
- """
- if lenient_issubclass(dependency_param.annotation, bots.Bot):
- dependant.bot_param_name = dependency_param.name
- return True
- elif lenient_issubclass(dependency_param.annotation, Message):
- dependant.message_param_name = dependency_param.name
- return True
- elif lenient_issubclass(dependency_param.annotation, async_client.AsyncClient):
- dependant.async_client_param_name = dependency_param.name
- return True
- elif lenient_issubclass(dependency_param.annotation, sync_client.Client):
- dependant.sync_client_param_name = dependency_param.name
- return True
-
- return False
diff --git a/botx/dependencies/solving.py b/botx/dependencies/solving.py
deleted file mode 100644
index 2cda9de1..00000000
--- a/botx/dependencies/solving.py
+++ /dev/null
@@ -1,145 +0,0 @@
-"""Functions for solving dependencies."""
-
-from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, cast
-
-from botx import concurrency
-from botx.dependencies.models import (
- CacheKey,
- Dependant,
- DependenciesCache,
- get_dependant,
-)
-from botx.models.messages.message import Message
-
-
-async def solve_sub_dependency(
- message: Message,
- dependant: Dependant,
- solved_values: Dict[str, Any],
- dependency_overrides_provider: Any,
- dependency_cache: Dict[CacheKey, Any],
-) -> None:
- """
- Solve single sub dependency.
-
- Arguments:
- message: incoming message that is used for solving this sub dependency.
- dependant: dependency that is solving while calling this function.
- solved_values: already filled validated_values that are required for this
- dependency.
- dependency_overrides_provider: an object with `dependency_overrides` attribute
- that contains overrides for dependencies.
- dependency_cache: cache that contains already solved dependency and result for
- it.
-
- """
- call = cast(Callable, dependant.call)
- use_sub_dependant = dependant
-
- overrides = getattr(
- dependency_overrides_provider
- if dependency_overrides_provider is not None
- else message.bot,
- "dependency_overrides",
- {},
- )
- if overrides:
- call = overrides.get(dependant.call, dependant.call)
- use_sub_dependant = get_dependant(call=call, name=dependant.name)
-
- solving_result = await solve_dependencies(
- message=message,
- dependant=use_sub_dependant,
- dependency_overrides_provider=dependency_overrides_provider,
- dependency_cache=dependency_cache,
- )
- dependency_cache.update(solving_result[1])
-
- dependant.cache_key = dependant.cache_key
- if dependant.use_cache and dependant.cache_key in dependency_cache:
- solved = dependency_cache[dependant.cache_key]
- else:
- solved = await concurrency.callable_to_coroutine(call, **solving_result[0])
-
- if dependant.name is not None:
- solved_values[dependant.name] = solved
- if dependant.cache_key not in dependency_cache:
- dependency_cache[dependant.cache_key] = solved
-
-
-async def solve_dependencies(
- *,
- message: Message,
- dependant: Dependant,
- dependency_overrides_provider: Any = None,
- dependency_cache: Optional[Dict[CacheKey, Any]] = None,
-) -> Tuple[Dict[str, Any], DependenciesCache]:
- """
- Resolve all required dependencies for Dependant using incoming message.
-
- Arguments:
- message: incoming Message with all necessary data.
- dependant: Dependant object for which all sub dependencies should be solved.
- dependency_overrides_provider: an object with `dependency_overrides` attribute
- that contains overrides for dependencies.
- dependency_cache: cache that contains already solved dependency and result for
- it.
-
- Returns:
- Keyword arguments with their vales and cache.
-
- """
- solved_values: Dict[str, Any] = {}
- dependency_cache = dependency_cache or {}
- for sub_dependant in dependant.dependencies:
- await solve_sub_dependency(
- message=message,
- dependant=sub_dependant,
- solved_values=solved_values,
- dependency_overrides_provider=dependency_overrides_provider,
- dependency_cache=dependency_cache,
- )
-
- if dependant.message_param_name:
- solved_values[dependant.message_param_name] = message
- if dependant.bot_param_name:
- solved_values[dependant.bot_param_name] = message.bot
- if dependant.async_client_param_name:
- solved_values[dependant.async_client_param_name] = message.bot.client
- if dependant.sync_client_param_name:
- solved_values[dependant.sync_client_param_name] = message.bot.sync_client
- return solved_values, dependency_cache
-
-
-def get_executor(
- dependant: Dependant,
- dependency_overrides_provider: Any = None,
-) -> Callable[[Message], Awaitable[None]]:
- """Get an execution callable for passed dependency.
-
- Arguments:
- dependant: passed dependency for which execution callable should be generated.
- dependency_overrides_provider: dependency overrider that will be passed to the
- execution.
-
- Returns:
- Asynchronous executor for handling message.
-
- Raises:
- AssertionError: raised if there is no callable in `dependant.call`.
- """
- if dependant.call is None:
- raise AssertionError("dependant.call must be present")
-
- async def factory(message: Message) -> None:
- solved_values, _ = await solve_dependencies(
- message=message,
- dependant=dependant,
- dependency_overrides_provider=dependency_overrides_provider,
- )
- await concurrency.callable_to_coroutine(
- cast(Callable, dependant.call),
- **solved_values,
- )
-
- return factory
diff --git a/botx/exception_handlers.py b/botx/exception_handlers.py
deleted file mode 100644
index 9262bc35..00000000
--- a/botx/exception_handlers.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""Define several handlers for builtin exceptions from this library."""
-
-from typing import Any
-
-from loguru import logger
-
-from botx.exceptions import NoMatchFound
-from botx.models.messages import message as messages
-
-
-async def dependency_failure_exception_handler(*_: Any) -> None:
- """Just do nothing if there is this error, since it's just a signal for stop.
-
- Arguments:
- _: default arguments passed to exception handler.
- """
-
-
-async def no_match_found_exception_handler(
- exception: NoMatchFound,
- message: messages.Message,
-) -> None:
- """Log that handler was not found.
-
- Arguments:
- exception: raised NoMatchFound exception.
- message: message on which processing error was raised.
- """
- logger.info("handler for {0!r} was not found", exception.search_param)
diff --git a/botx/exceptions.py b/botx/exceptions.py
deleted file mode 100644
index de36642b..00000000
--- a/botx/exceptions.py
+++ /dev/null
@@ -1,112 +0,0 @@
-"""Exceptions that are used in this library."""
-from typing import Any, Dict
-from uuid import UUID
-
-
-class BotXException(Exception):
- """Base error for exception in this library."""
-
- #: template that should be rendered on __str__ call.
- message_template: str = ""
-
- def __init__(self, **kwargs: Any) -> None:
- """Init exception with passed query_params.
-
- Arguments:
- kwargs: key-arguments that will be stored in instance.
- """
- self.__dict__ = kwargs
-
- def __str__(self) -> str:
- """Render string representation.
-
- Returns:
- String representation of error.
- """
- return self.message_template.format(**self.__dict__)
-
-
-class NoMatchFound(BotXException):
- """Raised by collector if no matching handler exists."""
-
- message_template = "handler for {search_param} not found"
-
- #: body for which handler was not found.
- search_param: str
-
-
-class DependencyFailure(BotXException):
- """Raised when there is error in dependency and flow should be stopped."""
-
-
-class BotXAPIError(BotXException):
- """Raised if there is an error in requests to BotX API."""
-
- message_template = (
- "unable to send {method} {url} to BotX API ({status}): {response_content}"
- )
-
- #: URL from request.
- url: str
-
- #: HTTP method.
- method: str
-
- #: response from API.
- response_content: Dict[str, Any]
-
- # HTTP status code.
- status: int
-
-
-class UnknownBotError(BotXException):
- """Raised if bot does not know bot."""
-
- message_template = "unknown bot {bot_id}"
-
- #: bot id that is unregistered.
- bot_id: UUID
-
-
-class BotXAPIRouteDeprecated(BotXAPIError):
- """Raised if API route was deprecated."""
-
- message_template = "route {method} {url} is deprecated"
-
- #: bot id that is unregistered.
- bot_id: UUID
-
-
-class TokenError(BotXException):
- """Raised if token is invalid."""
-
- message_template = "invalid token for bot {bot_id}"
-
- bot_id: UUID
-
-
-class BotXJSONDecodeError(BotXException):
- """Raised if response body cannot be processed."""
-
- message_template = "unable to process response body from {method} {url}"
-
- #: URL from request.
- url: str
-
- #: HTTP method.
- method: str
-
-
-class BotXConnectError(BotXException):
- """Raised if unable to connect to service."""
-
- message_template = (
- "unable to connect to service {method} {url}. "
- "Make sure you specified the correct host in bot credentials."
- )
-
- #: URL from request.
- url: str
-
- #: HTTP method.
- method: str
diff --git a/botx/image_validators.py b/botx/image_validators.py
new file mode 100644
index 00000000..d66e30c3
--- /dev/null
+++ b/botx/image_validators.py
@@ -0,0 +1,23 @@
+from botx.async_buffer import AsyncBufferReadable, get_file_size
+from botx.constants import STICKER_IMAGE_MAX_SIZE
+
+PNG_MAGIC_BYTES: bytes = b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"
+
+
+async def ensure_file_content_is_png(async_buffer: AsyncBufferReadable) -> None:
+ magic_bytes = await async_buffer.read(8)
+
+ await async_buffer.seek(0)
+
+ if magic_bytes != PNG_MAGIC_BYTES:
+ raise ValueError("Passed file is not PNG")
+
+
+async def ensure_sticker_image_size_valid(async_buffer: AsyncBufferReadable) -> None:
+ file_size = await get_file_size(async_buffer)
+
+ if file_size > STICKER_IMAGE_MAX_SIZE:
+ max_file_size_mb = STICKER_IMAGE_MAX_SIZE / 1024 / 1024
+ raise ValueError(
+ f"Passed file size is greater than {max_file_size_mb:.1f} Mb",
+ )
diff --git a/botx/logger.py b/botx/logger.py
new file mode 100644
index 00000000..ef97cab6
--- /dev/null
+++ b/botx/logger.py
@@ -0,0 +1,48 @@
+import json
+from copy import deepcopy
+from typing import TYPE_CHECKING, Any, Dict
+
+from loguru import logger as _logger
+
+from botx.constants import MAX_FILE_LEN_IN_LOGS
+
+if TYPE_CHECKING: # To avoid circular import
+ from loguru import Logger
+
+
+def pformat_jsonable_obj(jsonable_obj: Any) -> str:
+ return json.dumps(jsonable_obj, sort_keys=True, indent=4, ensure_ascii=False)
+
+
+def trim_file_data_in_outgoing_json(json_body: Any) -> Any:
+ if not isinstance(json_body, dict):
+ return json_body
+
+ if json_body.get("file"):
+ json_body = deepcopy(json_body)
+ json_body["file"]["data"] = (
+ json_body["file"]["data"][:MAX_FILE_LEN_IN_LOGS] + "..."
+ )
+
+ return json_body
+
+
+def trim_file_data_in_incoming_json(json_body: Dict[str, Any]) -> Dict[str, Any]:
+ if json_body.get("attachments"):
+ # Max one attach per-message
+ # Link and Location doesn't have content
+ if json_body["attachments"][0]["data"].get("content"):
+ json_body = deepcopy(json_body)
+ json_body["attachments"][0]["data"]["content"] = (
+ json_body["attachments"][0]["data"]["content"][:MAX_FILE_LEN_IN_LOGS]
+ + "..."
+ )
+
+ return json_body
+
+
+def setup_logger() -> "Logger":
+ return _logger
+
+
+logger = setup_logger()
diff --git a/botx/middlewares/__init__.py b/botx/middlewares/__init__.py
deleted file mode 100644
index 42632b0a..00000000
--- a/botx/middlewares/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition ob built-in middlewares for botx."""
diff --git a/botx/middlewares/authorization.py b/botx/middlewares/authorization.py
deleted file mode 100644
index e0abc69b..00000000
--- a/botx/middlewares/authorization.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Middleware for retrieving tokens from BotX API before processing message."""
-
-from botx.middlewares.base import BaseMiddleware
-from botx.models.messages.message import Message
-from botx.typing import AsyncExecutor
-
-
-class AuthorizationMiddleware(BaseMiddleware):
- """Middleware for retrieving tokens from BotX API before processing message."""
-
- async def dispatch(self, message: Message, call_next: AsyncExecutor) -> None:
- """Obtain token for bot for handling answers to message.
-
- Arguments:
- message: incoming message.
- call_next: next executor in chain.
- """
- bot = message.bot
- bot_account = bot.get_account_by_bot_id(message.bot_id)
- if bot_account.token is None:
- token = await bot.get_token(
- message.host,
- message.bot_id,
- bot_account.signature,
- )
- bot_account.token = token
- await call_next(message)
diff --git a/botx/middlewares/base.py b/botx/middlewares/base.py
deleted file mode 100644
index 312e6edf..00000000
--- a/botx/middlewares/base.py
+++ /dev/null
@@ -1,65 +0,0 @@
-"""Definition of base for custom middlewares.
-
-Important:
- Middleware should implement `dispatch` method that can be a common function or
- an asynchronous function.
-
-```python
-class MyAsyncBotXMiddleware(BaseMiddleware):
- async def dispatch(
- self, message: Message, call_next: AsyncExecutor,
- ) -> None:
- await call_next(message)
-
-class MySyncBotXMiddleware(BaseMiddleware):
- def dispatch(self, message: Message, call_next: SyncExecutor) -> None:
- call_next(message)
-```
-"""
-
-from typing import Callable, Optional
-
-from botx import concurrency
-from botx.models.messages.message import Message
-from botx.typing import Executor, MiddlewareDispatcher, SyncExecutor
-
-
-def _default_dispatch(
- _middleware: "BaseMiddleware",
- _message: Message,
- _call_next: SyncExecutor,
-) -> None:
- raise NotImplementedError
-
-
-class BaseMiddleware:
- """Base middleware entity."""
-
- dispatch: Callable = _default_dispatch
-
- def __init__(
- self,
- executor: Executor,
- dispatch: Optional[MiddlewareDispatcher] = None,
- ) -> None:
- """Init middleware with required query_params.
-
- Arguments:
- executor: callable object that accept message and will be executed after
- middlewares.
- dispatch: middleware logic executor.
- """
- self.executor = executor
- self.dispatch_func = dispatch or self.dispatch
-
- async def __call__(self, message: Message) -> None:
- """Call middleware dispatcher as normal handler executor.
-
- Arguments:
- message: incoming message.
- """
- executor = self.executor
- if not concurrency.is_awaitable(self.dispatch_func):
- executor = concurrency.async_to_sync(self.executor)
-
- await concurrency.callable_to_coroutine(self.dispatch_func, message, executor)
diff --git a/botx/middlewares/exceptions.py b/botx/middlewares/exceptions.py
deleted file mode 100644
index 54f2187c..00000000
--- a/botx/middlewares/exceptions.py
+++ /dev/null
@@ -1,133 +0,0 @@
-"""Definition of base middleware class and some default middlewares."""
-
-from typing import Callable, Dict, Optional, Type
-
-from loguru import logger
-
-from botx import concurrency
-from botx.middlewares.base import BaseMiddleware
-from botx.models import files
-from botx.models.messages.message import Message
-from botx.typing import AsyncExecutor, Executor
-
-
-class ExceptionMiddleware(BaseMiddleware):
- """Custom middleware that is default and used to handle registered errors."""
-
- def __init__(self, executor: Executor) -> None:
- """Init middleware with required query_params.
-
- Arguments:
- executor: callable object that accept message and will be executed after
- middleware.
- """
- super().__init__(executor)
- self._exception_handlers: Dict[Type[Exception], Callable] = {}
-
- async def dispatch(self, message: Message, call_next: AsyncExecutor) -> None:
- """Wrap executor for catching exception or log them.
-
- Arguments:
- message: incoming message that will be passed to executor.
- call_next: next executor that should be called after this.
- """
- try:
- await call_next(message)
- except Exception as exc:
- await self._handle_error_in_handler(exc, message)
-
- def add_exception_handler(
- self,
- exc_class: Type[Exception],
- handler: Callable,
- ) -> None:
- """Register handler for specific exception in middleware.
-
- Arguments:
- exc_class: exception class that should be handled by middleware.
- handler: handler for exception.
- """
- self._exception_handlers[exc_class] = handler
-
- def _lookup_handler_for_exception(self, exc: Exception) -> Optional[Callable]:
- """Find handler for exception.
-
- Arguments:
- exc: catched exception for which handler should be found.
-
- Returns:
- Found handler or None.
- """
- for exc_cls in type(exc).mro():
- handler = self._exception_handlers.get(exc_cls)
- if handler:
- return handler
-
- return None
-
- async def _handle_error_in_handler(self, exc: Exception, message: Message) -> None:
- """Pass error back to handler if there is one or log error.
-
- Arguments:
- exc: exception that occurred.
- message: message on which exception occurred.
- """
- exception_logger = logger.bind(
- botx_error=True,
- payload=message.incoming_message.copy(
- update={
- "body": _convert_text_to_logs_format(message.body),
- "file": _convert_file_to_logs_format(message.file),
- },
- ).dict(),
- )
- handler = self._lookup_handler_for_exception(exc)
-
- if handler is None:
- exception_logger.exception(
- "uncaught {0} exception in handler: {1}",
- type(exc).__name__,
- exc,
- )
- return
-
- try:
- await concurrency.callable_to_coroutine(handler, exc, message)
- except Exception as error_handler_exc:
- exception_logger.exception(
- "uncaught {0} exception in error handler: {1}",
- type(error_handler_exc).__name__,
- error_handler_exc,
- )
-
-
-def _convert_text_to_logs_format(text: str) -> str:
- """Convert text into format that is suitable for logs.
-
- Arguments:
- text: text that should be formatted.
-
- Returns:
- Shape for logging in loguru.
- """
- max_log_text_length = 50
- start_text_index = 15
- end_text_index = 5
-
- return (
- "...".join((text[:start_text_index], text[-end_text_index:]))
- if len(text) > max_log_text_length
- else text
- )
-
-
-def _convert_file_to_logs_format(file: Optional[files.File]) -> Optional[dict]:
- """Convert file to a new file that will be showed in logs.
-
- Arguments:
- file: file that should be converted.
-
- Returns:
- New file or nothing.
- """
- return file.copy(update={"data": "[file content]"}).dict() if file else None
diff --git a/botx/middlewares/ns.py b/botx/middlewares/ns.py
deleted file mode 100644
index c5005826..00000000
--- a/botx/middlewares/ns.py
+++ /dev/null
@@ -1,261 +0,0 @@
-"""Definition for middleware that precess next step handlers logic."""
-
-import contextlib
-from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
-from uuid import UUID
-
-from loguru import logger
-from pydantic import BaseConfig, BaseModel
-
-from botx import Bot, Collector, concurrency, converters, exceptions
-from botx.collecting.handlers.handler import Handler
-from botx.collecting.handlers.name_generators import get_name_from_callable
-from botx.dependencies.models import Depends
-from botx.middlewares.base import BaseMiddleware
-from botx.models.messages.message import Message
-from botx.typing import Executor
-
-
-class NextStepHandlerState(BaseModel):
- """Information about next step handler."""
-
- class Config(BaseConfig):
- arbitrary_types_allowed = True
-
- #: name of handler that should be called.
- name: str
-
- #: arguments that should be set on message for handler.
- arguments: Dict[str, Any]
-
-
-class NextStepMiddleware(BaseMiddleware):
- """
- Naive next step handlers middleware. May be useful in simple apps or as base.
-
- Important:
- This middleware should be the last included into bot, since it will break
- execution if right handler will be found.
- """
-
- def __init__( # noqa: WPS211
- self,
- executor: Executor,
- bot: Bot,
- functions: Union[Dict[str, Callable], Sequence[Callable]],
- break_handler: Optional[Union[Handler, str, Callable]] = None,
- dependencies: Optional[Sequence[Depends]] = None,
- dependency_overrides_provider: Any = None,
- ) -> None:
- """Init middleware with required query_params.
-
- Arguments:
- executor: next callable that should be executed.
- bot: bot that will store ns state.
- functions: dict of functions and their names that will be used as next step
- handlers or set of sequence of functions that will be registered by
- their names.
- break_handler: handler instance or name of handler that will break next step
- handlers chain.
- dependencies: background dependencies that should be applied to handlers.
- dependency_overrides_provider: object that will override dependencies for
- this handler.
- """
- super().__init__(executor)
-
- dependencies = converters.optional_sequence_to_list(
- bot.collector.dependencies,
- ) + converters.optional_sequence_to_list(
- dependencies,
- ) # noqa: W503
- dep_override = (
- dependency_overrides_provider or bot.collector.dependency_overrides_provider
- )
- bot.state.ns_collector = Collector(
- dependencies=dependencies,
- dependency_overrides_provider=dep_override,
- )
- bot.state.ns_store = {}
-
- bot.state.ns_break_handler = None
- if break_handler:
- if isinstance(break_handler, Handler):
- bot.state.ns_collector.handlers.append(break_handler)
- bot.state.ns_break_handler = break_handler.name
- elif callable(break_handler):
- register_function_as_ns_handler(bot, break_handler)
- bot.state.ns_break_handler = get_name_from_callable(break_handler)
- else:
- bot.state.ns_collector.handlers.append(
- bot.collector.handler_for(break_handler),
- )
- bot.state.ns_break_handler = break_handler
-
- if isinstance(functions, dict):
- functions_dict = functions
- else:
- functions_dict = {get_name_from_callable(func): func for func in functions}
-
- for name, function in functions_dict.items():
- register_function_as_ns_handler(bot, function, name)
-
- async def dispatch(self, message: Message, call_next: Executor) -> None:
- """Execute middleware logic.
-
- Arguments:
- message: incoming message.
- call_next: next executor in middleware chain.
- """
- if message.bot.state.ns_break_handler:
- break_handler = message.bot.state.ns_collector.handler_for(
- message.bot.state.ns_break_handler,
- )
- if break_handler.matches(message):
- await self.drop_next_step_handlers_chain(message)
- await break_handler(message)
- return
-
- try:
- next_handler, state = await self.lookup_next_handler_for_message(message)
- except (exceptions.NoMatchFound, IndexError, KeyError, RuntimeError):
- await concurrency.callable_to_coroutine(call_next, message)
- return
-
- key = get_chain_key_by_message(message)
- logger.bind(botx_ns_middleware=True, payload={"next_step_key": key}).info(
- "botx: found next step handler",
- )
-
- for state_argument in state.arguments.items():
- setattr(message.state, state_argument[0], state_argument[1])
- await next_handler(message)
-
- async def lookup_next_handler_for_message(
- self,
- message: Message,
- ) -> Tuple[Handler, NextStepHandlerState]:
- """Find handler in bot storage or in handlers.
-
- Arguments:
- message: message for which next step handler should be found.
-
- Returns:
- Found handler and state with arguments for message.
- """
- handlers: List[NextStepHandlerState] = message.bot.state.ns_store[
- get_chain_key_by_message(message)
- ]
- handler_state = handlers.pop()
- return (
- message.bot.state.ns_collector.handler_for(handler_state.name),
- handler_state,
- )
-
- async def drop_next_step_handlers_chain(self, message: Message) -> None:
- """Drop registered chain for message.
-
- Arguments:
- message: message for which chain should be dropped.
- """
- with contextlib.suppress(KeyError):
- message.bot.state.ns_store.pop(get_chain_key_by_message(message))
-
-
-def get_chain_key_by_message(message: Message) -> Tuple[str, UUID, UUID, UUID]:
- """Generate key for next step handlers chain from message.
-
- Arguments:
- message: message from which key should be generated.
-
- Returns:
- Key using which handler should be found.
-
- Raises:
- RuntimeError: raised if key for chain can not be built.
- """
- # key is a tuple of (host, bot_id, chat_id, user_huid)
- if not (message.user_huid and message.group_chat_id):
- raise RuntimeError("key for chain can be obtained only for messages from users")
-
- return message.host, message.bot_id, message.group_chat_id, message.user_huid
-
-
-def register_function_as_ns_handler(
- bot: Bot,
- func: Callable,
- name: Optional[str] = None,
-) -> None:
- """Register new function that can be called as next step handler.
-
- !!! warning
- This functions should not be called to dynamically register new functions in
- handlers or elsewhere, since state on different time can be changed somehow.
-
- Arguments:
- bot: bot that stores ns state.
- func: functions that will be called as ns handler. Will be transformed to
- coroutine if it is not already.
- name: name for new function. Will be generated from `func` if not passed.
-
- Raises:
- ValueError: raised if there is error to register handler.
- """
- name = name or get_name_from_callable(func)
- collector: Collector = bot.state.ns_collector
- try:
- collector.add_handler(
- body=name,
- name=name,
- handler=func,
- include_in_status=False,
- dependencies=collector.dependencies,
- dependency_overrides_provider=collector.dependency_overrides_provider,
- )
- except AssertionError as exc:
- raise ValueError(exc.args)
-
-
-def register_next_step_handler(
- message: Message,
- func: Union[str, Callable],
- **ns_arguments: Any,
-) -> None:
- """Register new next step handler for next message from user.
-
- !!! info
- While registration handler for next message this function fill first try to find
- handlers that were registered using `register_function_as_ns_handler`, then
- handlers that are registered in bot itself and then if no one was found an
- exception will be raised.
-
- Arguments:
- message: incoming message.
- func: function name of function which name will be retrieved to register next
- handler.
- ns_arguments: arguments that will be stored in message state while executing
- handler with next message.
-
- Raises:
- ValueError: raised if passed message does not include user_huid or if handler
- that should be registered as next step does not exists.
- """
- if message.user_huid is None:
- raise ValueError(
- "message for which ns handler is registered should include user_huid",
- )
-
- bot = message.bot
- collector: Collector = bot.state.ns_collector
- name = get_name_from_callable(func) if callable(func) else func
-
- try:
- collector.handler_for(name)
- except exceptions.NoMatchFound:
- raise ValueError(
- "bot does not have registered next step handler with name {0}".format(name),
- )
-
- key = get_chain_key_by_message(message)
-
- store: List[NextStepHandlerState] = bot.state.ns_store.setdefault(key, [])
- store.append(NextStepHandlerState(name=name, arguments=ns_arguments))
diff --git a/botx/missing.py b/botx/missing.py
new file mode 100644
index 00000000..193aacd7
--- /dev/null
+++ b/botx/missing.py
@@ -0,0 +1,26 @@
+from typing import Any, Literal, TypeVar, Union
+
+
+class _UndefinedType:
+ """For fields that can be skipped."""
+
+ def __bool__(self) -> Literal[False]:
+ return False
+
+ def __repr__(self) -> str:
+ return "Undefined"
+
+
+RequiredType = TypeVar("RequiredType")
+Undefined = _UndefinedType()
+
+Missing = Union[RequiredType, _UndefinedType]
+MissingOptional = Union[RequiredType, None, _UndefinedType]
+
+
+def not_undefined(*args: Any) -> Any:
+ for arg in args:
+ if arg is not Undefined:
+ return arg
+
+ raise ValueError("All arguments have `Undefined` type")
diff --git a/botx/models/__init__.py b/botx/models/__init__.py
index 1e8935c6..e69de29b 100644
--- a/botx/models/__init__.py
+++ b/botx/models/__init__.py
@@ -1 +0,0 @@
-"""Pydantic models, data classes or other entities."""
diff --git a/botx/models/api_base.py b/botx/models/api_base.py
new file mode 100644
index 00000000..6d219c5c
--- /dev/null
+++ b/botx/models/api_base.py
@@ -0,0 +1,82 @@
+import json
+from enum import Enum
+from typing import Any, Dict, List, Optional, Set, Union, cast
+
+from pydantic import BaseModel
+from pydantic.json import pydantic_encoder
+
+from botx.missing import Undefined
+
+
+def _remove_undefined(
+ origin_obj: Union[Dict[str, Any], List[Any]],
+) -> Union[Dict[str, Any], List[Any]]:
+ if isinstance(origin_obj, dict):
+ new_dict = {}
+
+ for key, value in origin_obj.items():
+ if value is Undefined:
+ continue
+
+ if isinstance(value, (list, dict)):
+ new_value = _remove_undefined(value)
+ if new_value or len(new_value) == len(value):
+ new_dict[key] = new_value
+ else:
+ new_dict[key] = value
+
+ return new_dict
+
+ elif isinstance(origin_obj, list):
+ new_list = []
+
+ for value in origin_obj:
+ if value is Undefined:
+ continue
+
+ if isinstance(value, (list, dict)):
+ new_value = _remove_undefined(value)
+ if new_value or len(new_value) == len(value):
+ new_list.append(new_value)
+ else:
+ new_list.append(value)
+
+ return new_list
+
+ raise NotImplementedError
+
+
+class PayloadBaseModel(BaseModel):
+ def json(self) -> str: # type: ignore [override]
+ clean_dict = _remove_undefined(self.dict())
+ return json.dumps(clean_dict, default=pydantic_encoder, ensure_ascii=False)
+
+ def jsonable_dict(self) -> Dict[str, Any]:
+ return cast(
+ Dict[str, Any],
+ json.loads(self.json()),
+ )
+
+
+class VerifiedPayloadBaseModel(PayloadBaseModel):
+ """Pydantic base model for API models."""
+
+ class Config:
+ use_enum_values = True
+
+
+class UnverifiedPayloadBaseModel(PayloadBaseModel):
+ def __init__(
+ self,
+ _fields_set: Optional[Set[str]] = None,
+ **kwargs: Any,
+ ) -> None:
+ model = BaseModel.construct(_fields_set, **kwargs)
+ self.__dict__.update(model.__dict__) # noqa: WPS609 (Replace self attrs)
+
+ class Config:
+ arbitrary_types_allowed = True
+
+
+class StrEnum(str, Enum): # noqa: WPS600 (pydantic needs this inheritance)
+ """Enum base for API models."""
diff --git a/botx/models/async_files.py b/botx/models/async_files.py
new file mode 100644
index 00000000..f7f2fcc1
--- /dev/null
+++ b/botx/models/async_files.py
@@ -0,0 +1,249 @@
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
+from typing import AsyncGenerator, Literal, Union, cast
+from uuid import UUID
+
+from aiofiles.tempfile import SpooledTemporaryFile
+
+from botx.bot.contextvars import bot_id_var, bot_var, chat_id_var
+from botx.constants import CHUNK_SIZE
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.enums import (
+ APIAttachmentTypes,
+ AttachmentTypes,
+ convert_attachment_type_from_domain,
+ convert_attachment_type_to_domain,
+)
+
+
+@dataclass
+class AsyncFileBase:
+ type: AttachmentTypes
+ filename: str
+ size: int
+
+ is_async_file: Literal[True]
+
+ _file_id: UUID
+ _file_url: str
+ _file_mimetype: str
+ _file_hash: str
+
+ @asynccontextmanager
+ async def open(self) -> AsyncGenerator[SpooledTemporaryFile, None]:
+ bot = bot_var.get()
+
+ async with SpooledTemporaryFile(max_size=CHUNK_SIZE) as tmp_file:
+ await bot.download_file(
+ bot_id=bot_id_var.get(),
+ chat_id=chat_id_var.get(),
+ file_id=self._file_id,
+ async_buffer=tmp_file,
+ )
+
+ yield tmp_file
+
+
+@dataclass
+class Image(AsyncFileBase):
+ type: Literal[AttachmentTypes.IMAGE]
+
+
+@dataclass
+class Video(AsyncFileBase):
+ type: Literal[AttachmentTypes.VIDEO]
+
+ duration: int
+
+
+@dataclass
+class Document(AsyncFileBase):
+ type: Literal[AttachmentTypes.DOCUMENT]
+
+
+@dataclass
+class Voice(AsyncFileBase):
+ type: Literal[AttachmentTypes.VOICE]
+
+ duration: int
+
+
+class APIAsyncFileBase(VerifiedPayloadBaseModel):
+ type: APIAttachmentTypes
+ file: str
+ file_mime_type: str
+ file_id: UUID
+ file_name: str
+ file_size: int
+ file_hash: str
+
+ class Config:
+ """BotX sends extra fields which are used by client only.
+
+ We skip their validation, but extra fields will be saved during
+ serialization/deserialization.
+ """
+
+ extra = "allow"
+
+
+class ApiAsyncFileImage(APIAsyncFileBase):
+ type: Literal[APIAttachmentTypes.IMAGE]
+
+
+class ApiAsyncFileVideo(APIAsyncFileBase):
+ type: Literal[APIAttachmentTypes.VIDEO]
+
+ duration: int
+
+
+class ApiAsyncFileDocument(APIAsyncFileBase):
+ type: Literal[APIAttachmentTypes.DOCUMENT]
+
+
+class ApiAsyncFileVoice(APIAsyncFileBase):
+ type: Literal[APIAttachmentTypes.VOICE]
+
+ duration: int
+
+
+APIAsyncFile = Union[
+ ApiAsyncFileImage,
+ ApiAsyncFileVideo,
+ ApiAsyncFileDocument,
+ ApiAsyncFileVoice,
+]
+
+File = Union[Image, Video, Document, Voice]
+
+
+def convert_async_file_from_domain(file: File) -> APIAsyncFile:
+ attachment_type = convert_attachment_type_from_domain(file.type)
+
+ if attachment_type == APIAttachmentTypes.IMAGE:
+ attachment_type = cast(Literal[APIAttachmentTypes.IMAGE], attachment_type)
+ file = cast(Image, file)
+
+ return ApiAsyncFileImage(
+ type=attachment_type,
+ file_name=file.filename,
+ file_size=file.size,
+ file_id=file._file_id,
+ file=file._file_url,
+ file_mime_type=file._file_mimetype,
+ file_hash=file._file_hash,
+ )
+
+ if attachment_type == APIAttachmentTypes.VIDEO:
+ attachment_type = cast(Literal[APIAttachmentTypes.VIDEO], attachment_type)
+ file = cast(Video, file)
+
+ return ApiAsyncFileVideo(
+ type=attachment_type,
+ file_name=file.filename,
+ file_size=file.size,
+ duration=file.duration,
+ file_id=file._file_id,
+ file=file._file_url,
+ file_mime_type=file._file_mimetype,
+ file_hash=file._file_hash,
+ )
+
+ if attachment_type == APIAttachmentTypes.DOCUMENT:
+ attachment_type = cast(Literal[APIAttachmentTypes.DOCUMENT], attachment_type)
+ file = cast(Document, file)
+
+ return ApiAsyncFileDocument(
+ type=attachment_type,
+ file_name=file.filename,
+ file_size=file.size,
+ file_id=file._file_id,
+ file=file._file_url,
+ file_mime_type=file._file_mimetype,
+ file_hash=file._file_hash,
+ )
+
+ if attachment_type == APIAttachmentTypes.VOICE:
+ attachment_type = cast(Literal[APIAttachmentTypes.VOICE], attachment_type)
+ file = cast(Voice, file)
+
+ return ApiAsyncFileVoice(
+ type=attachment_type,
+ file_name=file.filename,
+ file_size=file.size,
+ duration=file.duration,
+ file_id=file._file_id,
+ file=file._file_url,
+ file_mime_type=file._file_mimetype,
+ file_hash=file._file_hash,
+ )
+
+ raise NotImplementedError(f"Unsupported attachment type: {attachment_type}")
+
+
+def convert_async_file_to_domain(async_file: APIAsyncFile) -> File:
+ attachment_type = convert_attachment_type_to_domain(async_file.type)
+
+ if attachment_type == AttachmentTypes.IMAGE:
+ attachment_type = cast(Literal[AttachmentTypes.IMAGE], attachment_type)
+ async_file = cast(ApiAsyncFileImage, async_file)
+
+ return Image(
+ type=attachment_type,
+ filename=async_file.file_name,
+ size=async_file.file_size,
+ is_async_file=True,
+ _file_id=async_file.file_id,
+ _file_mimetype=async_file.file_mime_type,
+ _file_url=async_file.file,
+ _file_hash=async_file.file_hash,
+ )
+
+ if attachment_type == AttachmentTypes.VIDEO:
+ attachment_type = cast(Literal[AttachmentTypes.VIDEO], attachment_type)
+ async_file = cast(ApiAsyncFileVideo, async_file)
+
+ return Video(
+ type=attachment_type,
+ filename=async_file.file_name,
+ size=async_file.file_size,
+ duration=async_file.duration,
+ is_async_file=True,
+ _file_id=async_file.file_id,
+ _file_mimetype=async_file.file_mime_type,
+ _file_url=async_file.file,
+ _file_hash=async_file.file_hash,
+ )
+
+ if attachment_type == AttachmentTypes.DOCUMENT:
+ attachment_type = cast(Literal[AttachmentTypes.DOCUMENT], attachment_type)
+ async_file = cast(ApiAsyncFileDocument, async_file)
+
+ return Document(
+ type=attachment_type,
+ filename=async_file.file_name,
+ size=async_file.file_size,
+ is_async_file=True,
+ _file_id=async_file.file_id,
+ _file_mimetype=async_file.file_mime_type,
+ _file_url=async_file.file,
+ _file_hash=async_file.file_hash,
+ )
+
+ if attachment_type == AttachmentTypes.VOICE:
+ attachment_type = cast(Literal[AttachmentTypes.VOICE], attachment_type)
+ async_file = cast(ApiAsyncFileVoice, async_file)
+
+ return Voice(
+ type=attachment_type,
+ filename=async_file.file_name,
+ size=async_file.file_size,
+ duration=async_file.duration,
+ is_async_file=True,
+ _file_id=async_file.file_id,
+ _file_mimetype=async_file.file_mime_type,
+ _file_url=async_file.file,
+ _file_hash=async_file.file_hash,
+ )
+
+ raise NotImplementedError(f"Unsupported attachment type: {attachment_type}")
diff --git a/botx/models/attachments.py b/botx/models/attachments.py
index 1a942f6a..3a3d3ab8 100644
--- a/botx/models/attachments.py
+++ b/botx/models/attachments.py
@@ -1,370 +1,438 @@
-"""Module with attachments for botx."""
+import base64
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import AsyncGenerator, Literal, Union, cast
-from typing import List, Optional, Union, cast
+from aiofiles.tempfile import SpooledTemporaryFile
-from pydantic import Field
+from botx.async_buffer import AsyncBufferReadable
+from botx.constants import CHUNK_SIZE
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.enums import (
+ APIAttachmentTypes,
+ AttachmentTypes,
+ convert_attachment_type_to_domain,
+)
-from botx.models.base import BotXBaseModel
-from botx.models.enums import AttachmentsTypes, LinkProtos
-from botx.models.files import File
-try:
- from typing import Literal # noqa: WPS433
-except ImportError:
- from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401
+@dataclass
+class FileAttachmentBase:
+ type: AttachmentTypes
+ filename: str
+ size: int
+ is_async_file: Literal[False]
-class FileAttachment(BotXBaseModel):
- """Class that represents file in RFC 2397 format."""
+ content: bytes
- #: name of file.
- file_name: Optional[str]
-
- #: file content in RFC 2397 format.
- content: str
+ @asynccontextmanager
+ async def open(self) -> AsyncGenerator[SpooledTemporaryFile, None]:
+ async with SpooledTemporaryFile(max_size=CHUNK_SIZE) as tmp_file:
+ await tmp_file.write(self.content)
+ await tmp_file.seek(0)
+ yield tmp_file
-class Image(FileAttachment):
- """Image model from botx."""
- file_name: str = "image.jpg"
+@dataclass
+class AttachmentImage(FileAttachmentBase):
+ type: Literal[AttachmentTypes.IMAGE]
-class Video(FileAttachment):
- """Video model from botx."""
+@dataclass
+class AttachmentVideo(FileAttachmentBase):
+ type: Literal[AttachmentTypes.VIDEO]
- file_name: str = "video.mp4"
-
- #: video duration
duration: int
-class Document(FileAttachment):
- """Document model from botx."""
-
- file_name: str = "document.docx"
+@dataclass
+class AttachmentDocument(FileAttachmentBase):
+ type: Literal[AttachmentTypes.DOCUMENT]
-class Voice(BotXBaseModel):
- """Voice model from botx."""
+@dataclass
+class AttachmentVoice(FileAttachmentBase):
+ type: Literal[AttachmentTypes.VOICE]
- #: file content in RFC 2397 format.
- content: str
-
- #: voice duration
duration: int
-class Location(BotXBaseModel):
- """Location model from botx."""
-
- #: name of location
- location_name: str
-
- #: address of location
- location_address: str
+@dataclass
+class AttachmentLocation:
+ type: Literal[AttachmentTypes.LOCATION]
- #: latitude of location
- location_lat: float
+ name: str
+ address: str
+ latitude: str
+ longitude: str
- #: longitude of location
- location_lng: float
+@dataclass
+class AttachmentContact:
+ type: Literal[AttachmentTypes.CONTACT]
-class Contact(BotXBaseModel):
- """Contact model from botx."""
+ name: str
- #: name of contact
- contact_name: str
+@dataclass
+class AttachmentLink:
+ type: Literal[AttachmentTypes.LINK]
-class Link(BotXBaseModel):
- """Class that marked as Link from botx."""
-
- #: url of link
url: str
+ title: str
+ preview: str
+ text: str
- #: title of url
- url_title: Optional[str] = None
-
- #: link on preview of this link
- url_preview: Optional[str] = None
-
- #: text on preview
- url_text: Optional[str] = None
- def is_mail(self) -> bool:
- """Confirm that is email link."""
- return self.url.startswith(LinkProtos.email)
-
- def is_telephone(self) -> bool:
- """Confirm that is telephone link."""
- return self.url.startswith(LinkProtos.telephone)
-
- def is_link(self) -> bool:
- """Confirm that is link on resource."""
- return not (self.is_mail() or self.is_telephone())
+IncomingFileAttachment = Union[
+ AttachmentImage,
+ AttachmentVideo,
+ AttachmentDocument,
+ AttachmentVoice,
+]
- @property
- def mailto(self) -> str:
- """Property that retuning email address without protocol."""
- if not self.is_mail():
- raise AttributeError("mailto")
- return self.url[len(LinkProtos.email) :] # noqa: E203
- @property
- def tel(self) -> str:
- """Property that retuning telephone number without protocol."""
- if not self.is_telephone():
- raise AttributeError("telephone number")
- return self.url[len(LinkProtos.telephone) :] # noqa: E203
+@dataclass
+class OutgoingAttachment:
+ content: bytes
+ filename: str
+ is_async_file: Literal[False] = False
+ @classmethod
+ async def from_async_buffer(
+ cls,
+ async_buffer: AsyncBufferReadable,
+ filename: str,
+ ) -> "OutgoingAttachment":
+ return cls(
+ content=await async_buffer.read(),
+ filename=filename,
+ )
-Attachments = Union[Image, Video, Document, Voice, Location, Contact, Link]
+class BotAPIAttachmentImageData(VerifiedPayloadBaseModel):
+ content: str
+ file_name: str
-class ImageAttachment(BotXBaseModel):
- """BotX API image attachment container."""
- #: type of attachment
- type: Literal[AttachmentsTypes.image] = Field(default=AttachmentsTypes.image)
+class BotAPIAttachmentImage(VerifiedPayloadBaseModel):
+ type: Literal[APIAttachmentTypes.IMAGE]
+ data: BotAPIAttachmentImageData
- #: content of attachment
- data: Image
+class BotAPIAttachmentVideoData(VerifiedPayloadBaseModel):
+ content: str
+ file_name: str
+ duration: int
-class VideoAttachment(BotXBaseModel):
- """BotX API video attachment container."""
- #: type of attachment
- type: Literal[AttachmentsTypes.video] = Field(default=AttachmentsTypes.video)
+class BotAPIAttachmentVideo(VerifiedPayloadBaseModel):
+ type: Literal[APIAttachmentTypes.VIDEO]
+ data: BotAPIAttachmentVideoData
- #: content of attachment
- data: Video
+class BotAPIAttachmentDocumentData(VerifiedPayloadBaseModel):
+ content: str
+ file_name: str
-class DocumentAttachment(BotXBaseModel):
- """BotX API document attachment container."""
- #: type of attachment
- type: Literal[AttachmentsTypes.document] = Field(default=AttachmentsTypes.document)
+class BotAPIAttachmentDocument(VerifiedPayloadBaseModel):
+ type: Literal[APIAttachmentTypes.DOCUMENT]
+ data: BotAPIAttachmentDocumentData
- #: content of attachment
- data: Document
+class BotAPIAttachmentVoiceData(VerifiedPayloadBaseModel):
+ content: str
+ duration: int
-class VoiceAttachment(BotXBaseModel):
- """BotX API voice attachment container."""
- #: type of attachment
- type: Literal[AttachmentsTypes.voice] = Field(default=AttachmentsTypes.voice)
+class BotAPIAttachmentVoice(VerifiedPayloadBaseModel):
+ type: Literal[APIAttachmentTypes.VOICE]
+ data: BotAPIAttachmentVoiceData
- #: content of attachment
- data: Voice
+class BotAPIAttachmentLocationData(VerifiedPayloadBaseModel):
+ location_name: str
+ location_address: str
+ location_lat: str
+ location_lng: str
-class ContactAttachment(BotXBaseModel):
- """BotX API contact attachment container."""
- #: type of attachment
- type: Literal[AttachmentsTypes.contact] = Field(default=AttachmentsTypes.contact)
+class BotAPIAttachmentLocation(VerifiedPayloadBaseModel):
+ type: Literal[APIAttachmentTypes.LOCATION]
+ data: BotAPIAttachmentLocationData
- #: content of attachment
- data: Contact
+class BotAPIAttachmentContactData(VerifiedPayloadBaseModel):
+ contact_name: str
-class LocationAttachment(BotXBaseModel):
- """BotX API location attachment container."""
- #: type of attachment
- type: Literal[AttachmentsTypes.location] = Field(default=AttachmentsTypes.location)
+class BotAPIAttachmentContact(VerifiedPayloadBaseModel):
+ type: Literal[APIAttachmentTypes.CONTACT]
+ data: BotAPIAttachmentContactData
- #: content of attachment
- data: Location
+class BotAPIAttachmentLinkData(VerifiedPayloadBaseModel):
+ url: str
+ url_title: str
+ url_preview: str
+ url_text: str
-class LinkAttachment(BotXBaseModel):
- """BotX API link attachment container."""
- #: type of attachment
- type: Literal[AttachmentsTypes.link] = Field(default=AttachmentsTypes.link)
+class BotAPIAttachmentLink(VerifiedPayloadBaseModel):
+ type: Literal[APIAttachmentTypes.LINK]
+ data: BotAPIAttachmentLinkData
- #: content of attachment
- data: Link
+BotAPIAttachment = Union[
+ BotAPIAttachmentVideo,
+ BotAPIAttachmentImage,
+ BotAPIAttachmentDocument,
+ BotAPIAttachmentVoice,
+ BotAPIAttachmentLocation,
+ BotAPIAttachmentContact,
+ BotAPIAttachmentLink,
+]
-Attachment = Union[
- ImageAttachment,
- VideoAttachment,
- DocumentAttachment,
- VoiceAttachment,
- ContactAttachment,
- LocationAttachment,
- LinkAttachment,
+IncomingAttachment = Union[
+ IncomingFileAttachment,
+ AttachmentLocation,
+ AttachmentContact,
+ AttachmentLink,
]
-class AttachList(BotXBaseModel): # noqa: WPS214, WPS338
- """Additional wrapped class for use property."""
-
- __root__: List[Attachment]
-
- def _get_attach_by_type(self, attach_type: AttachmentsTypes) -> Attachments:
- for attach in self.all_attachments:
- if attach.type == attach_type:
- return attach.data
- raise AttributeError(attach_type)
-
- @property
- def image(self) -> Image:
- """Parse attachments.
-
- Returns:
- Image: image from attachments.
- Raises:
- AttributeError: message has no image.
- """
- attach = self._get_attach_by_type(AttachmentsTypes.image)
- return cast(Image, attach)
-
- @property
- def document(self) -> Document:
- """Parse attachments.
-
- Returns:
- Document: document from attachments.
- Raises:
- AttributeError: message has no document.
- """
- attach = self._get_attach_by_type(AttachmentsTypes.document)
- return cast(Document, attach)
-
- @property
- def location(self) -> Location:
- """Parse attachments.
-
- Returns:
- Location: location from attachments.
- Raises:
- AttributeError: message has no location.
- """
- attach = self._get_attach_by_type(AttachmentsTypes.location)
- return cast(Location, attach)
-
- @property
- def contact(self) -> Contact:
- """Parse attachments.
-
- Returns:
- Contact: contact from attachments.
- Raises:
- AttributeError: message has no contact.
- """
- attach = self._get_attach_by_type(AttachmentsTypes.contact)
- return cast(Contact, attach)
-
- @property
- def voice(self) -> Voice:
- """Parse attachments.
-
- Returns:
- Voice: voice from attachments
- Raises:
- AttributeError: message has no voice.
- """
- attach = self._get_attach_by_type(AttachmentsTypes.voice)
- return cast(Voice, attach)
-
- @property
- def video(self) -> Video:
- """Parse attachments.
-
- Returns:
- Video: video from attachments.
- Raises:
- AttributeError: message has no video.
- """
- attach = self._get_attach_by_type(AttachmentsTypes.video)
- return cast(Video, attach)
-
- @property
- def link(self) -> Link:
- """Parse attachments.
-
- Returns:
- Link: lint to resource from attachments.
- Raises:
- AttributeError: message has no link.
- """
- attach = self._get_attach_by_type(AttachmentsTypes.link)
- if attach.is_link(): # type: ignore
- return cast(Link, attach)
- raise AttributeError(AttachmentsTypes.link)
-
- @property
- def email(self) -> str:
- """Parse attachments.
-
- Returns:
- str: email from attachments.
- Raises:
- AttributeError: message has no email.
- """
- attach = self._get_attach_by_type(AttachmentsTypes.link)
- if attach.is_mail(): # type: ignore
- return attach.mailto # type: ignore
- raise AttributeError(AttachmentsTypes.link)
-
- @property
- def telephone(self) -> str:
- """Parse attachments.
-
- Returns:
- str: telephone number from attachments.
- Raises:
- AttributeError: message has no telephone.
- """
- attach = self._get_attach_by_type(AttachmentsTypes.link)
- if attach.is_telephone(): # type: ignore
- return attach.tel # type: ignore
- raise AttributeError(AttachmentsTypes.link)
-
- @property
- def all_attachments(self) -> List[Attachment]:
- """Search attachments in message.
-
- Returns:
- List of attachments.
- """
- return self.__root__
-
- @property
- def file(self) -> File:
- """Search file in message's attachments.
-
- Returns:
- Botx file from video, image or document.
- Raises:
- AttributeError: message has no file.
- """
- for attachment in self.all_attachments:
- if isinstance(attachment.data, FileAttachment):
- return File.construct(
- file_name=attachment.data.file_name,
- data=attachment.data.content,
- )
- raise AttributeError
-
- @property
- def attach_type(self) -> AttachmentsTypes:
- """Get attachment type.
-
- Returns:
- AttachmentsTypes: Attachment type.
- Raises:
- AttributeError: message has no attachment.
- """
- if self.all_attachments:
- return self.all_attachments[0].type
-
- raise AttributeError
+def convert_api_attachment_to_domain( # noqa: WPS212
+ api_attachment: BotAPIAttachment,
+) -> IncomingAttachment:
+ attachment_type = convert_attachment_type_to_domain(api_attachment.type)
+
+ if attachment_type == AttachmentTypes.IMAGE:
+ attachment_type = cast(Literal[AttachmentTypes.IMAGE], attachment_type)
+ api_attachment = cast(BotAPIAttachmentImage, api_attachment)
+ content = decode_rfc2397(api_attachment.data.content)
+
+ return AttachmentImage(
+ type=attachment_type,
+ filename=api_attachment.data.file_name,
+ size=len(content),
+ is_async_file=False,
+ content=content,
+ )
+
+ if attachment_type == AttachmentTypes.VIDEO:
+ attachment_type = cast(Literal[AttachmentTypes.VIDEO], attachment_type)
+ api_attachment = cast(BotAPIAttachmentVideo, api_attachment)
+ content = decode_rfc2397(api_attachment.data.content)
+
+ return AttachmentVideo(
+ type=attachment_type,
+ filename=api_attachment.data.file_name,
+ size=len(content),
+ is_async_file=False,
+ content=content,
+ duration=api_attachment.data.duration,
+ )
+
+ if attachment_type == AttachmentTypes.DOCUMENT:
+ attachment_type = cast(Literal[AttachmentTypes.DOCUMENT], attachment_type)
+ api_attachment = cast(BotAPIAttachmentDocument, api_attachment)
+ content = decode_rfc2397(api_attachment.data.content)
+
+ return AttachmentDocument(
+ type=attachment_type,
+ filename=api_attachment.data.file_name,
+ size=len(content),
+ is_async_file=False,
+ content=content,
+ )
+
+ if attachment_type == AttachmentTypes.VOICE:
+ attachment_type = cast(Literal[AttachmentTypes.VOICE], attachment_type)
+ api_attachment = cast(BotAPIAttachmentVoice, api_attachment)
+ content = decode_rfc2397(api_attachment.data.content)
+
+ return AttachmentVoice(
+ type=attachment_type,
+ filename="record.mp3",
+ size=len(content),
+ is_async_file=False,
+ content=content,
+ duration=api_attachment.data.duration,
+ )
+
+ if attachment_type == AttachmentTypes.LOCATION:
+ attachment_type = cast(Literal[AttachmentTypes.LOCATION], attachment_type)
+ api_attachment = cast(BotAPIAttachmentLocation, api_attachment)
+
+ return AttachmentLocation(
+ type=attachment_type,
+ name=api_attachment.data.location_name,
+ address=api_attachment.data.location_address,
+ latitude=api_attachment.data.location_lat,
+ longitude=api_attachment.data.location_lng,
+ )
+
+ if attachment_type == AttachmentTypes.CONTACT:
+ attachment_type = cast(Literal[AttachmentTypes.CONTACT], attachment_type)
+ api_attachment = cast(BotAPIAttachmentContact, api_attachment)
+
+ return AttachmentContact(
+ type=attachment_type,
+ name=api_attachment.data.contact_name,
+ )
+
+ if attachment_type == AttachmentTypes.LINK:
+ attachment_type = cast(Literal[AttachmentTypes.LINK], attachment_type)
+ api_attachment = cast(BotAPIAttachmentLink, api_attachment)
+
+ return AttachmentLink(
+ type=attachment_type,
+ url=api_attachment.data.url,
+ title=api_attachment.data.url_title,
+ preview=api_attachment.data.url_preview,
+ text=api_attachment.data.url_text,
+ )
+
+ raise NotImplementedError(f"Unsupported attachment type: {attachment_type}")
+
+
+def decode_rfc2397(encoded_content: str) -> bytes:
+ # "data:image/gif;base64,aGVsbG8=" -> b"hello"
+ return base64.b64decode(encoded_content.split(",", 1)[1].encode())
+
+
+EXTENSIONS_TO_MIMETYPES = MappingProxyType(
+ {
+ # application
+ "7z": "application/x-7z-compressed",
+ "abw": "application/x-abiword",
+ "ai": "application/postscript",
+ "arc": "application/x-freearc",
+ "azw": "application/vnd.amazon.ebook",
+ "bin": "application/octet-stream",
+ "bz": "application/x-bzip",
+ "bz2": "application/x-bzip2",
+ "cda": "application/x-cdf",
+ "csh": "application/x-csh",
+ "doc": "application/msword",
+ "docx": (
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ ),
+ "eot": "application/vnd.ms-fontobject",
+ "eps": "application/postscript",
+ "epub": "application/epub+zip",
+ "gz": "application/gzip",
+ "jar": "application/java-archive",
+ "json-api": "application/vnd.api+json",
+ "json-patch": "application/json-patch+json",
+ "json": "application/json",
+ "jsonld": "application/ld+json",
+ "mdb": "application/x-msaccess",
+ "mpkg": "application/vnd.apple.installer+xml",
+ "odp": "application/vnd.oasis.opendocument.presentation",
+ "ods": "application/vnd.oasis.opendocument.spreadsheet",
+ "odt": "application/vnd.oasis.opendocument.text",
+ "ogx": "application/ogg",
+ "pdf": "application/pdf",
+ "php": "application/x-httpd-php",
+ "ppt": "application/vnd.ms-powerpoint",
+ "pptx": (
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation"
+ ),
+ "ps": "application/postscript",
+ "rar": "application/vnd.rar",
+ "rtf": "application/rtf",
+ "sh": "application/x-sh",
+ "swf": "application/x-shockwave-flash",
+ "tar": "application/x-tar",
+ "vsd": "application/vnd.visio",
+ "wasm": "application/wasm",
+ "webmanifest": "application/manifest+json",
+ "xhtml": "application/xhtml+xml",
+ "xls": "application/vnd.ms-excel",
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "xul": "application/vnd.mozilla.xul+xml",
+ "zip": "application/zip",
+ # audio
+ "aac": "audio/aac",
+ "mid": "audio/midi",
+ "midi": "audio/midi",
+ "mp3": "audio/mpeg",
+ "oga": "audio/ogg",
+ "opus": "audio/opus",
+ "wav": "audio/wav",
+ "weba": "audio/webm",
+ # font
+ "otf": "font/otf",
+ "ttf": "font/ttf",
+ "woff": "font/woff",
+ "woff2": "font/woff2",
+ # image
+ "avif": "image/avif",
+ "bmp": "image/bmp",
+ "gif": "image/gif",
+ "ico": "image/vnd.microsoft.icon",
+ "jpeg": "image/jpeg",
+ "jpg": "image/jpeg",
+ "png": "image/png",
+ "svg": "image/svg+xml",
+ "svgz": "image/svg+xml",
+ "tif": "image/tiff",
+ "tiff": "image/tiff",
+ "webp": "image/webp",
+ # text
+ "css": "text/css",
+ "csv": "text/csv",
+ "htm": "text/html",
+ "html": "text/html",
+ "ics": "text/calendar",
+ "js": "text/javascript",
+ "mjs": "text/javascript",
+ "txt": "text/plain",
+ "text": "text/plain",
+ "xml": "text/xml",
+ # video
+ "3g2": "video/3gpp2",
+ "3gp": "video/3gpp",
+ "avi": "video/x-msvideo",
+ "mov": "video/quicktime",
+ "mp4": "video/mp4",
+ "mpeg": "video/mpeg",
+ "mpg": "video/mpeg",
+ "ogv": "video/ogg",
+ "ts": "video/mp2t",
+ "webm": "video/webm",
+ "wmv": "video/x-ms-wmv",
+ },
+)
+DEFAULT_MIMETYPE = "application/octet-stream"
+
+
+def encode_rfc2397(content: bytes, mimetype: str) -> str:
+ b64_content = base64.b64encode(content).decode()
+ return f"data:{mimetype};base64,{b64_content}"
+
+
+class BotXAPIAttachment(UnverifiedPayloadBaseModel):
+ file_name: str
+ data: str
+
+ @classmethod
+ def from_file_attachment(
+ cls,
+ attachment: Union[IncomingFileAttachment, OutgoingAttachment],
+ ) -> "BotXAPIAttachment":
+ assert attachment.content is not None
+
+ mimetype = EXTENSIONS_TO_MIMETYPES.get(
+ attachment.filename.split(".")[-1],
+ DEFAULT_MIMETYPE,
+ )
+
+ return cls(
+ file_name=attachment.filename,
+ data=encode_rfc2397(attachment.content, mimetype),
+ )
diff --git a/botx/models/attachments_meta.py b/botx/models/attachments_meta.py
deleted file mode 100644
index 7c2469b9..00000000
--- a/botx/models/attachments_meta.py
+++ /dev/null
@@ -1,87 +0,0 @@
-"""Module with attachments meta for botx."""
-
-from typing import Optional, Union
-
-from pydantic import Field
-
-from botx.models.base import BotXBaseModel
-from botx.models.enums import AttachmentsTypes
-
-try:
- from typing import Literal # noqa: WPS433
-except ImportError:
- from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401
-
-
-class FileAttachmentMeta(BotXBaseModel):
- """Common metadata of file."""
-
- #: type of attachment
- type: str
-
- #: name of file.
- file_name: str
-
- #: mime type of file
- file_mime_type: Optional[str]
-
- #: file preview in RFC 2397 format.
- file_preview_base64: Optional[str]
-
-
-class ImageAttachmentMeta(FileAttachmentMeta):
- """BotX API image attachment meta container."""
-
- #: type of attachment
- type: Literal[AttachmentsTypes.image] = Field(default=AttachmentsTypes.image)
-
-
-class VideoAttachmentMeta(FileAttachmentMeta):
- """BotX API video attachment meta container."""
-
- #: type of attachment
- type: Literal[AttachmentsTypes.video] = Field(default=AttachmentsTypes.video)
-
-
-class DocumentAttachmentMeta(FileAttachmentMeta):
- """BotX API document attachment meta container."""
-
- #: type of attachment
- type: Literal[AttachmentsTypes.document] = Field(default=AttachmentsTypes.document)
-
-
-class VoiceAttachmentMeta(FileAttachmentMeta):
- """BotX API voice attachment meta container."""
-
- #: type of attachment
- type: Literal[AttachmentsTypes.voice] = Field(default=AttachmentsTypes.voice)
-
-
-class ContactAttachmentMeta(BotXBaseModel):
- """BotX API contact attachment meta container."""
-
- #: type of attachment
- type: Literal[AttachmentsTypes.contact] = Field(default=AttachmentsTypes.contact)
-
- #: name of contact
- contact_name: str
-
-
-class LocationAttachmentMeta(BotXBaseModel):
- """BotX API location attachment meta container."""
-
- #: type of attachment
- type: Literal[AttachmentsTypes.location] = Field(default=AttachmentsTypes.location)
-
- #: address of location
- location_address: str
-
-
-AttachmentMeta = Union[
- ImageAttachmentMeta,
- VideoAttachmentMeta,
- DocumentAttachmentMeta,
- VoiceAttachmentMeta,
- ContactAttachmentMeta,
- LocationAttachmentMeta,
-]
diff --git a/botx/models/base.py b/botx/models/base.py
deleted file mode 100644
index 95c23d9a..00000000
--- a/botx/models/base.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Module with base model classes."""
-from pydantic import BaseModel
-
-
-class BotXBaseModel(BaseModel):
- """Base class for configure all models."""
-
- class Config:
- use_enum_values = True
diff --git a/botx/models/base_command.py b/botx/models/base_command.py
new file mode 100644
index 00000000..8e7963ce
--- /dev/null
+++ b/botx/models/base_command.py
@@ -0,0 +1,73 @@
+from dataclasses import dataclass
+from typing import Any, Dict, Literal, Optional
+from uuid import UUID
+
+from pydantic import validator
+
+from botx.bot.api.exceptions import UnsupportedBotAPIVersionError
+from botx.constants import BOT_API_VERSION
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.bot_account import BotAccount
+from botx.models.enums import APIChatTypes, BotAPIClientPlatforms, BotAPICommandTypes
+
+
+class BotAPICommandPayload(VerifiedPayloadBaseModel):
+ body: str
+ command_type: Literal[BotAPICommandTypes.USER]
+ data: Dict[str, Any]
+ metadata: Dict[str, Any]
+
+
+class BotAPIDeviceMeta(VerifiedPayloadBaseModel):
+ pushes: Optional[bool]
+ timezone: Optional[str]
+ permissions: Optional[Dict[str, Any]]
+
+
+class BaseBotAPIContext(VerifiedPayloadBaseModel):
+ host: str
+
+
+class BotAPIUserContext(BaseBotAPIContext):
+ user_huid: UUID
+ ad_domain: Optional[str]
+ ad_login: Optional[str]
+ username: Optional[str]
+ is_admin: Optional[bool]
+ is_creator: Optional[bool]
+
+
+class BotAPIChatContext(BaseBotAPIContext):
+ group_chat_id: UUID
+ chat_type: APIChatTypes
+
+
+class BotAPIDeviceContext(BaseBotAPIContext):
+ app_version: Optional[str]
+ platform: Optional[BotAPIClientPlatforms]
+ platform_package_id: Optional[str]
+ device: Optional[str]
+ device_meta: Optional[BotAPIDeviceMeta]
+ device_software: Optional[str]
+ manufacturer: Optional[str]
+ locale: Optional[str]
+
+
+class BotAPIBaseCommand(VerifiedPayloadBaseModel):
+ bot_id: UUID
+ sync_id: UUID
+ proto_version: int
+
+ @validator("proto_version", pre=True)
+ @classmethod
+ def validate_proto_version(cls, version: Any) -> int:
+ if isinstance(version, int) and version == BOT_API_VERSION:
+ return version
+
+ raise UnsupportedBotAPIVersionError(version)
+
+
+@dataclass
+class BotCommandBase:
+ bot: BotAccount
+ raw_command: Optional[Dict[str, Any]]
diff --git a/botx/models/bot_account.py b/botx/models/bot_account.py
new file mode 100644
index 00000000..5c201ea7
--- /dev/null
+++ b/botx/models/bot_account.py
@@ -0,0 +1,13 @@
+from dataclasses import dataclass
+from uuid import UUID
+
+
+@dataclass
+class BotAccount:
+ id: UUID
+ host: str
+
+
+@dataclass
+class BotAccountWithSecret(BotAccount):
+ secret_key: str
diff --git a/botx/models/bot_sender.py b/botx/models/bot_sender.py
new file mode 100644
index 00000000..5709c7ad
--- /dev/null
+++ b/botx/models/bot_sender.py
@@ -0,0 +1,10 @@
+from dataclasses import dataclass
+from typing import Optional
+from uuid import UUID
+
+
+@dataclass
+class BotSender:
+ huid: UUID
+ is_chat_admin: Optional[bool]
+ is_chat_creator: Optional[bool]
diff --git a/botx/models/buttons.py b/botx/models/buttons.py
deleted file mode 100644
index 8ba2137a..00000000
--- a/botx/models/buttons.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""Pydantic models for bubbles and keyboard buttons."""
-
-from typing import Optional
-
-from pydantic import validator
-
-from botx.models.base import BotXBaseModel
-from botx.models.enums import ButtonHandlerTypes
-
-
-class ButtonOptions(BotXBaseModel):
- """Extra options for buttons, like disabling output by tap."""
-
- #: if True then text won't shown for user in messenger.
- silent: bool = True
-
- #: button width weight (the more weight, the more occupied space).
- h_size: int = 1
-
- #: show toast with `alert_text` when user press the button
- show_alert: bool = False
-
- #: text to be shown in toast (show command body if `alert_text` is `None`).
- alert_text: Optional[str] = None
-
- #: platform, that handle command from markup. If `bot` - command should be send
- # to bot, else(`client`) should be executed by client.
- handler: ButtonHandlerTypes = ButtonHandlerTypes.bot
-
- @validator("h_size")
- def h_size_should_be_positive(cls, h_size: int) -> int: # noqa: N805
- """Validate that `h_size` is positive integer.
-
- Arguments:
- h_size: width weight for validation.
-
- Returns:
- Validated `h_size`.
-
- Raises:
- ValueError: if `h_size` is not valid.
- """
- if h_size < 1:
- raise ValueError("h_size should be positive integer")
-
- return h_size
-
-
-class Button(BotXBaseModel):
- """Base class for ui element like bubble or keyboard button."""
-
- #: command that will be triggered by click on the element.
- command: str
-
- #: text that will be shown on the element.
- label: Optional[str] = None
-
- #: extra payload that will be stored in button and then received in new message.
- data: dict = {} # noqa: WPS110
-
- #: options for button.
- opts: ButtonOptions = ButtonOptions()
-
- @validator("label", always=True)
- def label_as_command_if_none(
- cls,
- label: Optional[str],
- values: dict, # noqa: N805, WPS110
- ) -> str:
- """Return command as label if it is `None`.
-
- Arguments:
- label: value that should be checked.
- values: all other validated_values checked before.
-
- Returns:
- Label for button.
- """
- if label is None:
- return values["command"]
-
- return label
-
-
-class BubbleElement(Button):
- """Bubble buttons that is shown under messages."""
-
-
-class KeyboardElement(Button):
- """Keyboard buttons that are placed instead of real keyboard."""
diff --git a/botx/models/chats.py b/botx/models/chats.py
index 7586808d..9c12c0eb 100644
--- a/botx/models/chats.py
+++ b/botx/models/chats.py
@@ -1,75 +1,74 @@
-"""Entities for chats."""
-
+from dataclasses import dataclass
from datetime import datetime
-from typing import Iterator, List, Optional
+from datetime import datetime as dt
+from typing import List, Optional
from uuid import UUID
-from botx.models.base import BotXBaseModel
-from botx.models.enums import ChatTypes
-from botx.models.users import UserFromChatSearch
-
-
-class ChatFromSearch(BotXBaseModel):
- """Chat from search request."""
+from botx.models.enums import ChatTypes, UserKinds
- #: name of chat.
- name: str
- #: description of chat
- description: Optional[str]
+@dataclass
+class Chat:
+ id: UUID
+ type: ChatTypes
- #: type of chat.
- chat_type: ChatTypes
- #: HUID of chat creator.
- creator: UUID
+@dataclass
+class ChatListItem:
+ """Chat from list.
- #: ID of chat.
- group_chat_id: UUID
+ Attributes:
+ chat_id: Chat id.
+ chat_type: Chat Type.
+ name: Chat name.
+ description: Chat description.
+ members: Chat members.
+ created_at: Chat creation datetime.
+ updated_at: Last chat update datetime.
+ """
- #: users in chat.
- members: List[UserFromChatSearch]
-
- #: creation datetime of chat.
- inserted_at: datetime
-
-
-class BotChatFromList(BotXBaseModel):
- """Chat from list."""
-
- #: name of chat.
+ chat_id: UUID
+ chat_type: ChatTypes
name: str
-
- #: description of chat.
description: Optional[str]
-
- #: type of chat.
- chat_type: ChatTypes
-
- #: ID of chat.
- group_chat_id: UUID
-
- #: users in chat.
members: List[UUID]
+ created_at: datetime
+ updated_at: datetime
- #: datetime bot joined in chat.
- inserted_at: datetime
- #: update datetime of chat.
- updated_at: datetime
+@dataclass
+class ChatInfoMember:
+ """Chat member.
+ Attributes:
+ is_admin: Is user admin.
+ huid: User huid.
+ kind: User type.
+ """
-class BotChatList(BotXBaseModel):
- """Bot's chat list response model."""
+ is_admin: bool
+ huid: UUID
+ kind: UserKinds
- __root__: List[BotChatFromList]
- def __iter__(self) -> Iterator[BotChatFromList]: # type: ignore
- """Override iterator for pydantic model."""
- return iter(self.__root__)
+@dataclass
+class ChatInfo:
+ """Chat information.
- def __len__(self) -> int: # noqa: D105
- return len(self.__root__)
+ Attributes:
+ chat_type: Chat type.
+ creator_id: Chat creator id.
+ description: Chat description.
+ chat_id: Chat id.
+ created_at: Chat creation datetime.
+ members: Chat members.
+ name: Chat name.
+ """
- def __getitem__(self, key: int) -> BotChatFromList: # noqa: D105
- return self.__root__[key]
+ chat_type: ChatTypes
+ creator_id: UUID
+ description: Optional[str]
+ chat_id: UUID
+ created_at: dt
+ members: List[ChatInfoMember]
+ name: str
diff --git a/botx/models/commands.py b/botx/models/commands.py
new file mode 100644
index 00000000..acd6e64d
--- /dev/null
+++ b/botx/models/commands.py
@@ -0,0 +1,44 @@
+from typing import Union
+
+from botx.models.message.incoming_message import BotAPIIncomingMessage, IncomingMessage
+from botx.models.system_events.added_to_chat import AddedToChatEvent, BotAPIAddedToChat
+from botx.models.system_events.chat_created import BotAPIChatCreated, ChatCreatedEvent
+from botx.models.system_events.cts_login import BotAPICTSLogin, CTSLoginEvent
+from botx.models.system_events.cts_logout import BotAPICTSLogout, CTSLogoutEvent
+from botx.models.system_events.deleted_from_chat import (
+ BotAPIDeletedFromChat,
+ DeletedFromChatEvent,
+)
+from botx.models.system_events.internal_bot_notification import (
+ BotAPIInternalBotNotification,
+ InternalBotNotificationEvent,
+)
+from botx.models.system_events.left_from_chat import (
+ BotAPILeftFromChat,
+ LeftFromChatEvent,
+)
+from botx.models.system_events.smartapp_event import BotAPISmartAppEvent, SmartAppEvent
+
+BotAPISystemEvent = Union[
+ BotAPIChatCreated,
+ BotAPIAddedToChat,
+ BotAPIDeletedFromChat,
+ BotAPILeftFromChat,
+ BotAPICTSLogin,
+ BotAPICTSLogout,
+ BotAPIInternalBotNotification,
+ BotAPISmartAppEvent,
+]
+BotAPICommand = Union[BotAPIIncomingMessage, BotAPISystemEvent]
+
+SystemEvent = Union[
+ ChatCreatedEvent,
+ AddedToChatEvent,
+ DeletedFromChatEvent,
+ LeftFromChatEvent,
+ CTSLoginEvent,
+ CTSLogoutEvent,
+ InternalBotNotificationEvent,
+ SmartAppEvent,
+]
+BotCommand = Union[IncomingMessage, SystemEvent]
diff --git a/botx/models/constants.py b/botx/models/constants.py
deleted file mode 100644
index 01451fcd..00000000
--- a/botx/models/constants.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Definition of different constants that are used in models."""
-
-MAXIMUM_TEXT_LENGTH = 4096
diff --git a/botx/models/credentials.py b/botx/models/credentials.py
deleted file mode 100644
index 35ea7edd..00000000
--- a/botx/models/credentials.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""Definition of credentials that are used for access to BotX API."""
-
-import base64
-import hashlib
-import hmac
-from typing import Optional
-from uuid import UUID
-
-from botx.models.base import BotXBaseModel
-
-
-class BotXCredentials(BotXBaseModel):
- """Credentials to bot account in express."""
-
- #: host name of server.
- host: str
-
- #: secret that will be used for generating signature for bot.
- secret_key: str
-
- #: bot that retrieved token from API.
- bot_id: UUID
-
- #: token generated for bot.
- token: Optional[str] = None
-
- @property
- def signature(self) -> str:
- """Calculate signature for obtaining token for bot from BotX API.
-
- Returns:
- Calculated signature.
- """
- return base64.b16encode(
- hmac.new(
- key=self.secret_key.encode(),
- msg=str(self.bot_id).encode(),
- digestmod=hashlib.sha256,
- ).digest(),
- ).decode()
diff --git a/botx/models/datastructures.py b/botx/models/datastructures.py
deleted file mode 100644
index de69c87b..00000000
--- a/botx/models/datastructures.py
+++ /dev/null
@@ -1,44 +0,0 @@
-"""Entities that represent some structs that are used in this library."""
-
-from typing import Any, Optional
-
-
-class State:
- """An object that can be used to store arbitrary state."""
-
- _state: dict
-
- def __init__(self, state: Optional[dict] = None):
- """Init state with required query_params.
-
- Arguments:
- state: initial state.
- """
- state = state or {}
- super().__setattr__("_state", state) # noqa: WPS613
-
- def __setattr__(self, key: Any, new_value: Any) -> None:
- """Set state attribute.
-
- Arguments:
- key: key to set attribute.
- new_value: value of attribute.
- """
- self._state[key] = new_value
-
- def __getattr__(self, key: Any) -> Any:
- """Get state attribute.
-
- Arguments:
- key: key of retrieved attribute.
-
- Returns:
- Stored value.
-
- Raises:
- AttributeError: raised if attribute was not found in state.
- """
- try:
- return self._state[key]
- except KeyError:
- raise AttributeError("state has no attribute '{0}'".format(key))
diff --git a/botx/models/entities.py b/botx/models/entities.py
deleted file mode 100644
index 232ae68b..00000000
--- a/botx/models/entities.py
+++ /dev/null
@@ -1,294 +0,0 @@
-"""Entities that can be received in message."""
-
-from datetime import datetime
-from typing import Dict, List, Optional, Union, cast
-from uuid import UUID, uuid4
-
-from pydantic import Field, validator
-
-from botx.models.attachments_meta import AttachmentMeta
-from botx.models.base import BotXBaseModel
-from botx.models.enums import ChatTypes, EntityTypes, MentionTypes
-
-
-class Forward(BotXBaseModel):
- """Forward in message."""
-
- #: ID of chat from which forward received.
- group_chat_id: UUID
-
- #: ID of user that is author of message.
- sender_huid: UUID
-
- #: type of forward.
- forward_type: ChatTypes
-
- #: name of original chat.
- source_chat_name: Optional[str] = None
-
- #: id of original message event.
- source_sync_id: UUID
-
- #: id of event creation.
- source_inserted_at: datetime
-
-
-class UserMention(BotXBaseModel):
- """Mention for single user in chat or by `user_huid`."""
-
- #: huid of user that will be mentioned.
- user_huid: UUID
-
- #: name that will be used instead of default user name.
- name: Optional[str] = None
-
- #: connection type via that entity was mention
- conn_type: Optional[str] = None
-
-
-class ChatMention(BotXBaseModel):
- """Mention chat in message by `group_chat_id`."""
-
- #: id of chat that will be mentioned.
- group_chat_id: UUID
-
- #: name that will be used instead of default chat name.
- name: Optional[str] = None
-
-
-class Mention(BotXBaseModel):
- """Mention that is used in bot in messages."""
-
- #: unique id of mention.
- mention_id: Optional[UUID] = None
-
- #: information about mention object
- mention_data: Optional[Union[ChatMention, UserMention, Dict]]
-
- #: payload with data about mention.
- mention_type: MentionTypes = MentionTypes.user
-
- @validator("mention_id", pre=True, always=True)
- def generate_mention_id(cls, mention_id: Optional[UUID]) -> UUID: # noqa: N805
- """Verify that `mention_id` will be in mention.
-
- Arguments:
- mention_id: id that should present or new UUID4 will be generated.
-
- Returns:
- Mention ID.
- """
- return mention_id or uuid4()
-
- @validator("mention_data", pre=True, always=True)
- def ignore_empty_data(
- cls,
- mention_data: Union[ChatMention, UserMention, Dict], # noqa: N805
- ) -> Optional[Union[ChatMention, UserMention, Dict]]:
- """Pass empty dict into mention_data as None.
-
- Arguments:
- mention_data: dict of mention's data.
-
- Returns:
- Mention's data if is not empty or None.
- """
- if mention_data == {}: # noqa: WPS520
- return None
-
- return mention_data
-
- @validator("mention_type", pre=True, always=True)
- def check_that_type_matches_data( # noqa: WPS231, WPS210
- cls,
- mention_type: MentionTypes,
- values: dict, # noqa: N805, WPS110
- ) -> MentionTypes:
- """Verify that `mention_type` matches provided `mention_data`.
-
- Arguments:
- mention_type: mention type that should be consistent with data.
- values: verified data.
-
- Returns:
- Checked mention type.
-
- Raises:
- ValueError: raised if mention_type does not corresponds with data.
- """
- mention_data = values.get("mention_data")
- if (mention_type != MentionTypes.all_members) and (mention_data is None):
- raise ValueError("no `mention_data`, perhaps this entity isn't a mention")
-
- user_mention_types = {MentionTypes.user, MentionTypes.contact}
- chat_mention_types = {MentionTypes.chat, MentionTypes.channel}
-
- is_user_mention_signature = isinstance(mention_data, UserMention) and (
- mention_type in user_mention_types
- )
- is_chat_mention_signature = isinstance(mention_data, ChatMention) and (
- mention_type in chat_mention_types
- )
- is_mention_all_signature = mention_type == MentionTypes.all_members
-
- if not any( # noqa: WPS337
- {
- is_chat_mention_signature,
- is_mention_all_signature,
- is_user_mention_signature,
- },
- ):
- raise ValueError("No one suitable type for this mention_data signature")
-
- return mention_type
-
- @classmethod
- def build_from_values(
- cls,
- mention_type: MentionTypes,
- mentioned_entity_id: UUID,
- name: Optional[str] = None,
- mention_id: Optional[UUID] = None,
- ) -> "Mention":
- """Build mention.
-
- Simpler to use than constructor 'cause of flat values.
-
- Arguments:
- mention_type: mention type.
- mentioned_entity_id: id of mentioned entity (user, chat, etc.).
- name: for overriding mention name.
- mention_id: mention id (if not passed, will be generated).
-
- Raises:
- NotImplementedError: If unsupported mention type was passed.
-
- Returns:
- Built mention.
- """
- mention_data: Union[UserMention, ChatMention]
-
- if mention_type in {MentionTypes.user, MentionTypes.contact}:
- mention_data = UserMention(user_huid=mentioned_entity_id, name=name)
- elif mention_type in {MentionTypes.chat, MentionTypes.channel}:
- mention_data = ChatMention(group_chat_id=mentioned_entity_id, name=name)
- else:
- raise NotImplementedError("Unsupported mention type")
-
- return cls(
- mention_id=mention_id,
- mention_data=mention_data,
- mention_type=mention_type,
- )
-
- def to_botx_format(self) -> str:
- """Format mention to format, which can be parse by botx.
-
- Raises:
- NotImplementedError: If unsupported mention type was passed.
-
- Returns:
- Formatted mention.
- """
- formatted_mention_data = "{{mention:{0}}}".format(self.mention_id)
-
- if self.mention_type == MentionTypes.user:
- prefix = "@"
- elif self.mention_type == MentionTypes.contact:
- prefix = "@@"
- elif self.mention_type in {MentionTypes.chat, MentionTypes.channel}:
- prefix = "##"
- else:
- raise NotImplementedError("Unsupported mention type")
-
- return "{0}{1}".format(prefix, formatted_mention_data)
-
-
-class Reply(BotXBaseModel):
- """Message that was replied."""
-
- #: attachment metadata.
- attachment_meta: Optional[AttachmentMeta] = Field(alias="attachment")
-
- #: text of source message.
- body: Optional[str]
-
- #: mentions of source message.
- mentions: List[Mention] = []
-
- #: type of source message's chat.
- reply_type: ChatTypes
-
- #: uuid of sender.
- sender: UUID
-
- #: chat name of source message.
- source_chat_name: Optional[str]
-
- #: chat uuid of source message.
- source_group_chat_id: Optional[UUID]
-
- #: uuid of source message.
- source_sync_id: UUID
-
- class Config:
- allow_population_by_field_name = True
-
-
-class Entity(BotXBaseModel):
- """Additional entity that can be received by bot."""
-
- #: entity type.
- type: EntityTypes # noqa: WPS125
-
- #: entity data.
- data: Union[Forward, Mention, Reply] # noqa: WPS110
-
-
-class EntityList(BotXBaseModel):
- """Additional wrapped class for use property."""
-
- __root__: List[Entity]
-
- @property
- def mentions(self) -> List[Mention]:
- """Search mentions in message's entity.
-
- Returns:
- List of mentions.
- """
- return [
- cast(Mention, entity.data)
- for entity in self.__root__
- if entity.type == EntityTypes.mention
- ]
-
- @property
- def forward(self) -> Forward:
- """Search forward in message's entity.
-
- Returns:
- Information about forward.
-
- Raises:
- AttributeError: raised if message has no forward.
- """
- for entity in self.__root__:
- if entity.type == EntityTypes.forward: # pragma: no branch
- return cast(Forward, entity.data)
- raise AttributeError("forward")
-
- @property
- def reply(self) -> Reply:
- """Search reply in message's entity.
-
- Returns:
- Reply.
-
- Raises:
- AttributeError: raised if message has no reply.
- """
- for entity in self.__root__:
- if entity.type == EntityTypes.reply: # pragma: no branch
- return cast(Reply, entity.data)
- raise AttributeError("reply")
diff --git a/botx/models/enums.py b/botx/models/enums.py
index f395b451..5e430071 100644
--- a/botx/models/enums.py
+++ b/botx/models/enums.py
@@ -1,168 +1,239 @@
-"""Definition of enums that are used across different components of this library."""
+from enum import Enum, auto
-from enum import Enum
+from botx.models.api_base import StrEnum
-class SystemEvents(Enum):
- """System enums that bot can retrieve from BotX API in message.
+class AutoName(Enum):
+ def _generate_next_value_( # type: ignore # noqa: WPS120
+ name, # noqa: N805 (copied from official python docs)
+ start,
+ count,
+ last_values,
+ ):
+ return name
- !!! info
- NOTE: `file_transfer` is not a system event, but it is logical to place it in
- this enum.
- """
-
- #: `system:chat_created` event.
- chat_created = "system:chat_created"
-
- #: `system:added_to_chat` event.
- added_to_chat = "system:added_to_chat"
-
- #: `system:deleted_from_chat` event.
- deleted_from_chat = "system:deleted_from_chat"
-
- #: `system:left_from_chat` event.
- left_from_chat = "system:left_from_chat"
-
- #: `system:internal_bot_notification` event
- internal_bot_notification = "system:internal_bot_notification"
-
- #: `system:cts_login` event.
- cts_login = "system:cts_login"
-
- #: `system:cts_logout` event.
- cts_logout = "system:cts_logout"
- #: `system:smartapp_event` event.
- smartapp_event = "system:smartapp_event"
+class UserKinds(AutoName):
+ RTS_USER = auto()
+ CTS_USER = auto()
+ BOT = auto()
- #: `file_transfer` message.
- file_transfer = "file_transfer"
+class AttachmentTypes(AutoName):
+ IMAGE = auto()
+ VIDEO = auto()
+ DOCUMENT = auto()
+ VOICE = auto()
+ LOCATION = auto()
+ CONTACT = auto()
+ LINK = auto()
-class CommandTypes(str, Enum):
- """Enum that specify from whom command was received."""
- #: command received from user.
- user = "user"
+class ClientPlatforms(AutoName):
+ WEB = auto()
+ ANDROID = auto()
+ IOS = auto()
+ DESKTOP = auto()
- #: command received from system.
- system = "system"
+class MentionTypes(AutoName):
+ CONTACT = auto()
+ CHAT = auto()
+ CHANNEL = auto()
+ USER = auto()
+ ALL = auto()
-class ChatTypes(str, Enum):
- """Enum for type of chat."""
- #: private chat for user with bot.
- chat = "chat"
+class ChatTypes(AutoName):
+ """BotX chat types.
- #: chat with several users.
- group_chat = "group_chat"
-
- #: channel chat.
- channel = "channel"
-
- # botx
- botx = "botx" # todo replies incoming with whith type
-
-
-class UserKinds(str, Enum):
- """Enum for type of user."""
-
- #: normal user.
- user = "user"
-
- #: normal user, but will present if all users in chat are from the same CTS.
- cts_user = "cts_user"
-
- #: bot user.
- bot = "botx"
+ Attributes:
+ PERSONAL_CHAT: Personal chat with user.
+ GROUP_CHAT: Group chat.
+ CHANNEL: Public channel.
+ """
+ PERSONAL_CHAT = auto()
+ GROUP_CHAT = auto()
+ CHANNEL = auto()
-class Statuses(str, Enum):
- """Enum for status of operation in BotX API."""
- #: operation was successfully proceed.
- ok = "ok"
+class APIChatTypes(StrEnum):
+ CHAT = "chat"
+ GROUP_CHAT = "group_chat"
+ CHANNEL = "channel"
- #: there was an error while processing operation.
- error = "error"
+class BotAPICommandTypes(StrEnum):
+ USER = "user"
+ SYSTEM = "system"
-class EntityTypes(str, Enum):
- """Types for entities that could be received by bot."""
- #: mention entity.
- mention = "mention"
+class BotAPIClientPlatforms(StrEnum):
+ WEB = "web"
+ ANDROID = "android"
+ IOS = "ios"
+ DESKTOP = "desktop"
- #: forward entity.
- forward = "forward"
- #: reply entity.
- reply = "reply"
+class BotAPIEntityTypes(StrEnum):
+ MENTION = "mention"
+ FORWARD = "forward"
+ REPLY = "reply"
-class AttachmentsTypes(str, Enum):
- """Types for attachments that could be received by bot."""
+class BotAPIMentionTypes(StrEnum):
+ CONTACT = "contact"
+ CHAT = "chat"
+ CHANNEL = "channel"
+ USER = "user"
+ ALL = "all"
- image = "image"
- video = "video"
- document = "document"
- voice = "voice"
- contact = "contact"
- location = "location"
- link = "link"
+class APIUserKinds(StrEnum):
+ USER = "user"
+ CTS_USER = "cts_user"
+ BOTX = "botx"
-class MentionTypes(str, Enum):
- """Enum for available validated_values in mentions."""
- #: mention single user from chat in message.
- user = "user"
+class APIAttachmentTypes(StrEnum):
+ IMAGE = "image"
+ VIDEO = "video"
+ DOCUMENT = "document"
+ VOICE = "voice"
+ LOCATION = "location"
+ CONTACT = "contact"
+ LINK = "link"
- #: mention user by user_huid.
- contact = "contact"
- #: mention chat in message.
- chat = "chat"
+def convert_client_platform_to_domain(
+ client_platform: BotAPIClientPlatforms,
+) -> ClientPlatforms:
+ client_platforms_mapping = {
+ BotAPIClientPlatforms.WEB: ClientPlatforms.WEB,
+ BotAPIClientPlatforms.ANDROID: ClientPlatforms.ANDROID,
+ BotAPIClientPlatforms.IOS: ClientPlatforms.IOS,
+ BotAPIClientPlatforms.DESKTOP: ClientPlatforms.DESKTOP,
+ }
- #: mention channel in message.
- channel = "channel"
+ converted_type = client_platforms_mapping.get(client_platform)
+ if converted_type is None:
+ raise NotImplementedError(f"Unsupported client platform: {client_platform}")
- #: mention all users in chat
- all_members = "all"
+ return converted_type
-class LinkProtos(str, Enum):
- """Enum for protos of links in attachments."""
+def convert_mention_type_to_domain(mention_type: BotAPIMentionTypes) -> MentionTypes:
+ mention_types_mapping = {
+ BotAPIMentionTypes.CONTACT: MentionTypes.CONTACT,
+ BotAPIMentionTypes.CHAT: MentionTypes.CHAT,
+ BotAPIMentionTypes.CHANNEL: MentionTypes.CHANNEL,
+ BotAPIMentionTypes.USER: MentionTypes.USER,
+ BotAPIMentionTypes.ALL: MentionTypes.ALL,
+ }
- #: proto for attach with email.
- email = "mailto:"
+ converted_type = mention_types_mapping.get(mention_type)
+ if converted_type is None:
+ raise NotImplementedError(f"Unsupported mention type: {mention_type}")
- #: proto for attach with telephone number.
- telephone = "tel://"
+ return converted_type
-class ClientPlatformEnum(str, Enum):
- """Enum for distinguishing client platforms."""
+def convert_mention_type_from_domain(
+ mention_type: MentionTypes,
+) -> BotAPIMentionTypes:
+ embed_mention_types_mapping = {
+ MentionTypes.USER: BotAPIMentionTypes.USER,
+ MentionTypes.CONTACT: BotAPIMentionTypes.CONTACT,
+ MentionTypes.CHAT: BotAPIMentionTypes.CHAT,
+ MentionTypes.CHANNEL: BotAPIMentionTypes.CHANNEL,
+ MentionTypes.ALL: BotAPIMentionTypes.ALL,
+ }
- #: Web platform.
- web = "web"
+ converted_type = embed_mention_types_mapping.get(mention_type)
+ if converted_type is None:
+ raise NotImplementedError(f"Unsupported mention type: {mention_type}")
- #: Android platform.
- android = "android"
+ return converted_type
- #: iOS platform.
- ios = "ios"
- #: Desktop platform.
- desktop = "desktop"
+def convert_user_kind_to_domain(user_kind: APIUserKinds) -> UserKinds:
+ user_kinds_mapping = {
+ APIUserKinds.USER: UserKinds.RTS_USER,
+ APIUserKinds.CTS_USER: UserKinds.CTS_USER,
+ APIUserKinds.BOTX: UserKinds.BOT,
+ }
+ converted_type = user_kinds_mapping.get(user_kind)
+ if converted_type is None:
+ raise NotImplementedError(f"Unsupported user kind: {user_kind}")
-class ButtonHandlerTypes(str, Enum):
- """Enum for markup's `handler` field."""
+ return converted_type
+
+
+def convert_attachment_type_to_domain(
+ attachment_type: APIAttachmentTypes,
+) -> AttachmentTypes:
+ attachment_types_mapping = {
+ APIAttachmentTypes.IMAGE: AttachmentTypes.IMAGE,
+ APIAttachmentTypes.VIDEO: AttachmentTypes.VIDEO,
+ APIAttachmentTypes.DOCUMENT: AttachmentTypes.DOCUMENT,
+ APIAttachmentTypes.VOICE: AttachmentTypes.VOICE,
+ APIAttachmentTypes.LOCATION: AttachmentTypes.LOCATION,
+ APIAttachmentTypes.CONTACT: AttachmentTypes.CONTACT,
+ APIAttachmentTypes.LINK: AttachmentTypes.LINK,
+ }
+
+ converted_type = attachment_types_mapping.get(attachment_type)
+ if converted_type is None:
+ raise NotImplementedError(f"Unsupported attachment type: {attachment_type}")
+
+ return converted_type
+
+
+def convert_attachment_type_from_domain(
+ attachment_type: AttachmentTypes,
+) -> APIAttachmentTypes:
+ attachment_types_mapping = {
+ AttachmentTypes.IMAGE: APIAttachmentTypes.IMAGE,
+ AttachmentTypes.VIDEO: APIAttachmentTypes.VIDEO,
+ AttachmentTypes.DOCUMENT: APIAttachmentTypes.DOCUMENT,
+ AttachmentTypes.VOICE: APIAttachmentTypes.VOICE,
+ AttachmentTypes.LOCATION: APIAttachmentTypes.LOCATION,
+ AttachmentTypes.CONTACT: APIAttachmentTypes.CONTACT,
+ AttachmentTypes.LINK: APIAttachmentTypes.LINK,
+ }
+
+ converted_type = attachment_types_mapping.get(attachment_type)
+ if converted_type is None:
+ raise NotImplementedError(f"Unsupported attachment type: {attachment_type}")
+
+ return converted_type
+
+
+def convert_chat_type_from_domain(chat_type: ChatTypes) -> APIChatTypes:
+ chat_types_mapping = {
+ ChatTypes.PERSONAL_CHAT: APIChatTypes.CHAT,
+ ChatTypes.GROUP_CHAT: APIChatTypes.GROUP_CHAT,
+ ChatTypes.CHANNEL: APIChatTypes.CHANNEL,
+ }
+
+ converted_type = chat_types_mapping.get(chat_type)
+ if converted_type is None:
+ raise NotImplementedError(f"Unsupported chat type: {chat_type}")
+
+ return converted_type
+
+
+def convert_chat_type_to_domain(chat_type: APIChatTypes) -> ChatTypes:
+ chat_types_mapping = {
+ APIChatTypes.CHAT: ChatTypes.PERSONAL_CHAT,
+ APIChatTypes.GROUP_CHAT: ChatTypes.GROUP_CHAT,
+ APIChatTypes.CHANNEL: ChatTypes.CHANNEL,
+ }
- #: bot side process.
- bot = "bot"
+ converted_type = chat_types_mapping.get(chat_type)
+ if converted_type is None:
+ raise NotImplementedError(f"Unsupported chat type: {chat_type}")
- #: client side process.
- client = "client"
+ return converted_type
diff --git a/botx/models/errors.py b/botx/models/errors.py
deleted file mode 100644
index d9b259b1..00000000
--- a/botx/models/errors.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Definition of errors in processing request from BotX API."""
-
-from typing import List
-
-from botx.models.base import BotXBaseModel
-
-
-class BotDisabledErrorData(BotXBaseModel):
- """Data about occurred error."""
-
- #: message that will be shown to user.
- status_message: str
-
-
-class BotDisabledResponse(BotXBaseModel):
- """Response to BotX API if there was an error in handling incoming request."""
-
- #: error reason. *This should always be `bot_disabled` string.*
- reason: str = "bot_disabled"
-
- #: data about occurred error that should include `status_message` field in json.
- error_data: BotDisabledErrorData
-
- errors: List[str] = []
diff --git a/botx/models/events.py b/botx/models/events.py
deleted file mode 100644
index 4234508a..00000000
--- a/botx/models/events.py
+++ /dev/null
@@ -1,114 +0,0 @@
-"""Definition of different schemas for system events."""
-
-from types import MappingProxyType
-from typing import Any, Dict, List, Mapping, Optional, Type
-from uuid import UUID
-
-from botx.clients.types.message_payload import InternalBotNotificationPayload
-from botx.models.base import BotXBaseModel
-from botx.models.enums import ChatTypes, SystemEvents
-from botx.models.users import UserInChatCreated
-
-
-class ChatCreatedEvent(BotXBaseModel):
- """Shape for `system:chat_created` event data."""
-
- #: chat id from which event received.
- group_chat_id: UUID
-
- #: type of chat.
- chat_type: ChatTypes
-
- #: chat name.
- name: str
-
- #: HUID of user that created chat.
- creator: UUID
-
- #: list of users that are members of chat.
- members: List[UserInChatCreated]
-
-
-class AddedToChatEvent(BotXBaseModel):
- """Shape for `system:added_to_chat` event data."""
-
- #: members added to chat.
- added_members: List[UUID]
-
-
-class DeletedFromChatEvent(BotXBaseModel):
- """Shape for `system:deleted_from_chat` event data."""
-
- #: members deleted from chat
- deleted_members: List[UUID]
-
-
-class LeftFromChatEvent(BotXBaseModel):
- """Shape for `system:left_from_chat` event data."""
-
- #: left chat members
- left_members: List[UUID]
-
-
-class InternalBotNotificationEvent(BotXBaseModel):
- """Shape for `system:internal_bot_notification` event data."""
-
- #: notification data
- data: InternalBotNotificationPayload # noqa: WPS110
-
- #: user-defined extra options
- opts: Dict[str, Any]
-
-
-class CTSLoginEvent(BotXBaseModel):
- """Shape for `system:cts_login` event data."""
-
- #: huid of user which logged into CTS.
- user_huid: UUID
-
- #: CTS id.
- cts_id: UUID
-
-
-class CTSLogoutEvent(BotXBaseModel):
- """Shape for `system:cts_logout` event data."""
-
- #: huid of user which logged out from CTS.
- user_huid: UUID
-
- #: CTS id.
- cts_id: UUID
-
-
-class SmartAppEvent(BotXBaseModel):
- """Shape for `system:smartapp_event` event data."""
-
- #: unique request id
- ref: Optional[UUID] = None
-
- #: smartapp id
- smartapp_id: UUID
-
- #: event data
- data: Dict[str, Any] # noqa: WPS110
-
- #: event options
- opts: Dict[str, Any] = {}
-
- #: version of protocol smartapp <-> bot
- smartapp_api_version: int
-
-
-# dict for validating shape for different events
-EVENTS_SHAPE_MAP: Mapping[SystemEvents, Type[BotXBaseModel]] = MappingProxyType(
- {
- SystemEvents.chat_created: ChatCreatedEvent,
- SystemEvents.added_to_chat: AddedToChatEvent,
- SystemEvents.deleted_from_chat: DeletedFromChatEvent,
- SystemEvents.left_from_chat: LeftFromChatEvent,
- SystemEvents.internal_bot_notification: InternalBotNotificationEvent,
- SystemEvents.cts_login: CTSLoginEvent,
- SystemEvents.cts_logout: CTSLogoutEvent,
- SystemEvents.smartapp_event: SmartAppEvent,
- },
-)
diff --git a/botx/models/files.py b/botx/models/files.py
deleted file mode 100644
index 60912bd3..00000000
--- a/botx/models/files.py
+++ /dev/null
@@ -1,352 +0,0 @@
-"""Definition of file that can be included in incoming message or in sending result."""
-
-import base64
-from contextlib import contextmanager
-from io import BytesIO
-from pathlib import Path
-from types import MappingProxyType
-from typing import AnyStr, AsyncIterable, BinaryIO, Generator, Optional, TextIO, Union
-from uuid import UUID
-
-from base64io import Base64IO
-
-from botx.models.base import BotXBaseModel
-from botx.models.enums import AttachmentsTypes
-
-EXTENSIONS_TO_MIMETYPES = MappingProxyType(
- {
- # application
- ".7z": "application/x-7z-compressed",
- ".abw": "application/x-abiword",
- ".ai": "application/postscript",
- ".arc": "application/x-freearc",
- ".azw": "application/vnd.amazon.ebook",
- ".bin": "application/octet-stream",
- ".bz": "application/x-bzip",
- ".bz2": "application/x-bzip2",
- ".cda": "application/x-cdf",
- ".csh": "application/x-csh",
- ".doc": "application/msword",
- ".docx": (
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- ),
- ".eot": "application/vnd.ms-fontobject",
- ".eps": "application/postscript",
- ".epub": "application/epub+zip",
- ".gz": "application/gzip",
- ".jar": "application/java-archive",
- ".json-api": "application/vnd.api+json",
- ".json-patch": "application/json-patch+json",
- ".json": "application/json",
- ".jsonld": "application/ld+json",
- ".mdb": "application/x-msaccess",
- ".mpkg": "application/vnd.apple.installer+xml",
- ".odp": "application/vnd.oasis.opendocument.presentation",
- ".ods": "application/vnd.oasis.opendocument.spreadsheet",
- ".odt": "application/vnd.oasis.opendocument.text",
- ".ogx": "application/ogg",
- ".pdf": "application/pdf",
- ".php": "application/x-httpd-php",
- ".ppt": "application/vnd.ms-powerpoint",
- ".pptx": (
- "application/vnd.openxmlformats-officedocument.presentationml.presentation"
- ),
- ".ps": "application/postscript",
- ".rar": "application/vnd.rar",
- ".rtf": "application/rtf",
- ".sh": "application/x-sh",
- ".swf": "application/x-shockwave-flash",
- ".tar": "application/x-tar",
- ".vsd": "application/vnd.visio",
- ".wasm": "application/wasm",
- ".webmanifest": "application/manifest+json",
- ".xhtml": "application/xhtml+xml",
- ".xls": "application/vnd.ms-excel",
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- ".xul": "application/vnd.mozilla.xul+xml",
- ".zip": "application/zip",
- # audio
- ".aac": "audio/aac",
- ".mid": "audio/midi",
- ".midi": "audio/midi",
- ".mp3": "audio/mpeg",
- ".oga": "audio/ogg",
- ".opus": "audio/opus",
- ".wav": "audio/wav",
- ".weba": "audio/webm",
- # font
- ".otf": "font/otf",
- ".ttf": "font/ttf",
- ".woff": "font/woff",
- ".woff2": "font/woff2",
- # image
- ".avif": "image/avif",
- ".bmp": "image/bmp",
- ".gif": "image/gif",
- ".ico": "image/vnd.microsoft.icon",
- ".jpeg": "image/jpeg",
- ".jpg": "image/jpeg",
- ".png": "image/png",
- ".svg": "image/svg+xml",
- ".svgz": "image/svg+xml",
- ".tif": "image/tiff",
- ".tiff": "image/tiff",
- ".webp": "image/webp",
- # text
- ".css": "text/css",
- ".csv": "text/csv",
- ".htm": "text/html",
- ".html": "text/html",
- ".ics": "text/calendar",
- ".js": "text/javascript",
- ".mjs": "text/javascript",
- ".txt": "text/plain",
- ".text": "text/plain",
- ".xml": "text/xml",
- # video
- ".3g2": "video/3gpp2",
- ".3gp": "video/3gpp",
- ".avi": "video/x-msvideo",
- ".mov": "video/quicktime",
- ".mp4": "video/mp4",
- ".mpeg": "video/mpeg",
- ".mpg": "video/mpeg",
- ".ogv": "video/ogg",
- ".ts": "video/mp2t",
- ".webm": "video/webm",
- ".wmv": "video/x-ms-wmv",
- },
-)
-DEFAULT_MIMETYPE = "application/octet-stream"
-
-
-class NamedAsyncIterable(AsyncIterable):
- """AsyncIterable with `name` protocol."""
-
- name: str
-
-
-class File(BotXBaseModel): # noqa: WPS214
- """Object that represents file in RFC 2397 format."""
-
- #: name of file.
- file_name: str
-
- #: file content in RFC 2397 format.
- data: str # noqa: WPS110
-
- #: text under file.
- caption: Optional[str] = None
-
- @classmethod
- def from_file( # noqa: WPS210
- cls,
- file: Union[TextIO, BinaryIO],
- filename: Optional[str] = None,
- ) -> "File":
- """Convert file-like object into BotX API compatible file.
-
- Arguments:
- file: file-like object that will be used for creating file.
- filename: name that will be used for file, if was not passed, then will be
- retrieved from `file` `.name` property.
-
- Returns:
- Built file object.
- """
- filename = filename or Path(file.name).name
- encoded_file = BytesIO()
-
- text_mode = file.read(0) == "" # b"" if bytes mode
-
- with Base64IO(encoded_file) as b64_stream:
- if text_mode:
- for text_line in file: # TODO: Deprecate text mode in 0.17
- b64_stream.write(text_line.encode()) # type: ignore
- else:
- for line in file:
- b64_stream.write(line)
-
- encoded_file.seek(0)
- encoded_data = encoded_file.read().decode()
-
- media_type = cls._get_mimetype(filename)
- return cls(file_name=filename, data=cls._to_rfc2397(media_type, encoded_data))
-
- @classmethod
- async def async_from_file( # noqa: WPS210
- cls,
- file: NamedAsyncIterable,
- filename: Optional[str] = None,
- ) -> "File":
- """Convert async file-like object into BotX API compatible file.
-
- Arguments:
- file: async file-like object that will be used for creating file.
- filename: name that will be used for file, if was not passed, then will be
- retrieved from `file` `.name` property.
-
- Returns:
- Built File object.
- """
- assert hasattr( # noqa: WPS421
- file,
- "__aiter__",
- ), "file should support async iteration"
-
- filename = filename or Path(file.name).name
- media_type = cls._get_mimetype(filename)
-
- encoded_file = BytesIO()
-
- with Base64IO(encoded_file) as b64_stream:
- async for line in file: # pragma: no branch
- b64_stream.write(line)
-
- encoded_file.seek(0)
- encoded_data = encoded_file.read().decode()
-
- return cls(file_name=filename, data=cls._to_rfc2397(media_type, encoded_data))
-
- @contextmanager
- def file_chunks(self) -> Generator[bytes, None, None]:
- """Return file data in iterator that will return bytes."""
- encoded_file = BytesIO(self.data_in_base64.encode())
-
- with Base64IO(encoded_file) as decoded_file:
- yield decoded_file
-
- @classmethod
- def from_string(cls, data_of_file: AnyStr, filename: str) -> "File":
- """Build file from bytes or string passed to method in `data` with `filename` as name.
-
- Arguments:
- data_of_file: string or bytes that will be used for creating file.
- filename: name for new file.
-
- Returns:
- Built file object.
- """
- if isinstance(data_of_file, str):
- file_data = data_of_file.encode()
- else:
- file_data = data_of_file
- file = BytesIO(file_data)
- file.name = filename
- return cls.from_file(file)
-
- @property
- def file(self) -> BinaryIO:
- """Return file data in file-like object that will return bytes."""
- bytes_file = BytesIO(self.data_in_bytes)
- bytes_file.name = self.file_name
- return bytes_file
-
- @property
- def size_in_bytes(self) -> int:
- """Return file size in bytes."""
- with self.file_chunks() as chunks:
- return sum(len(chunk) for chunk in chunks) # type: ignore
-
- @property
- def data_in_bytes(self) -> bytes:
- """Return decoded file data in bytes."""
- return base64.b64decode(self.data_in_base64)
-
- @property
- def data_in_base64(self) -> str:
- """Return file data in base64 encoded string."""
- return self.data.split(",", 1)[1]
-
- @property
- def media_type(self) -> str:
- """Return media type of file."""
- return self._get_mimetype(self.file_name)
-
- @classmethod
- def get_ext_by_mimetype(cls, mimetype: str) -> Optional[str]:
- """Get extension by mimetype.
-
- Arguments:
- mimetype: mimetype of file.
-
- Returns:
- file extension or none if mimetype not found.
- """
- for ext, m_type in EXTENSIONS_TO_MIMETYPES.items():
- if m_type == mimetype:
- return ext
-
- return None
-
- @classmethod
- def _to_rfc2397(cls, media_type: str, encoded_data: str) -> str:
- """Apply RFC 2397 format to encoded file contents.
-
- Arguments:
- media_type: file media type.
- encoded_data: base64 encoded file contents.
-
- Returns:
- File contents converted to RFC 2397.
- """
- return "data:{0};base64,{1}".format(media_type, encoded_data)
-
- @classmethod
- def _get_mimetype(cls, filename: str) -> str:
- """Get mimetype by filename.
-
- Arguments:
- filename: file name to inspect.
-
- Returns:
- File mimetype.
- """
- file_extension = Path(filename).suffix.lower()
- return EXTENSIONS_TO_MIMETYPES.get(file_extension, DEFAULT_MIMETYPE)
-
-
-class MetaFile(BotXBaseModel):
- """File info from file service."""
-
- #: type of file
- type: AttachmentsTypes
-
- #: file url.
- file: str
-
- #: mime type of file.
- file_mime_type: str
-
- #: name of file.
- file_name: str
-
- #: file preview.
- file_preview: Optional[str]
-
- #: height of file (px).
- file_preview_height: Optional[int]
-
- #: width of file (px).
- file_preview_width: Optional[int]
-
- #: size of file.
- file_size: int
-
- #: hash of file.
- file_hash: str
-
- #: encryption algorithm of file.
- file_encryption_algo: str
-
- #: chunks size.
- chunk_size: int
-
- #: ID of file.
- file_id: UUID
-
- #: file caption.
- caption: Optional[str]
-
- #: media file duration.
- duration: Optional[int]
diff --git a/botx/models/menu.py b/botx/models/menu.py
deleted file mode 100644
index 931ee6ea..00000000
--- a/botx/models/menu.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""Pydantic models for bot menu."""
-
-from typing import List
-
-from botx.models.base import BotXBaseModel
-from botx.models.enums import Statuses
-
-
-class MenuCommand(BotXBaseModel):
- """Command that is shown in bot menu."""
-
- #: command description that will be shown in menu.
- description: str
-
- #: command body that will trigger command execution.
- body: str
-
- #: command name.
- name: str
-
-
-class StatusResult(BotXBaseModel):
- """Bot menu commands collection."""
-
- #: is bot enabled.
- enabled: bool = True
-
- #: status of bot.
- status_message: str = "Bot is working"
-
- #: list of bot commands that will be shown in menu.
- commands: List[MenuCommand] = []
-
-
-class Status(BotXBaseModel):
- """Object that should be returned on `/status` request from BotX API."""
-
- #: operation status.
- status: Statuses = Statuses.ok
-
- #: bot status.
- result: StatusResult = StatusResult()
diff --git a/tests/test_bots/__init__.py b/botx/models/message/__init__.py
similarity index 100%
rename from tests/test_bots/__init__.py
rename to botx/models/message/__init__.py
diff --git a/botx/models/message/edit_message.py b/botx/models/message/edit_message.py
new file mode 100644
index 00000000..11b0e0eb
--- /dev/null
+++ b/botx/models/message/edit_message.py
@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+from typing import Any, Dict, Union
+from uuid import UUID
+
+from botx.missing import Missing, Undefined
+from botx.models.attachments import IncomingFileAttachment, OutgoingAttachment
+from botx.models.message.markup import BubbleMarkup, KeyboardMarkup
+
+
+@dataclass
+class EditMessage:
+ bot_id: UUID
+ sync_id: UUID
+ body: Missing[str] = Undefined
+ metadata: Missing[Dict[str, Any]] = Undefined
+ bubbles: Missing[BubbleMarkup] = Undefined
+ keyboard: Missing[KeyboardMarkup] = Undefined
+ file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined
+ markup_auto_adjust: Missing[bool] = Undefined
diff --git a/botx/models/message/forward.py b/botx/models/message/forward.py
new file mode 100644
index 00000000..a8d02c32
--- /dev/null
+++ b/botx/models/message/forward.py
@@ -0,0 +1,24 @@
+from dataclasses import dataclass
+from typing import Literal
+from uuid import UUID
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.enums import BotAPIEntityTypes
+
+
+@dataclass
+class Forward:
+ chat_id: UUID
+ author_id: UUID
+ sync_id: UUID
+
+
+class BotAPIForwardData(VerifiedPayloadBaseModel):
+ group_chat_id: UUID
+ sender_huid: UUID
+ source_sync_id: UUID
+
+
+class BotAPIForward(VerifiedPayloadBaseModel):
+ type: Literal[BotAPIEntityTypes.FORWARD]
+ data: BotAPIForwardData
diff --git a/botx/models/message/incoming_message.py b/botx/models/message/incoming_message.py
new file mode 100644
index 00000000..a771458c
--- /dev/null
+++ b/botx/models/message/incoming_message.py
@@ -0,0 +1,281 @@
+from dataclasses import dataclass, field
+from types import SimpleNamespace
+from typing import Any, Dict, List, Optional, Tuple, Union, cast
+from uuid import UUID
+
+from pydantic import Field
+
+from botx.models.async_files import APIAsyncFile, File, convert_async_file_to_domain
+from botx.models.attachments import (
+ AttachmentContact,
+ AttachmentLink,
+ AttachmentLocation,
+ BotAPIAttachment,
+ FileAttachmentBase,
+ IncomingFileAttachment,
+ convert_api_attachment_to_domain,
+)
+from botx.models.base_command import (
+ BotAPIBaseCommand,
+ BotAPIChatContext,
+ BotAPICommandPayload,
+ BotAPIDeviceContext,
+ BotAPIUserContext,
+ BotCommandBase,
+)
+from botx.models.bot_account import BotAccount
+from botx.models.chats import Chat
+from botx.models.enums import (
+ AttachmentTypes,
+ BotAPIEntityTypes,
+ BotAPIMentionTypes,
+ ClientPlatforms,
+ convert_chat_type_to_domain,
+ convert_client_platform_to_domain,
+ convert_mention_type_to_domain,
+)
+from botx.models.message.forward import BotAPIForward, Forward
+from botx.models.message.mentions import (
+ BotAPIMention,
+ BotAPIMentionData,
+ BotAPINestedMentionData,
+ Mention,
+ MentionList,
+)
+from botx.models.message.reply import BotAPIReply, Reply
+
+
+@dataclass
+class UserDevice:
+ manufacturer: Optional[str]
+ device_name: Optional[str]
+ os: Optional[str]
+ pushes: Optional[bool]
+ timezone: Optional[str]
+ permissions: Optional[Dict[str, Any]]
+ platform: Optional[ClientPlatforms]
+ platform_package_id: Optional[str]
+ app_version: Optional[str]
+ locale: Optional[str]
+
+
+@dataclass
+class UserSender:
+ huid: UUID
+ ad_login: Optional[str]
+ ad_domain: Optional[str]
+ username: Optional[str]
+ is_chat_admin: Optional[bool]
+ is_chat_creator: Optional[bool]
+ device: UserDevice
+
+ @property
+ def upn(self) -> Optional[str]:
+ # https://docs.microsoft.com/en-us/windows/win32/secauthn/user-name-formats
+ if not (self.ad_login and self.ad_domain):
+ return None
+
+ return f"{self.ad_login}@{self.ad_domain}"
+
+
+@dataclass
+class IncomingMessage(BotCommandBase):
+ sync_id: UUID
+ source_sync_id: Optional[UUID]
+ body: str
+ data: Dict[str, Any]
+ metadata: Dict[str, Any]
+ sender: UserSender
+ chat: Chat
+ mentions: MentionList = field(default_factory=MentionList)
+ forward: Optional[Forward] = None
+ reply: Optional[Reply] = None
+ file: Optional[Union[File, IncomingFileAttachment]] = None
+ location: Optional[AttachmentLocation] = None
+ contact: Optional[AttachmentContact] = None
+ link: Optional[AttachmentLink] = None
+
+ state: SimpleNamespace = field(default_factory=SimpleNamespace)
+
+ @property
+ def argument(self) -> str:
+ split_body = self.body.split()
+ if not split_body:
+ return ""
+
+ command_len = len(split_body[0])
+ return self.body[command_len:].strip()
+
+ @property
+ def arguments(self) -> Tuple[str, ...]:
+ return tuple(arg.strip() for arg in self.argument.split())
+
+
+BotAPIEntity = Union[BotAPIMention, BotAPIForward, BotAPIReply]
+Entity = Union[Mention, Forward, Reply]
+
+
+def _convert_bot_api_mention_to_domain(api_mention_data: BotAPIMentionData) -> Mention:
+ entity_id: Optional[UUID] = None
+ name: Optional[str] = None
+
+ if api_mention_data.mention_type != BotAPIMentionTypes.ALL:
+ mention_data = cast(BotAPINestedMentionData, api_mention_data.mention_data)
+ entity_id = mention_data.entity_id
+ name = mention_data.name
+
+ return Mention(
+ type=convert_mention_type_to_domain(api_mention_data.mention_type),
+ entity_id=entity_id,
+ name=name,
+ )
+
+
+def convert_bot_api_entity_to_domain(api_entity: BotAPIEntity) -> Entity:
+ if api_entity.type == BotAPIEntityTypes.MENTION:
+ api_entity = cast(BotAPIMention, api_entity)
+ return _convert_bot_api_mention_to_domain(api_entity.data)
+
+ if api_entity.type == BotAPIEntityTypes.FORWARD:
+ api_entity = cast(BotAPIForward, api_entity)
+
+ return Forward(
+ chat_id=api_entity.data.group_chat_id,
+ author_id=api_entity.data.sender_huid,
+ sync_id=api_entity.data.source_sync_id,
+ )
+
+ if api_entity.type == BotAPIEntityTypes.REPLY:
+ api_entity = cast(BotAPIReply, api_entity)
+
+ mentions = MentionList()
+ for api_mention_data in api_entity.data.mentions:
+ mentions.append(_convert_bot_api_mention_to_domain(api_mention_data))
+
+ return Reply(
+ author_id=api_entity.data.sender,
+ sync_id=api_entity.data.source_sync_id,
+ body=api_entity.data.body,
+ mentions=mentions,
+ )
+
+ raise NotImplementedError(f"Unsupported entity type: {api_entity.type}")
+
+
+class BotAPIIncomingMessageContext(
+ BotAPIUserContext,
+ BotAPIChatContext,
+ BotAPIDeviceContext,
+):
+ """Class for merging contexts."""
+
+
+class BotAPIIncomingMessage(BotAPIBaseCommand):
+ payload: BotAPICommandPayload = Field(..., alias="command")
+ sender: BotAPIIncomingMessageContext = Field(..., alias="from")
+
+ source_sync_id: Optional[UUID]
+ attachments: List[BotAPIAttachment]
+ async_files: List[APIAsyncFile]
+ entities: List[BotAPIEntity]
+
+ def to_domain(self, raw_command: Dict[str, Any]) -> IncomingMessage: # noqa: WPS231
+ if self.sender.device_meta:
+ pushes = self.sender.device_meta.pushes
+ timezone = self.sender.device_meta.timezone
+ permissions = self.sender.device_meta.permissions
+ else:
+ pushes, timezone, permissions = None, None, None
+
+ device = UserDevice(
+ manufacturer=self.sender.manufacturer,
+ device_name=self.sender.device,
+ os=self.sender.device_software,
+ pushes=pushes,
+ timezone=timezone,
+ permissions=permissions,
+ platform=(
+ convert_client_platform_to_domain(self.sender.platform)
+ if self.sender.platform
+ else None
+ ),
+ platform_package_id=self.sender.platform_package_id,
+ app_version=self.sender.app_version,
+ locale=self.sender.locale,
+ )
+
+ sender = UserSender(
+ huid=self.sender.user_huid,
+ ad_login=self.sender.ad_login,
+ ad_domain=self.sender.ad_domain,
+ username=self.sender.username,
+ is_chat_admin=self.sender.is_admin,
+ is_chat_creator=self.sender.is_creator,
+ device=device,
+ )
+
+ chat = Chat(
+ id=self.sender.group_chat_id,
+ type=convert_chat_type_to_domain(self.sender.chat_type),
+ )
+
+ file: Optional[Union[File, IncomingFileAttachment]] = None
+ location: Optional[AttachmentLocation] = None
+ contact: Optional[AttachmentContact] = None
+ link: Optional[AttachmentLink] = None
+ if self.async_files:
+ # Always one async file per-message
+ file = convert_async_file_to_domain(self.async_files[0])
+ elif self.attachments:
+ # Always one attachment per-message
+ attachment_domain = convert_api_attachment_to_domain(self.attachments[0])
+ if isinstance(attachment_domain, FileAttachmentBase):
+ file = attachment_domain
+ elif attachment_domain.type == AttachmentTypes.LOCATION:
+ location = attachment_domain
+ elif attachment_domain.type == AttachmentTypes.CONTACT:
+ contact = attachment_domain
+ elif attachment_domain.type == AttachmentTypes.LINK:
+ link = attachment_domain
+ else:
+ raise NotImplementedError
+
+ mentions: MentionList = MentionList()
+ forward: Optional[Forward] = None
+ reply: Optional[Reply] = None
+ for entity in self.entities:
+ entity_domain = convert_bot_api_entity_to_domain(entity)
+ if isinstance(entity_domain, Mention):
+ mentions.append(entity_domain)
+ elif isinstance(entity_domain, Forward):
+ # Max one forward per message
+ forward = entity_domain
+ elif isinstance(entity_domain, Reply):
+ # Max one reply per message
+ reply = entity_domain
+ else:
+ raise NotImplementedError
+
+ bot = BotAccount(
+ id=self.bot_id,
+ host=self.sender.host,
+ )
+
+ return IncomingMessage(
+ bot=bot,
+ sync_id=self.sync_id,
+ source_sync_id=self.source_sync_id,
+ body=self.payload.body,
+ data=self.payload.data,
+ metadata=self.payload.metadata,
+ sender=sender,
+ chat=chat,
+ raw_command=raw_command,
+ file=file,
+ location=location,
+ contact=contact,
+ link=link,
+ mentions=mentions,
+ forward=forward,
+ reply=reply,
+ )
diff --git a/botx/models/message/markup.py b/botx/models/message/markup.py
new file mode 100644
index 00000000..0f26b0ab
--- /dev/null
+++ b/botx/models/message/markup.py
@@ -0,0 +1,131 @@
+from dataclasses import dataclass, field
+from typing import Any, Dict, Iterator, List, Literal, Optional, Union
+
+from botx.missing import Missing, Undefined
+from botx.models.api_base import UnverifiedPayloadBaseModel
+
+
+@dataclass
+class Button:
+ command: str
+ label: str
+ data: Dict[str, Any] = field(default_factory=dict)
+
+ silent: bool = True # BotX has `False` as default, so Missing type can't be used
+ width_ratio: Missing[int] = Undefined
+ alert: Missing[str] = Undefined
+ process_on_client: Missing[bool] = Undefined
+
+
+ButtonRow = List[Button]
+
+
+class BaseMarkup:
+ def __init__(self, buttons: Optional[List[ButtonRow]] = None) -> None:
+ self._buttons = buttons or []
+
+ def __iter__(self) -> Iterator[ButtonRow]:
+ return iter(self._buttons)
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, BaseMarkup):
+ raise NotImplementedError
+
+ # https://github.com/wemake-services/wemake-python-styleguide/issues/2172
+ return self._buttons == other._buttons # noqa: WPS437
+
+ def add_built_button(self, button: Button, new_row: bool = True) -> None:
+ if new_row:
+ self._buttons.append([button])
+ return
+
+ if not self._buttons:
+ self._buttons.append([])
+
+ self._buttons[-1].append(button)
+
+ def add_button(
+ self,
+ command: str,
+ label: str,
+ data: Optional[Dict[str, Any]] = None,
+ silent: bool = True,
+ width_ratio: Missing[int] = Undefined,
+ alert: Missing[str] = Undefined,
+ process_on_client: Missing[bool] = Undefined,
+ new_row: bool = True,
+ ) -> None:
+ button = Button(
+ command=command,
+ label=label,
+ data=data or {},
+ silent=silent,
+ width_ratio=width_ratio,
+ alert=alert,
+ process_on_client=process_on_client,
+ )
+ self.add_built_button(button, new_row=new_row)
+
+ def add_row(self, button_row: ButtonRow) -> None:
+ self._buttons.append(button_row)
+
+
+class BubbleMarkup(BaseMarkup):
+ """Class for managing inline message buttons."""
+
+
+class KeyboardMarkup(BaseMarkup):
+ """Class for managing keyboard message buttons."""
+
+
+Markup = Union[BubbleMarkup, KeyboardMarkup]
+
+
+class BotXAPIButtonOptions(UnverifiedPayloadBaseModel):
+ silent: Missing[bool]
+ h_size: Missing[int]
+ show_alert: Missing[Literal[True]]
+ alert_text: Missing[str]
+ handler: Missing[Literal["client"]]
+
+
+class BotXAPIButton(UnverifiedPayloadBaseModel):
+ command: str
+ label: str
+ data: Dict[str, Any]
+ opts: BotXAPIButtonOptions
+
+
+class BotXAPIMarkup(UnverifiedPayloadBaseModel):
+ __root__: List[List[BotXAPIButton]]
+
+
+def api_button_from_domain(button: Button) -> BotXAPIButton:
+ show_alert: Missing[Literal[True]] = Undefined
+ if button.alert is not Undefined:
+ show_alert = True
+
+ handler: Missing[Literal["client"]] = Undefined
+ if button.process_on_client:
+ handler = "client"
+
+ return BotXAPIButton(
+ command=button.command,
+ label=button.label,
+ data=button.data,
+ opts=BotXAPIButtonOptions(
+ silent=button.silent,
+ h_size=button.width_ratio,
+ alert_text=button.alert,
+ show_alert=show_alert,
+ handler=handler,
+ ),
+ )
+
+
+def api_markup_from_domain(markup: Markup) -> BotXAPIMarkup:
+ return BotXAPIMarkup(
+ __root__=[
+ [api_button_from_domain(button) for button in buttons] for buttons in markup
+ ],
+ )
diff --git a/botx/models/message/mentions.py b/botx/models/message/mentions.py
new file mode 100644
index 00000000..a254c8a4
--- /dev/null
+++ b/botx/models/message/mentions.py
@@ -0,0 +1,276 @@
+import re
+from dataclasses import dataclass
+from typing import Dict, List, Literal, Optional, Tuple, Union
+from uuid import UUID, uuid4
+
+from pydantic import Field, validator
+
+from botx.missing import Missing, Undefined
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+from botx.models.enums import (
+ BotAPIEntityTypes,
+ BotAPIMentionTypes,
+ MentionTypes,
+ convert_mention_type_from_domain,
+)
+
+
+@dataclass
+class Mention:
+ type: MentionTypes
+ entity_id: Optional[UUID] = None
+ name: Optional[str] = None
+
+ def __str__(self) -> str:
+ name = self.name or ""
+ entity_id = self.entity_id or ""
+ mention_type = self.type.value
+ return f"{mention_type}:{entity_id}:{name}"
+
+ @classmethod
+ def user(cls, huid: UUID, name: Optional[str] = None) -> "Mention":
+ return cls(
+ type=MentionTypes.USER,
+ entity_id=huid,
+ name=name,
+ )
+
+ @classmethod
+ def contact(cls, huid: UUID, name: Optional[str] = None) -> "Mention":
+ return cls(
+ type=MentionTypes.CONTACT,
+ entity_id=huid,
+ name=name,
+ )
+
+ @classmethod
+ def chat(cls, chat_id: UUID, name: Optional[str] = None) -> "Mention":
+ return cls(
+ type=MentionTypes.CHAT,
+ entity_id=chat_id,
+ name=name,
+ )
+
+ @classmethod
+ def channel(cls, chat_id: UUID, name: Optional[str] = None) -> "Mention":
+ return cls(
+ type=MentionTypes.CHANNEL,
+ entity_id=chat_id,
+ name=name,
+ )
+
+ @classmethod
+ def all(cls) -> "Mention":
+ return cls(type=MentionTypes.ALL)
+
+
+class MentionList(List[Mention]):
+ @property
+ def contacts(self) -> List[Mention]:
+ return [mention for mention in self if mention.type == MentionTypes.CONTACT]
+
+ @property
+ def chats(self) -> List[Mention]:
+ return [mention for mention in self if mention.type == MentionTypes.CHAT]
+
+ @property
+ def channels(self) -> List[Mention]:
+ return [mention for mention in self if mention.type == MentionTypes.CHANNEL]
+
+ @property
+ def users(self) -> List[Mention]:
+ return [mention for mention in self if mention.type == MentionTypes.USER]
+
+ @property
+ def all_users_mentioned(self) -> bool:
+ for mention in self:
+ if mention.type == MentionTypes.ALL:
+ return True
+
+ return False
+
+
+class BotAPINestedPersonalMentionData(VerifiedPayloadBaseModel):
+ entity_id: UUID = Field(alias="user_huid")
+ name: str
+ conn_type: str
+
+
+class BotAPINestedGroupMentionData(VerifiedPayloadBaseModel):
+ entity_id: UUID = Field(alias="group_chat_id")
+ name: str
+
+
+BotAPINestedMentionData = Union[
+ BotAPINestedPersonalMentionData,
+ BotAPINestedGroupMentionData,
+]
+
+
+class BotAPIMentionData(VerifiedPayloadBaseModel):
+ mention_type: BotAPIMentionTypes
+ mention_id: UUID
+ mention_data: Optional[BotAPINestedMentionData]
+
+ @validator("mention_data", pre=True)
+ @classmethod
+ def validate_mention_data(
+ cls,
+ mention_data: Dict[str, str],
+ ) -> Optional[Dict[str, str]]:
+ # Mention data can be an empty dict
+ if not mention_data:
+ return None
+
+ return mention_data
+
+
+class BotAPIMention(VerifiedPayloadBaseModel):
+ type: Literal[BotAPIEntityTypes.MENTION]
+ data: BotAPIMentionData
+
+
+class BotXAPIPersonalMentionData(UnverifiedPayloadBaseModel):
+ user_huid: UUID
+ name: Missing[str]
+
+
+class BotXAPIUserMention(UnverifiedPayloadBaseModel):
+ mention_type: Literal[BotAPIMentionTypes.USER]
+ mention_id: UUID
+ mention_data: BotXAPIPersonalMentionData
+
+ def to_botx_embed_mention_format(self) -> str:
+ return f"@{{mention:{self.mention_id}}}"
+
+
+class BotXAPIContactMention(UnverifiedPayloadBaseModel):
+ mention_type: Literal[BotAPIMentionTypes.CONTACT]
+ mention_id: UUID
+ mention_data: BotXAPIPersonalMentionData
+
+ def to_botx_embed_mention_format(self) -> str:
+ return f"@@{{mention:{self.mention_id}}}"
+
+
+class BotXAPIGroupMentionData(UnverifiedPayloadBaseModel):
+ group_chat_id: UUID
+ name: Missing[str]
+
+
+class BotXAPIChatMention(UnverifiedPayloadBaseModel):
+ mention_type: Literal[BotAPIMentionTypes.CHAT]
+ mention_id: UUID
+ mention_data: BotXAPIGroupMentionData
+
+ def to_botx_embed_mention_format(self) -> str:
+ return f"##{{mention:{self.mention_id}}}"
+
+
+class BotXAPIChannelMention(UnverifiedPayloadBaseModel):
+ mention_type: Literal[BotAPIMentionTypes.CHANNEL]
+ mention_id: UUID
+ mention_data: BotXAPIGroupMentionData
+
+ def to_botx_embed_mention_format(self) -> str:
+ return f"##{{mention:{self.mention_id}}}"
+
+
+class BotXAPIAllMention(UnverifiedPayloadBaseModel):
+ mention_type: Literal[BotAPIMentionTypes.ALL]
+ mention_id: UUID
+
+ def to_botx_embed_mention_format(self) -> str:
+ return f"@{{mention:{self.mention_id}}}"
+
+
+BotXAPIMention = Union[
+ BotXAPIUserMention,
+ BotXAPIContactMention,
+ BotXAPIChatMention,
+ BotXAPIChannelMention,
+ BotXAPIAllMention,
+]
+
+
+def build_botx_api_embed_mention(
+ mention_dict: Dict[str, str],
+) -> BotXAPIMention:
+ mention_type = MentionTypes(mention_dict["mention_type"])
+ mentioned_entity_id = mention_dict["mentioned_entity_id"]
+ # re match will have "" if mention_name not passed
+ mention_name = mention_dict["mention_name"] or Undefined
+
+ if mention_type == MentionTypes.USER:
+ return BotXAPIUserMention(
+ mention_type=convert_mention_type_from_domain(mention_type),
+ mention_id=uuid4(),
+ mention_data=BotXAPIPersonalMentionData(
+ user_huid=UUID(mentioned_entity_id),
+ name=mention_name,
+ ),
+ )
+
+ if mention_type == MentionTypes.CONTACT:
+ return BotXAPIContactMention(
+ mention_type=convert_mention_type_from_domain(mention_type),
+ mention_id=uuid4(),
+ mention_data=BotXAPIPersonalMentionData(
+ user_huid=UUID(mentioned_entity_id),
+ name=mention_name,
+ ),
+ )
+
+ if mention_type == MentionTypes.CHAT:
+ return BotXAPIChatMention(
+ mention_type=convert_mention_type_from_domain(mention_type),
+ mention_id=uuid4(),
+ mention_data=BotXAPIGroupMentionData(
+ group_chat_id=UUID(mentioned_entity_id),
+ name=mention_name,
+ ),
+ )
+
+ if mention_type == MentionTypes.CHANNEL:
+ return BotXAPIChannelMention(
+ mention_type=convert_mention_type_from_domain(mention_type),
+ mention_id=uuid4(),
+ mention_data=BotXAPIGroupMentionData(
+ group_chat_id=UUID(mentioned_entity_id),
+ name=mention_name,
+ ),
+ )
+
+ if mention_type == MentionTypes.ALL:
+ return BotXAPIAllMention(
+ mention_type=convert_mention_type_from_domain(mention_type),
+ mention_id=uuid4(),
+ )
+
+ raise NotImplementedError
+
+
+EMBED_MENTION_RE = re.compile(
+ (
+ ""
+ "(?P.+?):"
+ r"(?P[0-9a-f\-]*?):"
+ "(?P.*?)"
+ r"<\/embed_mention>"
+ ),
+)
+
+
+def find_and_replace_embed_mentions(body: str) -> Tuple[str, List[BotXAPIMention]]:
+ mentions = []
+
+ for match in EMBED_MENTION_RE.finditer(body):
+ mention_dict = match.groupdict()
+ embed_mention = match.group(0)
+
+ mention = build_botx_api_embed_mention(mention_dict)
+ body = body.replace(embed_mention, mention.to_botx_embed_mention_format(), 1)
+
+ mentions.append(mention)
+
+ return body, mentions
diff --git a/botx/models/message/message_status.py b/botx/models/message/message_status.py
new file mode 100644
index 00000000..def98483
--- /dev/null
+++ b/botx/models/message/message_status.py
@@ -0,0 +1,12 @@
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Dict, List
+from uuid import UUID
+
+
+@dataclass
+class MessageStatus:
+ group_chat_id: UUID
+ sent_to: List[UUID]
+ read_by: Dict[UUID, datetime]
+ received_by: Dict[UUID, datetime]
diff --git a/botx/models/message/outgoing_message.py b/botx/models/message/outgoing_message.py
new file mode 100644
index 00000000..d3aa51ec
--- /dev/null
+++ b/botx/models/message/outgoing_message.py
@@ -0,0 +1,24 @@
+from dataclasses import dataclass
+from typing import Any, Dict, List, Union
+from uuid import UUID
+
+from botx.missing import Missing, Undefined
+from botx.models.attachments import IncomingFileAttachment, OutgoingAttachment
+from botx.models.message.markup import BubbleMarkup, KeyboardMarkup
+
+
+@dataclass
+class OutgoingMessage:
+ bot_id: UUID
+ chat_id: UUID
+ body: str
+ metadata: Missing[Dict[str, Any]] = Undefined
+ bubbles: Missing[BubbleMarkup] = Undefined
+ keyboard: Missing[KeyboardMarkup] = Undefined
+ file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined
+ silent_response: Missing[bool] = Undefined
+ markup_auto_adjust: Missing[bool] = Undefined
+ recipients: Missing[List[UUID]] = Undefined
+ stealth_mode: Missing[bool] = Undefined
+ send_push: Missing[bool] = Undefined
+ ignore_mute: Missing[bool] = Undefined
diff --git a/botx/models/message/reply.py b/botx/models/message/reply.py
new file mode 100644
index 00000000..a18b3c7f
--- /dev/null
+++ b/botx/models/message/reply.py
@@ -0,0 +1,28 @@
+from dataclasses import dataclass
+from typing import List, Literal
+from uuid import UUID
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.enums import BotAPIEntityTypes
+from botx.models.message.mentions import BotAPIMentionData, MentionList
+
+
+@dataclass
+class Reply:
+ author_id: UUID
+ sync_id: UUID
+ body: str
+ mentions: MentionList
+
+
+class BotAPIReplyData(VerifiedPayloadBaseModel):
+ source_sync_id: UUID
+ sender: UUID
+ body: str
+ mentions: List[BotAPIMentionData]
+ # Ignoring attachments cause they don't have content
+
+
+class BotAPIReply(VerifiedPayloadBaseModel):
+ type: Literal[BotAPIEntityTypes.REPLY]
+ data: BotAPIReplyData
diff --git a/botx/models/message/reply_message.py b/botx/models/message/reply_message.py
new file mode 100644
index 00000000..b9034eaa
--- /dev/null
+++ b/botx/models/message/reply_message.py
@@ -0,0 +1,23 @@
+from dataclasses import dataclass
+from typing import Any, Dict, Union
+from uuid import UUID
+
+from botx.missing import Missing, Undefined
+from botx.models.attachments import IncomingFileAttachment, OutgoingAttachment
+from botx.models.message.markup import BubbleMarkup, KeyboardMarkup
+
+
+@dataclass
+class ReplyMessage:
+ bot_id: UUID
+ sync_id: UUID
+ body: str
+ metadata: Missing[Dict[str, Any]] = Undefined
+ bubbles: Missing[BubbleMarkup] = Undefined
+ keyboard: Missing[KeyboardMarkup] = Undefined
+ file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined
+ silent_response: Missing[bool] = Undefined
+ markup_auto_adjust: Missing[bool] = Undefined
+ stealth_mode: Missing[bool] = Undefined
+ send_push: Missing[bool] = Undefined
+ ignore_mute: Missing[bool] = Undefined
diff --git a/botx/models/messages/__init__.py b/botx/models/messages/__init__.py
deleted file mode 100644
index 5a28247a..00000000
--- a/botx/models/messages/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Entities for messages: incoming or outgoing."""
diff --git a/botx/models/messages/incoming_message.py b/botx/models/messages/incoming_message.py
deleted file mode 100644
index 3b8dbefb..00000000
--- a/botx/models/messages/incoming_message.py
+++ /dev/null
@@ -1,204 +0,0 @@
-"""Definition of messages received by bot or sent by it."""
-
-from typing import Any, Dict, List, Optional, Tuple, Union
-from uuid import UUID
-
-from pydantic import BaseConfig, BaseModel, Field, validator
-
-from botx.models import events
-from botx.models.attachments import AttachList
-from botx.models.entities import EntityList
-from botx.models.enums import ChatTypes, ClientPlatformEnum, CommandTypes
-from botx.models.files import File, MetaFile
-
-CommandDataType = Union[
- events.ChatCreatedEvent,
- events.AddedToChatEvent,
- events.DeletedFromChatEvent,
- events.LeftFromChatEvent,
- events.InternalBotNotificationEvent,
- events.CTSLoginEvent,
- events.CTSLogoutEvent,
- events.SmartAppEvent,
- Dict[str, Any],
-]
-
-
-class Command(BaseModel):
- """Command that should be proceed by bot."""
-
- #: incoming text message.
- body: str
-
- #: was command received from user or this is system event.
- command_type: CommandTypes
-
- #: command payload.
- data: CommandDataType = {} # noqa: WPS110
-
- #: command metadata.
- metadata: Dict[str, Any] = {}
-
- @property
- def command(self) -> str:
- """First word of body that was sent to bot."""
- return self.body.split(" ", 1)[0]
-
- @property
- def arguments(self) -> Tuple[str, ...]:
- """Words that are passed after command."""
- words = (word for word in self.body.split(" ")[1:])
- arguments = (arg for arg in words if arg and not arg.isspace())
-
- return tuple(arguments)
-
- @property
- def single_argument(self) -> str:
- """Line that passed after command."""
- body_len = len(self.command)
- return self.body[body_len:].strip()
-
- @property
- def data_dict(self) -> dict:
- """Command data as dictionary."""
- if isinstance(self.data, dict):
- return self.data
- return self.data.dict()
-
-
-class DeviceMeta(BaseModel):
- """User device metadata."""
-
- #: could send pushes.
- pushes: Optional[bool]
-
- #: user timezone.
- timezone: Optional[str]
-
- #: app permissions (microphone, camera, etc.)
- permissions: Optional[Dict[str, Any]]
-
-
-class Sender(BaseModel):
- """User that sent message to bot."""
-
- #: user id.
- user_huid: Optional[UUID]
-
- #: chat id.
- group_chat_id: Optional[UUID]
-
- #: type of chat.
- chat_type: Optional[ChatTypes]
-
- #: AD login of user.
- ad_login: Optional[str]
-
- #: AD domain of user.
- ad_domain: Optional[str]
-
- #: username of user.
- username: Optional[str]
-
- #: is user admin of chat.
- is_admin: Optional[bool]
-
- #: is user creator of chat.
- is_creator: Optional[bool]
-
- #: device brand.
- manufacturer: Optional[str]
-
- #: device name.
- device: Optional[str]
-
- #: device Operating System.
- device_software: Optional[str]
-
- #: device metadata.
- device_meta: Optional[DeviceMeta]
-
- #: client platform name.
- platform: Optional[ClientPlatformEnum]
-
- #: platform package ID with app data and device.
- platform_package_id: Optional[str]
-
- #: Express app version.
- app_version: Optional[str]
-
- #: session locale.
- locale: Optional[str]
-
- #: host from which user sent message.
- host: str
-
- @property
- def upn(self) -> Optional[str]:
- """User principal name.
-
- https://docs.microsoft.com/en-us/windows/win32/adschema/a-userprincipalname
- """
- if self.ad_login and self.ad_domain:
- return "{0}@{1}".format(self.ad_login, self.ad_domain)
-
- return None
-
-
-class IncomingMessage(BaseModel):
- """
- Message that was received by bot and should be handled.
-
- Warning:
- `file` is deprecated field for botx api v4+.
- """
-
- #: message event id on which bot should answer.
- sync_id: UUID
-
- #: command for bot.
- command: Command
-
- #: file attached to message.
- file: Optional[File] = None
-
- #: meta info for downloading files
- async_files: List[MetaFile] = Field(default_factory=list)
-
- #: information about user from which message was received.
- user: Sender = Field(..., alias="from")
-
- #: ID of message whose ui element was triggered to send this message.
- source_sync_id: Optional[UUID] = None
-
- #: id of bot that should handle message.
- bot_id: UUID
-
- #: additional entities that can be received by bot.
- entities: EntityList = Field([])
-
- #: attached documents and files to message.
- attachments: AttachList = Field([])
-
- class Config(BaseConfig):
- allow_population_by_field_name = True
-
- @validator("file", always=True, pre=True)
- def skip_file_validation(
- cls,
- file: Optional[Union[dict, File]], # noqa: N805
- ) -> Optional[File]:
- """Skip validation for incoming file since users have not such limits as bot.
-
- Arguments:
- file: file data that should be used for building file instance.
-
- Returns:
- Constructed file.
- """
- if isinstance(file, File):
- return file
- elif file is not None:
- return File.construct(**file)
-
- return None
diff --git a/botx/models/messages/message.py b/botx/models/messages/message.py
deleted file mode 100644
index 88e98aec..00000000
--- a/botx/models/messages/message.py
+++ /dev/null
@@ -1,174 +0,0 @@
-"""Definition of message object that is used in all bot handlers."""
-from __future__ import annotations
-
-from functools import partial
-from typing import Any, Dict, List, Optional, Type
-from uuid import UUID
-
-from botx.bots import bots
-from botx.models.attachments import AttachList
-from botx.models.chats import ChatTypes
-from botx.models.datastructures import State
-from botx.models.entities import EntityList
-from botx.models.enums import CommandTypes
-from botx.models.files import File, MetaFile
-from botx.models.messages.incoming_message import Command, IncomingMessage, Sender
-from botx.models.messages.sending.credentials import SendingCredentials
-
-
-class _ProxyProperty:
- def __init__(self, proxy_attribute_name: str, *nested_fields: str) -> None:
- self.proxy_attribute_name = proxy_attribute_name
- self.nested_fields = list(nested_fields)
-
- def __get__(self, instance: Message, _owner: Type[Message]) -> Any:
- proxy_object = getattr(instance, self.proxy_attribute_name)
- accessed_result = proxy_object
- for nested_field in self.nested_fields:
- accessed_result = getattr(accessed_result, nested_field)
- return accessed_result
-
- def __set_name__(self, _owner: Type[Message], name: str) -> None:
- self.nested_fields.append(name)
-
-
-def _proxy_property(proxy_attribute_name: str, *nested_fields: str) -> Any:
- return _ProxyProperty(proxy_attribute_name, *nested_fields)
-
-
-_message_proxy_property = partial(_proxy_property, "incoming_message")
-_user_proxy_property = partial(_message_proxy_property, "user")
-
-
-class Message:
- """Message that is used in handlers."""
-
- #: incoming message from BotX.
- incoming_message: IncomingMessage
-
- #: bot that handles this message processing.
- bot: "bots.Bot"
-
- #: state of message during processing.
- state: State
-
- #: ID of message event.
- sync_id: UUID = _message_proxy_property()
-
- #: ID of message whose ui element was triggered to send this message.
- source_sync_id: Optional[UUID] = _message_proxy_property()
-
- #: ID of bot that handles message in Express.
- bot_id: UUID = _message_proxy_property()
-
- #: access to command information.
- command: Command = _message_proxy_property()
-
- #: command body.
- body: str = _message_proxy_property("command")
-
- #: command metadata.
- metadata: dict = _message_proxy_property("command")
-
- #: file from message.
- file: Optional[File] = _message_proxy_property()
-
- #: Meta for download files.
- async_files: List[MetaFile] = _message_proxy_property()
-
- #: attachment from message v4+
- attachments: AttachList = _message_proxy_property()
-
- #: information about user that sent message.
- user: Sender = _message_proxy_property()
-
- #: HUID of user.
- user_huid: Optional[UUID] = _user_proxy_property()
-
- #: AD login of user.
- ad_login: Optional[str] = _user_proxy_property()
-
- #: AD domain of user.
- ad_domain: Optional[str] = _user_proxy_property()
-
- #: ID of chat from which message was received.
- group_chat_id: Optional[UUID] = _user_proxy_property()
-
- #: type of chat.
- chat_type: Optional[ChatTypes] = _user_proxy_property()
-
- #: host of CTS from which message was received.
- host: str = _user_proxy_property()
-
- #: external entities in message (mentions, forwards, etc)
- entities: EntityList = _message_proxy_property()
-
- #: credentials from message for using in requests.
- credentials: SendingCredentials
-
- #: flag for marking that message was received from button.
- sent_from_button: bool
-
- def __init__(self, message: IncomingMessage, bot: "bots.Bot") -> None:
- """Initialize and update fields.
-
- Arguments:
- message: incoming message.
- bot: bot that handles message processing.
- """
- self.incoming_message = message
- self.bot = bot
-
- self.state = State()
-
- self.credentials = SendingCredentials(
- sync_id=self.sync_id,
- bot_id=self.bot_id,
- host=self.host,
- chat_id=self.group_chat_id,
- )
-
- @classmethod
- def from_dict(cls, message: dict, bot: "bots.Bot") -> Message:
- """Parse incoming dict into message.
-
- Arguments:
- message: incoming message to bot as dictionary.
- bot: bot that handles message.
-
- Returns:
- Parsed message.
- """
- return cls(IncomingMessage(**message), bot)
-
- @property
- def is_forward(self) -> bool:
- """Check this message on forwarding.
-
- Returns:
- bool: True if message is forward else False
- """
- try: # noqa: WPS503
- self.entities.forward # noqa: WPS428
- except AttributeError:
- return False
- else:
- return True
-
- @property
- def is_system_event(self) -> bool:
- """Check if message is a system event.
-
- Returns:
- bool: Result of check.
- """
- return self.command.command_type == CommandTypes.system
-
- @property
- def data(self) -> Dict[Any, Any]: # noqa: WPS110
- """Concatenated metadata and data from UI element.
-
- Returns:
- dict: Concatenated metadata and data.
- """
- return {**self.metadata, **self.incoming_message.command.data_dict}
diff --git a/botx/models/messages/sending/__init__.py b/botx/models/messages/sending/__init__.py
deleted file mode 100644
index c62ba5ce..00000000
--- a/botx/models/messages/sending/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition for entities that are used in sending messages."""
diff --git a/botx/models/messages/sending/credentials.py b/botx/models/messages/sending/credentials.py
deleted file mode 100644
index 86540b58..00000000
--- a/botx/models/messages/sending/credentials.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""Definition for sending credentials."""
-
-from typing import Optional
-from uuid import UUID
-
-from botx.models.base import BotXBaseModel
-
-
-class SendingCredentials(BotXBaseModel):
- """Credentials that are required to send command or notification result."""
-
- #: message event id.
- sync_id: Optional[UUID] = None
-
- #: id of message that will be sent.
- message_id: Optional[UUID] = None
-
- #: chat id in which bot should send message.
- chat_id: Optional[UUID] = None
-
- #: bot that handles message.
- bot_id: Optional[UUID] = None
-
- #: host on which bot answers.
- host: Optional[str] = None
-
- #: token that is used for bot authorization on requests to BotX API.
- token: Optional[str] = None
diff --git a/botx/models/messages/sending/markup.py b/botx/models/messages/sending/markup.py
deleted file mode 100644
index 8b5ba892..00000000
--- a/botx/models/messages/sending/markup.py
+++ /dev/null
@@ -1,155 +0,0 @@
-"""Definition for markup attached to sent message."""
-
-from typing import List, Optional, Type, TypeVar
-
-from botx.models.base import BotXBaseModel
-from botx.models.buttons import BubbleElement, Button, ButtonOptions, KeyboardElement
-
-TUIElement = TypeVar("TUIElement", bound=Button)
-
-
-class MessageMarkup(BotXBaseModel):
- """Collection for bubbles and keyboard with some helper methods."""
-
- #: bubbles that will be attached to message.
- bubbles: List[List[BubbleElement]] = []
-
- #: keyboard elements that will be attached to message.
- keyboard: List[List[KeyboardElement]] = []
-
- def add_bubble( # noqa: WPS211
- self,
- command: str,
- label: Optional[str] = None,
- data: Optional[dict] = None, # noqa: WPS110
- options: Optional[ButtonOptions] = None,
- *,
- new_row: bool = True,
- ) -> None:
- """Add new bubble button to markup.
-
- Arguments:
- command: command that will be triggered on bubble click.
- label: label that will be shown on bubble.
- data: payload that will be attached to bubble.
- options: add special effects to bubble.
- new_row: place bubble on new row or on current.
- """
- self._add_ui_element(
- ui_cls=BubbleElement,
- ui_array=self.bubbles,
- command=command,
- label=label,
- data=data,
- opts=options,
- new_row=new_row,
- )
-
- def add_bubble_element(
- self,
- element: BubbleElement,
- *,
- new_row: bool = True,
- ) -> None:
- """Add new button to markup from existing element.
-
- Arguments:
- element: existed bubble element.
- new_row: place bubble on new row or on current.
- """
- self._add_ui_element(
- ui_cls=BubbleElement,
- ui_array=self.bubbles,
- command=element.command,
- label=element.label,
- data=element.data,
- opts=element.opts,
- new_row=new_row,
- )
-
- def add_keyboard_button( # noqa: WPS211
- self,
- command: str,
- label: Optional[str] = None,
- data: Optional[dict] = None, # noqa: WPS110
- options: Optional[ButtonOptions] = None,
- *,
- new_row: bool = True,
- ) -> None:
- """Add new keyboard button to markup.
-
- Arguments:
- command: command that will be triggered on keyboard click.
- label: label that will be shown on keyboard button.
- data: payload that will be attached to keyboard.
- options: add special effects to keyboard button.
- new_row: place keyboard on new row or on current.
- """
- self._add_ui_element(
- ui_cls=KeyboardElement,
- ui_array=self.keyboard,
- command=command,
- label=label,
- data=data,
- opts=options,
- new_row=new_row,
- )
-
- def add_keyboard_button_element(
- self,
- element: KeyboardElement,
- *,
- new_row: bool = True,
- ) -> None:
- """Add new keyboard button to markup from existing element.
-
- Arguments:
- element: existed keyboard button element.
- new_row: place keyboard button on new row or on current.
- """
- self._add_ui_element(
- ui_cls=KeyboardElement,
- ui_array=self.keyboard,
- command=element.command,
- label=element.label,
- data=element.data,
- opts=element.opts,
- new_row=new_row,
- )
-
- def _add_ui_element( # noqa: WPS211
- self,
- ui_cls: Type[TUIElement],
- ui_array: List[List[TUIElement]],
- command: str,
- label: Optional[str] = None,
- data: Optional[dict] = None, # noqa: WPS110
- opts: Optional[ButtonOptions] = None,
- new_row: bool = True,
- ) -> None:
- """Add new button to bubble or keyboard arrays.
-
- Arguments:
- ui_cls: UIElement instance that should be added to array.
- ui_array: storage for ui elements.
- command: command that will be triggered on ui element click.
- label: label that will be shown on ui element.
- data: payload that will be attached to ui element.
- opts: add special effects ui element.
- new_row: place ui element on new row or on current.
- """
- element = ui_cls(
- command=command,
- label=label,
- data=(data or {}),
- opts=(opts or ButtonOptions()),
- )
-
- if new_row:
- ui_array.append([element])
- return
-
- if not ui_array:
- ui_array.append([])
-
- ui_array[-1].append(element)
diff --git a/botx/models/messages/sending/message.py b/botx/models/messages/sending/message.py
deleted file mode 100644
index 6b8aa470..00000000
--- a/botx/models/messages/sending/message.py
+++ /dev/null
@@ -1,651 +0,0 @@
-"""Message that is sent from bot."""
-import re
-from typing import Any # noqa: WPS235
-from typing import BinaryIO, Dict, List, Optional, TextIO, Tuple, Union, cast
-from uuid import UUID
-
-from botx.models.buttons import ButtonOptions
-from botx.models.entities import ChatMention, Mention, UserMention
-from botx.models.enums import MentionTypes
-from botx.models.files import File
-from botx.models.messages.message import Message
-from botx.models.messages.sending.credentials import SendingCredentials
-from botx.models.messages.sending.markup import MessageMarkup
-from botx.models.messages.sending.options import MessageOptions, NotificationOptions
-from botx.models.messages.sending.payload import MessagePayload
-from botx.models.typing import AvailableRecipients, BubbleMarkup, KeyboardMarkup
-
-try:
- from typing import Final # noqa: WPS433
-except ImportError:
- from typing_extensions import Final # type: ignore # noqa: WPS433, WPS440, F401
-
-ARGUMENTS_DUPLICATION_ERROR = (
- "{0} can not be passed along with manual validated_values for it"
-)
-
-EMBED_MENTION_TEMPLATE = (
- "" # noqa: WPS326
-)
-EMBED_MENTION_RE: Final = re.compile(
- r".+?):(?P[0-9a-f\-]+?):"
- r"(?P[0-9a-f\-]+?):(?P.+?)??>", # noqa: WPS326 C812
-)
-
-
-# currently I have no idea how to clean this.
-class SendingMessage: # noqa: WPS214
- """Message that will be sent by bot."""
-
- def __init__( # noqa: WPS211
- self,
- *,
- text: str = "",
- bot_id: Optional[UUID] = None,
- host: Optional[str] = None,
- sync_id: Optional[UUID] = None,
- chat_id: Optional[UUID] = None,
- message_id: Optional[UUID] = None,
- recipients: Optional[AvailableRecipients] = None,
- mentions: Optional[List[Mention]] = None,
- bubbles: Optional[BubbleMarkup] = None,
- keyboard: Optional[KeyboardMarkup] = None,
- notification_options: Optional[NotificationOptions] = None,
- file: Optional[File] = None,
- credentials: Optional[SendingCredentials] = None,
- options: Optional[MessageOptions] = None,
- markup: Optional[MessageMarkup] = None,
- metadata: Optional[Dict[str, Any]] = None,
- embed_mentions: bool = False,
- ) -> None:
- """Init message with required attributes.
-
- !!! info
- You should pass at least already built credentials or bot_id, host and
- one of sync_id, chat_id or chat_ids for message.
- !!! info
- You can not pass markup along with bubbles or keyboards. You can merge them
- manual before or after building message.
- !!! info
- You can not pass options along with any of recipients, mentions or
- notification_options. You can merge them manual before or after building
- message.
-
- Arguments:
- text: text for message.
- file: file that will be attached to message.
- bot_id: bot id.
- host: host for message.
- sync_id: message event id.
- chat_id: chat id.
- message_id: custom id of new message.
- credentials: message credentials.
- bubbles: bubbles that will be attached to message.
- keyboard: keyboard elements that will be attached to message.
- markup: message markup.
- recipients: recipients for message.
- mentions: mentions that will be attached to message.
- notification_options: configuration for notifications for message.
- options: message options.
- metadata: message metadata.
- embed_mentions: get mentions from text.
- """
- self.credentials: SendingCredentials = _build_credentials(
- bot_id=bot_id,
- host=host,
- sync_id=sync_id,
- message_id=message_id,
- chat_id=chat_id,
- credentials=(credentials.copy() if credentials else credentials),
- )
-
- options = _build_options(
- recipients=recipients,
- mentions=mentions,
- notification_options=notification_options,
- options=options,
- )
- if embed_mentions:
- updated_text, found_mentions = self._find_and_replace_embed_mentions(text)
-
- text = updated_text
- options.mentions = found_mentions
- options.raw_mentions = True
-
- self.payload: MessagePayload = MessagePayload(
- text=text,
- metadata=metadata or {},
- file=file,
- markup=_build_markup(bubbles=bubbles, keyboard=keyboard, markup=markup),
- options=options,
- )
-
- @classmethod
- def from_message(
- cls,
- *,
- text: str = "",
- file: Optional[File] = None,
- message: Message,
- embed_mentions: bool = False,
- ) -> "SendingMessage":
- """Build message for sending from incoming message.
-
- Arguments:
- text: text for message.
- file: file attached to message.
- message: incoming message.
- embed_mentions: get mentions from text.
-
- Returns:
- Built message.
- """
- return cls(
- text=text,
- file=file,
- sync_id=message.sync_id,
- chat_id=message.group_chat_id,
- bot_id=message.bot_id,
- host=message.host,
- embed_mentions=embed_mentions,
- )
-
- @classmethod
- def make_mention_embeddable(cls, mention: Mention) -> str:
- """Get mention as string, which can be embed in text.
-
- Arguments:
- mention: mention for embedding.
-
- Raises:
- NotImplementedError: If unsupported mention type was passed.
-
- Returns:
- Formatted mention.
- """
- if mention.mention_type in {MentionTypes.user, MentionTypes.contact}:
- assert isinstance(mention.mention_data, UserMention) # for mypy
- mentioned_entity_id = mention.mention_data.user_huid
- elif mention.mention_type in {MentionTypes.chat, MentionTypes.channel}:
- assert isinstance(mention.mention_data, ChatMention) # for mypy
- mentioned_entity_id = mention.mention_data.group_chat_id
- else:
- raise NotImplementedError("Unsupported mention type")
-
- mention_name = mention.mention_data.name or ""
-
- return EMBED_MENTION_TEMPLATE.format(
- mention_type=mention.mention_type,
- mentioned_entity_id=mentioned_entity_id,
- mention_id=mention.mention_id,
- mention_name=mention_name,
- )
-
- @classmethod
- def build_embeddable_user_mention(
- cls,
- user_huid: UUID,
- name: Optional[str] = None,
- mention_id: Optional[UUID] = None,
- ) -> str:
- """Get user mention as string, which can be embed in text.
-
- Arguments:
- user_huid: user id to mention.
- name: for overriding mention name.
- mention_id: mention id (if not passed, will be generated).
-
- Returns:
- Formatted mention.
- """
- mention = Mention.build_from_values(
- MentionTypes.user,
- user_huid,
- name,
- mention_id,
- )
-
- return cls.make_mention_embeddable(mention)
-
- @classmethod
- def build_embeddable_contact_mention(
- cls,
- user_huid: UUID,
- name: Optional[str] = None,
- mention_id: Optional[UUID] = None,
- ) -> str:
- """Get contact mention as string, which can be embed in text.
-
- Arguments:
- user_huid: user id to mention.
- name: for overriding mention name.
- mention_id: mention id (if not passed, will be generated).
-
- Returns:
- Formatted mention.
- """
- mention = Mention.build_from_values(
- MentionTypes.contact,
- user_huid,
- name,
- mention_id,
- )
-
- return cls.make_mention_embeddable(mention)
-
- @classmethod
- def build_embeddable_chat_mention(
- cls,
- group_chat_id: UUID,
- name: Optional[str] = None,
- mention_id: Optional[UUID] = None,
- ) -> str:
- """Get chat mention as string, which can be embed in text.
-
- Arguments:
- group_chat_id: chat id to mention.
- name: for overriding mention name.
- mention_id: mention id (if not passed, will be generated).
-
- Returns:
- Formatted mention.
- """
- mention = Mention.build_from_values(
- MentionTypes.chat,
- group_chat_id,
- name,
- mention_id,
- )
-
- return cls.make_mention_embeddable(mention)
-
- @classmethod
- def build_embeddable_channel_mention(
- cls,
- group_chat_id: UUID,
- name: Optional[str] = None,
- mention_id: Optional[UUID] = None,
- ) -> str:
- """Get channel mention as string, which can be embed in text.
-
- Arguments:
- group_chat_id: channel id to mention.
- name: for overriding mention name.
- mention_id: mention id (if not passed, will be generated).
-
- Returns:
- Formatted mention.
- """
- mention = Mention.build_from_values(
- MentionTypes.channel,
- group_chat_id,
- name,
- mention_id,
- )
-
- return cls.make_mention_embeddable(mention)
-
- @property
- def text(self) -> str:
- """Text in message."""
- return self.payload.text
-
- @text.setter
- def text(self, text: str) -> None:
- """Text in message."""
- self.payload.text = text
-
- @property
- def metadata(self) -> Dict[str, Any]:
- """Metadata in message."""
- return self.payload.metadata
-
- @metadata.setter
- def metadata(self, metadata: Dict[str, Any]) -> None:
- self.payload.metadata = metadata
-
- @property
- def file(self) -> Optional[File]:
- """File attached to message."""
- return self.payload.file
-
- @file.setter
- def file(self, file: File) -> None:
- """File attached to message."""
- self.payload.file = file
-
- @property
- def markup(self) -> MessageMarkup:
- """Message markup."""
- return self.payload.markup
-
- @markup.setter
- def markup(self, markup: MessageMarkup) -> None:
- """Message markup."""
- self.payload.markup = markup
-
- @property
- def options(self) -> MessageOptions:
- """Message options."""
- return self.payload.options
-
- @options.setter
- def options(self, options: MessageOptions) -> None:
- """Message options."""
- self.payload.options = options
-
- @property
- def sync_id(self) -> Optional[UUID]:
- """Event id on which message should answer."""
- return self.credentials.sync_id
-
- @sync_id.setter
- def sync_id(self, sync_id: UUID) -> None:
- """Event id on which message should answer."""
- self.credentials.sync_id = sync_id
-
- @property
- def chat_id(self) -> Optional[UUID]:
- """Chat id in which message should be sent."""
- return self.credentials.chat_id
-
- @chat_id.setter
- def chat_id(self, chat_id: UUID) -> None:
- """Chat id in which message should be sent."""
- self.credentials.chat_id = chat_id
-
- @property
- def bot_id(self) -> UUID:
- """Bot id that handles message."""
- return cast(UUID, self.credentials.bot_id)
-
- @bot_id.setter
- def bot_id(self, bot_id: UUID) -> None:
- """Bot id that handles message."""
- self.credentials.bot_id = bot_id
-
- @property
- def host(self) -> str:
- """Host where BotX API places."""
- return cast(str, self.credentials.host)
-
- @host.setter
- def host(self, host: str) -> None:
- """Host where BotX API places."""
- self.credentials.host = host
-
- def add_file(
- self,
- file: Union[TextIO, BinaryIO, File],
- filename: Optional[str] = None,
- ) -> None:
- """Attach file to message.
-
- Arguments:
- file: file that should be attached to the message.
- filename: name for file that will be used if if can not be retrieved from
- file.
- """
- if isinstance(file, File):
- file.file_name = filename or file.file_name
- self.payload.file = file
- else:
- self.payload.file = File.from_file(file, filename=filename)
-
- def mention_user(self, user_huid: UUID, name: Optional[str] = None) -> None:
- """Mention user in message.
-
- Arguments:
- user_huid: id of user that should be mentioned.
- name: name that will be shown.
- """
- self.payload.options.mentions.append(
- Mention(mention_data=UserMention(user_huid=user_huid, name=name)),
- )
-
- def mention_contact(self, user_huid: UUID, name: Optional[str] = None) -> None:
- """Mention contact in message.
-
- Arguments:
- user_huid: id of user that should be mentioned.
- name: name that will be shown.
- """
- self.payload.options.mentions.append(
- Mention(
- mention_data=UserMention(user_huid=user_huid, name=name),
- mention_type=MentionTypes.contact,
- ),
- )
-
- def mention_chat(self, group_chat_id: UUID, name: Optional[str] = None) -> None:
- """Mention chat in message.
-
- Arguments:
- group_chat_id: id of chat that should be mentioned.
- name: name that will be shown.
- """
- self.payload.options.mentions.append(
- Mention(
- mention_data=ChatMention(group_chat_id=group_chat_id, name=name),
- mention_type=MentionTypes.chat,
- ),
- )
-
- def add_recipient(self, recipient: UUID) -> None:
- """Add new user that will receive message.
-
- Arguments:
- recipient: recipient for message.
- """
- if self.payload.options.recipients == "all":
- self.payload.options.recipients = []
-
- self.payload.options.recipients.append(recipient)
-
- def add_recipients(self, recipients: List[UUID]) -> None:
- """Add list of recipients that should receive message.
-
- Arguments:
- recipients: recipients for message.
- """
- if self.payload.options.recipients == "all":
- self.payload.options.recipients = []
-
- self.payload.options.recipients.extend(recipients)
-
- def add_bubble( # noqa: WPS211
- self,
- command: str,
- label: Optional[str] = None,
- data: Optional[dict] = None, # noqa: WPS110
- options: Optional[ButtonOptions] = None,
- *,
- new_row: bool = True,
- ) -> None:
- """Add new bubble button to message markup.
-
- Arguments:
- command: command that will be triggered on bubble click.
- label: label that will be shown on bubble.
- data: payload that will be attached to bubble.
- options: add special effects to bubble.
- new_row: place bubble on new row or on current.
- """
- self.payload.markup.add_bubble(command, label, data, options, new_row=new_row)
-
- def add_keyboard_button( # noqa: WPS211
- self,
- command: str,
- label: Optional[str] = None,
- data: Optional[dict] = None, # noqa: WPS110
- options: Optional[ButtonOptions] = None,
- *,
- new_row: bool = True,
- ) -> None:
- """Add new keyboard button to message markup.
-
- Arguments:
- command: command that will be triggered on keyboard click.
- label: label that will be shown on keyboard button.
- data: payload that will be attached to keyboard.
- options: add special effects to keyboard button.
- new_row: place keyboard on new row or on current.
- """
- self.payload.markup.add_keyboard_button(
- command,
- label,
- data,
- options,
- new_row=new_row,
- )
-
- def show_notification(self, show: bool) -> None:
- """Show notification about message.
-
- Arguments:
- show: show notification about message.
- """
- self.payload.options.notifications.send = show
-
- def force_notification(self, force: bool) -> None:
- """Break mute on bot messages.
-
- Arguments:
- force: break mute on bot messages.
- """
- self.payload.options.notifications.force_dnd = force
-
- def _find_and_replace_embed_mentions( # noqa: WPS210
- self,
- text: str,
- ) -> Tuple[str, List[Mention]]:
- mentions = []
-
- match = EMBED_MENTION_RE.search(text)
- while match:
- mention_dict = match.groupdict()
- embed_mention = match.group(0)
-
- mention = Mention.build_from_values(
- MentionTypes(mention_dict["mention_type"]),
- UUID(mention_dict["mentioned_entity_id"]),
- mention_dict["name"],
- UUID(mention_dict["mention_id"]),
- )
-
- text = text.replace(embed_mention, mention.to_botx_format())
- mentions.append(mention)
-
- match = EMBED_MENTION_RE.search(text)
-
- return text, mentions
-
-
-def _build_credentials( # noqa: WPS211
- bot_id: Optional[UUID] = None,
- host: Optional[str] = None,
- sync_id: Optional[UUID] = None,
- message_id: Optional[UUID] = None,
- chat_id: Optional[UUID] = None,
- credentials: Optional[SendingCredentials] = None,
-) -> SendingCredentials:
- """Build credentials for message.
-
- Arguments:
- bot_id: bot id.
- host: host for message.
- sync_id: message event id.
- message_id: id of new message.
- chat_id: chat id.
- credentials: message credentials.
-
- Returns:
- Credentials for message.
-
- Raises:
- AssertionError: raised if credentials were passed with separate parameters.
- """
- if bot_id and host:
- if credentials is not None:
- raise AssertionError(
- ARGUMENTS_DUPLICATION_ERROR.format("MessageCredentials"),
- )
-
- return SendingCredentials(
- bot_id=bot_id,
- host=host,
- sync_id=sync_id,
- chat_id=chat_id,
- message_id=message_id,
- )
-
- if credentials is None:
- raise AssertionError(
- "MessageCredentials or manual validated_values should be passed",
- )
-
- if credentials.message_id is None:
- credentials.message_id = message_id
-
- return credentials
-
-
-def _build_markup(
- bubbles: Optional[BubbleMarkup] = None,
- keyboard: Optional[KeyboardMarkup] = None,
- markup: Optional[MessageMarkup] = None,
-) -> MessageMarkup:
- """Build markup for message.
-
- Arguments:
- bubbles: bubbles that will be attached to message.
- keyboard: keyboard elements that will be attached to message.
- markup: message markup.
-
- Returns:
- Markup for message.
-
- Raises:
- AssertionError: raised if markup were passed with separate parameters.
- """
- if bubbles is not None or keyboard is not None:
- if markup is not None:
- raise AssertionError(
- "Markup can not be passed along with bubbles or keyboard elements",
- )
- return MessageMarkup(bubbles=bubbles or [], keyboard=keyboard or [])
-
- return markup or MessageMarkup()
-
-
-def _build_options(
- recipients: Optional[AvailableRecipients] = None,
- mentions: Optional[List[Mention]] = None,
- notification_options: Optional[NotificationOptions] = None,
- options: Optional[MessageOptions] = None,
-) -> MessageOptions:
- """Build options for message.
-
- Arguments:
- recipients: recipients for message.
- mentions: mentions that will be attached to message.
- notification_options: configuration for notifications for message.
- options: message options.
-
- Returns:
- Options for message.
-
- Raises:
- AssertionError: raised if options were passed with separate parameters.
- """
- if mentions or recipients or notification_options:
- if options is not None:
- raise AssertionError(ARGUMENTS_DUPLICATION_ERROR.format("MessageOptions"))
- return MessageOptions(
- recipients=recipients or "all",
- mentions=mentions or [],
- notifications=notification_options or NotificationOptions(),
- )
-
- return options or MessageOptions()
diff --git a/botx/models/messages/sending/options.py b/botx/models/messages/sending/options.py
deleted file mode 100644
index 962d7e62..00000000
--- a/botx/models/messages/sending/options.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""Special options for message."""
-
-from typing import List
-
-from botx.models.base import BotXBaseModel
-from botx.models.entities import Mention
-from botx.models.typing import AvailableRecipients
-
-
-class ResultPayloadOptions(BotXBaseModel):
- """Options for `notification` and `command_result` API entities."""
-
- #: don't show next user's input in chat
- silent_response: bool = False
-
-
-class NotificationOptions(BotXBaseModel):
- """Configurations for message notifications."""
-
- #: show notification about message.
- send: bool = True
-
- #: break mute on bot messages.
- force_dnd: bool = False
-
-
-class MessageOptions(BotXBaseModel):
- """Message options configuration."""
-
- #: users that should receive message.
- recipients: AvailableRecipients = "all"
-
- #: attached to message mentions.
- mentions: List[Mention] = []
-
- #: don't show next user's input in chat
- silent_response: bool = False
-
- #: deliver message only if stealth mode enabled
- stealth_mode: bool = False
-
- #: use in-text mentions
- raw_mentions: bool = False
-
- #: notification configuration.
- notifications: NotificationOptions = NotificationOptions()
diff --git a/botx/models/messages/sending/payload.py b/botx/models/messages/sending/payload.py
deleted file mode 100644
index b61b30c4..00000000
--- a/botx/models/messages/sending/payload.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""Payload for messages."""
-from __future__ import annotations
-
-from typing import Any, Dict, List, Optional
-
-from pydantic import Field
-
-from botx.models.base import BotXBaseModel
-from botx.models.constants import MAXIMUM_TEXT_LENGTH
-from botx.models.entities import Mention
-from botx.models.files import File
-from botx.models.messages.sending.markup import MessageMarkup
-from botx.models.messages.sending.options import MessageOptions, NotificationOptions
-from botx.models.typing import BubbleMarkup, KeyboardMarkup
-
-
-class MessagePayload(BotXBaseModel):
- """Message payload configuration."""
-
- #: message text.
- text: str = Field("", max_length=MAXIMUM_TEXT_LENGTH)
-
- #: message metadata.
- metadata: Dict[str, Any] = {}
-
- #: file attached to message.
- file: Optional[File] = None
-
- #: message markup.
- markup: MessageMarkup = MessageMarkup()
-
- #: message configuration.
- options: MessageOptions = MessageOptions()
-
-
-class UpdatePayload(BotXBaseModel):
- """Payload for message edition."""
-
- #: new message text.
- text: Optional[str] = Field(None, max_length=MAXIMUM_TEXT_LENGTH)
-
- #: file attached to message.
- file: Optional[File] = None
-
- #: new message bubbles.
- keyboard: Optional[KeyboardMarkup] = None
-
- #: new message keyboard.
- bubbles: Optional[BubbleMarkup] = None
-
- #: new message mentions.
- mentions: Optional[List[Mention]] = None
-
- #: new message options.
- opts: Optional[NotificationOptions] = None
-
- #: message metadata.
- metadata: Optional[Dict[str, Any]] = None
-
- @property
- def markup(self) -> MessageMarkup:
- """Markup for edited message."""
- return MessageMarkup(bubbles=self.bubbles or [], keyboard=self.keyboard or [])
-
- def set_markup(self, markup: MessageMarkup) -> None:
- """Markup for edited message.
-
- Arguments:
- markup: markup that should be applied to payload.
- """
- self.bubbles = markup.bubbles
- self.keyboard = markup.keyboard
-
- @classmethod
- def from_sending_payload(cls, payload: MessagePayload) -> UpdatePayload:
- """Create new update payload from existing payload for new message.
-
- Arguments:
- payload: payload that can be used for sending new message.
-
- Returns:
- Created payload for update.
- """
- update = cls()
- update.text = payload.text or None
- update.set_markup(payload.markup)
- update.mentions = payload.options.mentions
- update.file = payload.file
- update.metadata = payload.metadata
- return update
diff --git a/botx/models/method_callbacks.py b/botx/models/method_callbacks.py
new file mode 100644
index 00000000..dc248da9
--- /dev/null
+++ b/botx/models/method_callbacks.py
@@ -0,0 +1,21 @@
+from typing import Any, Dict, List, Literal, Union
+from uuid import UUID
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+
+
+class BotAPIMethodSuccessfulCallback(VerifiedPayloadBaseModel):
+ sync_id: UUID
+ status: Literal["ok"]
+ result: Dict[str, Any]
+
+
+class BotAPIMethodFailedCallback(VerifiedPayloadBaseModel):
+ sync_id: UUID
+ status: Literal["error"]
+ reason: str
+ errors: List[str]
+ error_data: Dict[str, Any]
+
+
+BotXMethodCallback = Union[BotAPIMethodSuccessfulCallback, BotAPIMethodFailedCallback]
diff --git a/botx/models/smartapps.py b/botx/models/smartapps.py
deleted file mode 100644
index 45169b4e..00000000
--- a/botx/models/smartapps.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""Definition of smartapp object."""
-
-from typing import Any, BinaryIO, Dict, List, Optional, TextIO, Union
-from uuid import UUID
-
-from botx.models.base import BotXBaseModel
-from botx.models.files import File, MetaFile
-from botx.models.messages.message import Message
-
-
-class SendingSmartAppEvent(BotXBaseModel):
- """SmartApp event with data."""
-
- #: unique request id
- ref: Optional[UUID] = None
-
- #: smartapp id
- smartapp_id: UUID
-
- #: event data
- data: Dict[str, Any] # noqa: WPS110
-
- #: event options
- opts: Dict[str, Any] = {}
-
- #: version of protocol smartapp <-> bot
- smartapp_api_version: int
-
- #: smartapp chat
- group_chat_id: Optional[UUID]
-
- #: files
- files: List[File] = []
-
- #: file's meta to upload
- async_files: List[MetaFile] = []
-
- @classmethod
- def from_message(
- cls,
- data: Dict[str, Any], # noqa: WPS110
- message: Message,
- ) -> "SendingSmartAppEvent":
- """Build smartapp event from message.
-
- Arguments:
- data: smartapp's data.
- message: incoming message.
-
- Returns:
- Built smartapp event.
- """
- return cls(
- ref=message.data["ref"],
- smartapp_id=message.data["smartapp_id"],
- data=data,
- opts=message.data["opts"],
- smartapp_api_version=message.data["smartapp_api_version"],
- group_chat_id=message.group_chat_id,
- )
-
- def add_file(
- self,
- file: Union[TextIO, BinaryIO, File],
- filename: Optional[str] = None,
- ) -> None:
- """Attach file to smartapp.
-
- Arguments:
- file: file that should be attached to the message.
- filename: name for file that will be used if if can not be retrieved from
- file.
- """
- if isinstance(file, File):
- file.file_name = filename or file.file_name
- self.files.append(file)
- else:
- self.files.append(File.from_file(file, filename=filename))
-
-
-class SendingSmartAppNotification(BotXBaseModel):
- """SmartApp notification with counter."""
-
- #: smartapp chat
- group_chat_id: Optional[UUID]
-
- #: unread notifications count
- smartapp_counter: int
-
- #: event options
- opts: Dict[str, Any] = {}
-
- #: version of protocol smartapp <-> bot
- smartapp_api_version: int
-
- @classmethod
- def from_message(
- cls,
- smartapp_counter: int,
- message: Message,
- ) -> "SendingSmartAppNotification":
- """Build smartapp notification from message.
-
- Arguments:
- smartapp_counter: smartapp notification counter.
- message: incoming message.
-
- Returns:
- Built smartapp notification.
- """
- return cls(
- smartapp_counter=smartapp_counter,
- opts=message.data["opts"],
- smartapp_api_version=message.data["smartapp_api_version"],
- group_chat_id=message.group_chat_id,
- )
diff --git a/botx/models/status.py b/botx/models/status.py
index 7433a49e..f7f0b930 100644
--- a/botx/models/status.py
+++ b/botx/models/status.py
@@ -1,28 +1,100 @@
-"""Module of model for status recipient."""
-from typing import Optional
+from dataclasses import asdict, dataclass
+from typing import Any, Dict, List, Literal, NewType, Optional, Union
from uuid import UUID
-from botx import ChatTypes
-from botx.models.base import BotXBaseModel
+from pydantic import validator
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.enums import APIChatTypes, ChatTypes, convert_chat_type_to_domain
+from botx.models.message.incoming_message import IncomingMessage
-class StatusRecipient(BotXBaseModel):
- """Model of recipients in status request."""
+BotMenu = NewType("BotMenu", Dict[str, str])
- #: bot that request status
+
+@dataclass
+class StatusRecipient:
bot_id: UUID
+ huid: UUID
+ ad_login: Optional[str]
+ ad_domain: Optional[str]
+ is_admin: Optional[bool]
+ chat_type: ChatTypes
- #: user that request status
- user_huid: UUID
+ @classmethod
+ def from_incoming_message(
+ cls,
+ incoming_message: IncomingMessage,
+ ) -> "StatusRecipient":
+ return StatusRecipient(
+ bot_id=incoming_message.bot.id,
+ huid=incoming_message.sender.huid,
+ ad_login=incoming_message.sender.ad_login,
+ ad_domain=incoming_message.sender.ad_domain,
+ is_admin=incoming_message.sender.is_chat_admin,
+ chat_type=incoming_message.chat.type,
+ )
- #: user's ad_login
- ad_login: Optional[str]
- #: user's ad_domain
+class BotAPIStatusRecipient(VerifiedPayloadBaseModel):
+ bot_id: UUID
+ user_huid: UUID
+ ad_login: Optional[str]
ad_domain: Optional[str]
-
- #: user has admin role
is_admin: Optional[bool]
+ chat_type: APIChatTypes
- #: chat type
- chat_type: ChatTypes
+ @validator("ad_login", "ad_domain", "is_admin", pre=True)
+ @classmethod
+ def replace_empty_string(
+ cls,
+ field_value: Union[str, bool],
+ ) -> Union[str, bool, None]:
+ if field_value == "":
+ return None
+
+ return field_value
+
+ def to_domain(self) -> StatusRecipient:
+ return StatusRecipient(
+ bot_id=self.bot_id,
+ huid=self.user_huid,
+ ad_login=self.ad_login,
+ ad_domain=self.ad_domain,
+ is_admin=self.is_admin,
+ chat_type=convert_chat_type_to_domain(self.chat_type),
+ )
+
+
+@dataclass
+class BotAPIBotMenuItem:
+ description: str
+ body: str
+ name: str
+
+
+BotAPIBotMenu = List[BotAPIBotMenuItem]
+
+
+@dataclass
+class BotAPIStatusResult:
+ commands: BotAPIBotMenu
+ enabled: Literal[True] = True
+ status_message: Optional[str] = None
+
+
+@dataclass
+class BotAPIStatus:
+ result: BotAPIStatusResult
+ status: Literal["ok"] = "ok"
+
+
+def build_bot_status_response(bot_menu: BotMenu) -> Dict[str, Any]:
+ commands = [
+ BotAPIBotMenuItem(body=command, name=command, description=description)
+ for command, description in bot_menu.items()
+ ]
+
+ status = BotAPIStatus(
+ result=BotAPIStatusResult(status_message="Bot is working", commands=commands),
+ )
+ return asdict(status)
diff --git a/botx/models/stickers.py b/botx/models/stickers.py
index c35f1686..05610b6a 100644
--- a/botx/models/stickers.py
+++ b/botx/models/stickers.py
@@ -1,71 +1,87 @@
-"""Models for stickers."""
-from datetime import datetime
+from dataclasses import dataclass
from typing import List, Optional
from uuid import UUID
-from botx.models.base import BotXBaseModel
+from botx.async_buffer import AsyncBufferWritable
+from botx.bot.contextvars import bot_var
-class Pagination(BotXBaseModel):
- """Model of pagination."""
-
- #: cursor hash
- after: Optional[str]
+@dataclass
+class Sticker:
+ """Sticker from sticker pack.
+ Attributes:
+ id: Sticker id.
+ emoji: Sticker emoji.
+ link: Sticker image link.
-class Sticker(BotXBaseModel):
- """Model of sticker from request by id."""
+ """
id: UUID
emoji: str
- link: str
- inserted_at: datetime
- updated_at: datetime
- deleted_at: Optional[datetime]
+ image_link: str
+ async def download(
+ self,
+ async_buffer: AsyncBufferWritable,
+ ) -> None:
+ bot = bot_var.get()
-class StickerFromPack(BotXBaseModel):
- """Model of sticker from sticker pack."""
+ response = await bot._httpx_client.get(self.image_link) # noqa: WPS437
+ response.raise_for_status()
- id: UUID
- emoji: str
- link: str
- preview: str
+ await async_buffer.write(response.content)
+ await async_buffer.seek(0)
-class StickerPackPreview(BotXBaseModel):
- """Model of sticker pack from pack list."""
- id: UUID
- name: str
- preview: Optional[str]
- public: Optional[bool]
- stickers_count: int
- stickers_order: Optional[List[UUID]]
- inserted_at: datetime
- updated_at: Optional[datetime]
- deleted_at: Optional[datetime]
+@dataclass
+class StickerPack:
+ """Sticker pack.
+ Attributes:
+ id: Sticker pack id.
+ name: Sticker pack name.
+ is_public: Is public pack.
+ stickers: Stickers data.
-class StickerPackList(BotXBaseModel):
- """Full model of sticker pack list response."""
+ """
- #: list of sticker packs
- packs: List[StickerPackPreview]
+ id: UUID
+ name: str
+ is_public: bool
+ stickers: List[Sticker]
- #: cursor
- pagination: Pagination
+@dataclass
+class StickerPackFromList:
+ """Sticker pack from list.
-class StickerPack(BotXBaseModel):
- """Model of sticker pack from request by id."""
+ Attributes:
+ id: Sticker pack id.
+ name: Sticker pack name.
+ is_public: Is public pack
+ stickers_count: Stickers count in pack
+ sticker_ids: Stickers ids in pack
+
+ """
id: UUID
name: str
- public: bool
- preview: Optional[str]
- stickers_order: Optional[List[UUID]]
- stickers: List[Sticker]
- inserted_at: datetime
- updated_at: datetime
- deleted_at: Optional[datetime]
+ is_public: bool
+ stickers_count: int
+ sticker_ids: Optional[List[UUID]] # Can be omitted in result
+
+
+@dataclass
+class StickerPackPage:
+ """Sticker pack page.
+
+ Attributes:
+ sticker_packs: Sticker pack list.
+ after: Base64 string for pagination.
+
+ """
+
+ sticker_packs: List[StickerPackFromList]
+ after: Optional[str]
diff --git a/tests/test_bots/test_bots/__init__.py b/botx/models/system_events/__init__.py
similarity index 100%
rename from tests/test_bots/test_bots/__init__.py
rename to botx/models/system_events/__init__.py
diff --git a/botx/models/system_events/added_to_chat.py b/botx/models/system_events/added_to_chat.py
new file mode 100644
index 00000000..08654cbc
--- /dev/null
+++ b/botx/models/system_events/added_to_chat.py
@@ -0,0 +1,56 @@
+from dataclasses import dataclass
+from typing import Any, Dict, List, Literal
+from uuid import UUID
+
+from pydantic import Field
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.base_command import (
+ BotAPIBaseCommand,
+ BotAPIChatContext,
+ BotCommandBase,
+)
+from botx.models.bot_account import BotAccount
+from botx.models.chats import Chat
+from botx.models.enums import BotAPICommandTypes, convert_chat_type_to_domain
+
+
+@dataclass
+class AddedToChatEvent(BotCommandBase):
+ """Event `system:added_to_chat`.
+
+ Attributes:
+ huids: List of added to chat user huids.
+ """
+
+ huids: List[UUID]
+ chat: Chat
+
+
+class BotAPIAddedToChatData(VerifiedPayloadBaseModel):
+ added_members: List[UUID]
+
+
+class BotAPIAddedToChatPayload(VerifiedPayloadBaseModel):
+ body: Literal["system:added_to_chat"] = "system:added_to_chat"
+ command_type: Literal[BotAPICommandTypes.SYSTEM]
+ data: BotAPIAddedToChatData
+
+
+class BotAPIAddedToChat(BotAPIBaseCommand):
+ payload: BotAPIAddedToChatPayload = Field(..., alias="command")
+ sender: BotAPIChatContext = Field(..., alias="from")
+
+ def to_domain(self, raw_command: Dict[str, Any]) -> AddedToChatEvent:
+ return AddedToChatEvent(
+ bot=BotAccount(
+ id=self.bot_id,
+ host=self.sender.host,
+ ),
+ raw_command=raw_command,
+ huids=self.payload.data.added_members,
+ chat=Chat(
+ id=self.sender.group_chat_id,
+ type=convert_chat_type_to_domain(self.sender.chat_type),
+ ),
+ )
diff --git a/botx/models/system_events/chat_created.py b/botx/models/system_events/chat_created.py
new file mode 100644
index 00000000..3e0c08d1
--- /dev/null
+++ b/botx/models/system_events/chat_created.py
@@ -0,0 +1,115 @@
+from dataclasses import dataclass
+from typing import Any, Dict, List, Literal, Optional
+from uuid import UUID
+
+from pydantic import Field
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.base_command import (
+ BotAPIBaseCommand,
+ BotAPIChatContext,
+ BotCommandBase,
+)
+from botx.models.bot_account import BotAccount
+from botx.models.chats import Chat
+from botx.models.enums import (
+ APIChatTypes,
+ APIUserKinds,
+ BotAPICommandTypes,
+ UserKinds,
+ convert_chat_type_to_domain,
+ convert_user_kind_to_domain,
+)
+
+
+@dataclass
+class ChatCreatedMember:
+ """ChatCreatedEvent member.
+
+ Attributes:
+ is_admin: Is user admin.
+ huid: User huid.
+ username: Username.
+ kind: User type.
+ """
+
+ is_admin: bool
+ huid: UUID
+ username: Optional[str]
+ kind: UserKinds
+
+
+@dataclass
+class ChatCreatedEvent(BotCommandBase):
+ """Event `system:chat_created`.
+
+ Attributes:
+ sync_id: Event sync id.
+ chat_id: Created chat id.
+ chat_name: Created chat name.
+ chat_type: Created chat type.
+ host: Created chat cts host.
+ creator_id: Creator huid.
+ members: List of created chat members.
+ """
+
+ chat: Chat
+ sync_id: UUID
+ chat_name: str
+ creator_id: UUID
+ members: List[ChatCreatedMember]
+
+
+class BotAPIChatMember(VerifiedPayloadBaseModel):
+ is_admin: bool = Field(..., alias="admin")
+ huid: UUID
+ name: Optional[str]
+ user_kind: APIUserKinds
+
+
+class BotAPIChatCreatedData(VerifiedPayloadBaseModel):
+ chat_type: APIChatTypes
+ creator: UUID
+ group_chat_id: UUID
+ members: List[BotAPIChatMember]
+ name: str
+
+
+class BotAPIChatCreatedPayload(VerifiedPayloadBaseModel):
+ body: Literal["system:chat_created"] = "system:chat_created"
+ command_type: Literal[BotAPICommandTypes.SYSTEM]
+ data: BotAPIChatCreatedData
+
+
+class BotAPIChatCreated(BotAPIBaseCommand):
+ payload: BotAPIChatCreatedPayload = Field(..., alias="command")
+ sender: BotAPIChatContext = Field(..., alias="from")
+
+ def to_domain(self, raw_command: Dict[str, Any]) -> ChatCreatedEvent:
+ members = [
+ ChatCreatedMember(
+ is_admin=member.is_admin,
+ huid=member.huid,
+ username=member.name,
+ kind=convert_user_kind_to_domain(member.user_kind),
+ )
+ for member in self.payload.data.members
+ ]
+
+ chat = Chat(
+ id=self.payload.data.group_chat_id,
+ type=convert_chat_type_to_domain(self.payload.data.chat_type),
+ )
+
+ return ChatCreatedEvent(
+ sync_id=self.sync_id,
+ bot=BotAccount(
+ id=self.bot_id,
+ host=self.sender.host,
+ ),
+ chat=chat,
+ chat_name=self.payload.data.name,
+ creator_id=self.payload.data.creator,
+ members=members,
+ raw_command=raw_command,
+ )
diff --git a/botx/models/system_events/cts_login.py b/botx/models/system_events/cts_login.py
new file mode 100644
index 00000000..9cda8b9d
--- /dev/null
+++ b/botx/models/system_events/cts_login.py
@@ -0,0 +1,50 @@
+from dataclasses import dataclass
+from typing import Any, Dict, Literal
+from uuid import UUID
+
+from pydantic import Field
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.base_command import (
+ BaseBotAPIContext,
+ BotAPIBaseCommand,
+ BotCommandBase,
+)
+from botx.models.bot_account import BotAccount
+from botx.models.enums import BotAPICommandTypes
+
+
+@dataclass
+class CTSLoginEvent(BotCommandBase):
+ """Event `system:cts_login`.
+
+ Attributes:
+ huid: user ID.
+ """
+
+ huid: UUID
+
+
+class BotAPICTSLoginData(VerifiedPayloadBaseModel):
+ user_huid: UUID
+
+
+class BotAPICTSLoginPayload(VerifiedPayloadBaseModel):
+ body: Literal["system:cts_login"] = "system:cts_login"
+ command_type: Literal[BotAPICommandTypes.SYSTEM]
+ data: BotAPICTSLoginData
+
+
+class BotAPICTSLogin(BotAPIBaseCommand):
+ payload: BotAPICTSLoginPayload = Field(..., alias="command")
+ sender: BaseBotAPIContext = Field(..., alias="from")
+
+ def to_domain(self, raw_command: Dict[str, Any]) -> CTSLoginEvent:
+ return CTSLoginEvent(
+ bot=BotAccount(
+ id=self.bot_id,
+ host=self.sender.host,
+ ),
+ raw_command=raw_command,
+ huid=self.payload.data.user_huid,
+ )
diff --git a/botx/models/system_events/cts_logout.py b/botx/models/system_events/cts_logout.py
new file mode 100644
index 00000000..df1f760e
--- /dev/null
+++ b/botx/models/system_events/cts_logout.py
@@ -0,0 +1,50 @@
+from dataclasses import dataclass
+from typing import Any, Dict, Literal
+from uuid import UUID
+
+from pydantic import Field
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.base_command import (
+ BaseBotAPIContext,
+ BotAPIBaseCommand,
+ BotCommandBase,
+)
+from botx.models.bot_account import BotAccount
+from botx.models.enums import BotAPICommandTypes
+
+
+@dataclass
+class CTSLogoutEvent(BotCommandBase):
+ """Event `system:cts_logout`.
+
+ Attributes:
+ huid: user ID.
+ """
+
+ huid: UUID
+
+
+class BotAPICTSLogoutData(VerifiedPayloadBaseModel):
+ user_huid: UUID
+
+
+class BotAPICTSLogoutPayload(VerifiedPayloadBaseModel):
+ body: Literal["system:cts_logout"] = "system:cts_logout"
+ command_type: Literal[BotAPICommandTypes.SYSTEM]
+ data: BotAPICTSLogoutData
+
+
+class BotAPICTSLogout(BotAPIBaseCommand):
+ payload: BotAPICTSLogoutPayload = Field(..., alias="command")
+ sender: BaseBotAPIContext = Field(..., alias="from")
+
+ def to_domain(self, raw_command: Dict[str, Any]) -> CTSLogoutEvent:
+ return CTSLogoutEvent(
+ bot=BotAccount(
+ id=self.bot_id,
+ host=self.sender.host,
+ ),
+ raw_command=raw_command,
+ huid=self.payload.data.user_huid,
+ )
diff --git a/botx/models/system_events/deleted_from_chat.py b/botx/models/system_events/deleted_from_chat.py
new file mode 100644
index 00000000..c5c1c808
--- /dev/null
+++ b/botx/models/system_events/deleted_from_chat.py
@@ -0,0 +1,57 @@
+from dataclasses import dataclass
+from typing import Any, Dict, List, Literal
+from uuid import UUID
+
+from pydantic import Field
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.base_command import (
+ BotAPIBaseCommand,
+ BotAPIChatContext,
+ BotCommandBase,
+)
+from botx.models.bot_account import BotAccount
+from botx.models.chats import Chat
+from botx.models.enums import BotAPICommandTypes, convert_chat_type_to_domain
+
+
+@dataclass
+class DeletedFromChatEvent(BotCommandBase):
+ """Event `system:deleted_from_chat`.
+
+ Attributes:
+ huids: List of deleted from chat user huids.
+ chat_id: Chat where the user was deleted from.
+ """
+
+ huids: List[UUID]
+ chat: Chat
+
+
+class BotAPIDeletedFromChatData(VerifiedPayloadBaseModel):
+ deleted_members: List[UUID]
+
+
+class BotAPIDeletedFromChatPayload(VerifiedPayloadBaseModel):
+ body: Literal["system:deleted_from_chat"] = "system:deleted_from_chat"
+ command_type: Literal[BotAPICommandTypes.SYSTEM]
+ data: BotAPIDeletedFromChatData
+
+
+class BotAPIDeletedFromChat(BotAPIBaseCommand):
+ payload: BotAPIDeletedFromChatPayload = Field(..., alias="command")
+ sender: BotAPIChatContext = Field(..., alias="from")
+
+ def to_domain(self, raw_command: Dict[str, Any]) -> DeletedFromChatEvent:
+ return DeletedFromChatEvent(
+ bot=BotAccount(
+ id=self.bot_id,
+ host=self.sender.host,
+ ),
+ raw_command=raw_command,
+ huids=self.payload.data.deleted_members,
+ chat=Chat(
+ id=self.sender.group_chat_id,
+ type=convert_chat_type_to_domain(self.sender.chat_type),
+ ),
+ )
diff --git a/botx/models/system_events/internal_bot_notification.py b/botx/models/system_events/internal_bot_notification.py
new file mode 100644
index 00000000..17d139aa
--- /dev/null
+++ b/botx/models/system_events/internal_bot_notification.py
@@ -0,0 +1,73 @@
+from dataclasses import dataclass
+from typing import Any, Dict, Literal
+
+from pydantic import Field
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.base_command import (
+ BotAPIBaseCommand,
+ BotAPIChatContext,
+ BotAPIUserContext,
+ BotCommandBase,
+)
+from botx.models.bot_account import BotAccount
+from botx.models.bot_sender import BotSender
+from botx.models.chats import Chat
+from botx.models.enums import BotAPICommandTypes, convert_chat_type_to_domain
+
+
+@dataclass
+class InternalBotNotificationEvent(BotCommandBase):
+ """Event `system:internal_bot_notification`.
+
+ Attributes:
+ data: user data.
+ opts: request options.
+ """
+
+ data: Dict[str, Any]
+ opts: Dict[str, Any]
+ chat: Chat
+ sender: BotSender
+
+
+class BotAPIInternalBotNotificationData(VerifiedPayloadBaseModel):
+ data: Dict[str, Any]
+ opts: Dict[str, Any]
+
+
+class BotAPIInternalBotNotificationPayload(VerifiedPayloadBaseModel):
+ body: Literal[
+ "system:internal_bot_notification"
+ ] = "system:internal_bot_notification"
+ command_type: Literal[BotAPICommandTypes.SYSTEM]
+ data: BotAPIInternalBotNotificationData
+
+
+class BotAPIBotContext(BotAPIChatContext, BotAPIUserContext):
+ """Bot context."""
+
+
+class BotAPIInternalBotNotification(BotAPIBaseCommand):
+ payload: BotAPIInternalBotNotificationPayload = Field(..., alias="command")
+ sender: BotAPIBotContext = Field(..., alias="from")
+
+ def to_domain(self, raw_command: Dict[str, Any]) -> InternalBotNotificationEvent:
+ return InternalBotNotificationEvent(
+ bot=BotAccount(
+ id=self.bot_id,
+ host=self.sender.host,
+ ),
+ raw_command=raw_command,
+ data=self.payload.data.data,
+ opts=self.payload.data.opts,
+ chat=Chat(
+ id=self.sender.group_chat_id,
+ type=convert_chat_type_to_domain(self.sender.chat_type),
+ ),
+ sender=BotSender(
+ huid=self.sender.user_huid,
+ is_chat_admin=self.sender.is_admin,
+ is_chat_creator=self.sender.is_creator,
+ ),
+ )
diff --git a/botx/models/system_events/left_from_chat.py b/botx/models/system_events/left_from_chat.py
new file mode 100644
index 00000000..0be7cf8e
--- /dev/null
+++ b/botx/models/system_events/left_from_chat.py
@@ -0,0 +1,56 @@
+from dataclasses import dataclass
+from typing import Any, Dict, List, Literal
+from uuid import UUID
+
+from pydantic import Field
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.base_command import (
+ BotAPIBaseCommand,
+ BotAPIChatContext,
+ BotCommandBase,
+)
+from botx.models.bot_account import BotAccount
+from botx.models.chats import Chat
+from botx.models.enums import BotAPICommandTypes, convert_chat_type_to_domain
+
+
+@dataclass
+class LeftFromChatEvent(BotCommandBase):
+ """Event `system:left_from_chat`.
+
+ Attributes:
+ huids: List of left from chat user huids.
+ """
+
+ huids: List[UUID]
+ chat: Chat
+
+
+class BotAPILeftFromChatData(VerifiedPayloadBaseModel):
+ left_members: List[UUID]
+
+
+class BotAPILeftFromChatPayload(VerifiedPayloadBaseModel):
+ body: Literal["system:left_from_chat"] = "system:left_from_chat"
+ command_type: Literal[BotAPICommandTypes.SYSTEM]
+ data: BotAPILeftFromChatData
+
+
+class BotAPILeftFromChat(BotAPIBaseCommand):
+ payload: BotAPILeftFromChatPayload = Field(..., alias="command")
+ sender: BotAPIChatContext = Field(..., alias="from")
+
+ def to_domain(self, raw_command: Dict[str, Any]) -> LeftFromChatEvent:
+ return LeftFromChatEvent(
+ bot=BotAccount(
+ id=self.bot_id,
+ host=self.sender.host,
+ ),
+ raw_command=raw_command,
+ huids=self.payload.data.left_members,
+ chat=Chat(
+ id=self.sender.group_chat_id,
+ type=convert_chat_type_to_domain(self.sender.chat_type),
+ ),
+ )
diff --git a/botx/models/system_events/smartapp_event.py b/botx/models/system_events/smartapp_event.py
new file mode 100644
index 00000000..a4c8846c
--- /dev/null
+++ b/botx/models/system_events/smartapp_event.py
@@ -0,0 +1,119 @@
+from dataclasses import dataclass
+from typing import Any, Dict, List, Literal
+from uuid import UUID
+
+from pydantic import Field
+
+from botx.models.api_base import VerifiedPayloadBaseModel
+from botx.models.async_files import APIAsyncFile, File, convert_async_file_to_domain
+from botx.models.base_command import (
+ BotAPIBaseCommand,
+ BotAPIChatContext,
+ BotAPIDeviceContext,
+ BotAPIUserContext,
+ BotCommandBase,
+)
+from botx.models.bot_account import BotAccount
+from botx.models.chats import Chat
+from botx.models.enums import (
+ BotAPICommandTypes,
+ convert_chat_type_to_domain,
+ convert_client_platform_to_domain,
+)
+from botx.models.message.incoming_message import UserDevice, UserSender
+
+
+@dataclass
+class SmartAppEvent(BotCommandBase):
+ """Event `system:smartapp_event`.
+
+ Attributes:
+ ref: Unique request id.
+ smartapp_id: also personnel chat_id.
+ data: Payload.
+ opts: Request options.
+ smartapp_api_version: Protocol version.
+ sender: Event sender.
+ """
+
+ ref: UUID
+ smartapp_id: UUID
+ data: Dict[str, Any] # noqa: WPS110
+ opts: Dict[str, Any]
+ smartapp_api_version: int
+ files: List[File]
+ chat: Chat
+ sender: UserSender
+
+
+class BotAPISmartAppData(VerifiedPayloadBaseModel):
+ ref: UUID
+ smartapp_id: UUID
+ data: Dict[str, Any] # noqa: WPS110
+ opts: Dict[str, Any]
+ smartapp_api_version: int
+
+
+class BotAPISmartAppPayload(VerifiedPayloadBaseModel):
+ body: Literal["system:smartapp_event"] = "system:smartapp_event"
+ command_type: Literal[BotAPICommandTypes.SYSTEM]
+ data: BotAPISmartAppData
+ metadata: Dict[str, Any]
+
+
+class BotAPISmartAppEventContext(
+ BotAPIUserContext,
+ BotAPIChatContext,
+ BotAPIDeviceContext,
+):
+ """Class for merging contexts."""
+
+
+class BotAPISmartAppEvent(BotAPIBaseCommand):
+ payload: BotAPISmartAppPayload = Field(..., alias="command")
+ sender: BotAPISmartAppEventContext = Field(..., alias="from")
+ async_files: List[APIAsyncFile]
+
+ def to_domain(self, raw_command: Dict[str, Any]) -> SmartAppEvent:
+ device = UserDevice(
+ manufacturer=self.sender.manufacturer,
+ device_name=self.sender.device,
+ os=self.sender.device_software,
+ pushes=None,
+ timezone=None,
+ permissions=None,
+ platform=(
+ convert_client_platform_to_domain(self.sender.platform)
+ if self.sender.platform
+ else None
+ ),
+ platform_package_id=self.sender.platform_package_id,
+ app_version=self.sender.app_version,
+ locale=self.sender.locale,
+ )
+
+ sender = UserSender(
+ huid=self.sender.user_huid,
+ ad_login=self.sender.ad_login,
+ ad_domain=self.sender.ad_domain,
+ username=self.sender.username,
+ is_chat_admin=self.sender.is_admin,
+ is_chat_creator=self.sender.is_creator,
+ device=device,
+ )
+
+ return SmartAppEvent(
+ bot=BotAccount(id=self.bot_id, host=self.sender.host),
+ raw_command=raw_command,
+ ref=self.payload.data.ref,
+ smartapp_id=self.payload.data.smartapp_id,
+ data=self.payload.data.data,
+ opts=self.payload.data.opts,
+ smartapp_api_version=self.payload.data.smartapp_api_version,
+ files=[convert_async_file_to_domain(file) for file in self.async_files],
+ chat=Chat(
+ id=self.sender.group_chat_id,
+ type=convert_chat_type_to_domain(self.sender.chat_type),
+ ),
+ sender=sender,
+ )
diff --git a/botx/models/typing.py b/botx/models/typing.py
deleted file mode 100644
index ea1f213a..00000000
--- a/botx/models/typing.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""Aliases for complex types from `typing` for models."""
-
-from typing import List, Union
-from uuid import UUID
-
-from botx.models.buttons import BubbleElement, KeyboardElement
-
-try:
- from typing import Literal # noqa: WPS433
-except ImportError:
- from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401
-
-BubblesRow = List[BubbleElement]
-BubbleMarkup = List[BubblesRow]
-
-KeyboardRow = List[KeyboardElement]
-KeyboardMarkup = List[KeyboardRow]
-
-AvailableRecipients = Union[List[UUID], Literal["all"]]
diff --git a/botx/models/users.py b/botx/models/users.py
index bfc1c1c5..fa43e7da 100644
--- a/botx/models/users.py
+++ b/botx/models/users.py
@@ -1,64 +1,28 @@
-"""Entities for users."""
-
+from dataclasses import dataclass
from typing import List, Optional
from uuid import UUID
-from botx.models.base import BotXBaseModel
-from botx.models.enums import UserKinds
+@dataclass
+class UserFromSearch:
+ """User from search.
-class UserInChatCreated(BotXBaseModel):
- """User that can be included in data in `system:chat_created` event."""
+ Attributes:
+ huid: User huid.
+ ad_login: User AD login.
+ ad_domain: User AD domain.
+ username: User name.
+ company: User company.
+ company_position: User company position.
+ department: User department.
+ emails: User emails.
+ """
- #: user HUID.
huid: UUID
-
- #: type of user.
- user_kind: UserKinds
-
- #: user username.
- name: Optional[str]
-
- #: is user administrator in chat.
- admin: bool
-
-
-class UserFromSearch(BotXBaseModel):
- """User from search request."""
-
- #: HUID of user from search.
- user_huid: UUID
-
- #: AD login of user.
ad_login: Optional[str]
-
- # AD domain of user.
ad_domain: Optional[str]
-
- #: visible username.
- name: str
-
- #: user's company.
+ username: str
company: Optional[str]
-
- #: user's position.
company_position: Optional[str]
-
- #: user's department.
department: Optional[str]
-
- #: user's emails.
emails: List[str]
-
-
-class UserFromChatSearch(BotXBaseModel):
- """User from chat search request."""
-
- #: is user admin of chat.
- admin: bool
-
- #: HUID of user.
- user_huid: UUID
-
- #: type of user.
- user_kind: UserKinds
diff --git a/botx/shared.py b/botx/shared.py
deleted file mode 100644
index 1a07cda1..00000000
--- a/botx/shared.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Shared config for pydantic dataclasses."""
-
-from pydantic import BaseConfig
-
-
-class BotXDataclassConfig(BaseConfig):
- """Config for pydantic dataclasses that allows custom types."""
-
- arbitrary_types_allowed = True
diff --git a/botx/testing/__init__.py b/botx/testing/__init__.py
deleted file mode 100644
index 47482079..00000000
--- a/botx/testing/__init__.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""Definition of entities for using in tests."""
-
-from botx.testing.building.builder import MessageBuilder
-
-try:
- from botx.testing.testing_client.client import TestClient # noqa: WPS433
-except ImportError:
- TestClient = None # type: ignore # noqa: WPS440
-
-__all__ = ("TestClient", "MessageBuilder") # noqa: WPS410
diff --git a/botx/testing/botx_mock/__init__.py b/botx/testing/botx_mock/__init__.py
deleted file mode 100644
index 69127fa2..00000000
--- a/botx/testing/botx_mock/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Mock for BotX API for httpx client using Starlette."""
diff --git a/botx/testing/botx_mock/asgi/__init__.py b/botx/testing/botx_mock/asgi/__init__.py
deleted file mode 100644
index 01768527..00000000
--- a/botx/testing/botx_mock/asgi/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""ASGI mock for BotX API for using with httpx."""
diff --git a/botx/testing/botx_mock/asgi/application.py b/botx/testing/botx_mock/asgi/application.py
deleted file mode 100644
index 836d730d..00000000
--- a/botx/testing/botx_mock/asgi/application.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""Definition of Starlette application that is mock for BotX API."""
-
-from typing import Any, Dict, List, Sequence, Tuple, Type
-
-from starlette.applications import Starlette
-from starlette.middleware.base import RequestResponseEndpoint
-from starlette.routing import Route
-
-from botx.clients.methods.base import BotXMethod
-from botx.testing.botx_mock.asgi.errors import ErrorMiddleware
-from botx.testing.botx_mock.asgi.routes import bots # noqa: WPS235
-from botx.testing.botx_mock.asgi.routes import (
- chats,
- command,
- events,
- files,
- notification,
- notifications,
- smartapps,
- stickers,
- users,
-)
-from botx.testing.typing import APIMessage, APIRequest
-
-_ENDPOINTS: Tuple[RequestResponseEndpoint, ...] = (
- # V2
- # bots
- bots.get_token,
- # V3
- # chats
- chats.get_info,
- chats.get_bot_chats,
- chats.post_add_admin_role,
- chats.post_add_user,
- chats.post_remove_user,
- chats.post_stealth_set,
- chats.post_stealth_disable,
- chats.post_create,
- chats.post_pin_message,
- chats.post_unpin_message,
- # command
- command.post_command_result,
- # events
- events.post_edit_event,
- events.post_reply_event,
- # notification
- notification.post_notification,
- notification.post_notification_direct,
- # users
- users.get_by_huid,
- users.get_by_email,
- users.get_by_login,
- # notifications
- notifications.post_internal_bot_notification,
- # files
- files.upload_file,
- files.download_file,
- # stickers
- stickers.get_sticker_pack_list,
- stickers.get_sticker_pack,
- stickers.get_sticker_from_sticker_pack,
- stickers.post_add_sticker_into_sticker_pack,
- stickers.post_delete_sticker_pack,
- stickers.post_delete_sticker_from_sticker_pack,
- stickers.post_create_sticker_pack,
- stickers.post_edit_sticker_pack,
- # smartapps
- smartapps.post_smartapp_event,
- smartapps.post_smartapp_notification,
-)
-
-
-def _create_starlette_routes() -> Sequence[Route]:
- routes = []
-
- for endpoint in _ENDPOINTS:
- url = endpoint.method.__url__ # type: ignore # noqa: WPS609
- method = endpoint.method.__method__ # type: ignore # noqa: WPS609
- routes.append(Route(url, endpoint, methods=[method]))
-
- return routes
-
-
-def get_botx_asgi_api(
- messages: List[APIMessage],
- requests: List[APIRequest],
- errors: Dict[Type[BotXMethod], Tuple[int, Any]],
-) -> Starlette:
- """Generate BotX API mock.
-
- Arguments:
- messages: list of message that were sent from bot and should be extended.
- requests: all requests that were sent from bot.
- errors: errors to be generated by mocked API.
-
- Returns:
- Generated BotX API mock for using with httpx.
- """
- botx_app = Starlette(routes=list(_create_starlette_routes()))
- botx_app.add_middleware(ErrorMiddleware)
- botx_app.state.messages = messages
- botx_app.state.requests = requests
- botx_app.state.errors = errors
-
- return botx_app
diff --git a/botx/testing/botx_mock/asgi/errors.py b/botx/testing/botx_mock/asgi/errors.py
deleted file mode 100644
index d0a13dd3..00000000
--- a/botx/testing/botx_mock/asgi/errors.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""Definition of middleware that will generate BotX API errors depending from flag."""
-from typing import Tuple, Type
-
-from pydantic import BaseModel
-from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
-from starlette.requests import Request
-from starlette.responses import Response
-from starlette.routing import Match
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.testing.botx_mock.asgi.responses import PydanticResponse
-
-
-def _fill_request_scope(request: Request) -> None:
- routes = request.app.router.routes
- for route in routes:
- match, scope = route.matches(request)
- if match == Match.FULL:
- request.scope = {**request.scope, **scope}
-
-
-def _get_error_from_request(
- request: Request,
-) -> Tuple[Type[BotXMethod], Tuple[int, BaseModel]]:
- _fill_request_scope(request)
- endpoint = request.scope["endpoint"]
- method = endpoint.method
- return method, request.app.state.errors.get(method)
-
-
-def should_generate_error_response(request: Request) -> bool:
- """Check if mocked API should generate error response.
-
- Arguments:
- request: request from Starlette route that contains application with required
- state.
-
- Returns:
- Result of check.
- """
- _, status_and_error_to_raise = _get_error_from_request(request)
- return bool(status_and_error_to_raise)
-
-
-def generate_error_response(request: Request) -> Response:
- """Generate error response for mocked BotX API.
-
- Arguments:
- request: request from Starlette route that contains application with required
- state.
-
- Returns:
- Generated response.
- """
- method, response_info = _get_error_from_request(request)
- status_code, error_data = response_info
-
- return PydanticResponse(
- APIErrorResponse[BaseModel](
- errors=["error from mock"],
- reason="asked_for_error",
- error_data=error_data,
- ),
- status_code=status_code,
- )
-
-
-class ErrorMiddleware(BaseHTTPMiddleware):
- """Middleware that will generate error response."""
-
- async def dispatch(
- self,
- request: Request,
- call_next: RequestResponseEndpoint,
- ) -> Response:
- """Generate error response for API call or pass request to mocked endpoint.
-
- Arguments:
- request: request that should be handled.
- call_next: next executor for mock.
-
- Returns:
- Mocked response.
- """
- if should_generate_error_response(request):
- return generate_error_response(request)
-
- return await call_next(request)
diff --git a/botx/testing/botx_mock/asgi/messages.py b/botx/testing/botx_mock/asgi/messages.py
deleted file mode 100644
index 7ad26966..00000000
--- a/botx/testing/botx_mock/asgi/messages.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Logic for extending messages and requests collections from test client."""
-
-import contextlib
-
-from starlette.requests import Request
-
-from botx.clients.methods.base import BotXMethod
-
-
-def add_message_to_collection(request: Request, message: BotXMethod) -> None:
- """Add new message to messages collection.
-
- Arguments:
- request: request from Starlette endpoint.
- message: message that should be added.
- """
- app = request.app
- with contextlib.suppress(AttributeError):
- app.state.messages.append(message)
-
- add_request_to_collection(request, message)
-
-
-def add_request_to_collection(http_request: Request, api_request: BotXMethod) -> None:
- """Add new API request to requests collection.
-
- Arguments:
- http_request: request from Starlette endpoint.
- api_request: API request that should be added.
- """
- app = http_request.app
- with contextlib.suppress(AttributeError):
- app.state.requests.append(api_request)
diff --git a/botx/testing/botx_mock/asgi/responses.py b/botx/testing/botx_mock/asgi/responses.py
deleted file mode 100644
index 11a84d35..00000000
--- a/botx/testing/botx_mock/asgi/responses.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""Common responses for mocks."""
-
-import uuid
-from typing import Any, Optional, Union
-
-from pydantic import BaseModel
-from starlette.responses import Response
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.command.command_result import CommandResult
-from botx.clients.methods.v3.notification.direct_notification import NotificationDirect
-from botx.clients.methods.v4.notifications.internal_bot_notification import (
- InternalBotNotification,
-)
-from botx.clients.types.response_results import (
- InternalBotNotificationResult,
- PushResult,
-)
-
-
-class PydanticResponse(Response):
- """Custom response to encode pydantic model from route."""
-
- def __init__( # noqa: WPS211
- self,
- model: Optional[BaseModel],
- raw_data: Optional[bytes] = None,
- status_code: int = 200,
- media_type: str = "application/json",
- **kwargs: Any,
- ) -> None:
- """Init custom response.
-
- Arguments:
- model: pydantic model that should be encoded.
- raw_data: binary data.
- status_code: response HTTP status code.
- media_type: content type of response.
- kwargs: other arguments to response constructor from starlette.
- """
- super().__init__(
- raw_data or model.json(by_alias=True), # type: ignore
- status_code,
- media_type=media_type,
- **kwargs,
- )
-
-
-def generate_push_response(
- payload: Union[CommandResult, NotificationDirect],
-) -> Response:
- """Generate response as like new message from bot was pushed.
-
- Arguments:
- payload: pushed message.
-
- Returns:
- Response with sync_id for new message.
- """
- sync_id = payload.event_sync_id or uuid.uuid4()
- return PydanticResponse(
- APIResponse[PushResult](result=PushResult(sync_id=sync_id)),
- )
-
-
-def generate_internal_bot_notification_response(
- payload: InternalBotNotification,
-) -> Response:
- """Generate response as like internal bot notification was sent.
-
- Arguments:
- payload: sent notification.
-
- Returns:
- Response with sync_id for new message.
- """
- sync_id = uuid.uuid4()
- return PydanticResponse(
- APIResponse[InternalBotNotificationResult](
- result=InternalBotNotificationResult(sync_id=sync_id),
- ),
- )
diff --git a/botx/testing/botx_mock/asgi/routes/__init__.py b/botx/testing/botx_mock/asgi/routes/__init__.py
deleted file mode 100644
index c259761e..00000000
--- a/botx/testing/botx_mock/asgi/routes/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition of routes for BotX API mock."""
diff --git a/botx/testing/botx_mock/asgi/routes/bots.py b/botx/testing/botx_mock/asgi/routes/bots.py
deleted file mode 100644
index 959ef5b0..00000000
--- a/botx/testing/botx_mock/asgi/routes/bots.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""Endpoints for bots resource."""
-
-from starlette.requests import Request
-from starlette.responses import Response
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v2.bots.token import Token
-from botx.testing.botx_mock.asgi.messages import add_request_to_collection
-from botx.testing.botx_mock.asgi.responses import PydanticResponse
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-
-
-@bind_implementation_to_method(Token)
-async def get_token(request: Request) -> Response:
- """Handle retrieving token from BotX API request.
-
- Arguments:
- request: starlette request for route.
-
- Returns:
- Return response with new token.
- """
- request_data = {**request.path_params, **request.query_params}
- payload = Token(**request_data)
- add_request_to_collection(request, payload)
- return PydanticResponse(APIResponse[str](result="real token"))
diff --git a/botx/testing/botx_mock/asgi/routes/chats.py b/botx/testing/botx_mock/asgi/routes/chats.py
deleted file mode 100644
index 05fd03a5..00000000
--- a/botx/testing/botx_mock/asgi/routes/chats.py
+++ /dev/null
@@ -1,214 +0,0 @@
-"""Endpoints for chats resource."""
-import uuid
-from datetime import datetime as dt
-
-from starlette import requests, responses
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.chats import add_admin_role # noqa: WPS235
-from botx.clients.methods.v3.chats import (
- add_user,
- chat_list,
- create,
- info,
- pin_message,
- remove_user,
- stealth_disable,
- stealth_set,
- unpin_message,
-)
-from botx.clients.types.response_results import ChatCreatedResult
-from botx.models import chats, enums, users
-from botx.testing.botx_mock.asgi.messages import add_request_to_collection
-from botx.testing.botx_mock.asgi.responses import PydanticResponse
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-
-
-@bind_implementation_to_method(info.Info)
-async def get_info(request: requests.Request) -> responses.Response:
- """Handle retrieving information of chat request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with information of chat.
- """
- payload = info.Info.parse_obj(request.query_params)
- add_request_to_collection(request, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- return PydanticResponse(
- APIResponse[chats.ChatFromSearch](
- result=chats.ChatFromSearch(
- name="chat name",
- chat_type=enums.ChatTypes.group_chat,
- creator=uuid.uuid4(),
- group_chat_id=payload.group_chat_id,
- members=[
- users.UserFromChatSearch(
- user_huid=uuid.uuid4(),
- user_kind=enums.UserKinds.user,
- admin=True,
- ),
- ],
- inserted_at=inserted_at,
- ),
- ),
- )
-
-
-@bind_implementation_to_method(chat_list.ChatList)
-async def get_bot_chats(request: requests.Request) -> responses.Response:
- """Return list of bot chats.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- List of bot chats.
- """
- payload = chat_list.ChatList.parse_obj(request.query_params)
- add_request_to_collection(request, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- updated_at = dt.fromisoformat("2019-09-29T10:30:48.358586+00:00")
- return PydanticResponse(
- APIResponse[chats.BotChatList](
- result=chats.BotChatList(
- __root__=[
- chats.BotChatFromList(
- name="chat name",
- description="test",
- chat_type=enums.ChatTypes.group_chat,
- group_chat_id=uuid.uuid4(),
- members=[uuid.uuid4()],
- inserted_at=inserted_at,
- updated_at=updated_at,
- ),
- ],
- ),
- ),
- )
-
-
-@bind_implementation_to_method(add_user.AddUser)
-async def post_add_user(request: requests.Request) -> responses.Response:
- """Handle adding of user to chat request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of adding.
- """
- payload = add_user.AddUser.parse_obj(await request.json())
- add_request_to_collection(request, payload)
- return PydanticResponse(APIResponse[bool](result=True))
-
-
-@bind_implementation_to_method(remove_user.RemoveUser)
-async def post_remove_user(request: requests.Request) -> responses.Response:
- """Handle removing of user to chat request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of removing.
- """
- payload = remove_user.RemoveUser.parse_obj(await request.json())
- add_request_to_collection(request, payload)
- return PydanticResponse(APIResponse[bool](result=True))
-
-
-@bind_implementation_to_method(stealth_set.StealthSet)
-async def post_stealth_set(request: requests.Request) -> responses.Response:
- """Handle stealth enabling in chat request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of enabling stealth.
- """
- payload = stealth_set.StealthSet.parse_obj(await request.json())
- add_request_to_collection(request, payload)
- return PydanticResponse(APIResponse[bool](result=True))
-
-
-@bind_implementation_to_method(stealth_disable.StealthDisable)
-async def post_stealth_disable(request: requests.Request) -> responses.Response:
- """Handle stealth disabling in chat request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of disabling stealth.
- """
- payload = stealth_disable.StealthDisable.parse_obj(await request.json())
- add_request_to_collection(request, payload)
- return PydanticResponse(APIResponse[bool](result=True))
-
-
-@bind_implementation_to_method(create.Create)
-async def post_create(request: requests.Request) -> responses.Response:
- """Handle creation of new chat request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of creation.
- """
- payload = create.Create.parse_obj(await request.json())
- add_request_to_collection(request, payload)
- return PydanticResponse(
- APIResponse[ChatCreatedResult](result=ChatCreatedResult(chat_id=uuid.uuid4())),
- )
-
-
-@bind_implementation_to_method(add_admin_role.AddAdminRole)
-async def post_add_admin_role(request: requests.Request) -> responses.Response:
- """Handle promoting users to admins request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of adding.
- """
- payload = add_admin_role.AddAdminRole.parse_obj(await request.json())
- add_request_to_collection(request, payload)
- return PydanticResponse(APIResponse[bool](result=True))
-
-
-@bind_implementation_to_method(pin_message.PinMessage)
-async def post_pin_message(request: requests.Request) -> responses.Response:
- """Handle pinning message in chat request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of pinning.
- """
- payload = pin_message.PinMessage.parse_obj(await request.json())
- add_request_to_collection(request, payload)
- return PydanticResponse(APIResponse[str](result="pinned"))
-
-
-@bind_implementation_to_method(unpin_message.UnpinMessage)
-async def post_unpin_message(request: requests.Request) -> responses.Response:
- """Handle unpinning message in chat request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of unpinning.
- """
- payload = unpin_message.UnpinMessage.parse_obj(await request.json())
- add_request_to_collection(request, payload)
- return PydanticResponse(APIResponse[str](result="unpinned"))
diff --git a/botx/testing/botx_mock/asgi/routes/command.py b/botx/testing/botx_mock/asgi/routes/command.py
deleted file mode 100644
index bde1af0c..00000000
--- a/botx/testing/botx_mock/asgi/routes/command.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Endpoints for command resource."""
-
-from starlette.requests import Request
-from starlette.responses import Response
-
-from botx.clients.methods.v3.command.command_result import CommandResult
-from botx.testing.botx_mock.asgi.messages import add_message_to_collection
-from botx.testing.botx_mock.asgi.responses import generate_push_response
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-
-
-@bind_implementation_to_method(CommandResult)
-async def post_command_result(request: Request) -> Response:
- """Handle command result request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = CommandResult.parse_obj(await request.json())
- add_message_to_collection(request, payload)
- return generate_push_response(payload)
diff --git a/botx/testing/botx_mock/asgi/routes/events.py b/botx/testing/botx_mock/asgi/routes/events.py
deleted file mode 100644
index bbe32640..00000000
--- a/botx/testing/botx_mock/asgi/routes/events.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""Endpoints for events resource."""
-
-from starlette.requests import Request
-from starlette.responses import Response
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.events.edit_event import EditEvent
-from botx.clients.methods.v3.events.reply_event import ReplyEvent
-from botx.testing.botx_mock.asgi.messages import add_message_to_collection
-from botx.testing.botx_mock.asgi.responses import PydanticResponse
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-
-
-@bind_implementation_to_method(EditEvent)
-async def post_edit_event(request: Request) -> Response:
- """Handle edition of event request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Empty json response.
- """
- payload = EditEvent.parse_obj(await request.json())
- add_message_to_collection(request, payload)
- return PydanticResponse(APIResponse[str](result="update_pushed"))
-
-
-@bind_implementation_to_method(ReplyEvent)
-async def post_reply_event(request: Request) -> Response:
- """Handle reply event request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Empty json response.
- """
- payload = ReplyEvent.parse_obj(await request.json())
- add_message_to_collection(request, payload)
- return PydanticResponse(APIResponse[str](result="reply_pushed"))
diff --git a/botx/testing/botx_mock/asgi/routes/files.py b/botx/testing/botx_mock/asgi/routes/files.py
deleted file mode 100644
index 37c4f56e..00000000
--- a/botx/testing/botx_mock/asgi/routes/files.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""Endpoints for chats resource."""
-
-import json
-
-from starlette.requests import Request
-from starlette.responses import Response
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.files.download import DownloadFile
-from botx.clients.methods.v3.files.upload import UploadFile
-from botx.models.files import File, MetaFile
-from botx.testing.botx_mock.asgi.messages import add_request_to_collection
-from botx.testing.botx_mock.asgi.responses import PydanticResponse
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.entities import create_test_metafile
-
-
-@bind_implementation_to_method(UploadFile)
-async def upload_file(request: Request) -> Response:
- """Handle retrieving information about user request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with metadata of file.
- """
- form = dict(await request.form())
- meta = json.loads(form["meta"])
- filename = form["content"].filename # type: ignore
- file = File.from_file(
- filename=filename,
- file=form["content"].file, # type: ignore
- )
- payload = UploadFile(
- group_chat_id=form["group_chat_id"], # type: ignore
- file=file,
- meta=meta,
- )
- add_request_to_collection(request, payload)
- return PydanticResponse(
- APIResponse[MetaFile](
- result=create_test_metafile(filename),
- ),
- )
-
-
-@bind_implementation_to_method(DownloadFile)
-async def download_file(request: Request) -> Response:
- """Handle retrieving information about user request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with file content.
- """
- payload = DownloadFile.parse_obj(request.query_params)
- add_request_to_collection(request, payload)
- return PydanticResponse(model=None, raw_data=b"content", media_type="text/plain")
diff --git a/botx/testing/botx_mock/asgi/routes/notification.py b/botx/testing/botx_mock/asgi/routes/notification.py
deleted file mode 100644
index 6b55e604..00000000
--- a/botx/testing/botx_mock/asgi/routes/notification.py
+++ /dev/null
@@ -1,44 +0,0 @@
-"""Endpoints for notification resource."""
-
-from starlette.requests import Request
-from starlette.responses import Response
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.notification.direct_notification import NotificationDirect
-from botx.clients.methods.v3.notification.notification import Notification
-from botx.testing.botx_mock.asgi.messages import add_message_to_collection
-from botx.testing.botx_mock.asgi.responses import (
- PydanticResponse,
- generate_push_response,
-)
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-
-
-@bind_implementation_to_method(Notification)
-async def post_notification(request: Request) -> Response:
- """Handle pushed notification request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = Notification.parse_obj(await request.json())
- add_message_to_collection(request, payload)
- return PydanticResponse(APIResponse[str](result="notification_pushed"))
-
-
-@bind_implementation_to_method(NotificationDirect)
-async def post_notification_direct(request: Request) -> Response:
- """Handle pushed notification request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = NotificationDirect.parse_obj(await request.json())
- add_message_to_collection(request, payload)
- return generate_push_response(payload)
diff --git a/botx/testing/botx_mock/asgi/routes/notifications.py b/botx/testing/botx_mock/asgi/routes/notifications.py
deleted file mode 100644
index 0e71827c..00000000
--- a/botx/testing/botx_mock/asgi/routes/notifications.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""Endpoints for notification resource."""
-
-from starlette.requests import Request
-from starlette.responses import Response
-
-from botx.clients.methods.v4.notifications.internal_bot_notification import (
- InternalBotNotification,
-)
-from botx.testing.botx_mock.asgi.messages import add_message_to_collection
-from botx.testing.botx_mock.asgi.responses import (
- generate_internal_bot_notification_response,
-)
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-
-
-@bind_implementation_to_method(InternalBotNotification)
-async def post_internal_bot_notification(request: Request) -> Response:
- """Handle pushed notification request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = InternalBotNotification.parse_obj(await request.json())
- add_message_to_collection(request, payload)
- return generate_internal_bot_notification_response(payload)
diff --git a/botx/testing/botx_mock/asgi/routes/smartapps.py b/botx/testing/botx_mock/asgi/routes/smartapps.py
deleted file mode 100644
index 94201969..00000000
--- a/botx/testing/botx_mock/asgi/routes/smartapps.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""Endpoints for smartapps."""
-
-from starlette.requests import Request
-from starlette.responses import Response
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.smartapps.smartapp_event import SmartAppEvent
-from botx.clients.methods.v3.smartapps.smartapp_notification import SmartAppNotification
-from botx.testing.botx_mock.asgi.messages import add_message_to_collection
-from botx.testing.botx_mock.asgi.responses import PydanticResponse
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-
-
-@bind_implementation_to_method(SmartAppEvent)
-async def post_smartapp_event(request: Request) -> Response:
- """Handle pushed smartapp event request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = SmartAppEvent.parse_obj(await request.json())
- add_message_to_collection(request, payload)
- return PydanticResponse(APIResponse[str](result="smartapp_event_pushed"))
-
-
-@bind_implementation_to_method(SmartAppNotification)
-async def post_smartapp_notification(request: Request) -> Response:
- """Handle pushed smartapp notification request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = SmartAppNotification.parse_obj(await request.json())
- add_message_to_collection(request, payload)
- return PydanticResponse(APIResponse[str](result="smartapp_notification_pushed"))
diff --git a/botx/testing/botx_mock/asgi/routes/stickers.py b/botx/testing/botx_mock/asgi/routes/stickers.py
deleted file mode 100644
index 75ec4033..00000000
--- a/botx/testing/botx_mock/asgi/routes/stickers.py
+++ /dev/null
@@ -1,287 +0,0 @@
-"""Endpoints for stickers."""
-import uuid
-from datetime import datetime as dt
-
-from starlette import requests, responses
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.stickers import (
- add_sticker,
- create_sticker_pack,
- delete_sticker,
- delete_sticker_pack,
- edit_sticker_pack,
- sticker,
- sticker_pack,
- sticker_pack_list,
-)
-from botx.models import stickers
-from botx.testing.botx_mock.asgi.messages import add_request_to_collection
-from botx.testing.botx_mock.asgi.responses import PydanticResponse
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.content import PNG_DATA
-
-
-@bind_implementation_to_method(sticker_pack_list.GetStickerPackList)
-async def get_sticker_pack_list(request: requests.Request) -> responses.Response:
- """Handle retrieving information of sticker pack list request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with list of sticker packs.
- """
- payload = sticker_pack_list.GetStickerPackList.parse_obj(request.query_params)
- add_request_to_collection(request, payload)
-
- pagination = stickers.Pagination(after=PNG_DATA)
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- sticker_packs = [
- stickers.StickerPackPreview(
- id=uuid.uuid4(),
- name="Test sticker pack",
- public=False,
- stickers_count=1,
- inserted_at=inserted_at,
- ),
- ]
- sticker_pack_list_response = stickers.StickerPackList(
- packs=sticker_packs,
- pagination=pagination,
- )
-
- return PydanticResponse(
- APIResponse[stickers.StickerPackList](
- result=sticker_pack_list_response,
- ),
- )
-
-
-@bind_implementation_to_method(sticker_pack.GetStickerPack)
-async def get_sticker_pack(request: requests.Request) -> responses.Response:
- """Handle retrieving information of sticker pack request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with information of sticker pack.
- """
- payload = sticker_pack.GetStickerPack.parse_obj(request.path_params)
- add_request_to_collection(request, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- pack_stickers = [
- stickers.Sticker(
- id=uuid.uuid4(),
- emoji="🐢",
- link="http://some_link.com",
- inserted_at=inserted_at,
- updated_at=inserted_at,
- ),
- ]
- sticker_pack_preview = stickers.StickerPack(
- id=uuid.uuid4(),
- name="Test sticker pack",
- preview=None,
- public=False,
- stickers_order=None,
- stickers=pack_stickers,
- inserted_at=inserted_at,
- updated_at=inserted_at,
- deleted_at=None,
- )
-
- return PydanticResponse(
- APIResponse[stickers.StickerPack](
- result=sticker_pack_preview,
- ),
- )
-
-
-@bind_implementation_to_method(sticker.GetSticker)
-async def get_sticker_from_sticker_pack(
- request: requests.Request,
-) -> responses.Response:
- """Handle retrieving information of sticker from sticker pack request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with information of sticker from sticker pack.
- """
- payload = sticker.GetSticker.parse_obj(request.path_params)
- add_request_to_collection(request, payload)
-
- sticker_from_sticker_pack = stickers.StickerFromPack(
- id=uuid.uuid4(),
- emoji="🐢",
- link="http://some_link.com",
- preview="http://preview_link.com",
- )
-
- return PydanticResponse(
- APIResponse[stickers.StickerFromPack](
- result=sticker_from_sticker_pack,
- ),
- )
-
-
-@bind_implementation_to_method(add_sticker.AddSticker)
-async def post_add_sticker_into_sticker_pack(
- request: requests.Request,
-) -> responses.Response:
- """Handle adding of sticker to sticker pack request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of adding.
- """
- payload = add_sticker.AddSticker.parse_obj(await request.json())
- add_request_to_collection(request, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- sticker_from_sticker_pack = stickers.Sticker(
- id=uuid.uuid4(),
- emoji="🐢",
- link="http://some_link.com",
- inserted_at=inserted_at,
- updated_at=inserted_at,
- )
-
- return PydanticResponse(
- APIResponse[stickers.Sticker](
- result=sticker_from_sticker_pack,
- ),
- )
-
-
-@bind_implementation_to_method(create_sticker_pack.CreateStickerPack)
-async def post_create_sticker_pack(request: requests.Request) -> responses.Response:
- """Handle creating of sticker pack request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of creating.
- """
- payload = create_sticker_pack.CreateStickerPack.parse_obj(request.query_params)
- add_request_to_collection(request, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- pack_stickers = [
- stickers.Sticker(
- id=uuid.uuid4(),
- emoji="🐢",
- link="http://some_link.com",
- inserted_at=inserted_at,
- updated_at=inserted_at,
- ),
- ]
- sticker_from_sticker_pack = stickers.StickerPack(
- id=uuid.uuid4(),
- name="Test sticker pack",
- preview=None,
- public=False,
- stickers_order=None,
- stickers=pack_stickers,
- inserted_at=inserted_at,
- updated_at=inserted_at,
- deleted_at=None,
- )
-
- return PydanticResponse(
- APIResponse[stickers.StickerPack](
- result=sticker_from_sticker_pack,
- ),
- )
-
-
-@bind_implementation_to_method(edit_sticker_pack.EditStickerPack)
-async def post_edit_sticker_pack(request: requests.Request) -> responses.Response:
- """Handle editing of sticker pack request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of editing.
- """
- payload = edit_sticker_pack.EditStickerPack.parse_obj(await request.json())
- add_request_to_collection(request, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- pack_stickers = [
- stickers.Sticker(
- id=uuid.uuid4(),
- emoji="🐢",
- link="http://some_link.com",
- inserted_at=inserted_at,
- updated_at=inserted_at,
- ),
- ]
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- sticker_from_sticker_pack = stickers.StickerPack(
- id=uuid.uuid4(),
- name="Test sticker pack",
- preview=None,
- public=False,
- stickers_order=None,
- stickers=pack_stickers,
- inserted_at=inserted_at,
- updated_at=inserted_at,
- deleted_at=None,
- )
-
- return PydanticResponse(
- APIResponse[stickers.StickerPack](
- result=sticker_from_sticker_pack,
- ),
- )
-
-
-@bind_implementation_to_method(delete_sticker_pack.DeleteStickerPack)
-async def post_delete_sticker_pack(request: requests.Request) -> responses.Response:
- """Handle deleting of sticker pack request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of deleting.
- """
- payload = delete_sticker_pack.DeleteStickerPack.parse_obj(request.path_params)
- add_request_to_collection(request, payload)
-
- return PydanticResponse(
- APIResponse[str](
- result="sticker_pack_deleted",
- ),
- )
-
-
-@bind_implementation_to_method(delete_sticker.DeleteSticker)
-async def post_delete_sticker_from_sticker_pack(
- request: requests.Request,
-) -> responses.Response:
- """Handle deleting of sticker from sticker pack pack request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with result of deleting.
- """
- payload = delete_sticker.DeleteSticker.parse_obj(request.path_params)
- add_request_to_collection(request, payload)
-
- return PydanticResponse(
- APIResponse[str](
- result="sticker_deleted",
- ),
- )
diff --git a/botx/testing/botx_mock/asgi/routes/users.py b/botx/testing/botx_mock/asgi/routes/users.py
deleted file mode 100644
index fc54fc23..00000000
--- a/botx/testing/botx_mock/asgi/routes/users.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""Endpoints for chats resource."""
-
-from starlette.requests import Request
-from starlette.responses import Response
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.users.by_email import ByEmail
-from botx.clients.methods.v3.users.by_huid import ByHUID
-from botx.clients.methods.v3.users.by_login import ByLogin
-from botx.models.users import UserFromSearch
-from botx.testing.botx_mock.asgi.messages import add_request_to_collection
-from botx.testing.botx_mock.asgi.responses import PydanticResponse
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.entities import create_test_user
-
-
-@bind_implementation_to_method(ByHUID)
-async def get_by_huid(request: Request) -> Response:
- """Handle retrieving information about user request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with information about user.
- """
- payload = ByHUID.parse_obj(request.query_params)
- add_request_to_collection(request, payload)
- return PydanticResponse(
- APIResponse[UserFromSearch](
- result=create_test_user(user_huid=payload.user_huid),
- ),
- )
-
-
-@bind_implementation_to_method(ByEmail)
-async def get_by_email(request: Request) -> Response:
- """Handle retrieving information about user request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with information about user.
- """
- payload = ByEmail.parse_obj(request.query_params)
- add_request_to_collection(request, payload)
- return PydanticResponse(
- APIResponse[UserFromSearch](result=create_test_user(email=payload.email)),
- )
-
-
-@bind_implementation_to_method(ByLogin)
-async def get_by_login(request: Request) -> Response:
- """Handle retrieving information about user request.
-
- Arguments:
- request: HTTP request from Starlette.
-
- Returns:
- Response with information about user.
- """
- payload = ByLogin.parse_obj(request.query_params)
- add_request_to_collection(request, payload)
- return PydanticResponse(
- APIResponse[UserFromSearch](
- result=create_test_user(ad=(payload.ad_login, payload.ad_domain)),
- ),
- )
diff --git a/botx/testing/botx_mock/binders.py b/botx/testing/botx_mock/binders.py
deleted file mode 100644
index f20c9a1a..00000000
--- a/botx/testing/botx_mock/binders.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""Decorator for binding custom BotXMethod with implementation."""
-from typing import Any, Callable, Type
-
-from botx.clients.methods.base import BotXMethod
-
-
-def bind_implementation_to_method(method: Type[BotXMethod]) -> Callable[..., Any]:
- """Bind implementation of async route to method.
-
- Arguments:
- method: method class to bind.
-
- Returns:
- Decorator that binds method and returns it.
- """
-
- def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
- func.method = method # type: ignore
- return func
-
- return decorator
diff --git a/botx/testing/botx_mock/entities.py b/botx/testing/botx_mock/entities.py
deleted file mode 100644
index 2ea60768..00000000
--- a/botx/testing/botx_mock/entities.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""Predefined mocked entities builder for routes."""
-import uuid
-from typing import Optional, Tuple
-
-from botx.models.enums import AttachmentsTypes
-from botx.models.files import MetaFile
-from botx.models.users import UserFromSearch
-
-
-def create_test_user(
- *,
- user_huid: Optional[uuid.UUID] = None,
- email: Optional[str] = None,
- ad: Optional[Tuple[str, str]] = None,
-) -> UserFromSearch:
- """Build test user for using in search.
-
- Arguments:
- user_huid: HUID of user for search.
- email: email of user for search.
- ad: AD credentials of user for search.
-
- Returns:
- "Found" user.
- """
- return UserFromSearch(
- user_huid=user_huid or uuid.uuid4(),
- ad_login=ad[0] if ad else "ad_login",
- ad_domain=ad[1] if ad else "ad_domain",
- name="test user",
- company="test company",
- company_position="test position",
- department="test department",
- emails=[email or "test@example.com"],
- )
-
-
-def create_test_metafile(filename: str = None) -> MetaFile:
- """Build test metafile for using in uploading.
-
- Arguments:
- filename: name of uploaded file.
-
- Returns:
- Metadata of uploaded file.
- """
- return MetaFile(
- type=AttachmentsTypes.image,
- file="https://service.to./image",
- file_mime_type="image/png",
- file_name=filename or "image.png",
- file_preview=None,
- file_preview_height=None,
- file_preview_width=None,
- file_size=100,
- file_hash="W1Sn1AkotkOpH0",
- file_encryption_algo="stream",
- chunk_size=10,
- file_id=uuid.UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
- caption=None,
- duration=None,
- )
diff --git a/botx/testing/botx_mock/wsgi/__init__.py b/botx/testing/botx_mock/wsgi/__init__.py
deleted file mode 100644
index 7d2452e7..00000000
--- a/botx/testing/botx_mock/wsgi/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""WSGI mock for BotX API for using with httpx."""
diff --git a/botx/testing/botx_mock/wsgi/application.py b/botx/testing/botx_mock/wsgi/application.py
deleted file mode 100644
index 91fe6b36..00000000
--- a/botx/testing/botx_mock/wsgi/application.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""Definition of Starlette application that is mock for BotX API."""
-
-from typing import Any, Callable, Dict, List, Sequence, Tuple, Type
-
-from molten import App, JSONParser, Route, Settings, SettingsComponent
-
-from botx.clients.methods.base import BotXMethod
-from botx.testing.botx_mock.wsgi.errors import error_middleware
-from botx.testing.botx_mock.wsgi.routes import bots # noqa: WPS235
-from botx.testing.botx_mock.wsgi.routes import (
- chats,
- command,
- events,
- files,
- notification,
- notifications,
- smartapps,
- stickers,
- users,
-)
-from botx.testing.typing import APIMessage, APIRequest
-
-_ENDPOINTS: Tuple[Callable[..., Any], ...] = (
- # V2
- # bots
- bots.get_token,
- # V3
- # chats
- chats.get_info,
- chats.get_bot_chats,
- chats.post_add_admin_role,
- chats.post_add_user,
- chats.post_remove_user,
- chats.post_stealth_set,
- chats.post_stealth_disable,
- chats.post_create,
- chats.post_pin_message,
- chats.post_unpin_message,
- # command
- command.post_command_result,
- # events
- events.post_edit_event,
- # notification
- notification.post_notification,
- notification.post_notification_direct,
- # users
- users.get_by_huid,
- users.get_by_email,
- users.get_by_login,
- # notifications
- notifications.post_internal_bot_notification,
- # files
- files.upload_file,
- files.download_file,
- # stickers
- stickers.get_sticker_pack_list,
- stickers.get_sticker_pack,
- stickers.get_sticker_from_sticker_pack,
- stickers.post_add_sticker_into_sticker_pack,
- stickers.post_delete_sticker_pack,
- stickers.delete_sticker_from_sticker_pack,
- stickers.post_create_sticker_pack,
- stickers.post_edit_sticker_pack,
- # smartapps
- smartapps.post_smartapp_event,
- smartapps.post_smartapp_notification,
-)
-
-
-def _create_molten_routes() -> Sequence[Route]:
- routes = []
-
- for endpoint in _ENDPOINTS:
- url = endpoint.method.__url__ # type: ignore # noqa: WPS609
- method = endpoint.method.__method__ # type: ignore # noqa: WPS609
- routes.append(Route(url, endpoint, method=method))
-
- return routes
-
-
-def get_botx_wsgi_api(
- messages: List[APIMessage],
- requests: List[APIRequest],
- errors: Dict[Type[BotXMethod], Tuple[int, Any]],
-) -> App:
- """Generate BotX API mock.
-
- Arguments:
- messages: list of message that were sent from bot and should be extended.
- requests: all requests that were sent from bot.
- errors: errors to be generated by mocked API.
-
- Returns:
- Generated BotX API mock for using with httpx.
- """
- return App(
- components=[
- SettingsComponent(
- Settings(messages=messages, requests=requests, errors=errors),
- ),
- ],
- routes=list(_create_molten_routes()),
- middleware=[error_middleware],
- parsers=[JSONParser()],
- )
diff --git a/botx/testing/botx_mock/wsgi/errors.py b/botx/testing/botx_mock/wsgi/errors.py
deleted file mode 100644
index 56705771..00000000
--- a/botx/testing/botx_mock/wsgi/errors.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""Definition of middleware that will generate BotX API errors depending from flag."""
-from typing import Any, Callable, Tuple, Type, cast
-
-from molten import BaseApp, Request, Response, Route, Settings
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIErrorResponse, BotXMethod
-from botx.testing.botx_mock.wsgi.responses import PydanticResponse
-
-
-def _get_error_from_request(
- request: Request,
- app: BaseApp,
- settings: Settings,
-) -> Tuple[Type[BotXMethod], Tuple[int, BaseModel]]:
- match = app.router.match(request.method, request.path)
- route, _ = cast(Tuple[Route, Any], match)
- method = route.handler.method # type: ignore
- return method, settings["errors"].get(method)
-
-
-def should_generate_error_response(
- request: Request,
- app: BaseApp,
- settings: Settings,
-) -> bool:
- """Check if mocked API should generate error response.
-
- Arguments:
- request: request from molten route.
- app: molten app that serves request.
- settings: application settings with storage.
-
- Returns:
- Result of check.
- """
- _, status_and_error_to_raise = _get_error_from_request(request, app, settings)
- return bool(status_and_error_to_raise)
-
-
-def generate_error_response(
- request: Request,
- app: BaseApp,
- settings: Settings,
-) -> Response:
- """Generate error response for mocked BotX API.
-
- Arguments:
- request: request from molten route.
- app: molten app that serves request.
- settings: application settings with storage.
-
- Returns:
- Generated response.
- """
- method, response_info = _get_error_from_request(request, app, settings)
- status_code, error_data = response_info
-
- return PydanticResponse(
- APIErrorResponse[BaseModel](
- errors=["error from mock"],
- reason="asked_for_error",
- error_data=error_data,
- ),
- # TODO: Drop unnecessary description.
- status_code="{0} ".format(status_code),
- )
-
-
-def error_middleware(handler: Callable[..., Any]) -> Callable[..., Any]:
- """Middleware that will generate error response.
-
- Arguments:
- handler: next handler for request for molten.
-
- Returns:
- Created handler for request.
- """
-
- def decorator(request: Request, app: BaseApp, settings: Settings) -> Any:
- if should_generate_error_response(request, app, settings):
- return generate_error_response(request, app, settings)
-
- return handler()
-
- return decorator
diff --git a/botx/testing/botx_mock/wsgi/messages.py b/botx/testing/botx_mock/wsgi/messages.py
deleted file mode 100644
index 4b0403d2..00000000
--- a/botx/testing/botx_mock/wsgi/messages.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""Logic for extending messages and requests collections from test client."""
-
-from molten import Settings
-
-from botx.clients.methods.base import BotXMethod
-
-
-def add_message_to_collection(settings: Settings, message: BotXMethod) -> None:
- """Add new message to messages collection.
-
- Arguments:
- settings: application settings with storage.
- message: message that should be added.
- """
- messages = settings["messages"]
- messages.append(message)
- add_request_to_collection(settings, message)
-
-
-def add_request_to_collection(settings: Settings, api_request: BotXMethod) -> None:
- """Add new API request to requests collection.
-
- Arguments:
- settings: application settings with storage.
- api_request: API request that should be added.
- """
- requests = settings["requests"]
- requests.append(api_request)
diff --git a/botx/testing/botx_mock/wsgi/responses.py b/botx/testing/botx_mock/wsgi/responses.py
deleted file mode 100644
index 30688a58..00000000
--- a/botx/testing/botx_mock/wsgi/responses.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""Common responses for mocks."""
-
-import uuid
-from typing import Any, Dict, Optional, Union
-
-from molten import HTTP_200, Response
-from pydantic import BaseModel
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.command.command_result import CommandResult
-from botx.clients.methods.v3.notification.direct_notification import NotificationDirect
-from botx.clients.methods.v4.notifications.internal_bot_notification import (
- InternalBotNotification,
-)
-from botx.clients.types.response_results import (
- InternalBotNotificationResult,
- PushResult,
-)
-
-
-class PydanticResponse(Response):
- """Custom response to encode pydantic model from route."""
-
- def __init__(
- self,
- model: Optional[BaseModel],
- raw_data: Optional[str] = None,
- status_code: str = HTTP_200,
- headers: Optional[Dict[Any, Any]] = None,
- ) -> None:
- """Init custom response.
-
- Arguments:
- model: pydantic model that should be encoded.
- raw_data: raw data that should be encoded.
- status_code: response HTTP status code.
- headers: headers for response.
- """
- headers = headers or {"Content-Type": "application/json"}
-
- super().__init__(
- status_code,
- headers,
- raw_data or model.json(by_alias=True), # type: ignore
- )
-
-
-def generate_push_response(
- payload: Union[CommandResult, NotificationDirect],
-) -> Response:
- """Generate response as like new message from bot was pushed.
-
- Arguments:
- payload: pushed message.
-
- Returns:
- Response with sync_id for new message.
- """
- sync_id = payload.event_sync_id or uuid.uuid4()
- return PydanticResponse(
- APIResponse[PushResult](result=PushResult(sync_id=sync_id)),
- )
-
-
-def generate_internal_bot_notification_response(
- payload: InternalBotNotification,
-) -> Response:
- """Generate response as like internal bot notification was sent.
-
- Arguments:
- payload: sent notification.
-
- Returns:
- Response with sync_id for new message.
- """
- sync_id = uuid.uuid4()
- return PydanticResponse(
- APIResponse[InternalBotNotificationResult](
- result=InternalBotNotificationResult(sync_id=sync_id),
- ),
- )
diff --git a/botx/testing/botx_mock/wsgi/routes/__init__.py b/botx/testing/botx_mock/wsgi/routes/__init__.py
deleted file mode 100644
index c259761e..00000000
--- a/botx/testing/botx_mock/wsgi/routes/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition of routes for BotX API mock."""
diff --git a/botx/testing/botx_mock/wsgi/routes/bots.py b/botx/testing/botx_mock/wsgi/routes/bots.py
deleted file mode 100644
index 87dcc39c..00000000
--- a/botx/testing/botx_mock/wsgi/routes/bots.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""Endpoints for bots resource."""
-from uuid import UUID
-
-from molten import Request, Response, Settings
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v2.bots.token import Token
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.wsgi.messages import add_request_to_collection
-from botx.testing.botx_mock.wsgi.responses import PydanticResponse
-
-
-@bind_implementation_to_method(Token)
-def get_token(bot_id: str, request: Request, settings: Settings) -> Response:
- """Handle retrieving token from BotX API request.
-
- Arguments:
- bot_id: ID of bot from query params.
- request: modten request for route.
- settings: application settings with storage.
-
- Returns:
- Return response with new token.
- """
- signature = request.params["signature"]
- payload = Token(bot_id=UUID(bot_id), signature=signature)
- add_request_to_collection(settings, payload)
- return PydanticResponse(APIResponse[str](result="real token"))
diff --git a/botx/testing/botx_mock/wsgi/routes/chats.py b/botx/testing/botx_mock/wsgi/routes/chats.py
deleted file mode 100644
index f0935f28..00000000
--- a/botx/testing/botx_mock/wsgi/routes/chats.py
+++ /dev/null
@@ -1,224 +0,0 @@
-"""Endpoints for chats resource."""
-import uuid
-from datetime import datetime as dt
-
-from molten import Request, RequestData, Response, Settings
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.chats import add_admin_role # noqa: WPS235
-from botx.clients.methods.v3.chats import (
- add_user,
- chat_list,
- create,
- info,
- pin_message,
- remove_user,
- stealth_disable,
- stealth_set,
- unpin_message,
-)
-from botx.clients.types.response_results import ChatCreatedResult
-from botx.models import chats, enums, users
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.wsgi.messages import add_request_to_collection
-from botx.testing.botx_mock.wsgi.responses import PydanticResponse
-
-
-@bind_implementation_to_method(info.Info)
-def get_info(request: Request, settings: Settings) -> Response:
- """Handle retrieving information of chat request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with information of chat.
- """
- payload = info.Info.parse_obj(request.params)
- add_request_to_collection(settings, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- return PydanticResponse(
- APIResponse[chats.ChatFromSearch](
- result=chats.ChatFromSearch(
- name="chat name",
- chat_type=enums.ChatTypes.group_chat,
- creator=uuid.uuid4(),
- group_chat_id=payload.group_chat_id,
- members=[
- users.UserFromChatSearch(
- user_huid=uuid.uuid4(),
- user_kind=enums.UserKinds.user,
- admin=True,
- ),
- ],
- inserted_at=inserted_at,
- ),
- ),
- )
-
-
-@bind_implementation_to_method(chat_list.ChatList)
-def get_bot_chats(request: Request, settings: Settings) -> Response:
- """Return list of bot chats.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- List of bot chats.
- """
- payload = chat_list.ChatList.parse_obj(request.params)
- add_request_to_collection(settings, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- updated_at = dt.fromisoformat("2019-09-29T10:30:48.358586+00:00")
- return PydanticResponse(
- APIResponse[chats.BotChatList](
- result=chats.BotChatList(
- __root__=[
- chats.BotChatFromList(
- name="chat name",
- description="test",
- chat_type=enums.ChatTypes.group_chat,
- group_chat_id=uuid.uuid4(),
- members=[uuid.uuid4()],
- inserted_at=inserted_at,
- updated_at=updated_at,
- ),
- ],
- ),
- ),
- )
-
-
-@bind_implementation_to_method(add_user.AddUser)
-def post_add_user(request_data: RequestData, settings: Settings) -> Response:
- """Handle adding of user to chat request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with result of adding.
- """
- payload = add_user.AddUser.parse_obj(request_data)
- add_request_to_collection(settings, payload)
- return PydanticResponse(APIResponse[bool](result=True))
-
-
-@bind_implementation_to_method(remove_user.RemoveUser)
-def post_remove_user(request_data: RequestData, settings: Settings) -> Response:
- """Handle removing of user to chat request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with result of removing.
- """
- payload = remove_user.RemoveUser.parse_obj(request_data)
- add_request_to_collection(settings, payload)
- return PydanticResponse(APIResponse[bool](result=True))
-
-
-@bind_implementation_to_method(stealth_set.StealthSet)
-def post_stealth_set(request_data: RequestData, settings: Settings) -> Response:
- """Handle stealth enabling in chat request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with result of enabling stealth.
- """
- payload = stealth_set.StealthSet.parse_obj(request_data)
- add_request_to_collection(settings, payload)
- return PydanticResponse(APIResponse[bool](result=True))
-
-
-@bind_implementation_to_method(stealth_disable.StealthDisable)
-def post_stealth_disable(request_data: RequestData, settings: Settings) -> Response:
- """Handle stealth disabling in chat request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with result of disabling stealth.
- """
- payload = stealth_disable.StealthDisable.parse_obj(request_data)
- add_request_to_collection(settings, payload)
- return PydanticResponse(APIResponse[bool](result=True))
-
-
-@bind_implementation_to_method(create.Create)
-def post_create(request_data: RequestData, settings: Settings) -> Response:
- """Handle creation of new chat request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with result of creation.
- """
- payload = create.Create.parse_obj(request_data)
- add_request_to_collection(settings, payload)
- return PydanticResponse(
- APIResponse[ChatCreatedResult](result=ChatCreatedResult(chat_id=uuid.uuid4())),
- )
-
-
-@bind_implementation_to_method(add_admin_role.AddAdminRole)
-def post_add_admin_role(request_data: RequestData, settings: Settings) -> Response:
- """Handle promoting users to admins request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with result of adding.
- """
- payload = add_admin_role.AddAdminRole.parse_obj(request_data)
- add_request_to_collection(settings, payload)
- return PydanticResponse(APIResponse[bool](result=True))
-
-
-@bind_implementation_to_method(pin_message.PinMessage)
-def post_pin_message(request_data: RequestData, settings: Settings) -> Response:
- """Handle pinning message in chat request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with result of pinning.
- """
- payload = pin_message.PinMessage.parse_obj(request_data)
- add_request_to_collection(settings, payload)
- return PydanticResponse(APIResponse[str](result="pinned"))
-
-
-@bind_implementation_to_method(unpin_message.UnpinMessage)
-def post_unpin_message(request_data: RequestData, settings: Settings) -> Response:
- """Handle unpinning message in chat request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with result of unpinning.
- """
- payload = unpin_message.UnpinMessage.parse_obj(request_data)
- add_request_to_collection(settings, payload)
- return PydanticResponse(APIResponse[str](result="pinned"))
diff --git a/botx/testing/botx_mock/wsgi/routes/command.py b/botx/testing/botx_mock/wsgi/routes/command.py
deleted file mode 100644
index 0f018cc3..00000000
--- a/botx/testing/botx_mock/wsgi/routes/command.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""Endpoints for command resource."""
-from molten import RequestData, Response, Settings
-
-from botx.clients.methods.v3.command.command_result import CommandResult
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.wsgi.messages import add_message_to_collection
-from botx.testing.botx_mock.wsgi.responses import generate_push_response
-
-
-@bind_implementation_to_method(CommandResult)
-def post_command_result(request_data: RequestData, settings: Settings) -> Response:
- """Handle command result request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = CommandResult.parse_obj(request_data)
- add_message_to_collection(settings, payload)
- return generate_push_response(payload)
diff --git a/botx/testing/botx_mock/wsgi/routes/events.py b/botx/testing/botx_mock/wsgi/routes/events.py
deleted file mode 100644
index 5a199027..00000000
--- a/botx/testing/botx_mock/wsgi/routes/events.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Endpoints for events resource."""
-from molten import RequestData, Response, Settings
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.events.edit_event import EditEvent
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.wsgi.messages import add_message_to_collection
-from botx.testing.botx_mock.wsgi.responses import PydanticResponse
-
-
-@bind_implementation_to_method(EditEvent)
-def post_edit_event(request_data: RequestData, settings: Settings) -> Response:
- """Handle edition of event request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Empty json response.
- """
- payload = EditEvent.parse_obj(request_data)
- add_message_to_collection(settings, payload)
- return PydanticResponse(APIResponse[str](result="update_pushed"))
diff --git a/botx/testing/botx_mock/wsgi/routes/files.py b/botx/testing/botx_mock/wsgi/routes/files.py
deleted file mode 100644
index 12be8c3c..00000000
--- a/botx/testing/botx_mock/wsgi/routes/files.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""Endpoints for chats resource."""
-
-import json
-
-from molten import Header, MultiPartParser, Request, RequestInput, Response, Settings
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.files.download import DownloadFile
-from botx.clients.methods.v3.files.upload import UploadFile
-from botx.models.files import File, MetaFile
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.entities import create_test_metafile
-from botx.testing.botx_mock.wsgi.messages import add_request_to_collection
-from botx.testing.botx_mock.wsgi.responses import PydanticResponse
-
-
-@bind_implementation_to_method(UploadFile)
-def upload_file(request: Request, settings: Settings) -> Response:
- """Handle retrieving information about user request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with metadata of file.
- """
- headers = dict(request.headers)
- form = MultiPartParser().parse(
- Header(headers["content-type"]),
- Header(headers["content-length"]),
- RequestInput(request.body_file),
- )
-
- meta = json.loads(form["meta"]) # type: ignore
- file = File.from_file(
- filename=form["content"].filename, # type: ignore
- file=form["content"].stream, # type: ignore
- )
- payload = UploadFile(
- group_chat_id=form["group_chat_id"], # type: ignore
- file=file,
- meta=meta,
- )
- add_request_to_collection(settings, payload)
- return PydanticResponse(
- APIResponse[MetaFile](
- result=create_test_metafile(),
- ),
- )
-
-
-@bind_implementation_to_method(DownloadFile)
-def download_file(request: Request, settings: Settings) -> Response:
- """Handle retrieving information about user request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with file content.
- """
- payload = DownloadFile.parse_obj(request.params)
- add_request_to_collection(settings, payload)
- return PydanticResponse(
- model=None,
- raw_data="content",
- headers={"Content-Type": "text/plain"},
- )
diff --git a/botx/testing/botx_mock/wsgi/routes/notification.py b/botx/testing/botx_mock/wsgi/routes/notification.py
deleted file mode 100644
index 46dad979..00000000
--- a/botx/testing/botx_mock/wsgi/routes/notification.py
+++ /dev/null
@@ -1,44 +0,0 @@
-"""Endpoints for notification resource."""
-from molten import RequestData, Response, Settings
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.notification.direct_notification import NotificationDirect
-from botx.clients.methods.v3.notification.notification import Notification
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.wsgi.messages import add_message_to_collection
-from botx.testing.botx_mock.wsgi.responses import (
- PydanticResponse,
- generate_push_response,
-)
-
-
-@bind_implementation_to_method(Notification)
-def post_notification(request_data: RequestData, settings: Settings) -> Response:
- """Handle pushed notification request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = Notification.parse_obj(request_data)
- add_message_to_collection(settings, payload)
- return PydanticResponse(APIResponse[str](result="notification_pushed"))
-
-
-@bind_implementation_to_method(NotificationDirect)
-def post_notification_direct(request_data: RequestData, settings: Settings) -> Response:
- """Handle pushed notification request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = NotificationDirect.parse_obj(request_data)
- add_message_to_collection(settings, payload)
- return generate_push_response(payload)
diff --git a/botx/testing/botx_mock/wsgi/routes/notifications.py b/botx/testing/botx_mock/wsgi/routes/notifications.py
deleted file mode 100644
index cf8bda5c..00000000
--- a/botx/testing/botx_mock/wsgi/routes/notifications.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""Endpoints for notifications resource."""
-from molten import RequestData, Response, Settings
-
-from botx.clients.methods.v4.notifications.internal_bot_notification import (
- InternalBotNotification,
-)
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.wsgi.messages import add_message_to_collection
-from botx.testing.botx_mock.wsgi.responses import (
- generate_internal_bot_notification_response,
-)
-
-
-@bind_implementation_to_method(InternalBotNotification)
-def post_internal_bot_notification(
- request_data: RequestData,
- settings: Settings,
-) -> Response:
- """Handle pushed notification request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = InternalBotNotification.parse_obj(request_data)
- add_message_to_collection(settings, payload)
- return generate_internal_bot_notification_response(payload)
diff --git a/botx/testing/botx_mock/wsgi/routes/smartapps.py b/botx/testing/botx_mock/wsgi/routes/smartapps.py
deleted file mode 100644
index 81827dc7..00000000
--- a/botx/testing/botx_mock/wsgi/routes/smartapps.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Endpoints for smartapps."""
-
-from molten import RequestData, Response, Settings
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.smartapps.smartapp_event import SmartAppEvent
-from botx.clients.methods.v3.smartapps.smartapp_notification import SmartAppNotification
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.wsgi.messages import add_message_to_collection
-from botx.testing.botx_mock.wsgi.responses import PydanticResponse
-
-
-@bind_implementation_to_method(SmartAppEvent)
-def post_smartapp_event(request_data: RequestData, settings: Settings) -> Response:
- """Handle pushed smartapp event request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = SmartAppEvent.parse_obj(request_data)
- add_message_to_collection(settings, payload)
- return PydanticResponse(APIResponse[str](result="smartapp_event_pushed"))
-
-
-@bind_implementation_to_method(SmartAppNotification)
-def post_smartapp_notification(
- request_data: RequestData,
- settings: Settings,
-) -> Response:
- """Handle pushed smartapp notification request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with sync_id of pushed message.
- """
- payload = SmartAppNotification.parse_obj(request_data)
- add_message_to_collection(settings, payload)
- return PydanticResponse(APIResponse[str](result="smartapp_notification_pushed"))
diff --git a/botx/testing/botx_mock/wsgi/routes/stickers.py b/botx/testing/botx_mock/wsgi/routes/stickers.py
deleted file mode 100644
index 85f984df..00000000
--- a/botx/testing/botx_mock/wsgi/routes/stickers.py
+++ /dev/null
@@ -1,292 +0,0 @@
-"""Endpoints for stickers."""
-import uuid
-from datetime import datetime as dt
-
-from molten import Request, RequestData, Response, Settings
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.stickers import (
- add_sticker,
- create_sticker_pack,
- delete_sticker,
- delete_sticker_pack,
- edit_sticker_pack,
- sticker,
- sticker_pack,
- sticker_pack_list,
-)
-from botx.models import stickers
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.wsgi.messages import add_request_to_collection
-from botx.testing.botx_mock.wsgi.responses import PydanticResponse
-
-
-@bind_implementation_to_method(sticker_pack_list.GetStickerPackList)
-def get_sticker_pack_list(request: Request, settings: Settings) -> Response:
- """Handle retrieving information of sticker pack list request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with list of sticker packs.
- """
- payload = sticker_pack_list.GetStickerPackList.parse_obj(request.params)
- add_request_to_collection(settings, payload)
-
- pagination = stickers.Pagination(
- after="ABAmCAFTpX1ajK8O_ezuPEQ1AA0ACnVwZGF0ZWRfYXQAf____gAAAAA=",
- )
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- sticker_packs = [
- stickers.StickerPackPreview(
- id=uuid.uuid4(),
- name="Test sticker pack",
- public=False,
- stickers_count=1,
- inserted_at=inserted_at,
- ),
- ]
- pack_list = stickers.StickerPackList(
- packs=sticker_packs,
- pagination=pagination,
- )
-
- return PydanticResponse(
- APIResponse[stickers.StickerPackList](
- result=pack_list,
- ),
- )
-
-
-@bind_implementation_to_method(sticker_pack.GetStickerPack)
-def get_sticker_pack(request: Request, settings: Settings) -> Response:
- """Handle retrieving information of sticker pack request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with information of sticker pack.
- """
- payload = sticker_pack.GetStickerPack.parse_obj(request.params)
- add_request_to_collection(settings, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- pack_stickers = [
- stickers.Sticker(
- id=uuid.uuid4(),
- emoji="🐢",
- link="http://some_link.com",
- inserted_at=inserted_at,
- updated_at=inserted_at,
- ),
- ]
- sticker_pack_preview = stickers.StickerPack(
- id=uuid.uuid4(),
- name="Test sticker pack",
- preview=None,
- public=False,
- stickers_order=None,
- stickers=pack_stickers,
- inserted_at=inserted_at,
- updated_at=inserted_at,
- deleted_at=None,
- )
-
- return PydanticResponse(
- APIResponse[stickers.StickerPack](
- result=sticker_pack_preview,
- ),
- )
-
-
-@bind_implementation_to_method(sticker.GetSticker)
-def get_sticker_from_sticker_pack(request: Request, settings: Settings) -> Response:
- """Handle retrieving information of sticker from sticker pack request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with information of sticker from sticker pack.
- """
- payload = sticker.GetSticker.parse_obj(request.params)
-
- add_request_to_collection(settings, payload)
- sticker_from_sticker_pack = stickers.StickerFromPack(
- id=uuid.uuid4(),
- emoji="🐢",
- link="http://some_link.com",
- preview="http://preview_link.com",
- )
-
- return PydanticResponse(
- APIResponse[stickers.StickerFromPack](
- result=sticker_from_sticker_pack,
- ),
- )
-
-
-@bind_implementation_to_method(add_sticker.AddSticker)
-def post_add_sticker_into_sticker_pack(
- request_data: RequestData,
- settings: Settings,
-) -> Response:
- """Handle adding of sticker to sticker pack request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with result of adding.
- """
- payload = add_sticker.AddSticker.parse_obj(request_data)
- add_request_to_collection(settings, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- sticker_from_sticker_pack = stickers.Sticker(
- id=uuid.uuid4(),
- emoji="🐢",
- link="http://some_link.com",
- inserted_at=inserted_at,
- updated_at=inserted_at,
- )
-
- return PydanticResponse(
- APIResponse[stickers.Sticker](
- result=sticker_from_sticker_pack,
- ),
- )
-
-
-@bind_implementation_to_method(create_sticker_pack.CreateStickerPack)
-def post_create_sticker_pack(request: Request, settings: Settings) -> Response:
- """Handle creating of sticker pack request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with result of creating.
- """
- payload = create_sticker_pack.CreateStickerPack.parse_obj(request.params)
- add_request_to_collection(settings, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- pack_stickers = [
- stickers.Sticker(
- id=uuid.uuid4(),
- emoji="🐢",
- link="http://some_link.com",
- inserted_at=inserted_at,
- updated_at=inserted_at,
- ),
- ]
- sticker_from_sticker_pack = stickers.StickerPack(
- id=uuid.uuid4(),
- name="Test sticker pack",
- preview=None,
- public=False,
- stickers_order=None,
- stickers=pack_stickers,
- inserted_at=inserted_at,
- updated_at=inserted_at,
- deleted_at=None,
- )
-
- return PydanticResponse(
- APIResponse[stickers.StickerPack](
- result=sticker_from_sticker_pack,
- ),
- )
-
-
-@bind_implementation_to_method(edit_sticker_pack.EditStickerPack)
-def post_edit_sticker_pack(request_data: RequestData, settings: Settings) -> Response:
- """Handle editing of sticker pack request.
-
- Arguments:
- request_data: parsed json data from request.
- settings: application settings with storage.
-
- Returns:
- Response with result of editing.
- """
- payload = edit_sticker_pack.EditStickerPack.parse_obj(request_data)
- add_request_to_collection(settings, payload)
-
- inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00")
- pack_stickers = [
- stickers.Sticker(
- id=uuid.uuid4(),
- emoji="🐢",
- link="http://some_link.com",
- inserted_at=inserted_at,
- updated_at=inserted_at,
- ),
- ]
- sticker_from_sticker_pack = stickers.StickerPack(
- id=uuid.uuid4(),
- name="Test sticker pack",
- preview=None,
- public=False,
- stickers_order=None,
- stickers=pack_stickers,
- inserted_at=inserted_at,
- updated_at=inserted_at,
- deleted_at=None,
- )
-
- return PydanticResponse(
- APIResponse[stickers.StickerPack](
- result=sticker_from_sticker_pack,
- ),
- )
-
-
-@bind_implementation_to_method(delete_sticker_pack.DeleteStickerPack)
-def post_delete_sticker_pack(request: Request, settings: Settings) -> Response:
- """Handle deleting of sticker pack request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with result of deleting.
- """
- payload = delete_sticker_pack.DeleteStickerPack.parse_obj(request.params)
- add_request_to_collection(settings, payload)
-
- return PydanticResponse(
- APIResponse[str](
- result="sticker_pack_deleted",
- ),
- )
-
-
-@bind_implementation_to_method(delete_sticker.DeleteSticker)
-def delete_sticker_from_sticker_pack(request: Request, settings: Settings) -> Response:
- """Handle deleting of sticker from sticker pack request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with result of deleting.
- """
- payload = delete_sticker.DeleteSticker.parse_obj(request.params)
- add_request_to_collection(settings, payload)
-
- return PydanticResponse(
- APIResponse[str](
- result="sticker_deleted",
- ),
- )
diff --git a/botx/testing/botx_mock/wsgi/routes/users.py b/botx/testing/botx_mock/wsgi/routes/users.py
deleted file mode 100644
index 1aef2425..00000000
--- a/botx/testing/botx_mock/wsgi/routes/users.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""Endpoints for chats resource."""
-
-from molten import Request, Response, Settings
-
-from botx.clients.methods.base import APIResponse
-from botx.clients.methods.v3.users.by_email import ByEmail
-from botx.clients.methods.v3.users.by_huid import ByHUID
-from botx.clients.methods.v3.users.by_login import ByLogin
-from botx.models.users import UserFromSearch
-from botx.testing.botx_mock.binders import bind_implementation_to_method
-from botx.testing.botx_mock.entities import create_test_user
-from botx.testing.botx_mock.wsgi.messages import add_request_to_collection
-from botx.testing.botx_mock.wsgi.responses import PydanticResponse
-
-
-@bind_implementation_to_method(ByHUID)
-def get_by_huid(request: Request, settings: Settings) -> Response:
- """Handle retrieving information about user request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with information about user.
- """
- payload = ByHUID.parse_obj(dict(request.params))
- add_request_to_collection(settings, payload)
- return PydanticResponse(
- APIResponse[UserFromSearch](
- result=create_test_user(user_huid=payload.user_huid),
- ),
- )
-
-
-@bind_implementation_to_method(ByEmail)
-def get_by_email(request: Request, settings: Settings) -> Response:
- """Handle retrieving information about user request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with information about user.
- """
- payload = ByEmail.parse_obj(dict(request.params))
- add_request_to_collection(settings, payload)
- return PydanticResponse(
- APIResponse[UserFromSearch](result=create_test_user(email=payload.email)),
- )
-
-
-@bind_implementation_to_method(ByLogin)
-def get_by_login(request: Request, settings: Settings) -> Response:
- """Handle retrieving information about user request.
-
- Arguments:
- request: HTTP request from Molten.
- settings: application settings with storage.
-
- Returns:
- Response with information about user.
- """
- payload = ByLogin.parse_obj(dict(request.params))
- add_request_to_collection(settings, payload)
- return PydanticResponse(
- APIResponse[UserFromSearch](
- result=create_test_user(ad=(payload.ad_login, payload.ad_domain)),
- ),
- )
diff --git a/botx/testing/building/__init__.py b/botx/testing/building/__init__.py
deleted file mode 100644
index c32f8003..00000000
--- a/botx/testing/building/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Entities responsible for MessageBuilder."""
diff --git a/botx/testing/building/attachments.py b/botx/testing/building/attachments.py
deleted file mode 100644
index 8b574ccb..00000000
--- a/botx/testing/building/attachments.py
+++ /dev/null
@@ -1,147 +0,0 @@
-"""Mixin for building attachments."""
-from dataclasses import field
-
-from botx.models import attachments as attach
-from botx.testing import content as test_content
-
-
-class BuildAttachmentsMixin:
- """Mixin for building attachments in message."""
-
- attachments: attach.AttachList = field(default_factory=list) # type: ignore
-
- def image(
- self,
- content: str = test_content.PNG_DATA, # noqa: WPS110
- file_name: str = "image.jpg",
- ) -> None:
- """Add image into incoming message.
-
- Arguments:
- content: image content in RFC 2397 format.
- file_name: name of file.
- """
- self.attachments.__root__.append(
- attach.ImageAttachment(
- data=attach.Image(content=content, file_name=file_name),
- ),
- )
-
- def document(
- self,
- content: str = test_content.DOCX_DATA, # noqa: WPS110
- file_name: str = "document.docx",
- ) -> None:
- """Add document into incoming message.
-
- Arguments:
- content: document content in RFC 2397 format.
- file_name: name of file.
- """
- self.attachments.__root__.append(
- attach.DocumentAttachment(
- data=attach.Document(content=content, file_name=file_name),
- ),
- )
-
- def location(
- self,
- location_name: str = "loc_name",
- location_address: str = "loc_address",
- location_lat: int = 0,
- location_lng: int = 0,
- ) -> None:
- """Add location into incoming message.
-
- Arguments:
- location_name: name of location.
- location_lat: latitude.
- location_lng: longitude.
- location_address: address of location.
- """
- self.attachments.__root__.append(
- attach.LocationAttachment(
- data=attach.Location(
- location_name=location_name,
- location_lat=location_lat,
- location_lng=location_lng,
- location_address=location_address,
- ),
- ),
- )
-
- def voice(
- self,
- content: str = test_content.MP3_DATA, # noqa: WPS110
- duration: int = 10, # noqa: WPS110
- ) -> None:
- """Add voice into incoming message.
-
- Arguments:
- content: voice content in RFC 2397 format.
- duration: voice duration.
- """
- self.attachments.__root__.append(
- attach.VoiceAttachment(
- data=attach.Voice(duration=duration, content=content),
- ),
- )
-
- def video(
- self,
- content: str = test_content.MP4_DATA, # noqa: WPS110
- file_name: str = "video.mp4",
- duration: int = 10,
- ) -> None:
- """Add video into incoming message.
-
- Arguments:
- content: voice content in RFC 2397 format.
- file_name: name of file.
- duration: video duration.
- """
- self.attachments.__root__.append(
- attach.VideoAttachment(
- data=attach.Video(
- content=content,
- file_name=file_name,
- duration=duration,
- ),
- ),
- )
-
- def link(
- self,
- url: str = "https://google.com",
- url_preview: str = "https://image.jpg",
- url_text: str = "foo",
- url_title: str = "bar",
- ) -> None:
- """Add link into incoming message.
-
- Arguments:
- url: link on resource.
- url_preview: link on url preview.
- url_text: presented text on link.
- url_title: title of link.
- """
- self.attachments.__root__.append(
- attach.LinkAttachment(
- data=attach.Link(
- url=url,
- url_preview=url_preview,
- url_text=url_text,
- url_title=url_title,
- ),
- ),
- )
-
- def contact(self, contact_name: str = "Foo") -> None:
- """Add link into incoming message.
-
- Arguments:
- contact_name: name of contact
- """
- self.attachments.__root__.append(
- attach.ContactAttachment(data=attach.Contact(contact_name=contact_name)),
- )
diff --git a/botx/testing/building/builder.py b/botx/testing/building/builder.py
deleted file mode 100644
index 16300c28..00000000
--- a/botx/testing/building/builder.py
+++ /dev/null
@@ -1,99 +0,0 @@
-"""Builder for messages in tests."""
-
-import uuid
-from dataclasses import field
-from typing import Any, Optional
-
-from pydantic import BaseConfig, validator
-from pydantic.dataclasses import dataclass
-
-from botx.models.attachments import AttachList
-from botx.models.entities import EntityList
-from botx.models.enums import ChatTypes, ClientPlatformEnum, CommandTypes
-from botx.models.messages.incoming_message import (
- Command,
- DeviceMeta,
- IncomingMessage,
- Sender,
-)
-from botx.testing.building.attachments import BuildAttachmentsMixin
-from botx.testing.building.entites import BuildEntityMixin
-from botx.testing.building.validators import (
- convert_to_acceptable_file,
- validate_body_corresponds_command,
- validate_command_type_corresponds_command,
-)
-
-
-def _build_default_user() -> Sender:
- return Sender(
- user_huid=uuid.uuid4(),
- group_chat_id=uuid.uuid4(),
- chat_type=ChatTypes.chat,
- ad_login="test_user",
- ad_domain="example.com",
- username="Test User",
- is_admin=True,
- is_creator=True,
- host="cts.example.com",
- manufacturer="Google",
- device="Chrome 87.0",
- device_software="macOS 10.15.7",
- device_meta=DeviceMeta(
- pushes=False,
- timezone="Asia/Novosibirsk",
- permissions={"microphone": True, "notifications": False},
- ),
- platform=ClientPlatformEnum.web,
- platform_package_id=None,
- app_version="1.15.52",
- locale="en",
- )
-
-
-class BuilderConfig(BaseConfig):
- """Config for builder dataclass."""
-
- validate_assignment = True
-
-
-@dataclass(config=BuilderConfig)
-class MessageBuilder(BuildAttachmentsMixin, BuildEntityMixin): # noqa: WPS214
- """Builder for command message for bot."""
-
- bot_id: uuid.UUID = field(default_factory=uuid.uuid4)
-
- command_data: dict = field(default_factory=dict)
- system_command: bool = field(default=False)
- file: Optional[Any] = field(default=None)
- attachments: AttachList = field(default_factory=list) # type: ignore
- user: Sender = field(default_factory=_build_default_user)
- entities: EntityList = field(default_factory=list) # type: ignore
- body: str = field(default="")
-
- _body_and_command_validator = validator("body", always=True)(
- validate_body_corresponds_command,
- )
- _command_type_and_data_validator = validator("system_command", always=True)(
- validate_command_type_corresponds_command,
- )
- _file_converter = validator("file", always=True)(convert_to_acceptable_file)
-
- @property
- def message(self) -> IncomingMessage:
- """Message that was built by builder."""
- command_type = CommandTypes.system if self.system_command else CommandTypes.user
- command = Command(
- body=self.body,
- command_type=command_type,
- data=self.command_data,
- )
- return IncomingMessage(
- sync_id=uuid.uuid4(),
- command=command,
- attachments=self.attachments,
- file=self.file,
- bot_id=self.bot_id,
- user=self.user,
- entities=self.entities,
- )
diff --git a/botx/testing/building/entites.py b/botx/testing/building/entites.py
deleted file mode 100644
index bf75b669..00000000
--- a/botx/testing/building/entites.py
+++ /dev/null
@@ -1,160 +0,0 @@
-"""Mixin for building entities."""
-
-import uuid
-from dataclasses import field
-from datetime import datetime
-from typing import Optional
-
-from botx.models.attachments_meta import DocumentAttachmentMeta
-from botx.models.entities import (
- ChatMention,
- Entity,
- EntityList,
- Forward,
- Mention,
- MentionTypes,
- Reply,
- UserMention,
-)
-from botx.models.enums import ChatTypes, EntityTypes
-from botx.models.messages.message import Message
-
-
-class BuildEntityMixin:
- """Mixin for building entities in message."""
-
- entities: EntityList = field(default_factory=list) # type: ignore
-
- def mention_contact(self, user_huid: uuid.UUID) -> None:
- """Add contact mention to message for bot.
-
- Arguments:
- user_huid: huid of user to mention.
- """
- self.entities.__root__.append(
- Entity(
- type=EntityTypes.mention,
- data=Mention(
- mention_data=UserMention(user_huid=user_huid),
- mention_type=MentionTypes.contact,
- ),
- ),
- )
-
- def mention_user(self, user_huid: uuid.UUID) -> None:
- """Add user mention to message for bot.
-
- Arguments:
- user_huid: huid of user to mention.
- """
- self.entities.__root__.append(
- Entity(
- type=EntityTypes.mention,
- data=Mention(mention_data=UserMention(user_huid=user_huid)),
- ),
- )
-
- def mention_chat(self, chat_id: uuid.UUID) -> None:
- """Add chat mention to message for bot.
-
- Arguments:
- chat_id: id of chat to mention.
- """
- self.entities.__root__.append(
- Entity(
- type=EntityTypes.mention,
- data=Mention(
- mention_data=ChatMention(group_chat_id=chat_id),
- mention_type=MentionTypes.chat,
- ),
- ),
- )
-
- def mention(self, mention: Mention) -> None:
- """Add mention by mention model.
-
- Arguments:
- mention: mention model to build.
- """
- self.entities.__root__.append(Entity(type=EntityTypes.mention, data=mention))
-
- def reply(
- self,
- *,
- message: Optional[Message] = None,
- reply: Optional[Reply] = None,
- source_chat_name: str = "chat",
- ) -> None:
- """Add reply to message for bot.
-
- Arguments:
- message: replied message.
- reply: reply model to build.
- source_chat_name: name of chat where message was reply.
-
- Raises:
- ValueError: raise if conflict of requirement arguments.
- """
- if message and not reply:
- mentions = message.entities.mentions
-
- reply = Reply(
- attachment=DocumentAttachmentMeta(file_name="test.doc"),
- body=message.body,
- mentions=mentions,
- reply_type=ChatTypes(message.chat_type),
- sender=message.user_huid, # type: ignore
- source_chat_name=source_chat_name,
- source_sync_id=message.sync_id,
- source_group_chat_id=message.group_chat_id,
- )
- elif reply and not message:
- pass # noqa: WPS420
- else:
- raise ValueError("Must be replied message of reply model")
- self.entities.__root__.append(Entity(type=EntityTypes.reply, data=reply))
-
- def forward(
- self,
- *,
- message: Optional[Message] = None,
- forward: Optional[Forward] = None,
- source_chat_name: str = "chat",
- source_inserted_at: datetime = datetime( # noqa: B008, WPS404
- 1,
- 1,
- 1,
- 1,
- 1,
- 1,
- 1,
- None,
- ),
- ) -> None:
- """Add forward to message for bot.
-
- Arguments:
- message: forwarded message.
- forward: forward model to build.
- source_chat_name: name of chat where message was forward.
- source_inserted_at: ts of forwarded message.
-
- Raises:
- ValueError: raise if conflict of requirement arguments.
- """
- if message and not forward:
- assert message.group_chat_id is not None
- forward = Forward(
- group_chat_id=message.group_chat_id,
- sender_huid=message.user_huid or uuid.uuid4(),
- forward_type=ChatTypes(message.chat_type),
- source_chat_name=source_chat_name,
- source_sync_id=message.sync_id,
- source_inserted_at=source_inserted_at,
- )
- elif forward and not message:
- pass # noqa: WPS420
- else:
- raise ValueError("Must be forwarding message or forward")
-
- self.entities.__root__.append(Entity(type=EntityTypes.forward, data=forward))
diff --git a/botx/testing/building/validators.py b/botx/testing/building/validators.py
deleted file mode 100644
index 3d1ea8d7..00000000
--- a/botx/testing/building/validators.py
+++ /dev/null
@@ -1,119 +0,0 @@
-"""Validators and converters for fields in builder."""
-
-from typing import Any, BinaryIO, Dict, Optional, TextIO, Union
-
-from botx.models import enums, events, files
-from botx.models.messages.incoming_message import Sender
-
-
-def validate_body_corresponds_command(body: str, values: dict) -> str: # noqa: WPS110
- """Check that passed body can be proceed.
-
- Arguments:
- body: passed body.
- values: already validated validated_values.
-
- Returns:
- Checked passed body.
- """
- _check_system_command_properties(
- body,
- values.get("system_command", False),
- values["command_data"],
- values,
- )
- return body
-
-
-def validate_command_type_corresponds_command(
- is_system_command: bool,
- values: dict, # noqa: WPS110
-) -> bool:
- """Check that command type corresponds body.
-
- Arguments:
- is_system_command: is command marked as system command.
- values: already validated validated_values.
-
- Returns:
- Checked flag.
- """
- if is_system_command:
- _check_system_command_properties(
- values["body"],
- is_system_command,
- values["command_data"],
- values,
- )
-
- return is_system_command
-
-
-def convert_to_acceptable_file(
- file: Optional[Union[files.File, BinaryIO, TextIO]],
-) -> Optional[files.File]:
- """Convert file to File that can be passed into message.
-
- Arguments:
- file: passed file.
-
- Returns:
- Converted file.
- """
- if isinstance(file, files.File) or file is None:
- return file
-
- new_file = files.File.from_file(file, filename="temp.txt")
- new_file.file_name = file.name
- return new_file
-
-
-def _check_system_command_properties(
- body: str,
- is_system_command: bool,
- command_data: dict,
- validated_values: dict,
-) -> None:
- if is_system_command:
- event = enums.SystemEvents(body) # check that is real system event
- event_shape = events.EVENTS_SHAPE_MAP.get(event)
- if event_shape is not None:
- event_shape.parse_obj(command_data) # check event data
- _event_checkers[event](**validated_values) # type: ignore
-
-
-def _check_common_system_event(user: Sender, **_kwargs: Any) -> None:
- error_field = ""
- if user.user_huid is not None:
- error_field = "user_huid"
- elif user.ad_login is not None:
- error_field = "ad_login"
- elif user.ad_domain is not None:
- error_field = "ad_domain"
- elif user.username is not None:
- error_field = "username"
-
- if error_field:
- raise ValueError(
- "user in system:chat_created can not have {0}".format(error_field),
- )
-
-
-def _check_file_transfer_event(file: Optional[files.File], **_kwargs: Any) -> None:
- if file is None:
- raise ValueError("file_transfer event should have attached file")
-
-
-def _check_internal_notification_event(
- command_data: Dict[str, Any],
- **_kwargs: Any,
-) -> None:
- assert "data" in command_data
-
-
-_event_checkers = {
- enums.SystemEvents.chat_created: _check_common_system_event,
- enums.SystemEvents.added_to_chat: _check_common_system_event,
- enums.SystemEvents.file_transfer: _check_file_transfer_event,
- enums.SystemEvents.internal_bot_notification: _check_internal_notification_event,
-}
diff --git a/botx/testing/content.py b/botx/testing/content.py
deleted file mode 100644
index eae291a9..00000000
--- a/botx/testing/content.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""Module with test data content for files."""
-
-JPG_DATA = "data:image/jpeg;base64,Lg=="
-JPEG_DATA = "data:image/jpeg;base64,Lg=="
-GIF_DATA = "data:image/gif;base64,Lg=="
-PNG_DATA = "data:image/png;base64,Lg=="
-DOC_DATA = "data:application/msword;base64,Lg=="
-DOCX_DATA = "data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,Lg=="
-XLS_DATA = "data:application/vnd.ms-excel;base64,Lg=="
-XLSX_DATA = (
- "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,Lg=="
-)
-TXT_DATA = "data:text/plain;base64,Lg=="
-PDF_DATA = "data:application/pdf;base64,Lg=="
-HTML_DATA = "data:text/html;base64,Lg=="
-JSON_DATA = "data:application/json;base64,Lg=="
-SIG_DATA = "data:application/pgp-signature;base64,Lg=="
-PPT_DATA = "data:application/vnd.ms-powerpoint;base64,Lg=="
-PPTX_DATA = "data:application/vnd.openxmlformats-officedocument.presentationml.presentation;base64,Lg=="
-MP3_DATA = "data:audio/mpeg;base64,Lg=="
-MP4_DATA = "data:video/mp4;base64,Lg=="
-GZ_DATA = "data:text/plain;base64,Lg=="
-TGZ_DATA = "data:application/x-tar;base64,Lg=="
-ZIP_DATA = "data:application/zip;base64,Lg=="
-RAR_DATA = "data:application/vnd.rar;base64,Lg=="
diff --git a/botx/testing/testing_client/__init__.py b/botx/testing/testing_client/__init__.py
deleted file mode 100644
index 089d6c46..00000000
--- a/botx/testing/testing_client/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Definition of client for testing."""
diff --git a/botx/testing/testing_client/base.py b/botx/testing/testing_client/base.py
deleted file mode 100644
index 3b608876..00000000
--- a/botx/testing/testing_client/base.py
+++ /dev/null
@@ -1,125 +0,0 @@
-"""Base for testing client for bots."""
-from __future__ import annotations
-
-import asyncio
-from concurrent.futures import ThreadPoolExecutor
-from contextlib import contextmanager
-from typing import Any, Dict, Generator, List, Optional, Tuple, Type
-
-import httpx
-
-from botx.bots.bots import Bot
-from botx.clients.methods.base import BotXMethod
-from botx.middlewares.exceptions import ExceptionMiddleware
-from botx.models.messages.incoming_message import IncomingMessage
-from botx.models.messages.message import Message
-from botx.testing.botx_mock.asgi.application import get_botx_asgi_api
-from botx.testing.botx_mock.wsgi.application import get_botx_wsgi_api
-from botx.testing.typing import APIMessage, APIRequest
-
-
-class _ExceptionMiddleware(ExceptionMiddleware):
- """Replacement of built-in ExceptionMiddleware that will raise errors."""
-
- async def _handle_error_in_handler(self, exc: Exception, message: Message) -> None:
- handler = self._lookup_handler_for_exception(exc)
-
- if handler is None:
- raise exc
-
- await super()._handle_error_in_handler(exc, message)
-
-
-ErrorsOverrides = Dict[Type[BotXMethod], Tuple[int, Any]]
-
-
-class BaseTestClient:
- """Base for testing client for bots."""
-
- def __init__(
- self,
- bot: Bot,
- errors: Optional[ErrorsOverrides] = None,
- suppress_errors: bool = False,
- ) -> None:
- """Init client with required query_params.
-
- Arguments:
- bot: bot that should be tested.
- errors: errors that should be raised from methods calls.
- suppress_errors: if True then don't raise raise errors from handlers.
- """
- self.bot: Bot = bot
- self._original_http_client = bot.client.http_client
- self._original_sync_http_client = bot.sync_client.http_client
- self._error_middleware: Optional[ExceptionMiddleware] = None
- self._messages: List[APIMessage] = []
- self._requests: List[APIRequest] = []
- self._errors = errors or {}
- self._suppress_errors = suppress_errors
-
- def __enter__(self) -> BaseTestClient:
- """Mock original HTTP clients."""
- is_error_middleware = isinstance(
- self.bot.exception_middleware,
- ExceptionMiddleware,
- )
- if not self._suppress_errors and is_error_middleware:
- self._error_middleware = self.bot.exception_middleware
- self.bot.exception_middleware = _ExceptionMiddleware(
- self.bot.exception_middleware.executor,
- )
- self.bot.exception_middleware._exception_handlers = ( # noqa: WPS437
- self._error_middleware._exception_handlers # noqa: WPS437
- )
-
- self.bot.client.http_client = httpx.AsyncClient(
- app=get_botx_asgi_api(self._messages, self._requests, self._errors),
- )
- self.bot.sync_client.http_client = httpx.Client(
- app=get_botx_wsgi_api(self._messages, self._requests, self._errors),
- )
-
- return self
-
- def __exit__(self, *_: Any) -> None:
- """Restore original HTTP client and clear storage."""
- if self._error_middleware is not None:
- self.bot.exception_middleware = self._error_middleware
-
- ThreadPoolExecutor().submit(
- asyncio.run,
- self.bot.client.http_client.aclose(),
- ).result()
- self.bot.client.http_client = self._original_http_client
- self.bot.sync_client.http_client = self._original_sync_http_client
- self._messages = []
-
- @contextmanager
- def error_client(
- self,
- errors: Dict[Type[BotXMethod], Tuple[int, Any]],
- ) -> Generator[BaseTestClient, None, None]:
- """Enter into new test client that adds error responses to mocks.
-
- Arguments:
- errors: overrides for errors in context.
-
- Yields:
- New client with overridden errors.
- """
- override_errors = {**self._errors, **errors}
- with self.__class__(self.bot, override_errors, self._suppress_errors) as client:
- yield client
-
- async def send_command(self, message: IncomingMessage, sync: bool = True) -> None:
- """Send command message to bot.
-
- Arguments:
- message: message with command for bot.
- sync: if is `True` then wait while command is full executed.
- """
- await self.bot.execute_command(message.dict())
-
- if sync:
- await self.bot.wait_current_handlers()
diff --git a/botx/testing/testing_client/client.py b/botx/testing/testing_client/client.py
deleted file mode 100644
index a1e18aa4..00000000
--- a/botx/testing/testing_client/client.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""Definition of client for testing."""
-from typing import Tuple, Union
-
-from botx.clients.methods.v3.command.command_result import CommandResult
-from botx.clients.methods.v3.events.edit_event import EditEvent
-from botx.clients.methods.v3.events.reply_event import ReplyEvent
-from botx.clients.methods.v3.notification.direct_notification import NotificationDirect
-from botx.clients.methods.v3.notification.notification import Notification
-from botx.testing.testing_client.base import BaseTestClient
-from botx.testing.typing import APIMessage, APIRequest
-
-
-class TestClient(BaseTestClient):
- """Test client for testing bots."""
-
- # https://docs.pytest.org/en/latest/changelog.html#changes
- # Allow to skip test classes from being collected
- __test__: bool = False
-
- @property
- def requests(self) -> Tuple[APIRequest, ...]:
- """Return all requests that were sent by bot.
-
- Returns:
- Sequence of requests that were sent from bot.
- """
- return tuple(request.copy(deep=True) for request in self._requests)
-
- @property
- def messages(self) -> Tuple[APIMessage, ...]:
- """Return all entities that were sent by bot.
-
- Returns:
- Sequence of messages that were sent from bot.
- """
- return tuple(message.copy(deep=True) for message in self._messages)
-
- @property
- def command_results(self) -> Tuple[CommandResult, ...]:
- """Return all command results that were sent by bot.
-
- Returns:
- Sequence of command results that were sent from bot.
- """
- return tuple(
- message for message in self.messages if isinstance(message, CommandResult)
- )
-
- @property
- def notifications(self) -> Tuple[Union[Notification, NotificationDirect], ...]:
- """Return all notifications that were sent by bot.
-
- Returns:
- Sequence of notifications that were sent by bot.
- """
- return tuple(
- message
- for message in self.messages
- if isinstance(message, (Notification, NotificationDirect))
- )
-
- @property
- def message_updates(self) -> Tuple[EditEvent, ...]:
- """Return all updates that were sent by bot.
-
- Returns:
- Sequence of updates that were sent by bot.
- """
- return tuple(
- message for message in self.messages if isinstance(message, EditEvent)
- )
-
- @property
- def replies(self) -> Tuple[ReplyEvent, ...]:
- """Return all replies that were sent by bot.
-
- Returns:
- Sequence of replies that were sent by bot.
- """
- return tuple(
- message for message in self.messages if isinstance(message, ReplyEvent)
- )
diff --git a/botx/testing/typing.py b/botx/testing/typing.py
deleted file mode 100644
index c7be802f..00000000
--- a/botx/testing/typing.py
+++ /dev/null
@@ -1,77 +0,0 @@
-"""Typings for test client and mocks."""
-
-from typing import Union
-
-from botx.clients.methods.v2.bots import token
-from botx.clients.methods.v3.chats import (
- add_admin_role,
- add_user,
- chat_list,
- create,
- info,
- remove_user,
- stealth_disable,
- stealth_set,
-)
-from botx.clients.methods.v3.command import command_result
-from botx.clients.methods.v3.events import edit_event, reply_event
-from botx.clients.methods.v3.files import download, upload
-from botx.clients.methods.v3.notification import direct_notification, notification
-from botx.clients.methods.v3.stickers import (
- add_sticker,
- create_sticker_pack,
- delete_sticker,
- delete_sticker_pack,
- edit_sticker_pack,
- sticker,
- sticker_pack,
- sticker_pack_list,
-)
-from botx.clients.methods.v3.users import by_email, by_huid, by_login
-
-APIMessage = Union[
- command_result.CommandResult,
- notification.Notification,
- direct_notification.NotificationDirect,
- edit_event.EditEvent,
- reply_event.ReplyEvent,
-]
-
-APIRequest = Union[
- # V2
- # bots
- token.Token,
- # V3
- # chats
- add_admin_role.AddAdminRole,
- add_user.AddUser,
- chat_list.ChatList,
- info.Info,
- remove_user.RemoveUser,
- stealth_disable.StealthDisable,
- stealth_set.StealthSet,
- create.Create,
- # command
- command_result.CommandResult,
- # notification
- notification.Notification,
- direct_notification.NotificationDirect,
- # events
- edit_event.EditEvent,
- # users
- by_huid.ByHUID,
- by_email.ByEmail,
- by_login.ByLogin,
- # files
- upload.UploadFile,
- download.DownloadFile,
- # stickers
- sticker_pack_list.GetStickerPackList,
- sticker_pack.GetStickerPack,
- sticker.GetSticker,
- add_sticker.AddSticker,
- delete_sticker_pack.DeleteStickerPack,
- delete_sticker.DeleteSticker,
- create_sticker_pack.CreateStickerPack,
- edit_sticker_pack.EditStickerPack,
-]
diff --git a/botx/typing.py b/botx/typing.py
deleted file mode 100644
index dea6e83f..00000000
--- a/botx/typing.py
+++ /dev/null
@@ -1,35 +0,0 @@
-"""Aliases for complex types from `typing`."""
-
-from typing import TYPE_CHECKING, Awaitable, Callable, TypeVar, Union
-
-from botx.models.messages import message
-
-if TYPE_CHECKING:
- from botx.bots.bots import Bot # noqa: WPS433
-
-try:
- from typing import Literal # noqa: WPS433
-except ImportError:
- from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401
-
-ExceptionT = TypeVar("ExceptionT", bound=Exception)
-
-# Something that can handle new message
-AsyncExecutor = Callable[[message.Message], Awaitable[None]]
-SyncExecutor = Callable[[message.Message], None]
-Executor = Union[AsyncExecutor, SyncExecutor]
-
-# Middlware dispatchers
-AsyncMiddlewareDispatcher = Callable[[message.Message, AsyncExecutor], Awaitable[None]]
-SyncMiddlewareDispatcher = Callable[[message.Message, SyncExecutor], None]
-MiddlewareDispatcher = Union[AsyncMiddlewareDispatcher, SyncMiddlewareDispatcher]
-
-# Exception handlers
-AsyncExceptionHandler = Callable[[ExceptionT, message.Message], Awaitable[None]]
-SyncExceptionHandler = Callable[[ExceptionT, message.Message], None]
-ExceptionHandler = Union[AsyncExceptionHandler, SyncExceptionHandler]
-
-# Startup and shutdown events
-AsyncLifespanEvent = Callable[["Bot"], Awaitable[None]]
-SyncLifespanEvent = Callable[["Bot"], None]
-BotLifespanEvent = Union[AsyncLifespanEvent, SyncLifespanEvent]
diff --git a/docs/botx_api.md b/docs/botx_api.md
new file mode 100644
index 00000000..29e40ec8
--- /dev/null
+++ b/docs/botx_api.md
@@ -0,0 +1,19 @@
+# BotX API
+
+---
+
+## [Bots API](https://hackmd.ccsteam.ru/s/E9MPeOxjP#Bots-API)
+
+### [Получение токена](https://hackmd.ccsteam.ru/s/E9MPeOxjP#%D0%9F%D0%BE%D0%BB%D1%83%D1%87%D0%B5%D0%BD%D0%B8%D0%B5-%D1%82%D0%BE%D0%BA%D0%B5%D0%BD%D0%B0)
+
+Токен можно получить для каждого из добавленных аккаунтов бота. Для выбора аккаунта
+используется его ID.
+
+!!! note
+
+ Вряд ли вам когда-нибудь понадобится запрашивать токен вручную, `pybotx`
+ получает их автоматически.
+
+``` py
+--8<-- "docs/snippets/client/bots_api/get_token.py"
+```
diff --git a/docs/changelog.md b/docs/changelog.md
deleted file mode 100644
index d207ca3f..00000000
--- a/docs/changelog.md
+++ /dev/null
@@ -1,864 +0,0 @@
-## 0.28.0 (Nov 11, 2021)
-
-### Added
-
-* SmartApps main functionality.
-
-
-## 0.27.0 (Nov 8, 2021)
-
-### Added
-
-* `pin_message` and `unpin_message` methods.
-
-
-## 0.26.0 (Nov 1, 2021)
-
-### Added
-
-* Added methods for interacting with sticker pack/stickers - `get_sticker_pack_list`, `get_sticker_pack`. `get_sticker_from_pack`, `create_sticker_pack`, `add_sticker`, `edit_sticker_pack`, `delete_sticker_pack`, `delete_sticker`.
-
-
-## 0.25.1 (Oct 22, 2021)
-
-### Changed
-
-* Add `embed_mentions` argument in `answer_message` method.
-
-
-## 0.25.0 (Sep 17, 2021)
-
-### Added
-
-* `cts_login` and `cts_logout` system events.
-
-
-## 0.24.0 (Sep 14, 2021)
-
-### Removed
-
-* File extensions validation.
-* `File.has_supported_extension` classmethod.
-
-### Added
-
-* Multiple mime-types.
-
-### Changed
-
-* `File.get_ext_by_mimetype` now don't raise `ValueError` and returns `None` if mimetype not found.
-
-
-## 0.23.2 (Sep 09, 2021)
-
-### Added
-
-* Add `file_name` param to `download_file` method to provide ability to change the returned file name.
-
-
-## 0.23.1 (Aug 30, 2021)
-
-### Fixed
-
-* Memory leak in bot.tasks collection.
-
-
-## 0.23.0 (Aug 23, 2021)
-
-### Added
-
-* Add method for uploading files to chat.
-* Add method for downloading files from chat.
-
-### Changed
-
-* Add `data` and `files` fields to `HTTPRequest` for sending multipart/form-data in request.
-* Add `expected_type` field to `HTTPRequest` and `expected_type` property to `BaseBotXMethod`
- to allow interacting with non JSON responses.
-* Add `should_process_as_error` field to `HTTPRequest` so that errors that are not in
- the range of 400 to 599 can be added.
-* Add `raw_data` to `HTTPResponse`so that you can process raw content of response.
-
-
-## 0.22.1 (Aug 23, 2021)
-
-### Fixed
-
-* Add `embed_mentions` argument in `SendingMessage.from_message` method.
-* Fix `EMBED_MENTION_RE` expression.
-
-
-## 0.22.0 (Aug 19, 2021)
-
-Tested on BotX 1.44.0-rc2
-
-### Added
-
-* Sending and handling internal bot notifications.
-
-
-## 0.21.3 (Aug 17, 2021)
-
-### Fixed
-
-* Bot's method `authorize()` now not fall if cant take some tokens. Just logging and skip invalid credentials.
-
-
-## 0.21.2 (Aug 3, 2021)
-
-### Fixed
-
-* `File` is now serializing when sending message.
-
-
-## 0.21.1 (Jul 28, 2021)
-
-### Fixed
-
-* Make the `body` attribute of `Reply` event optional.
-* Add `AttachmentMeta` model to `Reply` event instead of `Attachments`.
-
-
-## 0.21.0 (Jul 23, 2021)
-
-Tested on BotX 1.42.0-rc4
-
-### Fixed
-
-* Remove `Dict[str, Any]` from type of `error_data` field of `BotDisabledResponse`,
- now it can only be `BotDisabledErrorData`.
-
-
-## 0.20.4 (Jul 23, 2021)
-
-Tested on BotX 1.42.0-rc4
-
-### Added
-
-* Add possibility to send message that visible only in stealth mode.
-* Add support for embed mentions (can be used anywhere in text).
-
-### Fixed
-
-* Fix silent response by changing option location in method call.
-
-
-## 0.20.3 (Jul 22, 2021)
-
-Tested on BotX 1.42.0-rc4
-
-### Add
-
-* Add possibility to create chats with enabled shared_history option (Bot.create_chat).
-
-
-## 0.20.2 (Jul 22, 2021)
-
-Tested on BotX 1.42.0-rc4
-
-### Changed
-
-* Exceptions thrown in `exception_handler` are now logged.
-
-
-## 0.20.1 (Jul 19, 2021)
-
-Tested on BotX 1.42.0-rc4
-
-### Added
-
-* Add `bot not found` error handler for `Token` method.
-* Add `invalid bot credentials` error handler for `Token` method.
-* Add `connection error` handler for all BotX methods.
-* Add `JSON decoding error` handler for all BotX methods.
-
-
-## 0.20.0 (Jul 08, 2021)
-
-Tested on BotX 1.42.0-rc4
-
-### Added
-
-* Add method for retrieving list of bot's chats
-* Add `inserted_at` field to `ChatFromSearch` model
-
-### Changed
-
-* `HTTPRequest` & `HTTPResponse` moved to `clients.types`
-* `HTTPRequest` now work with JSON (dict) instead of bytes. It improves consistency with
- `HTTPResponse` and will be useful in interceptors implementation.
-* Reply event field `source_chat_name` is optional now
-* Forward event field `source_sync_id` is required now
-
-### Removed
-
-* `HTTPResponse` bytes content property
-
-### Fixed
-
-* `File` is now deleted when the message is updated
-* `Bot_id` is now displayed in the request
-* Add description for `BotXAPIError`
-
-
-## 0.19.1 (May 21, 2021)
-
-Tested on BotX 1.40.0-rc0
-
-### Changed
-
-* `method.host` assignment moved to method constructor in unit-tests
-
-
-## 0.19.0 (May 18, 2021)
-
-Tested on BotX 1.40.0-rc0
-
-### Added
-
-* Bot now has method `authorize` for explicit authorization each account
-* Add `source_sync_id` field to `Message` which contains id of the message if it was sent from button
-
-### Changed
-
-* `ExpressServer` renamed to `BotXCredentials`
-* Bot id now required for `BotXCredentials`
-
-### Removed
-
-* `send_from_button` field from `Message`
-* `ui` flag from button's data
-
-### Fixed
-
-* Fix pydantic deprecation warning: `whole` flag renamed to `each_item`
-
-## 0.18.4 (Apr 08, 2021)
-
-Tested on BotX 1.40.0-rc0
-
-### Changed
-
-* Raise special exception (BotXAPIRouteDeprecated) on response 410 GONE
-
-## 0.18.3 (Apr 05, 2021)
-
-### Added
-
-* Edit events and answer message now support metadata
-
-## 0.18.2 (Apr 01, 2021)
-
-### Changed
-
-* Bot can recognize mention all (@all) now
-
-## 0.18.1 (Mar 22, 2021)
-
-### Changed
-
-* Fixed empty label bug, now you can use empty string as button label
-
-## 0.18.0 (Mar 13, 2021)
-
-### Changed
-
-* Now `message.data` returns concatenated metadata and data from UI element
-* `message.user.email` renamed to `message.user.upn`
-
-## 0.17.1 (Feb 9, 2021)
-
-### Added
-
-* Add `handler` option for bubbles and keyboard buttons
-
-
-## 0.17.0 (Jan 28, 2021)
-
-### Changed
-
-* Mimetypes are now taken from constant dict, not `mimetypes` library
-* All models use enum's fields by value
-
-### Fixed
-
-* `File.media_type` property
-
-### Added
-
-* Now `File` has property `size_in_bytes`
-* New fields in **message.user**: `manufacturer`, `device`, `device_software`,
- `device_meta`, `platform`, `platform_package_id`, `app_version`, `locale`.
-
-
-## 0.16.10 (Dec 29, 2020)
-
-### Added
-
-* Attachments now have property `attach_type` with type of attachment
-
-
-## 0.16.9 (Dec 29, 2020)
-
-### Fixed
-
-* Dependencies added trought `include_collector` don't called
-
-
-## 0.16.8 (Dec 24, 2020)
-
-### Added
-
-* `async_from_file`, `file_chunks` methods for `File` to async work with attachments
-* new dependency "base64io"
-
-### Changed
-
-* `from_file` now uses stream base64 encoding
-
-
-## 0.16.7 (Dec 23, 2020)
-
-### Fixed
-
-* has_supported_extension now supports uppercase extensions
-
-
-## 0.16.6 (Dec 22, 2020)
-
-### Changed
-
-* Attachments now have default value for type
-
-### Fixed
-
-* Default handler don't process system events anymore
-
-
-## 0.16.5 (Dec 22, 2020)
-
-### Added
-
-* New static method to check that file extension can be handled by BotX API
-
-
-## 0.16.4 (Dec 16, 2020)
-
-### Fixed
-
-* Fixed casting Voice attachment to Video
-
-
-## 0.16.3 (Dec 14, 2020)
-
-### Fixed
-
-* Now you can accept File with unsupported extension
-
-
-## 0.16.2 (Dec 11, 2020)
-
-### Fixed
-
-* Now you can reply with mention (no text or file)
-
-
-## 0.16.1 (Dec 09, 2020)
-
-### Added
-
-* StatusRecipient import from core module
-
-
-## 0.16.0 (Dec 07, 2020)
-
-### Added
-
-* Support of attachments in messages for bot's api v4
-* Support of reply in messages for bot's api v4
-* Builder of attachments in MessageBuilder
-* Test content in RFC 2397 format
-* Entity building methods for `MessageBuilder`
-* Flag `is_forward` for `Message`
-* Bot's method `reply` for reply by message
-
-### Changed
-
-* Type of `message.entities` from `List[Attachment]` to is `EntityList`
-* `send()` sending only direct notification, not command result
-
-
-## 0.15.17 (Nov 20, 2020)
-
-### Changed
-
-* Now you can update attached file while updating message
-
-
-## 0.15.16 (Nov 19, 2020)
-
-### Added
-
-* New event `left_from_chat`, which stores HUIDs of members who left the chat
-
-
-## 0.15.15 (Nov 17, 2020)
-
-### Added
-
-* New event `deleted_from_chat`, which stores deleted members HUIDs
-
-
-## 0.15.14 (Nov 13, 2020)
-
-### Fixed
-
-* `added_to_chat` event is created properly now
-
-
-## 0.15.13 (Nov 6, 2020)
-
-## Added
-
-* Add `bot_id` to outgoing messages debug logs. Incoming messages already have it
-
-
-## 0.15.12 (Oct 30, 2020)
-
-### Added
-
-* Added width weight for bubbles and keyboard buttons (`h_size`). The more width weight,
- the more horizontal space is occupied by button.
-
-
-## 0.15.11 (Oct 30, 2020)
-
-### Added
-
-* Added `.sig`, `.mp3` and `.mp4` to allowed file formats
-
-
-## 0.15.10 (Oct 29, 2020)
-
-### Added
-
-* Added alerts (toast on button press) for bubbles and keyboard buttons
-
-
-## 0.15.9 (Oct 28, 2020)
-
-### Added
-
-* New message option 'silent_response' to hide next user messages in chat
-
-
-## 0.15.8 (Oct 23, 2020)
-
-### Changed
-
-* `httpx` dependency was updated to 0.16
-* `loguru` dependency was updated to 0.5
-
-
-## 0.15.7 (Oct 22, 2020)
-
-### Added
-
-* New bot method `add_admin_roles` for promoting users to admins (bot should have
- admin role itself)
-
-
-## 0.15.6 (Oct 12, 2020)
-
-### Fixed
-
-* Fix `system:chat_created` event (`UserInChatCreated.name` now optional)
-
-
-## 0.15.5 (Sep 16, 2020)
-
-### Added
-
-* Added `.ppt` and `.pptx` to allowed file formats
-
-
-## 0.15.4 (Sep 4, 2020)
-
-### Added
-
-* Update accepted extensions list (`.jpg`, `.jpeg`, `.gif`, `.png`, `.json`, `.zip`, `.rar`)
-
-
-## 0.15.3 (Aug 26, 2020)
-
-### Added
-
-* Models were added to API Reference
-
-### Fixed
-
-* Fix "handler not found" error message
-
-
-## 0.15.2 (Aug 7, 2020)
-
-### Fixed
-
-* Fix overwriting SendingMessage credentials
-
-
-## 0.15.1 (Aug 4, 2020)
-
-### Fixed
-
-* Fix optional fields in UserFromSearch model
-
-
-## 0.15.0 (Jul 23, 2020)
-
-### Added
-
-* Added startup and shutdown lifespan events.
-* Added support for synchronous requests (now `molten` is required for tests).
-* Added support for `system:added_to_chat` event.
-* Added support for forwarded messages.
-* Added support for passing additional arguments to `Bot.status()`.
-* Added methods for:
- * chat creation;
- * information about chat retrieving;
- * search user by email, user HUID or AD login/domain.
-* Add client flag for logger.
-* Allow to update message through `.send()` from bot.
-* Add `metadata` property to message.
-
-### Fixed
-
-* Fix information about sender for `system:chat_created` event.
-* Fix crash when forwarding message to bot.
-* Fix error on creating empty credentials.
-
-### Changed
-
-* Simplify middlewares. Now sync middlewares will receive sync `call_next` and
-asynchronous async `call_next`.
-* `TestClient` will now propagate unhandled errors.
-* Rewrite inner clients. They now work with `methods` classes.
-* Update `httpx` to `^0.13.0`
-* Use bot as default dependency overrides provider.
-* Simplify cache key.
-
-
-## 0.14.1 (Apr 29, 2020)
-
-### Added
-
-* Add flag for messages sent from buttons.
-
-### Fixed
-
-* Attach dependencies on default handler when collector is included by another.
-* Suppress `KeyError` when dropping key from next steps storage.
-
-## 0.14.0 (Apr 3, 2020)
-
-### Added
-
-* Support for external entities in incoming message, like mentions.
-* Add ability to specify custom message id for new message from bot.
-
-### Changed
-
-* Refactor exceptions to be more usefule. Now exceptions have additional properties with extra data.
-* If there will be an error when convering function to handler or dependency, then exception will contain information about failed attributes.
-* Collector will iterate through handlers in right order.
-* Change deploing documentation to github pages from master branch.
-
-### Fixed
-
-* Fix shape for `bot_disabled` response.
-
-## 0.13.6 (Mar 20, 2020)
-
-### Added
-
-* Add Netlify for documention preview.
-* Parse handler docstring as `full_description` for handler.
-* Preserve order of added handlers.
-
-### Changed
-
-* Skip validation for incoming file, so files with unsupported extensions.
-
-### Fixed
-
-* Fix logging file.
-
-## 0.13.5 (Mar 6, 2020)
-
-### Added
-
-* Add channel type into `MentionTypes`.
-
-### Changed
-
-* Replace travis with github actions.
-
-### Fixed
-
-* Fix dependencies extending when use `Collector.include_collector` and dependencies are
-defined in collector initialization.
-* Fix default handler including into another collector.
-* Fix message text logging.
-* Fix internal links in docs.
-
-## 0.13.4 (Mar 3, 2020)
-
-### Added
-
-* Examples of bots that are built using `pybotx`:
- * Bot that defines finite-state machine behaviour for handlers.
-
-### Changed
-
-* Log exception traceback with `logger.exception` instead of `logger.error` when error was
-not caught.
-* Default handler will be excluded from status by default (as it was in library versions before 0.13.0).
-
-## 0.13.3 (Feb 26, 2020)
-
-### Added
-
-* Add background dependencies to next step middleware.
-* Next step break handler can be registered as function.
-* Add methods to add/remove users to/from chat using `Bot.add_users_into_chat()` and `Bot.remove_users_from_chat()`.
-
-### Fixed
-
-* Add missing `dependency_overrides_provider` to `botx.collecting.Collector.add_handler`.
-* Encode message update payload by alias.
-
-### Changed
-
-* Refactored next step middleware
-* Next step middleware won't now lookup for handler in bot.
-* Disable `loguru` logger by default.
-
-## 0.13.2 (Feb 14, 2020)
-
-### Fixed
-
-* Check that there are futures while stopping bot.
-* Strip command in `Handler.command_for` so no space at the end.
-
-## 0.13.1 (Feb 6, 2020)
-
-### Added
-
-* Stealth mode enable/disable methods `Bot.enable_stealth_mode()` and `Bot.disable_stealth_mode()`.
-
-## 0.13.0 (Jan 20, 2020)
-
-!!! warning
- A lot of breaking changes. See API reference for more information.
-
-### Added
-* Added `Silent buttons`.
-* Added `botx.TestClient` for writing tests for bots.
-* Added `botx.MessageBuilder` for building message for tests.
-* `botx.Bot` can now accept sequence of `collecting.Handler`.
-* `botx.Message` is now not a pydantic model. Use `botx.IncomingMessage` in webhooks.
-
-### Changed
-
-* `AsyncBot` renamed to `Bot` in `botx.bots`.
-* `.stop` method was renamed to `.shutdown` in `botx.Bot`.
-* `UserKindEnum` renamed to `UserKinds`.
-* `ChatTypeEnum` renamed to `ChatTypes`.
-
-### Removed
-
-* Removed `botx.bots.BaseBot`, only `botx.bots.Bot` is now available.
-* Removed `botx.BotCredentials`. Credentials for bot should be registered via
-sequence of `botx.ExpressServer` instances.
-* `.credentials` property, `.add_credentials`, `.add_cts` methods were removed in `botx.Bot`.
-Known hosts can be obtained via `.known_hosts` field.
-* `.start` method in `botx.Bot` was removed.
-
-## 0.12.4 (Oct 12, 2019)
-
-### Added
-
-* Add `cts_user` value to `UserKindEnum`.
-
-## 0.12.3 (Oct 12, 2019)
-
-### Changed
-
-* Update `httpx` to `0.7.5`.
-* Use `https` for connecting to BotX API.
-
-### Fixed
-
-* Remove reference about `HandlersCollector.regex_handler` from docs.
-
-## 0.12.2 (Sep 8, 2019)
-
-### Fixed
-
-* Clear `AsyncBot` tasks on shutdown.
-
-## 0.12.1 (Sep 2, 2019)
-
-### Changed
-
-* Upgrade `pydantic` to `0.32.2`.
-
-### Added
-
-* Added `channel` type to `ChatTypeEnum`.
-
-### Fixed
-
-* Export `UserKindEnum` from `botx`.
-
-
-## 0.12.0 (Aug 30, 2019)
-
-### Changed
-
-* `HandlersCollector.system_command_handler` now takes an `event` argument of type `SystemEventsEnum` instead of the deleted argument `comamnd`.
-* `MessageCommand.data` field will now automatically converted to events data types corresponding to special events,
-such as creating a new chat with a bot.
-* Replaced `requests` and `aiohttp` with `httpx`.
-* Moved synchronous `Bot` to `botx.sync` module. The current `Bot` is an alias to the `AsyncBot`.
-* `Bot.status` again became a coroutine to add the ability to receive different commands for different users
-depending on different conditions defined in the handlers (to be added to future releases, when BotX API support comes up).
-* Changed methods signatures. See `api-reference` for details.
-
-### Added
-
-* Added logging via `loguru`.
-* `Bot` can now accept both coroutines and normal functions.
-* Added mechanism for catching exceptions.
-* Add ability to use sync and async functions to send data from `Bot`.
-* Added dependency injection system
-* Added parsing command query_params into handler arguments.
-
-### Removed
-
-* `system_command_handler` argument has been removed from the `HandlersCollector.handler` method.
-* Dropped `aiojobs`.
-
-### Fixed
-
-* Fixed `opts` shape.
-
-## 0.11.3 (Jul 24, 2019)
-
-### Fixed
-
-* Catch `IndexError` when trying to get next step handler for the message and there isn't available.
-
-## 0.11.2 (Jul 17, 2019)
-
-### Removed
-
-* `.data` field in `BubbleElement` and `KeyboardElement` was removed to fix problem in displaying markup on some clients.
-
-## 0.11.1 (Jun 28, 2019)
-
-### Fixed
-
-* Exception won't be raised on successful status codes from the BotX API.
-
-## 0.11.0 (Jun 27, 2019)
-
-### Changed
-
-* `MkDocs` documentation and move to `github`.
-* `BotXException` will be raised if there is an error in sending message, obtaining tokens, parsing incoming message data and some other cases.
-* Rename `CommandRouter` to `HandlersCollector`, changed methods, added some new decorators for specific commands.
-* Replaced `Bot.parse_status` method with the `Bot.status` property.
-* Added generating message for `BotXException` error.
-
-### Added
-
-* `ReplyMessage` class and `.reply` method to bots were added for building answers in command in more comfortable way.
-* Options for message notifications.
-* Bot's handlers can be registered as next step handlers.
-* `MessageUser` has now `email`.
-
-## 0.10.3 (May 31, 2019)
-
-### Fixed
-
-* Fixed passing positional and key arguments into logging wrapper for next step handlers.
-
-## 0.10.2 (May 31, 2019)
-
-### Added
-
-* Next step handlers can now receive positional and key arguments that are passed through their registration.
-
-## 0.10.1 (May 31, 2019)
-
-### Fixed
-
-* Return handler function from `CommandRouter.command` decorator instead of `CommandHandler` instance.
-
-## 0.10.0 (May 28, 2019)
-
-### Changed
-
-* Move `requests`, `aiohttp` and `aiojobs` to optional dependencies.
-* All handlers now receive a bot instance that processes current command execution as second argument for handler.
-* Files renamed using snake case.
-* Returned response text and status from methods for sending messages.
-
-### Added
-
-* Export `pydantic`'s `ValidationError` directly from `botx`.
-* Add Readme.md for library.
-* Add support for BotX API tokens for bots.
-* Add `py.typed` file for `mypy`.
-* Add `CommandRouter` for gathering command handlers together and some methods for handling specific commands.
-* Add ability to change handlers processing behaviour by using next step handlers.
-* Add `botx.bots.Bot.answer_message` method to bots for easier generating answers in commands.
-* Add mentions for users in chats.
-* Add abstract methods to `BaseBot` and `BaseDispatcher`.
-
-### Fixed
-
-* Fixed some mypy types issues.
-* Removed print.
-
-## 0.9.4 (Apr 23, 2019)
-
-### Changed
-
-* Change generation of command bodies for bot status by not forcing leading slash.
-
-## 0.9.3 (Apr 4, 2019)
-
-### Fixed
-
-* Close `aiohttp.client.ClientSession` when calling `AsyncBot.stop()`.
-
-## 0.9.2 (Mar 27, 2019)
-
-### Removed
-
-* Delete unused for now argument `bot` from thread wrapper.
-
-## 0.9.1 (Mar 27, 2019)
-
-### Fixed
-
-* Log unhandled exception from synchronous handlers.
-
-## 0.9.0 (Mar 18, 2019)
-
-### Added
-
-* First public release in PyPI.
-* Synchronous and asynchronous API for building bots.
diff --git a/docs/css/custom.css b/docs/css/custom.css
new file mode 100644
index 00000000..f0c708c1
--- /dev/null
+++ b/docs/css/custom.css
@@ -0,0 +1,10 @@
+div.autodoc-docstring {
+ padding-left: 20px;
+ margin-bottom: 30px;
+ border-left: 5px solid rgba(230, 230, 230);
+}
+
+div.autodoc-members {
+ padding-left: 20px;
+ margin-bottom: 15px;
+}
diff --git a/docs/development/collector.md b/docs/development/collector.md
deleted file mode 100644
index f984b9d4..00000000
--- a/docs/development/collector.md
+++ /dev/null
@@ -1,55 +0,0 @@
-At some point you may decide that it is time to split your handlers into several files.
-In order to make it as convenient as possible, `pybotx` provides a special mechanism that is similar to the mechanism
-of routers from traditional web frameworks like `Blueprint`s in `Flask`.
-
-Let's say you have a bot in the `bot.py` file that has many commands (public, hidden, next step) which can be divided into 3 groups:
-
- * commands to access `A` service.
- * commands to access `B` service.
- * general commands for handling files, saving user settings, etc.
-
-Let's divide these commands in a following way:
-
- 1. Leave the general commands in the `bot.py` file.
- 2. Move the commands related to `A` service to the `a_commands.py` file.
- 3. Move commands related to `B` service to the `b_commands.py` file.
-
-### Collector
-
-[Collector][botx.collecting.collectors.collector.Collector] is a class that can collect registered handlers
-inside itself and then transfer them to bot.
-
-Using [Collector][botx.collecting.collectors.collector.Collector] is quite simple:
-
- 1. Create an instance of the collector.
- 2. Register your handlers, just like you do it for your bot.
- 3. Include registered handlers in your [Bot][botx.bots.bots.Bot] instance using the [`.include_collector`][botx.bots.mixins.collectors.BotCollectingMixin.include_collector] method.
-
-Here is an example.
-
-If we have already divided our handlers into files, it will look something like this for the `a_commands.py` file:
-
-```Python3
-{!./src/development/collector/collector0/a_commands.py!}
-```
-
-And here is the `bot.py` file:
-
-```Python3
-{!./src/development/collector/collector0/bot.py!}
-```
-
-!!! warning
-
- If you try to add 2 handlers for the same command, `pybotx` will raise an exception indicating about merge error.
-
-### Advanced handlers registration
-
-There are different methods for handlers registration available on [Collector][botx.collecting.collectors.collector.Collector] and [Bot][botx.bots.bots.Bot] instances.
-You can register:
-
-* regular handlers using [`.handler`][botx.collecting.collectors.mixins.handler.HandlerMixin.handler] decorator.
-* default handlers, that will be used if matching handler was not found using [`.default`][botx.collecting.collectors.mixins.default.DefaultHandlerMixin.default] decorator.
-* hidden handlers, that won't be showed in bot's menu using [`.hidden`][botx.collecting.collectors.mixins.hidden.HiddenHandlerMixin.hidden] decorator.
-* system event handlers, that will be used for handling special events from BotX API using [`.system_event`][botx.collecting.collectors.mixins.system_events.SystemEventsHandlerMixin.system_event] decorator.
-* and some other type of handlers. See API reference for bot or collector for more information.
diff --git a/docs/development/dependencies-injection.md b/docs/development/dependencies-injection.md
deleted file mode 100644
index a51592b4..00000000
--- a/docs/development/dependencies-injection.md
+++ /dev/null
@@ -1,35 +0,0 @@
-`pybotx` has a dependency injection mechanism heavily inspired by [`FastAPI`](https://fastapi.tiangolo.com/tutorial/dependencies/).
-
-## Usage
-
-First, create a function that will execute some logic. It can be a coroutine or a simple function.
-Then write a handler for bot that will use this dependency:
-
-```python3
-{!./src/development/dependencies_injection/dependencies_injection0.py!}
-```
-
-## Dependencies with dependencies
-
-Each of your dependencies function can contain parameters with other dependencies. And all this will be solved at the runtime:
-
-```python3
-{!./src/development/dependencies_injection/dependencies_injection1.py!}
-```
-
-## Special dependencies: Bot and Message
-
-[Bot][botx.bots.bots.Bot] and `Message` objects and special case of dependencies.
-If you put an annotation for them into your function then this objects will be passed inside.
-It can be useful if you write something like authentication dependency:
-
-```python3
-{!./src/development/dependencies_injection/dependencies_injection2.py!}
-```
-
-[DependencyFailure][botx.exceptions.DependencyFailure] exception is used for preventing execution
-of dependencies after one that failed.
-
-Also, if you define a list of dependencies objects in the initialization of [collector][botx.collecting.collectors.collector.Collector] or [bot][botx.bots.bots.Bot] or in `.handler` decorator or others,
-then these dependencies will be processed as background dependencies.
-They will be executed before the handler and its' dependencies:
\ No newline at end of file
diff --git a/docs/development/first-steps.md b/docs/development/first-steps.md
deleted file mode 100644
index 41f520b6..00000000
--- a/docs/development/first-steps.md
+++ /dev/null
@@ -1,176 +0,0 @@
-Let's create a new bot, which will ask the user for his data, and then send some statistics from the collected information.
-Take echo-bot, from the [Introduction](../index.md), and gradually improve it step by step.
-
-## Starting point
-
-Right now we have the following code:
-
-```Python3
-{!./src/development/first_steps/first_steps0.py!}
-```
-
-## First, let's see how this code works
-We will explain only those parts that relate to `pybotx`, and not to the frameworks used in this documentation.
-
-### Step 1: import `Bot`, `Message`, `Status` and other classes
-
-```Python3 hl_lines="1"
-{!./src/development/first_steps/first_steps0.py!}
-```
-
-* `Bot` is a class that provides all the core functionality to your bots.
-* `Message` provides data to your handlers for commands.
-* `Status` is used here only to document the `FastAPI` route,
-but in fact it stores information about public commands that user of your bot should see in menu.
-* `ExpressServer` is used for storing information
-about servers with which your bot is able to communicate.
-* `IncomingMessage` is a pydantic model that is used
-for base validating of data, that was received on your bot's webhook.
-
-### Step 2: initialize your `Bot`
-
-```Python3 hl_lines="5"
-{!./src/development/first_steps/first_steps0.py!}
-```
-
-The `bot` variable will be an "instance" of the class `Bot`.
-We also register an instance of the cts server to get tokens and the ability to send requests to the API.
-
-### Step 3: define default handler
-
-```Python3 hl_lines="8"
-{!./src/development/first_steps/first_steps0.py!}
-```
-
-This handler will be called for all commands that have not appropriate handlers.
-We also set `include_in_status=False` so that handler won't be visible in menu and it won't
-complain about "wrong" body generated for it automatically.
-
-### Step 4: send text to user
-
-```Python3 hl_lines="10"
-{!./src/development/first_steps/first_steps0.py!}
-```
-
-[`.answer_message`][botx.bots.mixins.sending.SendingMixin.answer_message] will send text to the user by using
-`sync_id`, `bot_id` and `host` data from the `Message` instance.
-This is a simple wrapper for the [`.send`][botx.bots.mixins.sending.SendingMixin.send] method, which is used to
-gain more control over sending messages process, allowing you to specify a different
-host, bot_id, sync_id, group_chat_id or a list of them.
-
-### Step 5: register handler for bot proper shutdown.
-
-```Python3 hl_lines="14"
-{!./src/development/first_steps/first_steps0.py!}
-```
-
-The `.shutdown` method is used to stop pending handler.
-You must call them to be sure that the bot will work properly.
-
-### Step 6: define webhooks for bot
-
-```Python3 hl_lines="17 22"
-{!./src/development/first_steps/first_steps0.py!}
-```
-
-Here we define 2 `FastAPI` routes:
-
- * `GET` on `/status` will tell BotX API which commands are available for your bot.
- * `POST` on `/command` will receive data for incoming messages for your bot and execute handlers for commands.
-
-!!! info
-
- If `.execute_command` did not find a handler for
- the command in the message, it will raise an `NoMatch` error in background,
- which you probably want to [handle](./handling-errors.md). You can register default handler to process all commands that do not have their own handler.
-
-### Step 7 (Improvement): Reply to user if message was received from host, which is not registered
-
-We can send to BotX API a special response, that will say to user that bot can not communicate with
-user properly, since message was received from unknown host. We do it by handling
-[ServerUnknownError][botx.exceptions.ServerUnknownError] and returning to BotX API information
-about error.
-
-```Python3 hl_lines="35"
-{!./src/development/first_steps/first_steps1.py!}
-```
-
-## Define new handlers
-
-Let's define a new handler that will trigger a chain of questions for the user to collect information.
-
-We'll use the `/fill-info` command to start the chain:
-
-```Python3 hl_lines="14"
-{!./src/development/first_steps/first_steps2.py!}
-```
-
-Here we define a new handler for `/fill-info` command using `.handler` decorator.
-This decorator will generate for us body for our command and register it doing it available to handle.
-We also defined a `users_data` dictionary to store information from our users.
-
-Now let's define another 2 handlers for the commands that were mentioned in the message text that we send to the user:
-
- * `/my-info` will just send the information that users have filled out about themselves.
- * `/info` will send back the number of users who filled in information about themselves, their average age and number of male and female users.
- * `/infomation` is an alias to `/info` command.
-
-```Python3 hl_lines="32 50"
-{!./src/development/first_steps/first_steps3.py!}
-```
-
-Take a look at highlighted lines. `.handler` method takes a
-different number of arguments. The most commonly used arguments are `command` and `commands`.
-`command` is a single string that defines a command for a handler.
-`commands` is a list of strings that can be used to define a variety of aliases for a handler.
-You can use them together. In this case, they simply merge into one array as if you specified only `commands` argument.
-
-See also at how the commands themselves are declared:
-
- * for the `fill_info` function we have not defined any `command` but it will be implicitly converted to the `/fill-info` command.
- * for the `get_info_for_user` function we had explicitly specified `/my-info` string.
- * for the `get_processed_information` we specified a `commands` argument to define many aliases for the handler.
-
-## Register next step handlers
-
-`pybotx` provide you the ability to change mechanism of handlers processing by mechanism of
-middlewares. It also provides a middleware for handling chains of messages by [`Next Step Middleware`][botx.middlewares.ns.NextStepMiddleware].
-
-To use it you should define functions that will be used when messages that start chain will be handled.
-All functions should be defined before bot starts to handler messages, since dynamic registration
-can cause different hard to find problems.
-
-Lets' define these handlers and, finally, create a chain of questions from the bot to the user.
-
-First we should import our middleware and functions that will register function fo next
-message from user.
-
-```Python3 hl_lines="2"
-{!./src/development/first_steps/first_steps4.py!}
-```
-
-Next we should define our functions and register it in our middleware.
-
-```Python3 hl_lines="11 17 36 51 52 53"
-{!./src/development/first_steps/first_steps4.py!}
-```
-
-And the last part of this step is use
-[register_next_step_handler][botx.middlewares.ns.register_next_step_handler] function to
-register handler for next message from user.
-
-```Python3 hl_lines="14 24 28 33 48 68"
-{!./src/development/first_steps/first_steps4.py!}
-```
-
-### Recap
-
-What's going on here? We added one line to our `/fill-info` command to start a chain of
-questions for our user. We also defined 3 functions, whose signature is similar to the
-usual handler signature, but instead of registration them using the
-`.handler` decorator, we do this while registering out
-[`Next Step Middleware`][botx.middlewares.ns.NextStepMiddleware] for bot. We change message
-handling flow using the [register_next_step_handler][botx.middlewares.ns.register_next_step_handler] function.
-We pass into function our message as the first argument and the handler that will be
-executed for the next user message as the second. We also can pass key arguments if we need them
-and get them in our handler using message state then, but this not our case now.
diff --git a/docs/development/handling-errors.md b/docs/development/handling-errors.md
deleted file mode 100644
index 464bd21e..00000000
--- a/docs/development/handling-errors.md
+++ /dev/null
@@ -1,10 +0,0 @@
-`pybotx` provides a mechanism for registering a handler for exceptions that may occur in your command handlers.
-By default, these errors are simply logged to the console, but you can register different behavior and perform some actions.
-For example, you can handle database disconnection or another runtime errors. You can also use this mechanism to
-register the handler for an `Excpetion` error and send info about it to the Sentry with additional information.
-
-## Usage Example
-
-```python3
-{!./src/development/handling_errors/handling_errors0.py!}
-```
\ No newline at end of file
diff --git a/docs/development/logging.md b/docs/development/logging.md
deleted file mode 100644
index 450ca097..00000000
--- a/docs/development/logging.md
+++ /dev/null
@@ -1,7 +0,0 @@
-`pybotx` uses `loguru` internally to log things.
-
-To enable it, just import `logger` from `loguru` and call `logger.enable("botx")`:
-
-```Python3
-{!./src/development/logging/logging0.py!}
-```
\ No newline at end of file
diff --git a/docs/development/sending-data.md b/docs/development/sending-data.md
deleted file mode 100644
index 764c6afe..00000000
--- a/docs/development/sending-data.md
+++ /dev/null
@@ -1,115 +0,0 @@
-`Bot` from `pybotx` provide you 3 methods for sending message to the user (with some additional data) and 1 for sending the file:
-
-* `.send` - send a message by passing a `SendingMessage`.
-* `.answer_message` - send a message by passing text and the original `message` that was passed to the command handler.
-* `.send_message` - send message by passing text, `sync_id`, `group_chat_id` or list of them, `bot_id` and `host`.
-At most cases you'll prefer `.send` method over this one.
-* `.send_file` - send file using file-like object.
-
-!!! info
- Note about using different values to send messages
-
-
- * `sync_id` is the `UUID` accosiated with the message in Express.
- You should use it only in command handlers as answer on command or when changing already sent message.
- * `group_chat_id` - is the `UUID` accosiated with one of the chats in Express. In most cases, you should use it to
- send messages, outside of handlers.
-
-
-### Using `.send`
-
-`.send` is used to send a message.
-
-Here is an example of using this method outside from handler:
-
-```Python3
-{!./src/development/sending_data/sending_data0.py!}
-```
-
-or inside command handler:
-
-```Python3
-{!./src/development/sending_data/sending_data1.py!}
-```
-
-### Using `.answer_message`
-
-`.answer_message` is very useful for replying to command.
-
-```Python3
-{!./src/development/sending_data/sending_data2.py!}
-```
-
-### Send file
-
-There are several ways to send a file from bot:
-
-* Attach file to an instance of `SendingMessage`.
-* Pass file to `file` argument into `.answer_message` or `.send_message` methods.
-* Use `.send_file`.
-
-#### Attach file to already built message or during initialization
-
-```Python3
-{!./src/development/sending_data/sending_data3.py!}
-```
-
-#### Pass file as argument
-
-```Python3
-{!./src/development/sending_data/sending_data4.py!}
-```
-
-#### Using `.send_file`
-
-```Python3
-{!./src/development/sending_data/sending_data5.py!}
-```
-
-### Attach interactive buttons to your message
-
-You can attach bubbles or keyboard buttons to your message. This can be done using
-`MessageMarkup` class.
-A `Bubble` is a button that is stuck to your message.
-A `Keyboard` is a panel that will be displayed when
-you click on the messege with the icon.
-
-An attached collection of bubbles or keyboard buttons is a matrix of buttons.
-
-Adding these elements to your message is pretty easy.
-For example, if you want to add 3 buttons to a message (1 in the first line and 2 in the second)
-you can do something like this:
-
-```Python3
-{!./src/development/sending_data/sending_data6.py!}
-```
-
-Or like this:
-
-```Python3
-{!./src/development/sending_data/sending_data7.py!}
-```
-
-
-Also you can attach buttons to `SendingMessage` passing it
-into `__init__` or after:
-
-```Python3
-{!./src/development/sending_data/sending_data8.py!}
-```
-
-### Mention users or another chats in message
-
-You can mention users or another chats in your messages and they will receive notification
-from the chat, even if this chat was muted.
-
-There are 2 types of mentions for users:
-
-* Mention user in chat where message will be sent
-* Mention just user account
-
-Here is an example
-
-```Python3
-{!./src/development/sending_data/sending_data9.py!}
-```
diff --git a/docs/development/tests.md b/docs/development/tests.md
deleted file mode 100644
index 09e8f2e5..00000000
--- a/docs/development/tests.md
+++ /dev/null
@@ -1,32 +0,0 @@
-You can test the behaviour of your bot by writing unit tests. Since the main goal of the bot is to process commands and send
-results to the BotX API, you should be able to intercept the result between sending data to the API. You can do this by using [TestClient][botx.testing.testing_client.client.TestClient].
-Then you write some mocks and test your logic inside tests. In this example we will `pytest` for unit tests.
-
-## Example
-
-### Bot
-
-Suppose we have a bot that returns a message in the format `"Hello, {username}"` with the command `/hello`:
-
-`bot.py`:
-```python3
-{!./src/development/tests/tests0/bot.py!}
-```
-
-### Fixtures
-
-Now let's write some fixtures to use them in our tests:
-
-`conftest.py`:
-```python3
-{!./src/development/tests/tests0/conftest.py!}
-```
-
-### Tests
-
-Now we have fixtures for writing tests. Let's write a test to verify that the message body is in the required format:
-
-`test_format_command.py`
-```python3
-{!./src/development/tests/tests0/test_format_command.py!}
-```
diff --git a/docs/index.md b/docs/index.md
index 56dfe1ec..99408aba 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,99 +1,5 @@
-pybotx
-
- A little python framework for building bots for eXpress messenger.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+# Минимальный пример бота (интеграция с FastAPI)
-
----
-
-# Introduction
-
-`pybotx` is a framework for building bots for eXpress providing a mechanism for simple
-integration with your favourite asynchronous web frameworks.
-
-Main features:
-
- * Simple integration with your web apps.
- * Asynchronous API with synchronous as a fallback option.
- * 100% test coverage.
- * 100% type annotated codebase.
-
-
-!!! warning
- This library is under active development and its API may be unstable.
- Please lock the version you are using at the minor update level. For example, like this in `poetry`.
-
- [tool.poetry.dependencies]
- ...
- botx = "^0.15.0"
- ...
-
----
-
-## Requirements
-
-Python 3.7+
-
-`pybotx` use the following libraries:
-
-* pydantic for the data parts.
-* httpx for making HTTP calls to BotX API.
-* loguru for beautiful and powerful logs.
-* **Optional**. Starlette for tests.
-
-## Installation
-```bash
-$ pip install botx
+``` py
+--8<-- "docs/snippets/minimal_example.py"
```
-
-Or if you are going to write tests:
-
-```bash
-$ pip install botx[tests]
-```
-
-You will also need a web framework to create bots as the current BotX API only works with webhooks.
-This documentation will use FastAPI for the examples bellow.
-```bash
-$ pip install fastapi uvicorn
-```
-
-## Example
-
-Let's create a simple echo bot.
-
-* Create a file `main.py` with following content:
-```Python3
-{!./src/index/index0.py!}
-```
-
-* Deploy a bot on your server using uvicorn and set the url for the webhook in Express.
-```bash
-$ uvicorn main:app --host=0.0.0.0
-```
-
-This bot will send back every your message.
-
-## License
-
-This project is licensed under the terms of the MIT license.
diff --git a/docs/reference/bots.md b/docs/reference/bots.md
deleted file mode 100644
index 4da587f1..00000000
--- a/docs/reference/bots.md
+++ /dev/null
@@ -1,37 +0,0 @@
-::: botx.bots.bots
-
-::: botx.bots.mixins.collectors
-
-::: botx.bots.mixins.collecting.handler
-
-::: botx.bots.mixins.collecting.add_handler
-
-::: botx.bots.mixins.collecting.default
-
-::: botx.bots.mixins.collecting.hidden
-
-::: botx.bots.mixins.collecting.system_events
-
-::: botx.bots.mixins.exceptions
-
-::: botx.bots.mixins.lifespan
-
-::: botx.bots.mixins.middlewares
-
-::: botx.bots.mixins.sending
-
-::: botx.bots.mixins.clients
-
-::: botx.bots.mixins.requests.mixin
-
-::: botx.bots.mixins.requests.bots
-
-::: botx.bots.mixins.requests.chats
-
-::: botx.bots.mixins.requests.command
-
-::: botx.bots.mixins.requests.events
-
-::: botx.bots.mixins.requests.notification
-
-::: botx.bots.mixins.requests.users
diff --git a/docs/reference/clients/async-client.md b/docs/reference/clients/async-client.md
deleted file mode 100644
index 427183f1..00000000
--- a/docs/reference/clients/async-client.md
+++ /dev/null
@@ -1 +0,0 @@
-::: botx.clients.clients.async_client
diff --git a/docs/reference/clients/methods.md b/docs/reference/clients/methods.md
deleted file mode 100644
index 5d4989bc..00000000
--- a/docs/reference/clients/methods.md
+++ /dev/null
@@ -1,38 +0,0 @@
-## Base Method
-
-::: botx.clients.methods.base
-
-## V3
-
-## chats
-
-::: botx.clients.methods.v3.chats.add_admin_role
-
-::: botx.clients.methods.v3.chats.add_user
-
-::: botx.clients.methods.v3.chats.create
-
-::: botx.clients.methods.v3.chats.info
-
-::: botx.clients.methods.v3.chats.remove_user
-
-::: botx.clients.methods.v3.chats.stealth_disable
-
-::: botx.clients.methods.v3.chats.stealth_set
-
-## command
-
-::: botx.clients.methods.v3.command.command_result
-
-
-## notification
-
-::: botx.clients.methods.v3.notification.direct_notification
-
-# users
-
-::: botx.clients.methods.v3.users.by_email
-
-::: botx.clients.methods.v3.users.by_huid
-
-::: botx.clients.methods.v3.users.by_login
diff --git a/docs/reference/clients/sync-client.md b/docs/reference/clients/sync-client.md
deleted file mode 100644
index 2f24bd95..00000000
--- a/docs/reference/clients/sync-client.md
+++ /dev/null
@@ -1 +0,0 @@
-::: botx.clients.clients.sync_client
\ No newline at end of file
diff --git a/docs/reference/collecting.md b/docs/reference/collecting.md
deleted file mode 100644
index 8dcf2fbe..00000000
--- a/docs/reference/collecting.md
+++ /dev/null
@@ -1,11 +0,0 @@
-::: botx.collecting.collectors.base
-
-::: botx.collecting.collectors.collector
-
-::: botx.collecting.collectors.mixins.default
-
-::: botx.collecting.collectors.mixins.handler
-
-::: botx.collecting.collectors.mixins.hidden
-
-::: botx.collecting.collectors.mixins.system_events
diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md
deleted file mode 100644
index 05a55380..00000000
--- a/docs/reference/exceptions.md
+++ /dev/null
@@ -1 +0,0 @@
-::: botx.exceptions
\ No newline at end of file
diff --git a/docs/reference/middlewares/authorization.md b/docs/reference/middlewares/authorization.md
deleted file mode 100644
index 59fd3123..00000000
--- a/docs/reference/middlewares/authorization.md
+++ /dev/null
@@ -1 +0,0 @@
-::: botx.middlewares.authorization
\ No newline at end of file
diff --git a/docs/reference/middlewares/base.md b/docs/reference/middlewares/base.md
deleted file mode 100644
index b453947a..00000000
--- a/docs/reference/middlewares/base.md
+++ /dev/null
@@ -1 +0,0 @@
-::: botx.middlewares.base
\ No newline at end of file
diff --git a/docs/reference/middlewares/ns.md b/docs/reference/middlewares/ns.md
deleted file mode 100644
index d4d6af3e..00000000
--- a/docs/reference/middlewares/ns.md
+++ /dev/null
@@ -1 +0,0 @@
-::: botx.middlewares.ns
\ No newline at end of file
diff --git a/docs/reference/models.md b/docs/reference/models.md
deleted file mode 100644
index 2ca0f8f2..00000000
--- a/docs/reference/models.md
+++ /dev/null
@@ -1,39 +0,0 @@
-::: botx.models.buttons
-
-::: botx.models.chats
-
-::: botx.models.constants
-
-::: botx.models.credentials
-
-::: botx.models.datastructures
-
-::: botx.models.enums
-
-::: botx.models.errors
-
-::: botx.models.events
-
-::: botx.models.files
-
-::: botx.models.entities
-
-::: botx.models.menu
-
-::: botx.models.messages.incoming_message
-
-::: botx.models.messages.message
-
-::: botx.models.messages.sending.credentials
-
-::: botx.models.messages.sending.markup
-
-::: botx.models.messages.sending.message
-
-::: botx.models.messages.sending.options
-
-::: botx.models.messages.sending.payload
-
-::: botx.models.typing
-
-::: botx.models.users
diff --git a/docs/reference/testing/message-builder.md b/docs/reference/testing/message-builder.md
deleted file mode 100644
index c67c7e13..00000000
--- a/docs/reference/testing/message-builder.md
+++ /dev/null
@@ -1 +0,0 @@
-::: botx.testing.building.builder
\ No newline at end of file
diff --git a/docs/reference/testing/test-client.md b/docs/reference/testing/test-client.md
deleted file mode 100644
index c177fad8..00000000
--- a/docs/reference/testing/test-client.md
+++ /dev/null
@@ -1,3 +0,0 @@
-::: botx.testing.testing_client.base
-
-::: botx.testing.testing_client.client
\ No newline at end of file
diff --git a/docs/snippets/client/bots_api/get_token.py b/docs/snippets/client/bots_api/get_token.py
new file mode 100644
index 00000000..0b4d312c
--- /dev/null
+++ b/docs/snippets/client/bots_api/get_token.py
@@ -0,0 +1,17 @@
+import asyncio
+
+from botx import Bot, HandlerCollector, lifespan_wrapper
+
+# Не забудьте заполнить учётные данные бота
+built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[])
+
+
+async def main() -> None:
+ async with lifespan_wrapper(built_bot) as bot:
+ for bot_account in bot.bot_accounts:
+ token = await built_bot.get_token(bot_id=bot_account.id)
+ print(token) # noqa: WPS421
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/docs/snippets/minimal_example.py b/docs/snippets/minimal_example.py
new file mode 100644
index 00000000..fba6a750
--- /dev/null
+++ b/docs/snippets/minimal_example.py
@@ -0,0 +1,72 @@
+from http import HTTPStatus
+from uuid import UUID
+
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatCreatedEvent,
+ HandlerCollector,
+ IncomingMessage,
+ build_command_accepted_response,
+)
+
+collector = HandlerCollector()
+
+
+@collector.chat_created
+async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None:
+ await bot.answer_message("Hello!")
+
+
+@collector.command("/echo", description="Send back the received message body")
+async def echo_handler(message: IncomingMessage, bot: Bot) -> None:
+ await bot.answer_message(message.body)
+
+
+@collector.default_message_handler
+async def default_message_handler(event: IncomingMessage, bot: Bot) -> None:
+ await bot.answer_message("Sorry, command not found.")
+
+
+bot = Bot(
+ collectors=[collector],
+ bot_accounts=[
+ BotAccountWithSecret( # noqa: S106
+ # Replace fake account credentials with yours
+ id=UUID("123e4567-e89b-12d3-a456-426655440000"),
+ host="cts.example.com",
+ secret_key="e29b417773f2feab9dac143ee3da20c5",
+ ),
+ ],
+)
+
+app = FastAPI()
+app.add_event_handler("startup", bot.startup)
+app.add_event_handler("shutdown", bot.shutdown)
+
+
+@app.post("/command")
+async def command_handler(request: Request) -> JSONResponse:
+ bot.async_execute_raw_bot_command(await request.json())
+ return JSONResponse(
+ build_command_accepted_response(),
+ status_code=HTTPStatus.ACCEPTED,
+ )
+
+
+@app.get("/status")
+async def status_handler(request: Request) -> JSONResponse:
+ status = await bot.raw_get_status(dict(request.query_params))
+ return JSONResponse(status)
+
+
+@app.post("/notification/callback")
+async def callback_handler(request: Request) -> JSONResponse:
+ bot.set_raw_botx_method_result(await request.json())
+ return JSONResponse(
+ build_command_accepted_response(),
+ status_code=HTTPStatus.ACCEPTED,
+ )
diff --git a/docs/src/development/collector/collector0/a_commands.py b/docs/src/development/collector/collector0/a_commands.py
deleted file mode 100644
index 2af9f321..00000000
--- a/docs/src/development/collector/collector0/a_commands.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from botx import Collector, Message
-
-collector = Collector()
-
-
-@collector.handler
-async def my_handler_for_a_service(message: Message) -> None:
- # do something here
- print(f"Message from {message.group_chat_id} chat")
diff --git a/docs/src/development/collector/collector0/bot.py b/docs/src/development/collector/collector0/bot.py
deleted file mode 100644
index 7ba050f1..00000000
--- a/docs/src/development/collector/collector0/bot.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from botx import Bot
-
-from .a_commands import collector
-
-bot = Bot()
-bot.include_collector(collector)
-
-
-@bot.default(include_in_status=False)
-async def default_handler() -> None:
- print("default handler")
diff --git a/docs/src/development/dependencies_injection/dependencies_injection0.py b/docs/src/development/dependencies_injection/dependencies_injection0.py
deleted file mode 100644
index 42509691..00000000
--- a/docs/src/development/dependencies_injection/dependencies_injection0.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from uuid import UUID
-
-from botx import Bot, Depends, Message
-
-bot = Bot()
-
-
-def get_user_huid(message: Message) -> UUID:
- return message.user_huid
-
-
-@bot.handler
-async def my_handler(user_huid: UUID = Depends(get_user_huid)) -> None:
- print(f"Message from {user_huid}")
diff --git a/docs/src/development/dependencies_injection/dependencies_injection1.py b/docs/src/development/dependencies_injection/dependencies_injection1.py
deleted file mode 100644
index 382a7df3..00000000
--- a/docs/src/development/dependencies_injection/dependencies_injection1.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import asyncio
-from dataclasses import dataclass
-from uuid import UUID
-
-from botx import Bot, Depends, Message
-
-
-@dataclass
-class User:
- user_huid: UUID
- username: str
-
-
-bot = Bot()
-
-
-def get_user_huid_from_message(message: Message) -> UUID:
- return message.user_huid
-
-
-async def fetch_user_by_huid(
- user_huid: UUID = Depends(get_user_huid_from_message),
-) -> User:
- # some operations with db for example
- await asyncio.sleep(0.5)
- return User(user_huid=user_huid, username="Requested User")
-
-
-@bot.handler
-def my_handler(user: User = Depends(fetch_user_by_huid)) -> None:
- print(f"Message from {user.username}")
diff --git a/docs/src/development/dependencies_injection/dependencies_injection2.py b/docs/src/development/dependencies_injection/dependencies_injection2.py
deleted file mode 100644
index a6823be1..00000000
--- a/docs/src/development/dependencies_injection/dependencies_injection2.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import asyncio
-from dataclasses import dataclass
-from uuid import UUID
-
-from botx import Bot, Collector, DependencyFailure, Depends, Message
-
-
-@dataclass
-class User:
- user_huid: UUID
- username: str
- is_authenticated: bool
-
-
-collector = Collector()
-
-
-def get_user_huid_from_message(message: Message) -> UUID:
- return message.user_huid
-
-
-async def fetch_user_by_huid(
- user_huid: UUID = Depends(get_user_huid_from_message),
-) -> User:
- # some operations with db for example
- await asyncio.sleep(0.5)
- return User(user_huid=user_huid, username="Requested User", is_authenticated=False)
-
-
-async def authenticate_user(
- bot: Bot, message: Message, user: User = Depends(fetch_user_by_huid)
-) -> None:
- if not user.is_authenticated:
- await bot.answer_message("You should login first", message)
- raise DependencyFailure
-
-
-@collector.handler(dependencies=[Depends(authenticate_user)])
-def my_handler(user: User = Depends(fetch_user_by_huid)) -> None:
- print(f"Message from {user.username}")
diff --git a/docs/src/development/first_steps/first_steps0.py b/docs/src/development/first_steps/first_steps0.py
deleted file mode 100644
index 57505d76..00000000
--- a/docs/src/development/first_steps/first_steps0.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from uuid import UUID
-
-from fastapi import FastAPI
-from starlette.status import HTTP_202_ACCEPTED
-
-from botx import Bot, BotXCredentials, IncomingMessage, Message, Status
-
-bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))])
-
-
-@bot.default(include_in_status=False)
-async def echo_handler(message: Message) -> None:
- await bot.answer_message(message.body, message)
-
-
-app = FastAPI()
-app.add_event_handler("shutdown", bot.shutdown)
-
-
-@app.get("/status", response_model=Status)
-async def bot_status() -> Status:
- return await bot.status()
-
-
-@app.post("/command", status_code=HTTP_202_ACCEPTED)
-async def bot_command(message: IncomingMessage) -> None:
- await bot.execute_command(message.dict())
diff --git a/docs/src/development/first_steps/first_steps1.py b/docs/src/development/first_steps/first_steps1.py
deleted file mode 100644
index b76b1e3e..00000000
--- a/docs/src/development/first_steps/first_steps1.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from uuid import UUID
-
-from fastapi import FastAPI
-from starlette.requests import Request
-from starlette.responses import JSONResponse, Response
-from starlette.status import HTTP_202_ACCEPTED, HTTP_503_SERVICE_UNAVAILABLE
-
-from botx import (
- Bot,
- BotDisabledErrorData,
- BotDisabledResponse,
- BotXCredentials,
- IncomingMessage,
- Message,
- ServerUnknownError,
- Status,
-)
-
-bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))])
-
-
-@bot.default(include_in_status=False)
-async def echo_handler(message: Message) -> None:
- await bot.answer_message(message.body, message)
-
-
-app = FastAPI()
-app.add_event_handler("shutdown", bot.shutdown)
-
-
-@app.get("/status", response_model=Status)
-async def bot_status() -> Status:
- return await bot.status()
-
-
-@app.post("/command", status_code=HTTP_202_ACCEPTED)
-async def bot_command(message: IncomingMessage) -> None:
- await bot.execute_command(message.dict())
-
-
-@app.exception_handler(ServerUnknownError)
-async def message_from_unknown_server_hanlder(
- _request: Request, exc: ServerUnknownError
-) -> Response:
- return JSONResponse(
- status_code=HTTP_503_SERVICE_UNAVAILABLE,
- content=BotDisabledResponse(
- error_data=BotDisabledErrorData(
- status_message=(
- f"Sorry, bot can not communicate with user from {exc.host} CTS"
- ),
- ),
- ).dict(),
- )
diff --git a/docs/src/development/first_steps/first_steps2.py b/docs/src/development/first_steps/first_steps2.py
deleted file mode 100644
index 09afa4ca..00000000
--- a/docs/src/development/first_steps/first_steps2.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from uuid import UUID
-
-from fastapi import FastAPI
-from starlette.status import HTTP_202_ACCEPTED
-
-from botx import Bot, BotXCredentials, IncomingMessage, Message, Status
-
-users_data = {}
-bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))])
-
-
-@bot.default(include_in_status=False)
-async def echo_handler(message: Message) -> None:
- await bot.answer_message(message.body, message)
-
-
-@bot.handler
-async def fill_info(message: Message) -> None:
- if message.user_huid not in users_data:
- text = (
- "Hi! I'm a bot that will ask some questions about you.\n"
- "First of all: what is your name?"
- )
- else:
- text = (
- "You've already filled out information about yourself.\n"
- "You can view it by typing `/my-info` command.\n"
- "You can also view the processed information by typing `/info` command."
- )
-
- await bot.answer_message(text, message)
-
-
-app = FastAPI()
-app.add_event_handler("shutdown", bot.shutdown)
-
-
-@app.get("/status", response_model=Status)
-async def bot_status() -> Status:
- return await bot.status()
-
-
-@app.post("/command", status_code=HTTP_202_ACCEPTED)
-async def bot_command(message: IncomingMessage) -> None:
- await bot.execute_command(message.dict())
diff --git a/docs/src/development/first_steps/first_steps3.py b/docs/src/development/first_steps/first_steps3.py
deleted file mode 100644
index 2bdd0fff..00000000
--- a/docs/src/development/first_steps/first_steps3.py
+++ /dev/null
@@ -1,78 +0,0 @@
-from uuid import UUID
-
-from fastapi import FastAPI
-from starlette.status import HTTP_202_ACCEPTED
-
-from botx import Bot, BotXCredentials, IncomingMessage, Message, Status
-
-users_data = {}
-bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))])
-
-
-@bot.default(include_in_status=False)
-async def echo_handler(message: Message) -> None:
- await bot.answer_message(message.body, message)
-
-
-@bot.handler
-async def fill_info(message: Message) -> None:
- if message.user_huid not in users_data:
- text = (
- "Hi! I'm a bot that will ask some questions about you.\n"
- "First of all: what is your name?"
- )
- else:
- text = (
- "You've already filled out information about yourself.\n"
- "You can view it by typing `/my-info` command.\n"
- "You can also view the processed information by typing `/info` command."
- )
-
- await bot.answer_message(text, message)
-
-
-@bot.handler(command="/my-info")
-async def get_info_for_user(message: Message) -> None:
- if message.user_huid not in users_data:
- text = (
- "I have no information about you :(\n"
- "Type `/fill-info` so I can collect it, please."
- )
- await bot.answer_message(text, message)
- else:
- text = (
- f"Your name: {users_data[message.user_huid]['name']}\n"
- f"Your age: {users_data[message.user_huid]['age']}\n"
- f"Your gender: {users_data[message.user_huid]['gender']}\n"
- "This is all that I have now."
- )
- await bot.answer_message(text, message)
-
-
-@bot.handler(commands=["/info", "/information"])
-async def get_processed_information(message: Message) -> None:
- users_count = len(users_data)
- average_age = sum(user["age"] for user in users_data) / users_count
- gender_array = [1 if user["gender"] == "male" else 2 for user in users_data]
- text = (
- f"Count of users: {users_count}\n"
- f"Average age: {average_age}\n"
- f"Male users count: {gender_array.count(1)}\n"
- f"Female users count: {gender_array.count(2)}"
- )
-
- await bot.answer_message(text, message)
-
-
-app = FastAPI()
-app.add_event_handler("shutdown", bot.shutdown)
-
-
-@app.get("/status", response_model=Status)
-async def bot_status() -> Status:
- return await bot.status()
-
-
-@app.post("/command", status_code=HTTP_202_ACCEPTED)
-async def bot_command(message: IncomingMessage) -> None:
- await bot.execute_command(message.dict())
diff --git a/docs/src/development/first_steps/first_steps4.py b/docs/src/development/first_steps/first_steps4.py
deleted file mode 100644
index 69bb994c..00000000
--- a/docs/src/development/first_steps/first_steps4.py
+++ /dev/null
@@ -1,126 +0,0 @@
-from uuid import UUID
-
-from fastapi import FastAPI
-from starlette.status import HTTP_202_ACCEPTED
-
-from botx import Bot, BotXCredentials, IncomingMessage, Message, Status
-from botx.middlewares.ns import NextStepMiddleware, register_next_step_handler
-
-bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))])
-
-users_data = {}
-
-
-async def get_name(message: Message) -> None:
- users_data[message.user_huid]["name"] = message.body
- await bot.answer_message("Good! Move next: how old are you?", message)
- register_next_step_handler(message, get_age)
-
-
-async def get_age(message: Message) -> None:
- try:
- age = int(message.body)
- if age <= 2:
- await bot.answer_message(
- "Sorry, but it's not true. Say your real age, please!", message,
- )
- register_next_step_handler(message, get_age)
- else:
- users_data[message.user_huid]["age"] = age
- await bot.answer_message("Got it! Final question: your gender?", message)
- register_next_step_handler(message, get_gender)
- except ValueError:
- await bot.answer_message(
- "No, no, no. Pleas tell me your age in numbers!", message,
- )
- register_next_step_handler(message, get_age)
-
-
-async def get_gender(message: Message) -> None:
- gender = message.body
- if gender in ["male", "female"]:
- users_data[message.user_huid]["gender"] = gender
- await bot.answer_message(
- "Ok! Thanks for taking the time to answer my questions.", message,
- )
- else:
- await bot.answer_message(
- "Sorry, but I can not recognize your answer! Type 'male' or 'female', please!",
- message,
- )
- register_next_step_handler(message, get_gender)
-
-
-bot.add_middleware(
- NextStepMiddleware, bot=bot, functions={get_age, get_name, get_gender},
-)
-
-
-@bot.default(include_in_status=False)
-async def echo_handler(message: Message) -> None:
- await bot.answer_message(message.body, message)
-
-
-@bot.handler
-async def fill_info(message: Message) -> None:
- if message.user_huid not in users_data:
- text = (
- "Hi! I'm a bot that will ask some questions about you.\n"
- "First of all: what is your name?"
- )
- register_next_step_handler(message, get_name)
- else:
- text = (
- "You've already filled out information about yourself.\n"
- "You can view it by typing `/my-info` command.\n"
- "You can also view the processed information by typing `/info` command."
- )
-
- await bot.answer_message(text, message)
-
-
-@bot.handler(command="/my-info")
-async def get_info_for_user(message: Message) -> None:
- if message.user_huid not in users_data:
- text = (
- "I have no information about you :(\n"
- "Type `/fill-info` so I can collect it, please."
- )
- await bot.answer_message(text, message)
- else:
- text = (
- f"Your name: {users_data[message.user_huid]['name']}\n"
- f"Your age: {users_data[message.user_huid]['age']}\n"
- f"Your gender: {users_data[message.user_huid]['gender']}\n"
- "This is all that I have now."
- )
- await bot.answer_message(text, message)
-
-
-@bot.handler(commands=["/info", "/information"])
-async def get_processed_information(message: Message) -> None:
- users_count = len(users_data)
- average_age = sum(user["age"] for user in users_data) / users_count
- gender_array = [1 if user["gender"] == "male" else 2 for user in users_data]
- text = (
- f"Count of users: {users_count}\n"
- f"Average age: {average_age}\n"
- f"Male users count: {gender_array.count(1)}\n"
- f"Female users count: {gender_array.count(2)}"
- )
-
- await bot.answer_message(text, message)
-
-
-app = FastAPI()
-app.add_event_handler("shutdown", bot.shutdown)
-
-
-@app.get("/status", response_model=Status)
-async def bot_status() -> Status:
- return await bot.status()
-
-
-@app.post("/command", status_code=HTTP_202_ACCEPTED)
-async def bot_command(message: IncomingMessage) -> None:
- await bot.execute_command(message.dict())
diff --git a/docs/src/development/handling_errors/handling_errors0.py b/docs/src/development/handling_errors/handling_errors0.py
deleted file mode 100644
index 41cbd170..00000000
--- a/docs/src/development/handling_errors/handling_errors0.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from botx import Bot, Message
-
-bot = Bot()
-
-
-@bot.exception_handler(RuntimeError)
-async def error_handler(exc: Exception, msg: Message) -> None:
- await msg.bot.answer_message(f"Error occurred during handling command: {exc}", msg)
-
-
-@bot.handler
-async def handler_with_bug(message: Message) -> None:
- raise RuntimeError(message.body)
diff --git a/docs/src/development/logging/logging0.py b/docs/src/development/logging/logging0.py
deleted file mode 100644
index ac71a13e..00000000
--- a/docs/src/development/logging/logging0.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from loguru import logger
-
-from botx import Bot
-
-bot = Bot()
-logger.enable("botx")
diff --git a/docs/src/development/sending_data/sending_data0.py b/docs/src/development/sending_data/sending_data0.py
deleted file mode 100644
index b9b60d6a..00000000
--- a/docs/src/development/sending_data/sending_data0.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from uuid import UUID
-
-from botx import Bot, SendingMessage
-
-bot = Bot()
-CHAT_ID = UUID("1f972f5e-6d17-4f39-be5b-f7e20f1b4d13")
-BOT_ID = UUID("cc257e1c-c028-4181-a055-01e14ba881b0")
-CTS_HOST = "my-cts.example.com"
-
-
-async def some_function() -> None:
- message = SendingMessage(
- text="You were chosen by random.",
- bot_id=BOT_ID,
- host=CTS_HOST,
- chat_id=CHAT_ID,
- )
- await bot.send(message)
diff --git a/docs/src/development/sending_data/sending_data1.py b/docs/src/development/sending_data/sending_data1.py
deleted file mode 100644
index dfe95fb3..00000000
--- a/docs/src/development/sending_data/sending_data1.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from botx import Bot, Message, SendingMessage
-
-bot = Bot()
-
-
-@bot.handler(command="/my-handler")
-async def some_handler(message: Message) -> None:
- message = SendingMessage.from_message(
- text="You were chosen by random.", message=message,
- )
- await bot.send(message)
diff --git a/docs/src/development/sending_data/sending_data2.py b/docs/src/development/sending_data/sending_data2.py
deleted file mode 100644
index 0532089f..00000000
--- a/docs/src/development/sending_data/sending_data2.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from botx import Bot, Message
-
-bot = Bot()
-
-
-@bot.handler(command="/my-handler")
-async def some_handler(message: Message) -> None:
- await bot.answer_message(text="VERY IMPORTANT NOTIFICATION!!!", message=message)
diff --git a/docs/src/development/sending_data/sending_data3.py b/docs/src/development/sending_data/sending_data3.py
deleted file mode 100644
index 5d0c4ac8..00000000
--- a/docs/src/development/sending_data/sending_data3.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from botx import Bot, File, Message, SendingMessage
-
-bot = Bot()
-
-
-@bot.handler
-async def my_handler(message: Message) -> None:
- with open("my_file.txt") as f:
- notification = SendingMessage(
- file=File.from_file(f), credentials=message.credentials,
- )
-
- await bot.send(notification)
-
-
-@bot.handler
-async def another_handler(message: Message) -> None:
- notification = SendingMessage.from_message(message=message)
-
- with open("my_file.txt") as f:
- notification.add_file(f)
-
- await bot.send(notification)
diff --git a/docs/src/development/sending_data/sending_data4.py b/docs/src/development/sending_data/sending_data4.py
deleted file mode 100644
index 576425ec..00000000
--- a/docs/src/development/sending_data/sending_data4.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from botx import Bot, Message
-
-bot = Bot()
-
-
-@bot.handler
-async def my_handler(message: Message) -> None:
- with open("my_file.txt") as f:
- await bot.answer_message("Text that will be sent with file", message, file=f)
diff --git a/docs/src/development/sending_data/sending_data5.py b/docs/src/development/sending_data/sending_data5.py
deleted file mode 100644
index 8c3dc128..00000000
--- a/docs/src/development/sending_data/sending_data5.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from botx import Bot, Message
-
-bot = Bot()
-
-
-@bot.handler
-async def my_handler(message: Message) -> None:
- with open("my_file.txt") as f:
- await bot.send_file(f, message.credentials)
diff --git a/docs/src/development/sending_data/sending_data6.py b/docs/src/development/sending_data/sending_data6.py
deleted file mode 100644
index 3c0c08b2..00000000
--- a/docs/src/development/sending_data/sending_data6.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from botx import Bot, BubbleElement, Message, MessageMarkup
-
-bot = Bot()
-
-
-@bot.handler
-async def my_handler_with_direct_bubbles_definition(message: Message) -> None:
- await bot.answer_message(
- "Bubbles!!",
- message,
- markup=MessageMarkup(
- bubbles=[
- [BubbleElement(label="bubble 1", command="")],
- [
- BubbleElement(label="bubble 2", command=""),
- BubbleElement(label="bubble 3", command=""),
- ],
- ],
- ),
- )
diff --git a/docs/src/development/sending_data/sending_data7.py b/docs/src/development/sending_data/sending_data7.py
deleted file mode 100644
index 55be109d..00000000
--- a/docs/src/development/sending_data/sending_data7.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from botx import Bot, Message, MessageMarkup
-
-bot = Bot()
-
-
-@bot.handler
-async def my_handler_with_passing_predefined_markup(message: Message) -> None:
- markup = MessageMarkup()
- markup.add_bubble(command="", label="bubble 1")
- markup.add_bubble(command="", label="bubble 2", new_row=False)
- markup.add_bubble(command="", label="bubble 3")
-
- await bot.answer_message("Bubbles!!", message, markup=markup)
diff --git a/docs/src/development/sending_data/sending_data8.py b/docs/src/development/sending_data/sending_data8.py
deleted file mode 100644
index 540bda91..00000000
--- a/docs/src/development/sending_data/sending_data8.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from botx import Bot, Message, SendingMessage
-
-bot = Bot()
-
-
-@bot.handler
-async def my_handler_with_markup_in_sending_message(message: Message) -> None:
- reply = SendingMessage.from_message(text="More buttons!!!", message=message)
- reply.add_bubble(command="", label="bubble 1")
- reply.add_keyboard_button(command="", label="keyboard button 1", new_row=False)
- reply.add_keyboard_button(command="", label="keyboard button 2")
-
- await bot.send(reply)
diff --git a/docs/src/development/sending_data/sending_data9.py b/docs/src/development/sending_data/sending_data9.py
deleted file mode 100644
index 0d1a2e08..00000000
--- a/docs/src/development/sending_data/sending_data9.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from uuid import UUID
-
-from botx import Bot, Message, SendingMessage
-
-bot = Bot()
-CHAT_FOR_MENTION = UUID("369b49fd-b5eb-4d5b-8e4d-83b020ff2b14")
-USER_FOR_MENTION = UUID("cbf4b952-77d5-4484-aea0-f05fb622e089")
-
-
-@bot.handler
-async def my_handler_with_user_mention(message: Message) -> None:
- reply = SendingMessage.from_message(
- text="Hi! There is a notification with mention for you", message=message,
- )
- reply.mention_user(message.user_huid)
-
- await bot.send(reply)
-
-
-@bot.handler
-async def my_handler_with_chat_mention(message: Message) -> None:
- reply = SendingMessage.from_message(text="Check this chat", message=message)
- reply.mention_chat(CHAT_FOR_MENTION, name="Interesting chat")
- await bot.send(reply)
-
-
-@bot.handler
-async def my_handler_with_contact_mention(message: Message) -> None:
- reply = SendingMessage.from_message(
- text="You should request access!", message=message,
- )
- reply.mention_chat(USER_FOR_MENTION, name="Administrator")
- await bot.send(reply)
diff --git a/docs/src/development/tests/tests0/bot.py b/docs/src/development/tests/tests0/bot.py
deleted file mode 100644
index 03aa1ae1..00000000
--- a/docs/src/development/tests/tests0/bot.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from botx import Bot, Message
-
-bot = Bot()
-
-
-@bot.handler
-async def hello(message: Message) -> None:
- await bot.answer_message(f"Hello, {message.user.username}", message)
diff --git a/docs/src/development/tests/tests0/conftest.py b/docs/src/development/tests/tests0/conftest.py
deleted file mode 100644
index 6bdd3f52..00000000
--- a/docs/src/development/tests/tests0/conftest.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from uuid import UUID
-
-import pytest
-
-from botx import Bot, BotXCredentials, MessageBuilder, TestClient
-
-from .bot import bot
-
-
-@pytest.fixture
-def builder() -> MessageBuilder:
- builder = MessageBuilder()
- builder.user.host = "example.com"
- return builder
-
-
-@pytest.fixture
-def bot(builder: MessageBuilder) -> Bot:
- bot.bot_accounts.append(BotXCredentials(host=builder.user.host, secret_key="secret", bot_id=UUID("bot_id")))
- return bot
-
-
-@pytest.fixture
-def client(bot: Bot) -> TestClient:
- with TestClient(bot) as client:
- yield client
diff --git a/docs/src/development/tests/tests0/test_format_command.py b/docs/src/development/tests/tests0/test_format_command.py
deleted file mode 100644
index cec31a30..00000000
--- a/docs/src/development/tests/tests0/test_format_command.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import pytest
-
-from botx import Bot, MessageBuilder, TestClient
-
-
-@pytest.mark.asyncio
-async def test_hello_format(
- bot: Bot, builder: MessageBuilder, client: TestClient
-) -> None:
- builder.body = "/hello"
-
- await client.send_command(builder.message)
-
- command_result = client.command_results[0]
- assert command_result.result.body == f"Hello, {builder.user.username}"
diff --git a/docs/src/index/index0.py b/docs/src/index/index0.py
deleted file mode 100644
index 57505d76..00000000
--- a/docs/src/index/index0.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from uuid import UUID
-
-from fastapi import FastAPI
-from starlette.status import HTTP_202_ACCEPTED
-
-from botx import Bot, BotXCredentials, IncomingMessage, Message, Status
-
-bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))])
-
-
-@bot.default(include_in_status=False)
-async def echo_handler(message: Message) -> None:
- await bot.answer_message(message.body, message)
-
-
-app = FastAPI()
-app.add_event_handler("shutdown", bot.shutdown)
-
-
-@app.get("/status", response_model=Status)
-async def bot_status() -> Status:
- return await bot.status()
-
-
-@app.post("/command", status_code=HTTP_202_ACCEPTED)
-async def bot_command(message: IncomingMessage) -> None:
- await bot.execute_command(message.dict())
diff --git a/examples/README.md b/examples/README.md
deleted file mode 100644
index 6056ec49..00000000
--- a/examples/README.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Examples
-
-In this folder are small examples to show different ways of usage `pybotx` library for
-writing bots.
-
-### [Finite-state machine middleware](./fsm)
-
-An example of bot and middleware that defines finite-state machine behaviour.
\ No newline at end of file
diff --git a/examples/fsm/.env.example b/examples/fsm/.env.example
deleted file mode 100644
index 704e6987..00000000
--- a/examples/fsm/.env.example
+++ /dev/null
@@ -1,2 +0,0 @@
-CTS_HOST=cts.example.com
-BOT_SECRET=secret key
\ No newline at end of file
diff --git a/examples/fsm/README.md b/examples/fsm/README.md
deleted file mode 100644
index 6e368143..00000000
--- a/examples/fsm/README.md
+++ /dev/null
@@ -1,15 +0,0 @@
-## General
-
-A bot that defines middleware that process request in finite-state machine way.
-
-State is an enum (`enum.Enum`) with several values that are changed using
-`bot.middleware.change_state` function.
-
-This example shows definition of custom middleware and handlers processing logic.
-
-## Run
-
-start `uvicorn` ASGI server with bot on 8000 port with following command:
-```bash
-$ uvicorn bot.web:app
-```
\ No newline at end of file
diff --git a/examples/fsm/bot/bot.py b/examples/fsm/bot/bot.py
deleted file mode 100644
index 0777a586..00000000
--- a/examples/fsm/bot/bot.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from typing import Any
-from uuid import UUID
-
-from bot.config import BOT_SECRET, CTS_HOST
-from bot.handlers import FSMStates, fsm
-from bot.middleware import FlowError, FSMMiddleware, change_state
-from botx import Bot, BotXCredentials, Message
-
-bot = Bot(bot_accounts=[BotXCredentials(host=CTS_HOST, secret_key=str(BOT_SECRET), bot_id=UUID("bot_id"))])
-bot.add_middleware(FSMMiddleware, bot=bot, fsm=fsm)
-
-
-@bot.default(include_in_status=False)
-async def default_handler(message: Message) -> None:
- if message.body == "start":
- change_state(message, FSMStates.get_first_name)
- await message.bot.answer_message("enter first name", message)
- return
-
- await message.bot.answer_message("default handler", message)
-
-
-@bot.exception_handler(FlowError)
-async def flow_error_handler(*_: Any) -> None:
- pass
diff --git a/examples/fsm/bot/config.py b/examples/fsm/bot/config.py
deleted file mode 100644
index dfd190e5..00000000
--- a/examples/fsm/bot/config.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from loguru import logger
-from starlette.config import Config
-from starlette.datastructures import Secret
-
-config = Config(".env")
-
-CTS_HOST: str = config("CTS_HOST")
-BOT_SECRET: Secret = config("BOT_SECRET", cast=Secret)
-
-logger.enable("botx")
diff --git a/examples/fsm/bot/handlers.py b/examples/fsm/bot/handlers.py
deleted file mode 100644
index ab3217b5..00000000
--- a/examples/fsm/bot/handlers.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import pprint
-from collections import defaultdict
-from enum import Enum, auto
-from typing import DefaultDict, Dict
-from uuid import UUID
-
-from bot.middleware import FSM, FlowError
-from botx import Message
-
-
-class FSMStates(Enum):
- get_first_name = auto()
- get_middle_name = auto()
- get_last_name = auto()
- get_age = auto()
- get_gender = auto()
- end = auto()
-
-
-fsm = FSM(FSMStates)
-user_info: DefaultDict[UUID, Dict[str, str]] = defaultdict(dict)
-
-
-@fsm.handler(on_state=FSMStates.get_first_name, next_state=FSMStates.get_middle_name)
-async def get_first_name(message: Message) -> None:
- user_info[message.user_huid]["first_name"] = message.body
- await message.bot.answer_message("enter middle name", message)
-
-
-@fsm.handler(on_state=FSMStates.get_middle_name, next_state=FSMStates.get_last_name)
-async def get_middle_name(message: Message) -> None:
- user_info[message.user_huid]["middle_name"] = message.body
- await message.bot.answer_message("enter last name", message)
-
-
-@fsm.handler(
- on_state=FSMStates.get_last_name,
- next_state=FSMStates.get_age,
- on_failure=FSMStates.get_first_name,
-)
-async def get_last_name(message: Message) -> None:
- if message.body == "fail":
- await message.bot.answer_message("failed to read last name", message)
- raise FlowError
-
- await message.bot.answer_message("enter age", message)
- user_info[message.user_huid]["last_name"] = message.body
-
-
-@fsm.handler(on_state=FSMStates.get_age, next_state=FSMStates.get_gender)
-async def get_age(message: Message) -> None:
- await message.bot.answer_message("enter gender", message)
- user_info[message.user_huid]["age"] = message.body
-
-
-@fsm.handler(on_state=FSMStates.get_gender, next_state=None)
-async def get_gender(message: Message) -> None:
- user_info[message.user_huid]["gender"] = message.body
- await message.bot.answer_message("thanks for sharing info:", message)
- await message.bot.answer_message(pprint.pformat(user_info), message)
diff --git a/examples/fsm/bot/middleware.py b/examples/fsm/bot/middleware.py
deleted file mode 100644
index b485f685..00000000
--- a/examples/fsm/bot/middleware.py
+++ /dev/null
@@ -1,88 +0,0 @@
-from dataclasses import dataclass
-from enum import Enum
-from typing import Callable, Dict, Final, Optional, Type, Union
-
-from botx import Bot, Collector, Message
-from botx.concurrency import callable_to_coroutine
-from botx.middlewares.base import BaseMiddleware
-from botx.typing import Executor
-
-_default_transition: Final = object()
-
-
-@dataclass
-class Transition:
- on_failure: Optional[Union[Enum, object]] = _default_transition
- on_success: Optional[Union[Enum, object]] = _default_transition
-
-
-class FlowError(Exception):
- pass
-
-
-class FSM:
- def __init__(self, states: Type[Enum]) -> None:
- self.transitions: Dict[Enum, Transition] = {}
- self.collector = Collector()
- self.states = states
-
- def handler(
- self,
- on_state: Enum,
- next_state: Optional[Union[Enum, object]] = _default_transition,
- on_failure: Optional[Union[Enum, object]] = _default_transition,
- ) -> Callable:
- def decorator(handler: Callable) -> Callable:
- self.collector.add_handler(
- handler,
- body=on_state.name,
- name=on_state.name,
- include_in_status=False,
- )
- self.transitions[on_state] = Transition(
- on_success=next_state, on_failure=on_failure,
- )
-
- return handler
-
- return decorator
-
-
-def change_state(message: Message, new_state: Optional[Enum]) -> None:
- message.bot.state.fsm_state[(message.user_huid, message.group_chat_id)] = new_state
-
-
-class FSMMiddleware(BaseMiddleware):
- def __init__(
- self,
- executor: Executor,
- bot: Bot,
- fsm: FSM,
- initial_state: Optional[Enum] = None,
- ) -> None:
- super().__init__(executor)
- bot.state.fsm_state = {}
- self.fsm = fsm
- self.initial_state = initial_state
- for state in self.fsm.states:
- # check that for each state there is registered handler
- assert state in self.fsm.transitions
-
- async def dispatch(self, message: Message, call_next: Executor) -> None:
- current_state: Enum = message.bot.state.fsm_state.setdefault(
- (message.user_huid, message.group_chat_id), self.initial_state,
- )
- if current_state is not None:
- transition = self.fsm.transitions[current_state]
- handler = self.fsm.collector.handler_for(current_state.name)
- try:
- await handler(message)
- except Exception as exc:
- if transition.on_failure is not _default_transition:
- change_state(message, transition.on_failure)
- raise exc
- else:
- if transition.on_success is not _default_transition:
- change_state(message, transition.on_success)
- else:
- await callable_to_coroutine(call_next, message)
diff --git a/examples/fsm/bot/web.py b/examples/fsm/bot/web.py
deleted file mode 100644
index b1117420..00000000
--- a/examples/fsm/bot/web.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from fastapi import FastAPI
-
-from bot.bot import bot
-from botx import Status
-
-app = FastAPI()
-
-
-@app.get("/status", response_model=Status)
-async def bot_status() -> Status:
- return await bot.status()
-
-
-@app.post("/command")
-async def bot_command(message: dict) -> None:
- await bot.execute_command(message)
diff --git a/examples/fsm/poetry.lock b/examples/fsm/poetry.lock
deleted file mode 100644
index 7c3c5273..00000000
--- a/examples/fsm/poetry.lock
+++ /dev/null
@@ -1,599 +0,0 @@
-[[package]]
-category = "dev"
-description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-name = "appdirs"
-optional = false
-python-versions = "*"
-version = "1.4.3"
-
-[[package]]
-category = "dev"
-description = "Classes Without Boilerplate"
-name = "attrs"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "19.3.0"
-
-[package.extras]
-azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
-dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
-docs = ["sphinx", "zope.interface"]
-tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
-
-[[package]]
-category = "dev"
-description = "Removes unused imports and unused variables"
-name = "autoflake"
-optional = false
-python-versions = "*"
-version = "1.3.1"
-
-[package.dependencies]
-pyflakes = ">=1.1.0"
-
-[[package]]
-category = "dev"
-description = "The uncompromising code formatter."
-name = "black"
-optional = false
-python-versions = ">=3.6"
-version = "19.10b0"
-
-[package.dependencies]
-appdirs = "*"
-attrs = ">=18.1.0"
-click = ">=6.5"
-pathspec = ">=0.6,<1"
-regex = "*"
-toml = ">=0.9.4"
-typed-ast = ">=1.4.0"
-
-[package.extras]
-d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
-
-[[package]]
-category = "main"
-description = "A little python framework for building bots for eXpress"
-name = "botx"
-optional = false
-python-versions = ">=3.6,<4.0"
-version = "0.13.3"
-
-[package.dependencies]
-httpx = ">=0.11.0,<0.12.0"
-loguru = ">=0.4.0,<0.5.0"
-pydantic = ">=1.0,<2.0"
-
-[package.extras]
-docs = ["mkdocs (>=1.0,<2.0)", "mkdocs-material (>=4.4,<5.0)", "mkdocstrings (>=0.7,<0.8)", "markdown-include (>=0.5.1,<0.6.0)", "fastapi (>=0.47.0,<0.48.0)"]
-tests = ["starlette (>=0.12.9,<0.13.0)"]
-
-[[package]]
-category = "main"
-description = "Python package for providing Mozilla's CA Bundle."
-name = "certifi"
-optional = false
-python-versions = "*"
-version = "2019.11.28"
-
-[[package]]
-category = "main"
-description = "Universal encoding detector for Python 2 and 3"
-name = "chardet"
-optional = false
-python-versions = "*"
-version = "3.0.4"
-
-[[package]]
-category = "main"
-description = "Composable command line interface toolkit"
-name = "click"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "7.0"
-
-[[package]]
-category = "main"
-description = "Cross-platform colored terminal text."
-marker = "sys_platform == \"win32\""
-name = "colorama"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-version = "0.4.3"
-
-[[package]]
-category = "main"
-description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
-name = "fastapi"
-optional = false
-python-versions = ">=3.6"
-version = "0.48.0"
-
-[package.dependencies]
-pydantic = ">=0.32.2,<2.0.0"
-starlette = "0.12.9"
-
-[package.extras]
-all = ["requests", "aiofiles", "jinja2", "python-multipart", "itsdangerous", "pyyaml", "graphene", "ujson", "email-validator", "uvicorn", "async-exit-stack", "async-generator"]
-dev = ["pyjwt", "passlib", "autoflake", "flake8", "uvicorn", "graphene"]
-doc = ["mkdocs", "mkdocs-material", "markdown-include"]
-test = ["pytest (>=4.0.0)", "pytest-cov", "mypy", "black", "isort", "requests", "email-validator", "sqlalchemy", "peewee", "databases", "orjson", "async-exit-stack", "async-generator"]
-
-[[package]]
-category = "main"
-description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
-name = "h11"
-optional = false
-python-versions = "*"
-version = "0.9.0"
-
-[[package]]
-category = "main"
-description = "HTTP/2 State-Machine based protocol implementation"
-name = "h2"
-optional = false
-python-versions = "*"
-version = "3.2.0"
-
-[package.dependencies]
-hpack = ">=3.0,<4"
-hyperframe = ">=5.2.0,<6"
-
-[[package]]
-category = "main"
-description = "Pure-Python HPACK header compression"
-name = "hpack"
-optional = false
-python-versions = "*"
-version = "3.0.0"
-
-[[package]]
-category = "main"
-description = "Chromium HSTS Preload list as a Python package and updated daily"
-name = "hstspreload"
-optional = false
-python-versions = ">=3.6"
-version = "2020.2.25"
-
-[[package]]
-category = "main"
-description = "A collection of framework independent HTTP protocol utils."
-marker = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
-name = "httptools"
-optional = false
-python-versions = "*"
-version = "0.1.1"
-
-[package.extras]
-test = ["Cython (0.29.14)"]
-
-[[package]]
-category = "main"
-description = "The next generation HTTP client."
-name = "httpx"
-optional = false
-python-versions = ">=3.6"
-version = "0.11.1"
-
-[package.dependencies]
-certifi = "*"
-chardet = ">=3.0.0,<4.0.0"
-h11 = ">=0.8,<0.10"
-h2 = ">=3.0.0,<4.0.0"
-hstspreload = "*"
-idna = ">=2.0.0,<3.0.0"
-rfc3986 = ">=1.3,<2"
-sniffio = ">=1.0.0,<2.0.0"
-urllib3 = ">=1.0.0,<2.0.0"
-
-[[package]]
-category = "main"
-description = "HTTP/2 framing layer for Python"
-name = "hyperframe"
-optional = false
-python-versions = "*"
-version = "5.2.0"
-
-[[package]]
-category = "main"
-description = "Internationalized Domain Names in Applications (IDNA)"
-name = "idna"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "2.9"
-
-[[package]]
-category = "dev"
-description = "A Python utility / library to sort Python imports."
-name = "isort"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "4.3.21"
-
-[package.extras]
-pipfile = ["pipreqs", "requirementslib"]
-pyproject = ["toml"]
-requirements = ["pipreqs", "pip-api"]
-xdg_home = ["appdirs (>=1.4.0)"]
-
-[[package]]
-category = "main"
-description = "Python logging made (stupidly) simple"
-name = "loguru"
-optional = false
-python-versions = ">=3.5"
-version = "0.4.1"
-
-[package.dependencies]
-colorama = ">=0.3.4"
-win32-setctime = ">=1.0.0"
-
-[package.extras]
-dev = ["codecov (>=2.0.15)", "colorama (>=0.3.4)", "flake8 (>=3.7.7)", "isort (>=4.3.20)", "tox (>=3.9.0)", "tox-travis (>=0.12)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "Sphinx (>=2.2.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "black (>=19.3b0)"]
-
-[[package]]
-category = "dev"
-description = "Utility library for gitignore style pattern matching of file paths."
-name = "pathspec"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-version = "0.7.0"
-
-[[package]]
-category = "main"
-description = "Data validation and settings management using python 3.6 type hinting"
-name = "pydantic"
-optional = false
-python-versions = ">=3.6"
-version = "1.4"
-
-[package.extras]
-dotenv = ["python-dotenv (>=0.10.4)"]
-email = ["email-validator (>=1.0.3)"]
-typing_extensions = ["typing-extensions (>=3.7.2)"]
-
-[[package]]
-category = "dev"
-description = "passive checker of Python programs"
-name = "pyflakes"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "2.1.1"
-
-[[package]]
-category = "dev"
-description = "Alternative regular expression module, to replace re."
-name = "regex"
-optional = false
-python-versions = "*"
-version = "2020.2.20"
-
-[[package]]
-category = "main"
-description = "Validating URI References per RFC 3986"
-name = "rfc3986"
-optional = false
-python-versions = "*"
-version = "1.3.2"
-
-[package.extras]
-idna2008 = ["idna"]
-
-[[package]]
-category = "main"
-description = "Sniff out which async library your code is running under"
-name = "sniffio"
-optional = false
-python-versions = ">=3.5"
-version = "1.1.0"
-
-[[package]]
-category = "main"
-description = "The little ASGI library that shines."
-name = "starlette"
-optional = false
-python-versions = ">=3.6"
-version = "0.12.9"
-
-[package.extras]
-full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"]
-
-[[package]]
-category = "dev"
-description = "Python Library for Tom's Obvious, Minimal Language"
-name = "toml"
-optional = false
-python-versions = "*"
-version = "0.10.0"
-
-[[package]]
-category = "dev"
-description = "a fork of Python 2 and 3 ast modules with type comment support"
-name = "typed-ast"
-optional = false
-python-versions = "*"
-version = "1.4.1"
-
-[[package]]
-category = "main"
-description = "HTTP library with thread-safe connection pooling, file post, and more."
-name = "urllib3"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
-version = "1.25.8"
-
-[package.extras]
-brotli = ["brotlipy (>=0.6.0)"]
-secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
-socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
-
-[[package]]
-category = "main"
-description = "The lightning-fast ASGI server."
-name = "uvicorn"
-optional = false
-python-versions = "*"
-version = "0.11.3"
-
-[package.dependencies]
-click = ">=7.0.0,<8.0.0"
-h11 = ">=0.8,<0.10"
-httptools = ">=0.1.0,<0.2.0"
-uvloop = ">=0.14.0"
-websockets = ">=8.0.0,<9.0.0"
-
-[[package]]
-category = "main"
-description = "Fast implementation of asyncio event loop on top of libuv"
-marker = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
-name = "uvloop"
-optional = false
-python-versions = "*"
-version = "0.14.0"
-
-[[package]]
-category = "main"
-description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
-name = "websockets"
-optional = false
-python-versions = ">=3.6.1"
-version = "8.1"
-
-[[package]]
-category = "main"
-description = "A small Python utility to set file creation time on Windows"
-marker = "sys_platform == \"win32\""
-name = "win32-setctime"
-optional = false
-python-versions = ">=3.5"
-version = "1.0.1"
-
-[package.extras]
-dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
-
-[metadata]
-content-hash = "e6eb201134d6b1d79b81049d96a3cf895462598d51cfb9438a423f5c4033600b"
-python-versions = "^3.8"
-
-[metadata.files]
-appdirs = [
- {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"},
- {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"},
-]
-attrs = [
- {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
- {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
-]
-autoflake = [
- {file = "autoflake-1.3.1.tar.gz", hash = "sha256:680cb9dade101ed647488238ccb8b8bfb4369b53d58ba2c8cdf7d5d54e01f95b"},
-]
-black = [
- {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
- {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
-]
-botx = [
- {file = "botx-0.13.3-py3-none-any.whl", hash = "sha256:a87768a47105c699ad26bae1a5b930e360ccc792940f3f22ead3e80da2c7a158"},
- {file = "botx-0.13.3.tar.gz", hash = "sha256:b7a68a359c793a2ebaa0c7b86aa7284c997d5cf8bb0c3c3b1987337b86bb9c0c"},
-]
-certifi = [
- {file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"},
- {file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"},
-]
-chardet = [
- {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
- {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
-]
-click = [
- {file = "Click-7.0-py2.py3-none-any.whl", hash = "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13"},
- {file = "Click-7.0.tar.gz", hash = "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"},
-]
-colorama = [
- {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
- {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
-]
-fastapi = [
- {file = "fastapi-0.48.0-py3-none-any.whl", hash = "sha256:87d409d3ac3957713c016ba3b28fa5214e0903d4351cc3fe486b170d29e8aacd"},
- {file = "fastapi-0.48.0.tar.gz", hash = "sha256:2e00347f6a84291a5f04302733fbcf7c2ad9c674c0d0448cbee661db0e01ca16"},
-]
-h11 = [
- {file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"},
- {file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"},
-]
-h2 = [
- {file = "h2-3.2.0-py2.py3-none-any.whl", hash = "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5"},
- {file = "h2-3.2.0.tar.gz", hash = "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"},
-]
-hpack = [
- {file = "hpack-3.0.0-py2.py3-none-any.whl", hash = "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89"},
- {file = "hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"},
-]
-hstspreload = [
- {file = "hstspreload-2020.2.25.tar.gz", hash = "sha256:a1ba0c2730593a1922f93cd9c66ff620248090656102bf31e4559c01d7935e05"},
-]
-httptools = [
- {file = "httptools-0.1.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce"},
- {file = "httptools-0.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4"},
- {file = "httptools-0.1.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6"},
- {file = "httptools-0.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c"},
- {file = "httptools-0.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a"},
- {file = "httptools-0.1.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f"},
- {file = "httptools-0.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2"},
- {file = "httptools-0.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009"},
- {file = "httptools-0.1.1-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437"},
- {file = "httptools-0.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d"},
- {file = "httptools-0.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be"},
- {file = "httptools-0.1.1.tar.gz", hash = "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce"},
-]
-httpx = [
- {file = "httpx-0.11.1-py2.py3-none-any.whl", hash = "sha256:1d3893d3e4244c569764a6bae5c5a9fbbc4a6ec3825450b5696602af7a275576"},
- {file = "httpx-0.11.1.tar.gz", hash = "sha256:7d2bfb726eeed717953d15dddb22da9c2fcf48a4d70ba1456aa0a7faeda33cf7"},
-]
-hyperframe = [
- {file = "hyperframe-5.2.0-py2.py3-none-any.whl", hash = "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40"},
- {file = "hyperframe-5.2.0.tar.gz", hash = "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"},
-]
-idna = [
- {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"},
- {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"},
-]
-isort = [
- {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
- {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
-]
-loguru = [
- {file = "loguru-0.4.1-py3-none-any.whl", hash = "sha256:074b3caa6748452c1e4f2b302093c94b65d5a4c5a4d7743636b4121e06437b0e"},
- {file = "loguru-0.4.1.tar.gz", hash = "sha256:a6101fd435ac89ba5205a105a26a6ede9e4ddbb4408a6e167852efca47806d11"},
-]
-pathspec = [
- {file = "pathspec-0.7.0-py2.py3-none-any.whl", hash = "sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424"},
- {file = "pathspec-0.7.0.tar.gz", hash = "sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"},
-]
-pydantic = [
- {file = "pydantic-1.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:07911aab70f3bc52bb845ce1748569c5e70478ac977e106a150dd9d0465ebf04"},
- {file = "pydantic-1.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:012c422859bac2e03ab3151ea6624fecf0e249486be7eb8c6ee69c91740c6752"},
- {file = "pydantic-1.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:61d22d36808087d3184ed6ac0d91dd71c533b66addb02e4a9930e1e30833202f"},
- {file = "pydantic-1.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f863456d3d4bf817f2e5248553dee3974c5dc796f48e6ddb599383570f4215ac"},
- {file = "pydantic-1.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bbbed364376f4a0aebb9ea452ff7968b306499a9e74f4db69b28ff2cd4043a11"},
- {file = "pydantic-1.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e27559cedbd7f59d2375bfd6eea29a330ea1a5b0589c34d6b4e0d7bec6027bbf"},
- {file = "pydantic-1.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:50e4e948892a6815649ad5a9a9379ad1e5f090f17842ac206535dfaed75c6f2f"},
- {file = "pydantic-1.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8848b4eb458469739126e4c1a202d723dd092e087f8dbe3104371335f87ba5df"},
- {file = "pydantic-1.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:831a0265a9e3933b3d0f04d1a81bba543bafbe4119c183ff2771871db70524ab"},
- {file = "pydantic-1.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:47b8db7024ba3d46c3d4768535e1cf87b6c8cf92ccd81e76f4e1cb8ee47688b3"},
- {file = "pydantic-1.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:51f11c8bbf794a68086540da099aae4a9107447c7a9d63151edbb7d50110cf21"},
- {file = "pydantic-1.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6100d7862371115c40be55cc4b8d766a74b1d0dbaf99dbfe72bb4bac0faf89ed"},
- {file = "pydantic-1.4-py36.py37.py38-none-any.whl", hash = "sha256:72184c1421103cca128300120f8f1185fb42a9ea73a1c9845b1c53db8c026a7d"},
- {file = "pydantic-1.4.tar.gz", hash = "sha256:f17ec336e64d4583311249fb179528e9a2c27c8a2eaf590ec6ec2c6dece7cb3f"},
-]
-pyflakes = [
- {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"},
- {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"},
-]
-regex = [
- {file = "regex-2020.2.20-cp27-cp27m-win32.whl", hash = "sha256:99272d6b6a68c7ae4391908fc15f6b8c9a6c345a46b632d7fdb7ef6c883a2bbb"},
- {file = "regex-2020.2.20-cp27-cp27m-win_amd64.whl", hash = "sha256:974535648f31c2b712a6b2595969f8ab370834080e00ab24e5dbb9d19b8bfb74"},
- {file = "regex-2020.2.20-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5de40649d4f88a15c9489ed37f88f053c15400257eeb18425ac7ed0a4e119400"},
- {file = "regex-2020.2.20-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:82469a0c1330a4beb3d42568f82dffa32226ced006e0b063719468dcd40ffdf0"},
- {file = "regex-2020.2.20-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d58a4fa7910102500722defbde6e2816b0372a4fcc85c7e239323767c74f5cbc"},
- {file = "regex-2020.2.20-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f1ac2dc65105a53c1c2d72b1d3e98c2464a133b4067a51a3d2477b28449709a0"},
- {file = "regex-2020.2.20-cp36-cp36m-win32.whl", hash = "sha256:8c2b7fa4d72781577ac45ab658da44c7518e6d96e2a50d04ecb0fd8f28b21d69"},
- {file = "regex-2020.2.20-cp36-cp36m-win_amd64.whl", hash = "sha256:269f0c5ff23639316b29f31df199f401e4cb87529eafff0c76828071635d417b"},
- {file = "regex-2020.2.20-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bed7986547ce54d230fd8721aba6fd19459cdc6d315497b98686d0416efaff4e"},
- {file = "regex-2020.2.20-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:046e83a8b160aff37e7034139a336b660b01dbfe58706f9d73f5cdc6b3460242"},
- {file = "regex-2020.2.20-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:b33ebcd0222c1d77e61dbcd04a9fd139359bded86803063d3d2d197b796c63ce"},
- {file = "regex-2020.2.20-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bba52d72e16a554d1894a0cc74041da50eea99a8483e591a9edf1025a66843ab"},
- {file = "regex-2020.2.20-cp37-cp37m-win32.whl", hash = "sha256:01b2d70cbaed11f72e57c1cfbaca71b02e3b98f739ce33f5f26f71859ad90431"},
- {file = "regex-2020.2.20-cp37-cp37m-win_amd64.whl", hash = "sha256:113309e819634f499d0006f6200700c8209a2a8bf6bd1bdc863a4d9d6776a5d1"},
- {file = "regex-2020.2.20-cp38-cp38-manylinux1_i686.whl", hash = "sha256:25f4ce26b68425b80a233ce7b6218743c71cf7297dbe02feab1d711a2bf90045"},
- {file = "regex-2020.2.20-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9b64a4cc825ec4df262050c17e18f60252cdd94742b4ba1286bcfe481f1c0f26"},
- {file = "regex-2020.2.20-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:9ff16d994309b26a1cdf666a6309c1ef51ad4f72f99d3392bcd7b7139577a1f2"},
- {file = "regex-2020.2.20-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c7f58a0e0e13fb44623b65b01052dae8e820ed9b8b654bb6296bc9c41f571b70"},
- {file = "regex-2020.2.20-cp38-cp38-win32.whl", hash = "sha256:200539b5124bc4721247a823a47d116a7a23e62cc6695744e3eb5454a8888e6d"},
- {file = "regex-2020.2.20-cp38-cp38-win_amd64.whl", hash = "sha256:7f78f963e62a61e294adb6ff5db901b629ef78cb2a1cfce3cf4eeba80c1c67aa"},
- {file = "regex-2020.2.20.tar.gz", hash = "sha256:9e9624440d754733eddbcd4614378c18713d2d9d0dc647cf9c72f64e39671be5"},
-]
-rfc3986 = [
- {file = "rfc3986-1.3.2-py2.py3-none-any.whl", hash = "sha256:df4eba676077cefb86450c8f60121b9ae04b94f65f85b69f3f731af0516b7b18"},
- {file = "rfc3986-1.3.2.tar.gz", hash = "sha256:0344d0bd428126ce554e7ca2b61787b6a28d2bbd19fc70ed2dd85efe31176405"},
-]
-sniffio = [
- {file = "sniffio-1.1.0-py3-none-any.whl", hash = "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5"},
- {file = "sniffio-1.1.0.tar.gz", hash = "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"},
-]
-starlette = [
- {file = "starlette-0.12.9.tar.gz", hash = "sha256:c2ac9a42e0e0328ad20fe444115ac5e3760c1ee2ac1ff8cdb5ec915c4a453411"},
-]
-toml = [
- {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"},
- {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"},
- {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"},
-]
-typed-ast = [
- {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
- {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
- {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
- {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
- {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
- {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
- {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
- {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
- {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
- {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
- {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
- {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
- {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
- {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
- {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
- {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
- {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
- {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
- {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
- {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
- {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
-]
-urllib3 = [
- {file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"},
- {file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"},
-]
-uvicorn = [
- {file = "uvicorn-0.11.3-py3-none-any.whl", hash = "sha256:0f58170165c4495f563d8224b2f415a0829af0412baa034d6f777904613087fd"},
- {file = "uvicorn-0.11.3.tar.gz", hash = "sha256:6fdaf8e53bf1b2ddf0fe9ed06079b5348d7d1d87b3365fe2549e6de0d49e631c"},
-]
-uvloop = [
- {file = "uvloop-0.14.0-cp35-cp35m-macosx_10_11_x86_64.whl", hash = "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd"},
- {file = "uvloop-0.14.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726"},
- {file = "uvloop-0.14.0-cp36-cp36m-macosx_10_11_x86_64.whl", hash = "sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7"},
- {file = "uvloop-0.14.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362"},
- {file = "uvloop-0.14.0-cp37-cp37m-macosx_10_11_x86_64.whl", hash = "sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891"},
- {file = "uvloop-0.14.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95"},
- {file = "uvloop-0.14.0-cp38-cp38-macosx_10_11_x86_64.whl", hash = "sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5"},
- {file = "uvloop-0.14.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09"},
- {file = "uvloop-0.14.0.tar.gz", hash = "sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e"},
-]
-websockets = [
- {file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"},
- {file = "websockets-8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"},
- {file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"},
- {file = "websockets-8.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb"},
- {file = "websockets-8.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5"},
- {file = "websockets-8.1-cp36-cp36m-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"},
- {file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"},
- {file = "websockets-8.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989"},
- {file = "websockets-8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"},
- {file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"},
- {file = "websockets-8.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8"},
- {file = "websockets-8.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422"},
- {file = "websockets-8.1-cp37-cp37m-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"},
- {file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"},
- {file = "websockets-8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092"},
- {file = "websockets-8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485"},
- {file = "websockets-8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1"},
- {file = "websockets-8.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55"},
- {file = "websockets-8.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824"},
- {file = "websockets-8.1-cp38-cp38-win32.whl", hash = "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36"},
- {file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"},
- {file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"},
-]
-win32-setctime = [
- {file = "win32_setctime-1.0.1-py3-none-any.whl", hash = "sha256:568fd636c68350bcc54755213fe01966fe0a6c90b386c0776425944a0382abef"},
- {file = "win32_setctime-1.0.1.tar.gz", hash = "sha256:b47e5023ec7f0b4962950902b15bc56464a380d869f59d27dbf9ab423b23e8f9"},
-]
diff --git a/examples/fsm/pyproject.toml b/examples/fsm/pyproject.toml
deleted file mode 100644
index ee91f8b6..00000000
--- a/examples/fsm/pyproject.toml
+++ /dev/null
@@ -1,21 +0,0 @@
-[tool.poetry]
-name = "pybotx-fsm-example"
-version = "0.0.0"
-description = "An example of bot with FSM behaviour"
-authors = ["Nik Sidnev "]
-license = "MIT"
-
-[tool.poetry.dependencies]
-python = "^3.8"
-botx = "^0.13.0"
-fastapi = "^0.48.0"
-uvicorn = "^0.11.3"
-
-[tool.poetry.dev-dependencies]
-black = "^19.10b0"
-isort = "^4.3"
-autoflake = "^1.3"
-
-[build-system]
-requires = ["poetry>=1.0"]
-build-backend = "poetry.masonry.api"
diff --git a/examples/fsm/scripts/format b/examples/fsm/scripts/format
deleted file mode 100755
index 9fa80f09..00000000
--- a/examples/fsm/scripts/format
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/usr/bin/env bash
-
-set -e
-
-# Sort imports one per line, so autoflake can remove unused imports
-isort --recursive --force-single-line-imports bot
-
-autoflake --recursive --remove-all-unused-imports --remove-unused-variables --in-place bot
-black bot
-isort --recursive bot
-
-isort --recursive --thirdparty=botx bot
diff --git a/mkdocs.yml b/mkdocs.yml
index 10174435..b22de101 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,9 +1,9 @@
-site_name: pybotx
-site_url: !!python/object/apply:os.getenv ["SITE_URL", "https://expressapp.github.io/pybotx"]
+site_name: pybotx-next
+site_url: !!python/object/apply:os.getenv ["SITE_URL", "https://expressapp.github.io/pybotx-next"]
site_description: A little python library for building bots for Express
theme:
- name: 'material'
+ name: "material"
repo_name: ExpressApp/pybotx
repo_url: https://github.com/ExpressApp/pybotx
@@ -11,41 +11,20 @@ edit_uri: ''
nav:
- Introduction: 'index.md'
- - Tutorial - User Guide:
- - First Steps: 'development/first-steps.md'
- - Sending Messages And Files: 'development/sending-data.md'
- - Handlers Collecting: 'development/collector.md'
- - Handling Errors: 'development/handling-errors.md'
- - Dependencies Injection: 'development/dependencies-injection.md'
- - Logging: 'development/logging.md'
- - Testing: 'development/tests.md'
- - API Reference:
- - Bots: 'reference/bots.md'
- - Collecting: 'reference/collecting.md'
- - Middlewares:
- - Base: 'reference/middlewares/base.md'
- - Next Step: 'reference/middlewares/ns.md'
- - Authorization: 'reference/middlewares/authorization.md'
- - Models: 'reference/models.md'
- - Clients:
- - Methods: 'reference/clients/methods.md'
- - Async Client: 'reference/clients/async-client.md'
- - Synchronous Client: 'reference/clients/sync-client.md'
- - Testing:
- Client: 'reference/testing/test-client.md'
- Message Builder: 'reference/testing/message-builder.md'
- - Exceptions: 'reference/exceptions.md'
+ - BotX API: 'botx_api.md'
- - Changelog: 'changelog.md'
+plugins:
+ - search
markdown_extensions:
- - markdown.extensions.codehilite:
- guess_lang: false
- - markdown_include.include:
- base_path: docs
- admonition
- - codehilite
+ - pymdownx.snippets
+ - pymdownx.highlight:
+ anchor_linenums: true
+ linenums: true
+ - pymdownx.superfences
+ - codehilite:
+ css_class: highlight
-plugins:
- - search
- - mkdocstrings
+extra_css:
+ - css/custom.css
diff --git a/noxfile.py b/noxfile.py
deleted file mode 100644
index ddad127a..00000000
--- a/noxfile.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Run common tasks using nox."""
-import pathlib
-
-import nox
-from nox.sessions import Session
-
-TARGETS = ("botx", "tests")
-
-
-def _process_add_single_comma_path(session: Session, path: pathlib.Path) -> None:
- if path.is_dir():
- for new_path in path.iterdir():
- _process_add_single_comma_path(session, new_path)
-
- return
-
- if path.suffix not in {".py", ".pyi"}:
- return
-
- session.run(
- "add-trailing-comma", "--py36-plus", "--exit-zero-even-if-changed", str(path),
- )
-
-
-def _process_add_single_comma(session: Session, *paths: str) -> None:
- for target in paths:
- path = pathlib.Path(target)
- _process_add_single_comma_path(session, path)
-
-
-@nox.session(python=False, name="format")
-def run_formatters(session: Session) -> None:
- """Run all project formatters.
-
- Formatters to run:
- 1. isort with autoflake to remove all unused imports.
- 2. black for sinle style in all project.
- 3. add-trailing-comma to adding or removing comma from line.
- 4. isort for properly imports sorting.
- """
- # we need to run isort here, since autoflake is unable to understand unused imports
- # when they are multiline.
- # see https://github.com/myint/autoflake/issues/8
- session.run("isort", "--recursive", "--force-single-line-imports", *TARGETS)
- session.run(
- "autoflake",
- "--recursive",
- "--remove-all-unused-imports",
- "--remove-unused-variables",
- "--in-place",
- *TARGETS,
- )
- session.run("black", *TARGETS)
- _process_add_single_comma(session, *TARGETS)
- session.run("isort", "--recursive", *TARGETS)
-
-
-@nox.session(python=False)
-def lint(session: Session) -> None:
- """Run all project linters.
-
- Linters to run:
- 1. black for code format style.
- 2. mypy for type checking.
- 3. flake8 for common python code style issues.
- """
- session.run("black", "--check", "--diff", *TARGETS)
- session.run("mypy", *TARGETS)
- session.run("flake8", *TARGETS)
-
-
-@nox.session(python=False)
-def test(session: Session) -> None:
- """Run pytest."""
- session.run("pytest", "--cov-config=setup.cfg")
-
-
-@nox.session(python=False)
-def publish(session: Session) -> None:
- """Publish library on PyPI."""
- session.run("poetry", "publish", "--build")
-
-
-@nox.session(python=False, name="build-docs")
-def build_docs(session: Session) -> None:
- """Build MkDocs pages."""
- session.run("mkdocs", "build")
-
-
-@nox.session(python=False, name="serve-docs")
-def serve_docs(session: Session) -> None:
- """Serve MkDocs pages."""
- session.run("mkdocs", "serve", "--dev-addr", "0.0.0.0:8008")
diff --git a/poetry.lock b/poetry.lock
index 2e0c9328..3d8ec0d3 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,6 +1,6 @@
[[package]]
name = "add-trailing-comma"
-version = "2.1.0"
+version = "2.2.1"
description = "Automatically add trailing commas to calls and literals"
category = "dev"
optional = false
@@ -11,15 +11,15 @@ tokenize-rt = ">=3.0.1"
[[package]]
name = "aiofiles"
-version = "0.7.0"
+version = "0.8.0"
description = "File support for asyncio."
category = "main"
-optional = true
+optional = false
python-versions = ">=3.6,<4.0"
[[package]]
name = "anyio"
-version = "3.3.1"
+version = "3.5.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "main"
optional = false
@@ -28,34 +28,22 @@ python-versions = ">=3.6.2"
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
-typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[package.extras]
-doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
-test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
+doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
+test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
trio = ["trio (>=0.16)"]
[[package]]
-name = "appdirs"
-version = "1.4.4"
-description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
-name = "argcomplete"
-version = "1.12.3"
-description = "Bash tab completion for argparse"
+name = "asgiref"
+version = "3.5.0"
+description = "ASGI specs, helper code, and adapters"
category = "dev"
optional = false
-python-versions = "*"
-
-[package.dependencies]
-importlib-metadata = {version = ">=0.23,<5", markers = "python_version == \"3.7\""}
+python-versions = ">=3.7"
[package.extras]
-test = ["coverage", "flake8", "pexpect", "wheel"]
+tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
[[package]]
name = "astor"
@@ -75,17 +63,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "attrs"
-version = "21.2.0"
+version = "21.4.0"
description = "Classes Without Boilerplate"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "autoflake"
@@ -98,69 +86,54 @@ python-versions = "*"
[package.dependencies]
pyflakes = ">=1.1.0"
-[[package]]
-name = "backports.entry-points-selectable"
-version = "1.1.0"
-description = "Compatibility shim providing selectable entry points for older implementations"
-category = "dev"
-optional = false
-python-versions = ">=2.7"
-
-[package.dependencies]
-importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
-
-[package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"]
-
[[package]]
name = "bandit"
-version = "1.7.0"
+version = "1.7.2"
description = "Security oriented static analyser for python code."
category = "dev"
optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.7"
[package.dependencies]
colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""}
GitPython = ">=1.0.1"
PyYAML = ">=5.3.1"
-six = ">=1.10.0"
stevedore = ">=1.20.0"
-[[package]]
-name = "base64io"
-version = "1.0.3"
-description = ""
-category = "main"
-optional = false
-python-versions = "*"
+[package.extras]
+test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml"]
+toml = ["toml"]
+yaml = ["pyyaml"]
[[package]]
name = "black"
-version = "20.8b1"
+version = "21.12b0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.6.2"
[package.dependencies]
-appdirs = "*"
click = ">=7.1.2"
mypy-extensions = ">=0.4.3"
-pathspec = ">=0.6,<1"
-regex = ">=2020.1.8"
-toml = ">=0.10.1"
-typed-ast = ">=1.4.0"
-typing-extensions = ">=3.7.4"
+pathspec = ">=0.9.0,<1"
+platformdirs = ">=2"
+tomli = ">=0.2.6,<2.0.0"
+typing-extensions = [
+ {version = ">=3.10.0.0", markers = "python_version < \"3.10\""},
+ {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""},
+]
[package.extras]
colorama = ["colorama (>=0.4.3)"]
-d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
+d = ["aiohttp (>=3.7.4)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+python2 = ["typed-ast (>=1.4.3)"]
+uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "certifi"
-version = "2021.5.30"
+version = "2021.10.8"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
@@ -168,7 +141,7 @@ python-versions = "*"
[[package]]
name = "charset-normalizer"
-version = "2.0.4"
+version = "2.0.12"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
@@ -179,7 +152,7 @@ unicode_backport = ["unicodedata2"]
[[package]]
name = "click"
-version = "8.0.1"
+version = "8.0.4"
description = "Composable command line interface toolkit"
category = "dev"
optional = false
@@ -187,7 +160,6 @@ python-versions = ">=3.6"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
-importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
[[package]]
name = "colorama"
@@ -197,59 +169,31 @@ category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-[[package]]
-name = "colorlog"
-version = "4.8.0"
-description = "Log formatting with colors!"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-colorama = {version = "*", markers = "sys_platform == \"win32\""}
-
[[package]]
name = "coverage"
-version = "5.5"
+version = "6.3.2"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
-
-[package.extras]
-toml = ["toml"]
-
-[[package]]
-name = "coverage-conditional-plugin"
-version = "0.4.0"
-description = "Conditional coverage based on any rules you define!"
-category = "dev"
-optional = false
-python-versions = ">=3.6,<4.0"
+python-versions = ">=3.7"
[package.dependencies]
-coverage = ">=5.0,<6.0"
-packaging = ">=20.4"
+tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
+
+[package.extras]
+toml = ["tomli"]
[[package]]
name = "darglint"
-version = "1.8.0"
+version = "1.8.1"
description = "A utility for ensuring Google-style docstrings stay up to date with the source code."
category = "dev"
optional = false
python-versions = ">=3.6,<4.0"
-[[package]]
-name = "distlib"
-version = "0.3.2"
-description = "Distribution utilities"
-category = "dev"
-optional = false
-python-versions = "*"
-
[[package]]
name = "docutils"
-version = "0.17.1"
+version = "0.18.1"
description = "Docutils -- Python Documentation Utilities"
category = "dev"
optional = false
@@ -264,26 +208,35 @@ optional = false
python-versions = "*"
[[package]]
-name = "filelock"
-version = "3.0.12"
-description = "A platform independent file lock."
+name = "fastapi"
+version = "0.73.0"
+description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
category = "dev"
optional = false
-python-versions = "*"
+python-versions = ">=3.6.1"
+
+[package.dependencies]
+pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0"
+starlette = "0.17.1"
+
+[package.extras]
+all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
+dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
+doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"]
+test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"]
[[package]]
name = "flake8"
-version = "3.9.2"
+version = "4.0.1"
description = "the modular source code checker: pep8 pyflakes and co"
category = "dev"
optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+python-versions = ">=3.6"
[package.dependencies]
-importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
mccabe = ">=0.6.0,<0.7.0"
-pycodestyle = ">=2.7.0,<2.8.0"
-pyflakes = ">=2.3.0,<2.4.0"
+pycodestyle = ">=2.8.0,<2.9.0"
+pyflakes = ">=2.4.0,<2.5.0"
[[package]]
name = "flake8-bandit"
@@ -301,18 +254,18 @@ pycodestyle = "*"
[[package]]
name = "flake8-broken-line"
-version = "0.3.0"
+version = "0.4.0"
description = "Flake8 plugin to forbid backslashes for line breaks"
category = "dev"
optional = false
python-versions = ">=3.6,<4.0"
[package.dependencies]
-flake8 = ">=3.5,<4.0"
+flake8 = ">=3.5,<5"
[[package]]
name = "flake8-bugbear"
-version = "21.9.1"
+version = "21.11.29"
description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."
category = "dev"
optional = false
@@ -323,30 +276,29 @@ attrs = ">=19.2.0"
flake8 = ">=3.0.0"
[package.extras]
-dev = ["coverage", "black", "hypothesis", "hypothesmith"]
+dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit"]
[[package]]
name = "flake8-commas"
-version = "2.0.0"
+version = "2.1.0"
description = "Flake8 lint for trailing commas."
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
-flake8 = ">=2,<4.0.0"
+flake8 = ">=2"
[[package]]
name = "flake8-comprehensions"
-version = "3.6.1"
+version = "3.8.0"
description = "A flake8 plugin to help you write better list/set/dict comprehensions."
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.dependencies]
-flake8 = ">=3.0,<3.2.0 || >3.2.0,<4"
-importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+flake8 = ">=3.0,<3.2.0 || >3.2.0"
[[package]]
name = "flake8-debugger"
@@ -375,7 +327,7 @@ pydocstyle = ">=2.1"
[[package]]
name = "flake8-eradicate"
-version = "1.1.0"
+version = "1.2.0"
description = "Flake8 plugin to find commented out code"
category = "dev"
optional = false
@@ -384,31 +336,23 @@ python-versions = ">=3.6,<4.0"
[package.dependencies]
attrs = "*"
eradicate = ">=2.0,<3.0"
-flake8 = ">=3.5,<4.0"
+flake8 = ">=3.5,<5"
[[package]]
name = "flake8-isort"
-version = "4.0.0"
+version = "4.1.1"
description = "flake8 plugin that integrates isort ."
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
-flake8 = ">=3.2.1,<4"
+flake8 = ">=3.2.1,<5"
isort = ">=4.3.5,<6"
testfixtures = ">=6.8.0,<7"
[package.extras]
-test = ["pytest (>=4.0.2,<6)", "toml"]
-
-[[package]]
-name = "flake8-plugin-utils"
-version = "1.3.2"
-description = "The package provides base classes and utils for flake8 plugin writing"
-category = "dev"
-optional = false
-python-versions = ">=3.6,<4.0"
+test = ["pytest-cov"]
[[package]]
name = "flake8-polyfill"
@@ -421,20 +365,9 @@ python-versions = "*"
[package.dependencies]
flake8 = "*"
-[[package]]
-name = "flake8-pytest-style"
-version = "1.5.0"
-description = "A flake8 plugin checking common style issues or inconsistencies with pytest-based tests."
-category = "dev"
-optional = false
-python-versions = ">=3.6,<4.0"
-
-[package.dependencies]
-flake8-plugin-utils = ">=1.3.2,<2.0.0"
-
[[package]]
name = "flake8-quotes"
-version = "3.3.0"
+version = "3.3.1"
description = "Flake8 lint for quotes."
category = "dev"
optional = false
@@ -445,11 +378,11 @@ flake8 = "*"
[[package]]
name = "flake8-rst-docstrings"
-version = "0.2.3"
+version = "0.2.5"
description = "Python docstring reStructuredText (RST) validator"
category = "dev"
optional = false
-python-versions = ">=3.3"
+python-versions = ">=3.6"
[package.dependencies]
flake8 = ">=3.0.0"
@@ -469,7 +402,7 @@ flake8 = "*"
[[package]]
name = "ghp-import"
-version = "2.0.1"
+version = "2.0.2"
description = "Copy your docs directly to the gh-pages branch."
category = "dev"
optional = false
@@ -479,22 +412,22 @@ python-versions = "*"
python-dateutil = ">=2.8.1"
[package.extras]
-dev = ["twine", "markdown", "flake8"]
+dev = ["twine", "markdown", "flake8", "wheel"]
[[package]]
name = "gitdb"
-version = "4.0.7"
+version = "4.0.9"
description = "Git Object Database"
category = "dev"
optional = false
-python-versions = ">=3.4"
+python-versions = ">=3.6"
[package.dependencies]
-smmap = ">=3.0.1,<5"
+smmap = ">=3.0.1,<6"
[[package]]
name = "gitpython"
-version = "3.1.23"
+version = "3.1.27"
description = "GitPython is a python library used to interact with Git repositories"
category = "dev"
optional = false
@@ -502,7 +435,6 @@ python-versions = ">=3.7"
[package.dependencies]
gitdb = ">=4.0.1,<5"
-typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.10\""}
[[package]]
name = "h11"
@@ -514,7 +446,7 @@ python-versions = ">=3.6"
[[package]]
name = "httpcore"
-version = "0.13.7"
+version = "0.14.7"
description = "A minimal low-level HTTP client."
category = "main"
optional = false
@@ -522,15 +454,17 @@ python-versions = ">=3.6"
[package.dependencies]
anyio = ">=3.0.0,<4.0.0"
+certifi = "*"
h11 = ">=0.11,<0.13"
sniffio = ">=1.0.0,<2.0.0"
[package.extras]
http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]]
name = "httpx"
-version = "0.19.0"
+version = "0.21.3"
description = "The next generation HTTP client."
category = "main"
optional = false
@@ -539,17 +473,18 @@ python-versions = ">=3.6"
[package.dependencies]
certifi = "*"
charset-normalizer = "*"
-httpcore = ">=0.13.3,<0.14.0"
+httpcore = ">=0.14.0,<0.15.0"
rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
sniffio = "*"
[package.extras]
brotli = ["brotlicffi", "brotli"]
+cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10.0.0,<11.0.0)", "pygments (>=2.0.0,<3.0.0)"]
http2 = ["h2 (>=3,<5)"]
[[package]]
name = "idna"
-version = "3.2"
+version = "3.3"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
@@ -557,20 +492,19 @@ python-versions = ">=3.5"
[[package]]
name = "importlib-metadata"
-version = "4.8.1"
+version = "4.11.3"
description = "Read metadata from Python packages"
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.dependencies]
-typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
zipp = ">=0.5"
[package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
perf = ["ipython"]
-testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
[[package]]
name = "iniconfig"
@@ -582,7 +516,7 @@ python-versions = "*"
[[package]]
name = "isort"
-version = "5.9.3"
+version = "5.10.1"
description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false
@@ -596,7 +530,7 @@ plugins = ["setuptools"]
[[package]]
name = "jinja2"
-version = "3.0.1"
+version = "3.0.3"
description = "A very fast and expressive template engine."
category = "dev"
optional = false
@@ -608,21 +542,9 @@ MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
-[[package]]
-name = "livereload"
-version = "2.6.3"
-description = "Python LiveReload is an awesome tool for web developers"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-six = "*"
-tornado = {version = "*", markers = "python_version > \"2.7\""}
-
[[package]]
name = "loguru"
-version = "0.5.3"
+version = "0.6.0"
description = "Python logging made (stupidly) simple"
category = "main"
optional = false
@@ -633,40 +555,29 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
-dev = ["codecov (>=2.0.15)", "colorama (>=0.3.4)", "flake8 (>=3.7.7)", "tox (>=3.9.0)", "tox-travis (>=0.12)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "Sphinx (>=2.2.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "black (>=19.10b0)", "isort (>=5.1.1)"]
+dev = ["colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "tox (>=3.9.0)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "black (>=19.10b0)", "isort (>=5.1.1)", "Sphinx (>=4.1.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)"]
[[package]]
name = "markdown"
-version = "3.3.4"
+version = "3.3.6"
description = "Python implementation of Markdown."
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
-importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""}
[package.extras]
testing = ["coverage", "pyyaml"]
-[[package]]
-name = "markdown-include"
-version = "0.6.0"
-description = "This is an extension to Python-Markdown which provides an \"include\" function, similar to that found in LaTeX (and also the C pre-processor and Fortran). I originally wrote it for my FORD Fortran auto-documentation generator."
-category = "dev"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-markdown = "*"
-
[[package]]
name = "markupsafe"
-version = "2.0.1"
+version = "2.1.1"
description = "Safely add untrusted strings to HTML/XML markup."
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[[package]]
name = "mccabe"
@@ -686,7 +597,7 @@ python-versions = ">=3.6"
[[package]]
name = "mkdocs"
-version = "1.2.2"
+version = "1.2.3"
description = "Project documentation with Markdown."
category = "dev"
optional = false
@@ -707,32 +618,21 @@ watchdog = ">=2.0"
[package.extras]
i18n = ["babel (>=2.9.0)"]
-[[package]]
-name = "mkdocs-autorefs"
-version = "0.2.1"
-description = "Automatically link across pages in MkDocs."
-category = "dev"
-optional = false
-python-versions = ">=3.6,<4.0"
-
-[package.dependencies]
-Markdown = ">=3.3,<4.0"
-mkdocs = ">=1.1,<2.0"
-
[[package]]
name = "mkdocs-material"
-version = "7.2.6"
+version = "8.1.9"
description = "A Material Design theme for MkDocs"
category = "dev"
optional = false
-python-versions = "*"
+python-versions = ">=3.6"
[package.dependencies]
+jinja2 = ">=2.11.1"
markdown = ">=3.2"
-mkdocs = ">=1.2.2"
+mkdocs = ">=1.2.3"
mkdocs-material-extensions = ">=1.0"
-Pygments = ">=2.4"
-pymdown-extensions = ">=7.0"
+pygments = ">=2.10"
+pymdown-extensions = ">=9.0"
[[package]]
name = "mkdocs-material-extensions"
@@ -742,42 +642,9 @@ category = "dev"
optional = false
python-versions = ">=3.6"
-[[package]]
-name = "mkdocstrings"
-version = "0.15.2"
-description = "Automatic documentation from sources, for MkDocs."
-category = "dev"
-optional = false
-python-versions = ">=3.6,<4.0"
-
-[package.dependencies]
-Jinja2 = ">=2.11.1,<4.0"
-Markdown = ">=3.3,<4.0"
-MarkupSafe = ">=1.1,<3.0"
-mkdocs = ">=1.1.1,<2.0.0"
-mkdocs-autorefs = ">=0.1,<0.3"
-pymdown-extensions = ">=6.3,<9.0"
-pytkdocs = ">=0.2.0,<0.12.0"
-
-[[package]]
-name = "molten"
-version = "1.0.2"
-description = "A minimal, extensible, fast and productive API framework."
-category = "main"
-optional = true
-python-versions = ">=3.6"
-
-[package.dependencies]
-typing-extensions = ">=3.6,<4.0"
-typing-inspect = ">=0.3.1,<0.7"
-
-[package.extras]
-all = ["typing-extensions (>=3.6,<4.0)", "typing-inspect (>=0.3.1,<0.7)"]
-dev = ["typing-extensions (>=3.6,<4.0)", "typing-inspect (>=0.3.1,<0.7)", "alabaster (>0.7)", "sphinx (<1.8)", "sphinxcontrib-napoleon", "flake8", "flake8-bugbear", "flake8-quotes", "isort", "mypy", "bumpversion (>0.5,<0.6)", "dramatiq[rabbitmq] (>1.3,<2.0)", "gevent", "gunicorn (>19.8)", "jinja2 (>=2.10,<3.0)", "msgpack (>0.5,<0.6)", "prometheus-client (>=0.2,<0.3)", "sqlalchemy (>1.2,<2.0)", "toml (>0.9,<0.10)", "wsgicors (>=0.7,<0.8)", "pytest"]
-
[[package]]
name = "mypy"
-version = "0.812"
+version = "0.910"
description = "Optional static typing for Python"
category = "dev"
optional = false
@@ -785,11 +652,12 @@ python-versions = ">=3.5"
[package.dependencies]
mypy-extensions = ">=0.4.3,<0.5.0"
-typed-ast = ">=1.4.0,<1.5.0"
+toml = "*"
typing-extensions = ">=3.7.4"
[package.extras]
dmypy = ["psutil (>=4.0)"]
+python2 = ["typed-ast (>=1.4.0,<1.5.0)"]
[[package]]
name = "mypy-extensions"
@@ -799,35 +667,16 @@ category = "main"
optional = false
python-versions = "*"
-[[package]]
-name = "nox"
-version = "2021.6.12"
-description = "Flexible test automation."
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-
-[package.dependencies]
-argcomplete = ">=1.9.4,<2.0"
-colorlog = ">=2.6.1,<7.0.0"
-importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
-packaging = ">=20.9"
-py = ">=1.4.0,<2.0.0"
-virtualenv = ">=14.0.0"
-
-[package.extras]
-tox_to_nox = ["jinja2", "tox"]
-
[[package]]
name = "packaging"
-version = "21.0"
+version = "21.3"
description = "Core utilities for Python packages"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
-pyparsing = ">=2.0.2"
+pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "pathspec"
@@ -839,7 +688,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[[package]]
name = "pbr"
-version = "5.6.0"
+version = "5.8.1"
description = "Python Build Reasonableness"
category = "dev"
optional = false
@@ -847,22 +696,23 @@ python-versions = ">=2.6"
[[package]]
name = "pep8-naming"
-version = "0.11.1"
+version = "0.12.1"
description = "Check PEP-8 naming conventions, plugin for flake8"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
+flake8 = ">=3.9.1"
flake8-polyfill = ">=1.0.2,<2"
[[package]]
name = "platformdirs"
-version = "2.3.0"
+version = "2.5.1"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.extras]
docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"]
@@ -870,33 +720,31 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock
[[package]]
name = "pluggy"
-version = "0.13.1"
+version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-
-[package.dependencies]
-importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+python-versions = ">=3.6"
[package.extras]
dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "py"
-version = "1.10.0"
+version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycodestyle"
-version = "2.7.0"
+version = "2.8.0"
description = "Python style guide checker"
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pydantic"
@@ -929,7 +777,7 @@ toml = ["toml"]
[[package]]
name = "pyflakes"
-version = "2.3.1"
+version = "2.4.0"
description = "passive checker of Python programs"
category = "dev"
optional = false
@@ -937,7 +785,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pygments"
-version = "2.10.0"
+version = "2.11.2"
description = "Pygments is a syntax highlighting package written in Python."
category = "dev"
optional = false
@@ -945,22 +793,25 @@ python-versions = ">=3.5"
[[package]]
name = "pymdown-extensions"
-version = "8.2"
+version = "9.3"
description = "Extension pack for Python Markdown."
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.dependencies]
Markdown = ">=3.2"
[[package]]
name = "pyparsing"
-version = "2.4.7"
+version = "3.0.7"
description = "Python parsing module"
category = "dev"
optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+python-versions = ">=3.6"
+
+[package.extras]
+diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pytest"
@@ -974,7 +825,6 @@ python-versions = ">=3.6"
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
-importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
@@ -986,7 +836,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm
[[package]]
name = "pytest-asyncio"
-version = "0.15.1"
+version = "0.16.0"
description = "Pytest support for asyncio."
category = "dev"
optional = false
@@ -998,30 +848,17 @@ pytest = ">=5.4.0"
[package.extras]
testing = ["coverage", "hypothesis (>=5.7.1)"]
-[[package]]
-name = "pytest-clarity"
-version = "0.3.0a0"
-description = "A plugin providing an alternative, colourful diff output for failing assertions."
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-
-[package.dependencies]
-pytest = ">=3.5.0"
-termcolor = "1.1.0"
-
[[package]]
name = "pytest-cov"
-version = "2.12.1"
+version = "3.0.0"
description = "Pytest plugin for measuring coverage."
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.6"
[package.dependencies]
-coverage = ">=5.2.1"
+coverage = {version = ">=5.2.1", extras = ["toml"]}
pytest = ">=4.6"
-toml = "*"
[package.extras]
testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
@@ -1038,31 +875,23 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
six = ">=1.5"
[[package]]
-name = "python-multipart"
-version = "0.0.5"
-description = "A streaming multipart parser for Python"
-category = "main"
-optional = true
-python-versions = "*"
-
-[package.dependencies]
-six = ">=1.4.0"
-
-[[package]]
-name = "pytkdocs"
-version = "0.7.0"
-description = "Load Python objects documentation."
+name = "python-dotenv"
+version = "0.19.2"
+description = "Read key-value pairs from a .env file and set them as environment variables"
category = "dev"
optional = false
-python-versions = ">=3.6,<4.0"
+python-versions = ">=3.5"
+
+[package.extras]
+cli = ["click (>=5.0)"]
[[package]]
name = "pyyaml"
-version = "5.4.1"
+version = "6.0"
description = "YAML parser and emitter for Python"
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+python-versions = ">=3.6"
[[package]]
name = "pyyaml-env-tag"
@@ -1076,16 +905,37 @@ python-versions = ">=3.6"
pyyaml = "*"
[[package]]
-name = "regex"
-version = "2021.8.28"
-description = "Alternative regular expression module, to replace re."
+name = "requests"
+version = "2.26.0"
+description = "Python HTTP for Humans."
category = "dev"
optional = false
-python-versions = "*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
+idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
+urllib3 = ">=1.21.1,<1.27"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
+use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
+
+[[package]]
+name = "respx"
+version = "0.19.0"
+description = "A utility for mocking out the Python HTTPX and HTTP Core libraries."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+httpx = ">=0.21.0"
[[package]]
name = "restructuredtext-lint"
-version = "1.3.2"
+version = "1.4.0"
description = "reStructuredText linter"
category = "dev"
optional = false
@@ -1112,17 +962,17 @@ idna2008 = ["idna"]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
-category = "main"
+category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "smmap"
-version = "4.0.0"
+version = "5.0.0"
description = "A pure Python implementation of a sliding window memory map manager"
category = "dev"
optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
[[package]]
name = "sniffio"
@@ -1134,7 +984,7 @@ python-versions = ">=3.5"
[[package]]
name = "snowballstemmer"
-version = "2.1.0"
+version = "2.2.0"
description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
category = "dev"
optional = false
@@ -1142,7 +992,7 @@ python-versions = "*"
[[package]]
name = "starlette"
-version = "0.16.0"
+version = "0.17.1"
description = "The little ASGI library that shines."
category = "dev"
optional = false
@@ -1150,34 +1000,24 @@ python-versions = ">=3.6"
[package.dependencies]
anyio = ">=3.0.0,<4"
-typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[package.extras]
-full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "graphene"]
+full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"]
[[package]]
name = "stevedore"
-version = "3.4.0"
+version = "3.5.0"
description = "Manage dynamic plugins for Python applications"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
-importlib-metadata = {version = ">=1.7.0", markers = "python_version < \"3.8\""}
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
-[[package]]
-name = "termcolor"
-version = "1.1.0"
-description = "ANSII Color formatting for output in terminal."
-category = "dev"
-optional = false
-python-versions = "*"
-
[[package]]
name = "testfixtures"
-version = "6.18.1"
+version = "6.18.5"
description = "A collection of helpers and mock objects for unit tests and doc tests."
category = "dev"
optional = false
@@ -1190,7 +1030,7 @@ test = ["pytest (>=3.6)", "pytest-cov", "pytest-django", "zope.component", "sybi
[[package]]
name = "tokenize-rt"
-version = "4.1.0"
+version = "4.2.1"
description = "A wrapper around the stdlib `tokenize` which roundtrips."
category = "dev"
optional = false
@@ -1205,75 +1045,64 @@ optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
-name = "tornado"
-version = "6.1"
-description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
+name = "tomli"
+version = "1.2.3"
+description = "A lil' TOML parser"
category = "dev"
optional = false
-python-versions = ">= 3.5"
-
-[[package]]
-name = "typed-ast"
-version = "1.4.3"
-description = "a fork of Python 2 and 3 ast modules with type comment support"
-category = "dev"
-optional = false
-python-versions = "*"
+python-versions = ">=3.6"
[[package]]
name = "typing-extensions"
-version = "3.10.0.2"
-description = "Backported and Experimental Type Hints for Python 3.5+"
+version = "4.1.1"
+description = "Backported and Experimental Type Hints for Python 3.6+"
category = "main"
optional = false
-python-versions = "*"
+python-versions = ">=3.6"
[[package]]
-name = "typing-inspect"
-version = "0.6.0"
-description = "Runtime inspection utilities for typing module."
-category = "main"
-optional = true
-python-versions = "*"
+name = "urllib3"
+version = "1.26.9"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
-[package.dependencies]
-mypy-extensions = ">=0.3.0"
-typing-extensions = ">=3.7.4"
+[package.extras]
+brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
+secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
+socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
-name = "virtualenv"
-version = "20.7.2"
-description = "Virtual Python Environment builder"
+name = "uvicorn"
+version = "0.16.0"
+description = "The lightning-fast ASGI server."
category = "dev"
optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+python-versions = "*"
[package.dependencies]
-"backports.entry-points-selectable" = ">=1.0.4"
-distlib = ">=0.3.1,<1"
-filelock = ">=3.0.0,<4"
-importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
-platformdirs = ">=2,<3"
-six = ">=1.9.0,<2"
+asgiref = ">=3.4.0"
+click = ">=7.0"
+h11 = ">=0.8"
[package.extras]
-docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
-testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"]
+standard = ["httptools (>=0.2.0,<0.4.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "websockets (>=9.1)", "websockets (>=10.0)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"]
[[package]]
name = "watchdog"
-version = "2.1.5"
+version = "2.1.6"
description = "Filesystem events monitoring"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.extras]
-watchmedo = ["PyYAML (>=3.10)", "argh (>=0.24.1)"]
+watchmedo = ["PyYAML (>=3.10)"]
[[package]]
name = "wemake-python-styleguide"
-version = "0.15.3"
+version = "0.16.0"
description = "The strictest and most opinionated python linter ever"
category = "dev"
optional = false
@@ -1283,9 +1112,9 @@ python-versions = ">=3.6,<4.0"
astor = ">=0.8,<0.9"
attrs = "*"
darglint = ">=1.2,<2.0"
-flake8 = ">=3.7,<4.0"
+flake8 = ">=3.7,<5"
flake8-bandit = ">=2.1,<3.0"
-flake8-broken-line = ">=0.3,<0.4"
+flake8-broken-line = ">=0.3,<0.5"
flake8-bugbear = ">=20.1,<22.0"
flake8-commas = ">=2.0,<3.0"
flake8-comprehensions = ">=3.1,<4.0"
@@ -1296,14 +1125,13 @@ flake8-isort = ">=4.0,<5.0"
flake8-quotes = ">=3.0,<4.0"
flake8-rst-docstrings = ">=0.2.3,<0.3.0"
flake8-string-format = ">=0.3,<0.4"
-importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
-pep8-naming = ">=0.11,<0.12"
+pep8-naming = ">=0.11,<0.13"
pygments = ">=2.4,<3.0"
-typing_extensions = ">=3.6,<4.0"
+typing_extensions = ">=3.6,<5.0"
[[package]]
name = "win32-setctime"
-version = "1.0.3"
+version = "1.1.0"
description = "A small Python utility to set file creation time on Windows"
category = "main"
optional = false
@@ -1314,44 +1142,37 @@ dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
[[package]]
name = "zipp"
-version = "3.5.0"
+version = "3.7.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
-
-[extras]
-tests = ["aiofiles", "molten", "python-multipart"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
[metadata]
lock-version = "1.1"
-python-versions = "^3.7"
-content-hash = "4f4ed47faa04787bd89453652c1cf77f4e419feafba93388c15cab12c1b5d6b1"
+python-versions = ">=3.8,<3.11"
+content-hash = "29869ce33ae5ceefbd9ee52a0ad98196ea521d93607f83421d5494f88c63e2aa"
[metadata.files]
add-trailing-comma = [
- {file = "add_trailing_comma-2.1.0-py2.py3-none-any.whl", hash = "sha256:f462403aa2e997e20855708edb57536d1d3310d5c5fac7e80542578eb47fdb10"},
- {file = "add_trailing_comma-2.1.0.tar.gz", hash = "sha256:f9864ffbc12ea4e54916a356d57341ab58f612867c2ad453339c51004807e8ce"},
+ {file = "add_trailing_comma-2.2.1-py2.py3-none-any.whl", hash = "sha256:981c18282b38ec5bceab80ef11485440334d2a274fcf3fce1f91692374b6d818"},
+ {file = "add_trailing_comma-2.2.1.tar.gz", hash = "sha256:1640e97c4e85132633a6cb19b29e392dbaf9516292388afa685f7ef1012468e0"},
]
aiofiles = [
- {file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"},
- {file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"},
+ {file = "aiofiles-0.8.0-py3-none-any.whl", hash = "sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937"},
+ {file = "aiofiles-0.8.0.tar.gz", hash = "sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59"},
]
anyio = [
- {file = "anyio-3.3.1-py3-none-any.whl", hash = "sha256:d7c604dd491eca70e19c78664d685d5e4337612d574419d503e76f5d7d1590bd"},
- {file = "anyio-3.3.1.tar.gz", hash = "sha256:85913b4e2fec030e8c72a8f9f98092eeb9e25847a6e00d567751b77e34f856fe"},
-]
-appdirs = [
- {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
- {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
+ {file = "anyio-3.5.0-py3-none-any.whl", hash = "sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e"},
+ {file = "anyio-3.5.0.tar.gz", hash = "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6"},
]
-argcomplete = [
- {file = "argcomplete-1.12.3-py2.py3-none-any.whl", hash = "sha256:291f0beca7fd49ce285d2f10e4c1c77e9460cf823eef2de54df0c0fec88b0d81"},
- {file = "argcomplete-1.12.3.tar.gz", hash = "sha256:2c7dbffd8c045ea534921e63b0be6fe65e88599990d8dc408ac8c542b72a5445"},
+asgiref = [
+ {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"},
+ {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"},
]
astor = [
{file = "astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5"},
@@ -1362,146 +1183,116 @@ atomicwrites = [
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
]
attrs = [
- {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
- {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
+ {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
+ {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
]
autoflake = [
{file = "autoflake-1.4.tar.gz", hash = "sha256:61a353012cff6ab94ca062823d1fb2f692c4acda51c76ff83a8d77915fba51ea"},
]
-"backports.entry-points-selectable" = [
- {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"},
- {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"},
-]
bandit = [
- {file = "bandit-1.7.0-py3-none-any.whl", hash = "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07"},
- {file = "bandit-1.7.0.tar.gz", hash = "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"},
-]
-base64io = [
- {file = "base64io-1.0.3-py2.py3-none-any.whl", hash = "sha256:e9a6c9f470e34f8debaad26134bcf3f0bcbf677dac73e32295cfb2915d30815b"},
- {file = "base64io-1.0.3.tar.gz", hash = "sha256:24f2d0fe765c35339e1b2d33aa95f9137b1b765b594164fad1016c15827a7073"},
+ {file = "bandit-1.7.2-py3-none-any.whl", hash = "sha256:e20402cadfd126d85b68ed4c8862959663c8c372dbbb1fca8f8e2c9f55a067ec"},
+ {file = "bandit-1.7.2.tar.gz", hash = "sha256:6d11adea0214a43813887bfe71a377b5a9955e4c826c8ffd341b494e3ab25260"},
]
black = [
- {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"},
+ {file = "black-21.12b0-py3-none-any.whl", hash = "sha256:a615e69ae185e08fdd73e4715e260e2479c861b5740057fde6e8b4e3b7dd589f"},
+ {file = "black-21.12b0.tar.gz", hash = "sha256:77b80f693a569e2e527958459634f18df9b0ba2625ba4e0c2d5da5be42e6f2b3"},
]
certifi = [
- {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
- {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
+ {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
+ {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
]
charset-normalizer = [
- {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"},
- {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"},
+ {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
+ {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
]
click = [
- {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
- {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
+ {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"},
+ {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"},
]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
]
-colorlog = [
- {file = "colorlog-4.8.0-py2.py3-none-any.whl", hash = "sha256:3dd15cb27e8119a24c1a7b5c93f9f3b455855e0f73993b1c25921b2f646f1dcd"},
- {file = "colorlog-4.8.0.tar.gz", hash = "sha256:59b53160c60902c405cdec28d38356e09d40686659048893e026ecbd589516b1"},
-]
coverage = [
- {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"},
- {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"},
- {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"},
- {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"},
- {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"},
- {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"},
- {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"},
- {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"},
- {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"},
- {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"},
- {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"},
- {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"},
- {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"},
- {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"},
- {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"},
- {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"},
- {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"},
- {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"},
- {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"},
- {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"},
- {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"},
- {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"},
- {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"},
- {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"},
- {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"},
- {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"},
- {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"},
- {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"},
- {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"},
- {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"},
- {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"},
- {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"},
- {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"},
- {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"},
- {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"},
- {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"},
- {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"},
- {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"},
- {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"},
- {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"},
- {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"},
- {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"},
- {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"},
- {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"},
- {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"},
- {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"},
- {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"},
- {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"},
- {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"},
- {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"},
- {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"},
- {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"},
-]
-coverage-conditional-plugin = [
- {file = "coverage-conditional-plugin-0.4.0.tar.gz", hash = "sha256:3ca50bb03b32f8ba8cf63b18d830f334cb75f401579040dd5c61df1c4687fef0"},
- {file = "coverage_conditional_plugin-0.4.0-py3-none-any.whl", hash = "sha256:3bf586f7b793a9e4e2950f7af32711012929b39d1a4b53a4829489e763fda2dc"},
+ {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"},
+ {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"},
+ {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"},
+ {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"},
+ {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"},
+ {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"},
+ {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"},
+ {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"},
+ {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"},
+ {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"},
+ {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"},
+ {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"},
+ {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"},
+ {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"},
+ {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"},
+ {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"},
+ {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"},
+ {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"},
+ {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"},
+ {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"},
+ {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"},
+ {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"},
+ {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"},
+ {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"},
+ {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"},
+ {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"},
+ {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"},
+ {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"},
+ {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"},
+ {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"},
+ {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"},
+ {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"},
+ {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"},
+ {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"},
+ {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"},
+ {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"},
+ {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"},
+ {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"},
+ {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"},
+ {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"},
+ {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"},
]
darglint = [
- {file = "darglint-1.8.0-py3-none-any.whl", hash = "sha256:ac6797bcc918cd8d8f14c168a4a364f54e1aeb4ced59db58e7e4c6dfec2fe15c"},
- {file = "darglint-1.8.0.tar.gz", hash = "sha256:aa605ef47817a6d14797d32b390466edab621768ea4ca5cc0f3c54f6d8dcaec8"},
-]
-distlib = [
- {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"},
- {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"},
+ {file = "darglint-1.8.1-py3-none-any.whl", hash = "sha256:5ae11c259c17b0701618a20c3da343a3eb98b3bc4b5a83d31cdd94f5ebdced8d"},
+ {file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"},
]
docutils = [
- {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"},
- {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"},
+ {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"},
+ {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"},
]
eradicate = [
{file = "eradicate-2.0.0.tar.gz", hash = "sha256:27434596f2c5314cc9b31410c93d8f7e8885747399773cd088d3adea647a60c8"},
]
-filelock = [
- {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
- {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
+fastapi = [
+ {file = "fastapi-0.73.0-py3-none-any.whl", hash = "sha256:f0a618aff5f6942862f2d3f20f39b1c037e33314d1b8207fd1c3a2cca76dfd8c"},
+ {file = "fastapi-0.73.0.tar.gz", hash = "sha256:dcfee92a7f9a72b5d4b7ca364bd2b009f8fc10d95ed5769be20e94f39f7e5a15"},
]
flake8 = [
- {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},
- {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"},
+ {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
+ {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
]
flake8-bandit = [
{file = "flake8_bandit-2.1.2.tar.gz", hash = "sha256:687fc8da2e4a239b206af2e54a90093572a60d0954f3054e23690739b0b0de3b"},
]
flake8-broken-line = [
- {file = "flake8-broken-line-0.3.0.tar.gz", hash = "sha256:f74e052833324a9e5f0055032f7ccc54b23faabafe5a26241c2f977e70b10b50"},
- {file = "flake8_broken_line-0.3.0-py3-none-any.whl", hash = "sha256:611f79c7f27118e7e5d3dc098ef7681c40aeadf23783700c5dbee840d2baf3af"},
+ {file = "flake8-broken-line-0.4.0.tar.gz", hash = "sha256:771aab5aa0997666796fed249d0e48e6c01cdfeca8c95521eea28a38b7ced4c7"},
+ {file = "flake8_broken_line-0.4.0-py3-none-any.whl", hash = "sha256:e9c522856862239a2c7ef2c1de0276fa598572aa864bd4e9c7efc2a827538515"},
]
flake8-bugbear = [
- {file = "flake8-bugbear-21.9.1.tar.gz", hash = "sha256:2f60c8ce0dc53d51da119faab2d67dea978227f0f92ed3c44eb7d65fb2e06a96"},
- {file = "flake8_bugbear-21.9.1-py36.py37.py38-none-any.whl", hash = "sha256:45bfdccfb9f2d8aa140e33cac8f46f1e38215c13d5aa8650e7e188d84e2f94c6"},
+ {file = "flake8-bugbear-21.11.29.tar.gz", hash = "sha256:8b04cb2fafc6a78e1a9d873bd3988e4282f7959bb6b0d7c1ae648ec09b937a7b"},
+ {file = "flake8_bugbear-21.11.29-py36.py37.py38-none-any.whl", hash = "sha256:179e41ddae5de5e3c20d1f61736feeb234e70958fbb56ab3c28a67739c8e9a82"},
]
flake8-commas = [
- {file = "flake8-commas-2.0.0.tar.gz", hash = "sha256:d3005899466f51380387df7151fb59afec666a0f4f4a2c6a8995b975de0f44b7"},
- {file = "flake8_commas-2.0.0-py2.py3-none-any.whl", hash = "sha256:ee2141a3495ef9789a3894ed8802d03eff1eaaf98ce6d8653a7c573ef101935e"},
+ {file = "flake8-commas-2.1.0.tar.gz", hash = "sha256:940441ab8ee544df564ae3b3f49f20462d75d5c7cac2463e0b27436e2050f263"},
+ {file = "flake8_commas-2.1.0-py2.py3-none-any.whl", hash = "sha256:ebb96c31e01d0ef1d0685a21f3f0e2f8153a0381430e748bf0bbbb5d5b453d54"},
]
flake8-comprehensions = [
- {file = "flake8-comprehensions-3.6.1.tar.gz", hash = "sha256:4888de89248b7f7535159189ff693c77f8354f6d37a02619fa28c9921a913aa0"},
- {file = "flake8_comprehensions-3.6.1-py3-none-any.whl", hash = "sha256:e9a010b99aa90c05790d45281ad9953df44a4a08a1a8f6cd41f98b4fc6a268a0"},
+ {file = "flake8-comprehensions-3.8.0.tar.gz", hash = "sha256:8e108707637b1d13734f38e03435984f6b7854fa6b5a4e34f93e69534be8e521"},
+ {file = "flake8_comprehensions-3.8.0-py3-none-any.whl", hash = "sha256:9406314803abe1193c064544ab14fdc43c58424c0882f6ff8a581eb73fc9bb58"},
]
flake8-debugger = [
{file = "flake8-debugger-4.0.0.tar.gz", hash = "sha256:e43dc777f7db1481db473210101ec2df2bd39a45b149d7218a618e954177eda6"},
@@ -1512,148 +1303,121 @@ flake8-docstrings = [
{file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"},
]
flake8-eradicate = [
- {file = "flake8-eradicate-1.1.0.tar.gz", hash = "sha256:f5917d6dbca352efcd10c15fdab9c55c48f0f26f6a8d47898b25d39101f170a8"},
- {file = "flake8_eradicate-1.1.0-py3-none-any.whl", hash = "sha256:d8e39b684a37c257a53cda817d86e2d96c9ba3450ddc292742623a5dfee04d9e"},
+ {file = "flake8-eradicate-1.2.0.tar.gz", hash = "sha256:acaa1b6839ff00d284b805c432fdfa6047262bd15a5504ec945797e87b4de1fa"},
+ {file = "flake8_eradicate-1.2.0-py3-none-any.whl", hash = "sha256:51dc660d0c1c1ed93af0f813540bbbf72ab2d3466c14e3f3bac371c618b6042f"},
]
flake8-isort = [
- {file = "flake8-isort-4.0.0.tar.gz", hash = "sha256:2b91300f4f1926b396c2c90185844eb1a3d5ec39ea6138832d119da0a208f4d9"},
- {file = "flake8_isort-4.0.0-py2.py3-none-any.whl", hash = "sha256:729cd6ef9ba3659512dee337687c05d79c78e1215fdf921ed67e5fe46cce2f3c"},
-]
-flake8-plugin-utils = [
- {file = "flake8-plugin-utils-1.3.2.tar.gz", hash = "sha256:20fa2a8ca2decac50116edb42e6af0a1253ef639ad79941249b840531889c65a"},
- {file = "flake8_plugin_utils-1.3.2-py3-none-any.whl", hash = "sha256:1fe43e3e9acf3a7c0f6b88f5338cad37044d2f156c43cb6b080b5f9da8a76f06"},
+ {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"},
+ {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"},
]
flake8-polyfill = [
{file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"},
{file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"},
]
-flake8-pytest-style = [
- {file = "flake8-pytest-style-1.5.0.tar.gz", hash = "sha256:668ce8f55edf7db4ac386d2735c3b354b5cb47aa341a4655d91a5788dd03124b"},
- {file = "flake8_pytest_style-1.5.0-py3-none-any.whl", hash = "sha256:ec287a7dc4fe95082af5e408c8b2f8f4b6bcb366d5a17ff6c34112eb03446580"},
-]
flake8-quotes = [
- {file = "flake8-quotes-3.3.0.tar.gz", hash = "sha256:f1dd87830ed77ff2ce47fc0ee0fd87ae20e8f045355354ffbf4dcaa18d528217"},
+ {file = "flake8-quotes-3.3.1.tar.gz", hash = "sha256:633adca6fb8a08131536af0d750b44d6985b9aba46f498871e21588c3e6f525a"},
]
flake8-rst-docstrings = [
- {file = "flake8-rst-docstrings-0.2.3.tar.gz", hash = "sha256:3045794e1c8467fba33aaea5c246b8369efc9c44ef8b0b20199bb6df7a4bd47b"},
- {file = "flake8_rst_docstrings-0.2.3-py3-none-any.whl", hash = "sha256:565bbb391d7e4d0042924102221e9857ad72929cdd305b26501736ec22c1451a"},
+ {file = "flake8-rst-docstrings-0.2.5.tar.gz", hash = "sha256:4fe93f997dea45d9d3c8bd220f12f0b6c359948fb943b5b48021a3f927edd816"},
+ {file = "flake8_rst_docstrings-0.2.5-py3-none-any.whl", hash = "sha256:b99d9041b769b857efe45a448dc8c71b1bb311f9cacbdac5de82f96498105082"},
]
flake8-string-format = [
{file = "flake8-string-format-0.3.0.tar.gz", hash = "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2"},
{file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"},
]
ghp-import = [
- {file = "ghp-import-2.0.1.tar.gz", hash = "sha256:753de2eace6e0f7d4edfb3cce5e3c3b98cd52aadb80163303d1d036bda7b4483"},
+ {file = "ghp-import-2.0.2.tar.gz", hash = "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071"},
+ {file = "ghp_import-2.0.2-py3-none-any.whl", hash = "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46"},
]
gitdb = [
- {file = "gitdb-4.0.7-py3-none-any.whl", hash = "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0"},
- {file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"},
+ {file = "gitdb-4.0.9-py3-none-any.whl", hash = "sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd"},
+ {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"},
]
gitpython = [
- {file = "GitPython-3.1.23-py3-none-any.whl", hash = "sha256:de2e2aff068097b23d6dca5daf588078fd8996a4218f6ffa704a662c2b54f9ac"},
- {file = "GitPython-3.1.23.tar.gz", hash = "sha256:aaae7a3bfdf0a6db30dc1f3aeae47b71cd326d86b936fe2e158aa925fdf1471c"},
+ {file = "GitPython-3.1.27-py3-none-any.whl", hash = "sha256:5b68b000463593e05ff2b261acff0ff0972df8ab1b70d3cdbd41b546c8b8fc3d"},
+ {file = "GitPython-3.1.27.tar.gz", hash = "sha256:1c885ce809e8ba2d88a29befeb385fcea06338d3640712b59ca623c220bb5704"},
]
h11 = [
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
{file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
]
httpcore = [
- {file = "httpcore-0.13.7-py3-none-any.whl", hash = "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0"},
- {file = "httpcore-0.13.7.tar.gz", hash = "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3"},
+ {file = "httpcore-0.14.7-py3-none-any.whl", hash = "sha256:47d772f754359e56dd9d892d9593b6f9870a37aeb8ba51e9a88b09b3d68cfade"},
+ {file = "httpcore-0.14.7.tar.gz", hash = "sha256:7503ec1c0f559066e7e39bc4003fd2ce023d01cf51793e3c173b864eb456ead1"},
]
httpx = [
- {file = "httpx-0.19.0-py3-none-any.whl", hash = "sha256:9bd728a6c5ec0a9e243932a9983d57d3cc4a87bb4f554e1360fce407f78f9435"},
- {file = "httpx-0.19.0.tar.gz", hash = "sha256:92ecd2c00c688b529eda11cedb15161eaf02dee9116712f621c70d9a40b2cdd0"},
+ {file = "httpx-0.21.3-py3-none-any.whl", hash = "sha256:df9a0fd43fa79dbab411d83eb1ea6f7a525c96ad92e60c2d7f40388971b25777"},
+ {file = "httpx-0.21.3.tar.gz", hash = "sha256:7a3eb67ef0b8abbd6d9402248ef2f84a76080fa1c839f8662e6eb385640e445a"},
]
idna = [
- {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
- {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
+ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
+ {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
]
importlib-metadata = [
- {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"},
- {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"},
+ {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"},
+ {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
isort = [
- {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"},
- {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"},
+ {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
+ {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
]
jinja2 = [
- {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"},
- {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"},
-]
-livereload = [
- {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"},
+ {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"},
+ {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"},
]
loguru = [
- {file = "loguru-0.5.3-py3-none-any.whl", hash = "sha256:f8087ac396b5ee5f67c963b495d615ebbceac2796379599820e324419d53667c"},
- {file = "loguru-0.5.3.tar.gz", hash = "sha256:b28e72ac7a98be3d28ad28570299a393dfcd32e5e3f6a353dec94675767b6319"},
+ {file = "loguru-0.6.0-py3-none-any.whl", hash = "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"},
+ {file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"},
]
markdown = [
- {file = "Markdown-3.3.4-py3-none-any.whl", hash = "sha256:96c3ba1261de2f7547b46a00ea8463832c921d3f9d6aba3f255a6f71386db20c"},
- {file = "Markdown-3.3.4.tar.gz", hash = "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49"},
-]
-markdown-include = [
- {file = "markdown-include-0.6.0.tar.gz", hash = "sha256:6f5d680e36f7780c7f0f61dca53ca581bd50d1b56137ddcd6353efafa0c3e4a2"},
+ {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"},
+ {file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"},
]
markupsafe = [
- {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"},
- {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"},
- {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"},
- {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"},
- {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"},
- {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"},
- {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"},
- {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"},
- {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"},
- {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"},
- {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"},
- {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"},
+ {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"},
+ {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"},
+ {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"},
+ {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"},
+ {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"},
+ {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"},
+ {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"},
+ {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"},
+ {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"},
+ {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"},
+ {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"},
+ {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"},
+ {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"},
+ {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"},
+ {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"},
+ {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"},
+ {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"},
+ {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"},
+ {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"},
+ {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"},
+ {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"},
+ {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"},
+ {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"},
+ {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"},
+ {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"},
+ {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"},
+ {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"},
+ {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"},
+ {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"},
+ {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"},
+ {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"},
+ {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"},
+ {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"},
+ {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"},
+ {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"},
+ {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"},
+ {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"},
+ {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"},
+ {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"},
+ {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"},
]
mccabe = [
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
@@ -1664,92 +1428,77 @@ mergedeep = [
{file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"},
]
mkdocs = [
- {file = "mkdocs-1.2.2-py3-none-any.whl", hash = "sha256:d019ff8e17ec746afeb54eb9eb4112b5e959597aebc971da46a5c9486137f0ff"},
- {file = "mkdocs-1.2.2.tar.gz", hash = "sha256:a334f5bd98ec960638511366eb8c5abc9c99b9083a0ed2401d8791b112d6b078"},
-]
-mkdocs-autorefs = [
- {file = "mkdocs-autorefs-0.2.1.tar.gz", hash = "sha256:b8156d653ed91356e71675ce1fa1186d2b2c2085050012522895c9aa98fca3e5"},
- {file = "mkdocs_autorefs-0.2.1-py3-none-any.whl", hash = "sha256:f301b983a34259df90b3fcf7edc234b5e6c7065bd578781e66fd90b8cfbe76be"},
+ {file = "mkdocs-1.2.3-py3-none-any.whl", hash = "sha256:a1fa8c2d0c1305d7fc2b9d9f607c71778572a8b110fb26642aa00296c9e6d072"},
+ {file = "mkdocs-1.2.3.tar.gz", hash = "sha256:89f5a094764381cda656af4298727c9f53dc3e602983087e1fe96ea1df24f4c1"},
]
mkdocs-material = [
- {file = "mkdocs-material-7.2.6.tar.gz", hash = "sha256:4bdeff63904680865676ceb3193216934de0b33fa5b2446e0a84ade60929ee54"},
- {file = "mkdocs_material-7.2.6-py2.py3-none-any.whl", hash = "sha256:4c6939b9d7d5c6db948ab02df8525c64211828ddf33286acea8b9d2115cec369"},
+ {file = "mkdocs-material-8.1.9.tar.gz", hash = "sha256:a15873a5e116bf4615af4fcedc85a0537492464365286cba50310d96fb066958"},
+ {file = "mkdocs_material-8.1.9-py2.py3-none-any.whl", hash = "sha256:6feb433f29227b862418bd1009edeec2e52870770c476bf02840fc094b8823f2"},
]
mkdocs-material-extensions = [
{file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"},
{file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"},
]
-mkdocstrings = [
- {file = "mkdocstrings-0.15.2-py3-none-any.whl", hash = "sha256:8d6cbe64c07ae66739010979ca01d49dd2f64d1a45009f089d217b9cd2a65e36"},
- {file = "mkdocstrings-0.15.2.tar.gz", hash = "sha256:c2fee9a3a644647c06eb2044fdfede1073adfd1a55bf6752005d3db10705fe73"},
-]
-molten = [
- {file = "molten-1.0.2-py3-none-any.whl", hash = "sha256:e4316ecd97e721ac573ff744803542c0ebeee3f3d37575f42614db84f7f6b737"},
- {file = "molten-1.0.2.tar.gz", hash = "sha256:bc119f0f59f2ac1fab447ce23dc44d0d598784f27482c3be8c4fdf3ad722aae1"},
-]
mypy = [
- {file = "mypy-0.812-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49"},
- {file = "mypy-0.812-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c"},
- {file = "mypy-0.812-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521"},
- {file = "mypy-0.812-cp35-cp35m-win_amd64.whl", hash = "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"},
- {file = "mypy-0.812-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a"},
- {file = "mypy-0.812-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c"},
- {file = "mypy-0.812-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6"},
- {file = "mypy-0.812-cp36-cp36m-win_amd64.whl", hash = "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064"},
- {file = "mypy-0.812-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56"},
- {file = "mypy-0.812-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8"},
- {file = "mypy-0.812-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7"},
- {file = "mypy-0.812-cp37-cp37m-win_amd64.whl", hash = "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564"},
- {file = "mypy-0.812-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506"},
- {file = "mypy-0.812-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5"},
- {file = "mypy-0.812-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66"},
- {file = "mypy-0.812-cp38-cp38-win_amd64.whl", hash = "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e"},
- {file = "mypy-0.812-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a"},
- {file = "mypy-0.812-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a"},
- {file = "mypy-0.812-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97"},
- {file = "mypy-0.812-cp39-cp39-win_amd64.whl", hash = "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df"},
- {file = "mypy-0.812-py3-none-any.whl", hash = "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4"},
- {file = "mypy-0.812.tar.gz", hash = "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119"},
+ {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"},
+ {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"},
+ {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"},
+ {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"},
+ {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"},
+ {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"},
+ {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"},
+ {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"},
+ {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"},
+ {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"},
+ {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"},
+ {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"},
+ {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"},
+ {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"},
+ {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"},
+ {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"},
+ {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"},
+ {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"},
+ {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"},
+ {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"},
+ {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"},
+ {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"},
+ {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
-nox = [
- {file = "nox-2021.6.12-py3-none-any.whl", hash = "sha256:1e90df301f6622efb1c29d1586e5a5755846b1eb99b2764230304f8fa31d1734"},
- {file = "nox-2021.6.12.tar.gz", hash = "sha256:955dbeb8e657a08226f8c1c8f8d1e2a40fe5438a792056314f351e504639a80f"},
-]
packaging = [
- {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
- {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
+ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
+ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
]
pathspec = [
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
]
pbr = [
- {file = "pbr-5.6.0-py2.py3-none-any.whl", hash = "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"},
- {file = "pbr-5.6.0.tar.gz", hash = "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd"},
+ {file = "pbr-5.8.1-py2.py3-none-any.whl", hash = "sha256:27108648368782d07bbf1cb468ad2e2eeef29086affd14087a6d04b7de8af4ec"},
+ {file = "pbr-5.8.1.tar.gz", hash = "sha256:66bc5a34912f408bb3925bf21231cb6f59206267b7f63f3503ef865c1a292e25"},
]
pep8-naming = [
- {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"},
- {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"},
+ {file = "pep8-naming-0.12.1.tar.gz", hash = "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"},
+ {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"},
]
platformdirs = [
- {file = "platformdirs-2.3.0-py3-none-any.whl", hash = "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648"},
- {file = "platformdirs-2.3.0.tar.gz", hash = "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f"},
+ {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"},
+ {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"},
]
pluggy = [
- {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
- {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
+ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
+ {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
py = [
- {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
- {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
+ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
+ {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
]
pycodestyle = [
- {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
- {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
+ {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"},
+ {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"},
]
pydantic = [
{file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"},
@@ -1780,127 +1529,90 @@ pydocstyle = [
{file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"},
]
pyflakes = [
- {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
- {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
+ {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
+ {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"},
]
pygments = [
- {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"},
- {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"},
+ {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
+ {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
]
pymdown-extensions = [
- {file = "pymdown-extensions-8.2.tar.gz", hash = "sha256:b6daa94aad9e1310f9c64c8b1f01e4ce82937ab7eb53bfc92876a97aca02a6f4"},
- {file = "pymdown_extensions-8.2-py3-none-any.whl", hash = "sha256:141452d8ed61165518f2c923454bf054866b85cf466feedb0eb68f04acdc2560"},
+ {file = "pymdown-extensions-9.3.tar.gz", hash = "sha256:a80553b243d3ed2d6c27723bcd64ca9887e560e6f4808baa96f36e93061eaf90"},
+ {file = "pymdown_extensions-9.3-py3-none-any.whl", hash = "sha256:b37461a181c1c8103cfe1660081726a0361a8294cbfda88e5b02cefe976f0546"},
]
pyparsing = [
- {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
- {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
+ {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
+ {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
]
pytest = [
{file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"},
{file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"},
]
pytest-asyncio = [
- {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"},
- {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"},
-]
-pytest-clarity = [
- {file = "pytest-clarity-0.3.0a0.tar.gz", hash = "sha256:5cc99e3d9b7969dfe17e5f6072d45a917c59d363b679686d3c958a1ded2e4dcf"},
+ {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"},
+ {file = "pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b"},
]
pytest-cov = [
- {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"},
- {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"},
+ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
+ {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"},
]
python-dateutil = [
{file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
{file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
]
-python-multipart = [
- {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"},
-]
-pytkdocs = [
- {file = "pytkdocs-0.7.0-py3-none-any.whl", hash = "sha256:96c494143e70ccbb657bc4c0a93a97da0209f839f0236c08f227faedc51c1745"},
- {file = "pytkdocs-0.7.0.tar.gz", hash = "sha256:88c79290525f7658e8271ce19dd343c01c53bbe6c2801d1bfcc6792cad0636d5"},
+python-dotenv = [
+ {file = "python-dotenv-0.19.2.tar.gz", hash = "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f"},
+ {file = "python_dotenv-0.19.2-py2.py3-none-any.whl", hash = "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3"},
]
pyyaml = [
- {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
- {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"},
- {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"},
- {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"},
- {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"},
- {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"},
- {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"},
- {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"},
- {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"},
- {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"},
- {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"},
- {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"},
- {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"},
- {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"},
- {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"},
- {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"},
- {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"},
- {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"},
- {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"},
- {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"},
- {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"},
- {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"},
- {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"},
- {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"},
- {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"},
- {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"},
- {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"},
- {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
- {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
+ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
+ {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
+ {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
+ {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
+ {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
+ {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
+ {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
+ {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
+ {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
+ {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
+ {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
+ {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
+ {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
+ {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
+ {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
+ {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
+ {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
+ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
]
pyyaml-env-tag = [
{file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"},
{file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
]
-regex = [
- {file = "regex-2021.8.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9d05ad5367c90814099000442b2125535e9d77581855b9bee8780f1b41f2b1a2"},
- {file = "regex-2021.8.28-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3bf1bc02bc421047bfec3343729c4bbbea42605bcfd6d6bfe2c07ade8b12d2a"},
- {file = "regex-2021.8.28-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f6a808044faae658f546dd5f525e921de9fa409de7a5570865467f03a626fc0"},
- {file = "regex-2021.8.28-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a617593aeacc7a691cc4af4a4410031654f2909053bd8c8e7db837f179a630eb"},
- {file = "regex-2021.8.28-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79aef6b5cd41feff359acaf98e040844613ff5298d0d19c455b3d9ae0bc8c35a"},
- {file = "regex-2021.8.28-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0fc1f8f06977c2d4f5e3d3f0d4a08089be783973fc6b6e278bde01f0544ff308"},
- {file = "regex-2021.8.28-cp310-cp310-win32.whl", hash = "sha256:6eebf512aa90751d5ef6a7c2ac9d60113f32e86e5687326a50d7686e309f66ed"},
- {file = "regex-2021.8.28-cp310-cp310-win_amd64.whl", hash = "sha256:ac88856a8cbccfc14f1b2d0b829af354cc1743cb375e7f04251ae73b2af6adf8"},
- {file = "regex-2021.8.28-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c206587c83e795d417ed3adc8453a791f6d36b67c81416676cad053b4104152c"},
- {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8690ed94481f219a7a967c118abaf71ccc440f69acd583cab721b90eeedb77c"},
- {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:328a1fad67445550b982caa2a2a850da5989fd6595e858f02d04636e7f8b0b13"},
- {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c7cb4c512d2d3b0870e00fbbac2f291d4b4bf2634d59a31176a87afe2777c6f0"},
- {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66256b6391c057305e5ae9209941ef63c33a476b73772ca967d4a2df70520ec1"},
- {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8e44769068d33e0ea6ccdf4b84d80c5afffe5207aa4d1881a629cf0ef3ec398f"},
- {file = "regex-2021.8.28-cp36-cp36m-win32.whl", hash = "sha256:08d74bfaa4c7731b8dac0a992c63673a2782758f7cfad34cf9c1b9184f911354"},
- {file = "regex-2021.8.28-cp36-cp36m-win_amd64.whl", hash = "sha256:abb48494d88e8a82601af905143e0de838c776c1241d92021e9256d5515b3645"},
- {file = "regex-2021.8.28-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b4c220a1fe0d2c622493b0a1fd48f8f991998fb447d3cd368033a4b86cf1127a"},
- {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a332404baa6665b54e5d283b4262f41f2103c255897084ec8f5487ce7b9e8e"},
- {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c61dcc1cf9fd165127a2853e2c31eb4fb961a4f26b394ac9fe5669c7a6592892"},
- {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ee329d0387b5b41a5dddbb6243a21cb7896587a651bebb957e2d2bb8b63c0791"},
- {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f60667673ff9c249709160529ab39667d1ae9fd38634e006bec95611f632e759"},
- {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b844fb09bd9936ed158ff9df0ab601e2045b316b17aa8b931857365ea8586906"},
- {file = "regex-2021.8.28-cp37-cp37m-win32.whl", hash = "sha256:4cde065ab33bcaab774d84096fae266d9301d1a2f5519d7bd58fc55274afbf7a"},
- {file = "regex-2021.8.28-cp37-cp37m-win_amd64.whl", hash = "sha256:1413b5022ed6ac0d504ba425ef02549a57d0f4276de58e3ab7e82437892704fc"},
- {file = "regex-2021.8.28-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ed4b50355b066796dacdd1cf538f2ce57275d001838f9b132fab80b75e8c84dd"},
- {file = "regex-2021.8.28-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28fc475f560d8f67cc8767b94db4c9440210f6958495aeae70fac8faec631797"},
- {file = "regex-2021.8.28-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdc178caebd0f338d57ae445ef8e9b737ddf8fbc3ea187603f65aec5b041248f"},
- {file = "regex-2021.8.28-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:999ad08220467b6ad4bd3dd34e65329dd5d0df9b31e47106105e407954965256"},
- {file = "regex-2021.8.28-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:808ee5834e06f57978da3e003ad9d6292de69d2bf6263662a1a8ae30788e080b"},
- {file = "regex-2021.8.28-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d5111d4c843d80202e62b4fdbb4920db1dcee4f9366d6b03294f45ed7b18b42e"},
- {file = "regex-2021.8.28-cp38-cp38-win32.whl", hash = "sha256:473858730ef6d6ff7f7d5f19452184cd0caa062a20047f6d6f3e135a4648865d"},
- {file = "regex-2021.8.28-cp38-cp38-win_amd64.whl", hash = "sha256:31a99a4796bf5aefc8351e98507b09e1b09115574f7c9dbb9cf2111f7220d2e2"},
- {file = "regex-2021.8.28-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:04f6b9749e335bb0d2f68c707f23bb1773c3fb6ecd10edf0f04df12a8920d468"},
- {file = "regex-2021.8.28-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b006628fe43aa69259ec04ca258d88ed19b64791693df59c422b607b6ece8bb"},
- {file = "regex-2021.8.28-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121f4b3185feaade3f85f70294aef3f777199e9b5c0c0245c774ae884b110a2d"},
- {file = "regex-2021.8.28-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a577a21de2ef8059b58f79ff76a4da81c45a75fe0bfb09bc8b7bb4293fa18983"},
- {file = "regex-2021.8.28-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1743345e30917e8c574f273f51679c294effba6ad372db1967852f12c76759d8"},
- {file = "regex-2021.8.28-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e1e8406b895aba6caa63d9fd1b6b1700d7e4825f78ccb1e5260551d168db38ed"},
- {file = "regex-2021.8.28-cp39-cp39-win32.whl", hash = "sha256:ed283ab3a01d8b53de3a05bfdf4473ae24e43caee7dcb5584e86f3f3e5ab4374"},
- {file = "regex-2021.8.28-cp39-cp39-win_amd64.whl", hash = "sha256:610b690b406653c84b7cb6091facb3033500ee81089867ee7d59e675f9ca2b73"},
- {file = "regex-2021.8.28.tar.gz", hash = "sha256:f585cbbeecb35f35609edccb95efd95a3e35824cd7752b586503f7e6087303f1"},
+requests = [
+ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
+ {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
+]
+respx = [
+ {file = "respx-0.19.0-py2.py3-none-any.whl", hash = "sha256:1ac1cc99bf892ffd3e33108ae43d71d8309a58ac226965f4bd81ec055600f265"},
+ {file = "respx-0.19.0.tar.gz", hash = "sha256:4a09e15803c7450d45303520ec528794c9fd77b05984263bc83b78aabbb39413"},
]
restructuredtext-lint = [
- {file = "restructuredtext_lint-1.3.2.tar.gz", hash = "sha256:d3b10a1fe2ecac537e51ae6d151b223b78de9fafdd50e5eb6b08c243df173c80"},
+ {file = "restructuredtext_lint-1.4.0.tar.gz", hash = "sha256:1b235c0c922341ab6c530390892eb9e92f90b9b75046063e047cacfb0f050c45"},
]
rfc3986 = [
{file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
@@ -1911,163 +1623,87 @@ six = [
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
smmap = [
- {file = "smmap-4.0.0-py2.py3-none-any.whl", hash = "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2"},
- {file = "smmap-4.0.0.tar.gz", hash = "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182"},
+ {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"},
+ {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"},
]
sniffio = [
{file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
{file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
]
snowballstemmer = [
- {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"},
- {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"},
+ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
+ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
]
starlette = [
- {file = "starlette-0.16.0-py3-none-any.whl", hash = "sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f"},
- {file = "starlette-0.16.0.tar.gz", hash = "sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870"},
+ {file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"},
+ {file = "starlette-0.17.1.tar.gz", hash = "sha256:57eab3cc975a28af62f6faec94d355a410634940f10b30d68d31cb5ec1b44ae8"},
]
stevedore = [
- {file = "stevedore-3.4.0-py3-none-any.whl", hash = "sha256:920ce6259f0b2498aaa4545989536a27e4e4607b8318802d7ddc3a533d3d069e"},
- {file = "stevedore-3.4.0.tar.gz", hash = "sha256:59b58edb7f57b11897f150475e7bc0c39c5381f0b8e3fa9f5c20ce6c89ec4aa1"},
-]
-termcolor = [
- {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"},
+ {file = "stevedore-3.5.0-py3-none-any.whl", hash = "sha256:a547de73308fd7e90075bb4d301405bebf705292fa90a90fc3bcf9133f58616c"},
+ {file = "stevedore-3.5.0.tar.gz", hash = "sha256:f40253887d8712eaa2bb0ea3830374416736dc8ec0e22f5a65092c1174c44335"},
]
testfixtures = [
- {file = "testfixtures-6.18.1-py2.py3-none-any.whl", hash = "sha256:486be7b01eb71326029811878a3317b7e7994324621c0ec633c8e24499d8d5b3"},
- {file = "testfixtures-6.18.1.tar.gz", hash = "sha256:0a6422737f6d89b45cdef1e2df5576f52ad0f507956002ce1020daa9f44211d6"},
+ {file = "testfixtures-6.18.5-py2.py3-none-any.whl", hash = "sha256:7de200e24f50a4a5d6da7019fb1197aaf5abd475efb2ec2422fdcf2f2eb98c1d"},
+ {file = "testfixtures-6.18.5.tar.gz", hash = "sha256:02dae883f567f5b70fd3ad3c9eefb95912e78ac90be6c7444b5e2f46bf572c84"},
]
tokenize-rt = [
- {file = "tokenize_rt-4.1.0-py2.py3-none-any.whl", hash = "sha256:b37251fa28c21e8cce2e42f7769a35fba2dd2ecafb297208f9a9a8add3ca7793"},
- {file = "tokenize_rt-4.1.0.tar.gz", hash = "sha256:ab339b5ff829eb5e198590477f9c03c84e762b3e455e74c018956e7e326cbc70"},
+ {file = "tokenize_rt-4.2.1-py2.py3-none-any.whl", hash = "sha256:08a27fa032a81cf45e8858d0ac706004fcd523e8463415ddf1442be38e204ea8"},
+ {file = "tokenize_rt-4.2.1.tar.gz", hash = "sha256:0d4f69026fed520f8a1e0103aa36c406ef4661417f20ca643f913e33531b3b94"},
]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
-tornado = [
- {file = "tornado-6.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32"},
- {file = "tornado-6.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c"},
- {file = "tornado-6.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05"},
- {file = "tornado-6.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910"},
- {file = "tornado-6.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b"},
- {file = "tornado-6.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675"},
- {file = "tornado-6.1-cp35-cp35m-win32.whl", hash = "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5"},
- {file = "tornado-6.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68"},
- {file = "tornado-6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb"},
- {file = "tornado-6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c"},
- {file = "tornado-6.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921"},
- {file = "tornado-6.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558"},
- {file = "tornado-6.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c"},
- {file = "tornado-6.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085"},
- {file = "tornado-6.1-cp36-cp36m-win32.whl", hash = "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575"},
- {file = "tornado-6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795"},
- {file = "tornado-6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f"},
- {file = "tornado-6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102"},
- {file = "tornado-6.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4"},
- {file = "tornado-6.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd"},
- {file = "tornado-6.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01"},
- {file = "tornado-6.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d"},
- {file = "tornado-6.1-cp37-cp37m-win32.whl", hash = "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df"},
- {file = "tornado-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37"},
- {file = "tornado-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95"},
- {file = "tornado-6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a"},
- {file = "tornado-6.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5"},
- {file = "tornado-6.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288"},
- {file = "tornado-6.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f"},
- {file = "tornado-6.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6"},
- {file = "tornado-6.1-cp38-cp38-win32.whl", hash = "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326"},
- {file = "tornado-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c"},
- {file = "tornado-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5"},
- {file = "tornado-6.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe"},
- {file = "tornado-6.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea"},
- {file = "tornado-6.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2"},
- {file = "tornado-6.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0"},
- {file = "tornado-6.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd"},
- {file = "tornado-6.1-cp39-cp39-win32.whl", hash = "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c"},
- {file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"},
- {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"},
-]
-typed-ast = [
- {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"},
- {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"},
- {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"},
- {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"},
- {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"},
- {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"},
- {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"},
- {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"},
- {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"},
- {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"},
- {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"},
- {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"},
- {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"},
- {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"},
- {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"},
- {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"},
- {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"},
- {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"},
- {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"},
- {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"},
- {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"},
- {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"},
- {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"},
- {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"},
- {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"},
- {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"},
- {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"},
- {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"},
- {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"},
- {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
+tomli = [
+ {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"},
+ {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"},
]
typing-extensions = [
- {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
- {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
- {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
+ {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"},
+ {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"},
]
-typing-inspect = [
- {file = "typing_inspect-0.6.0-py2-none-any.whl", hash = "sha256:de08f50a22955ddec353876df7b2545994d6df08a2f45d54ac8c05e530372ca0"},
- {file = "typing_inspect-0.6.0-py3-none-any.whl", hash = "sha256:3b98390df4d999a28cf5b35d8b333425af5da2ece8a4ea9e98f71e7591347b4f"},
- {file = "typing_inspect-0.6.0.tar.gz", hash = "sha256:8f1b1dd25908dbfd81d3bebc218011531e7ab614ba6e5bf7826d887c834afab7"},
+urllib3 = [
+ {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"},
+ {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"},
]
-virtualenv = [
- {file = "virtualenv-20.7.2-py2.py3-none-any.whl", hash = "sha256:e4670891b3a03eb071748c569a87cceaefbf643c5bac46d996c5a45c34aa0f06"},
- {file = "virtualenv-20.7.2.tar.gz", hash = "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0"},
+uvicorn = [
+ {file = "uvicorn-0.16.0-py3-none-any.whl", hash = "sha256:d8c839231f270adaa6d338d525e2652a0b4a5f4c2430b5c4ef6ae4d11776b0d2"},
+ {file = "uvicorn-0.16.0.tar.gz", hash = "sha256:eacb66afa65e0648fcbce5e746b135d09722231ffffc61883d4fac2b62fbea8d"},
]
watchdog = [
- {file = "watchdog-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5f57ce4f7e498278fb2a091f39359930144a0f2f90ea8cbf4523c4e25de34028"},
- {file = "watchdog-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8b74d0d92a69a7ab5f101f9fe74e44ba017be269efa824337366ccbb4effde85"},
- {file = "watchdog-2.1.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:59767f476cd1f48531bf378f0300565d879688c82da8369ca8c52f633299523c"},
- {file = "watchdog-2.1.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:814d396859c95598f7576d15bc257c3bd3ba61fa4bc1db7dfc18f09070ded7da"},
- {file = "watchdog-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:28777dbed3bbd95f9c70f461443990a36c07dbf49ae7cd69932cdd1b8fb2850c"},
- {file = "watchdog-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5cf78f794c9d7bc64a626ef4f71aff88f57a7ae288e0b359a9c6ea711a41395f"},
- {file = "watchdog-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:43bf728eb7830559f329864ab5da2302c15b2efbac24ad84ccc09949ba753c40"},
- {file = "watchdog-2.1.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a7053d4d22dc95c5e0c90aeeae1e4ed5269d2f04001798eec43a654a03008d22"},
- {file = "watchdog-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6f3ad1d973fe8fc8fe64ba38f6a934b74346342fa98ef08ad5da361a05d46044"},
- {file = "watchdog-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41d44ef21a77a32b55ce9bf59b75777063751f688de51098859b7c7f6466589a"},
- {file = "watchdog-2.1.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ed4ca4351cd2bb0d863ee737a2011ca44d8d8be19b43509bd4507f8a449b376b"},
- {file = "watchdog-2.1.5-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8874d5ad6b7f43b18935d9b0183e29727a623a216693d6938d07dfd411ba462f"},
- {file = "watchdog-2.1.5-py3-none-manylinux2014_aarch64.whl", hash = "sha256:50a7f81f99d238f72185f481b493f9de80096e046935b60ea78e1276f3d76960"},
- {file = "watchdog-2.1.5-py3-none-manylinux2014_armv7l.whl", hash = "sha256:e40e33a4889382824846b4baa05634e1365b47c6fa40071dc2d06b4d7c715fc1"},
- {file = "watchdog-2.1.5-py3-none-manylinux2014_i686.whl", hash = "sha256:78b1514067ff4089f4dac930b043a142997a5b98553120919005e97fbaba6546"},
- {file = "watchdog-2.1.5-py3-none-manylinux2014_ppc64.whl", hash = "sha256:58ae842300cbfe5e62fb068c83901abe76e4f413234b7bec5446e4275eb1f9cb"},
- {file = "watchdog-2.1.5-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:b0cc7d8b7d60da6c313779d85903ce39a63d89d866014b085f720a083d5f3e9a"},
- {file = "watchdog-2.1.5-py3-none-manylinux2014_s390x.whl", hash = "sha256:e60d3bb7166b7cb830b86938d1eb0e6cfe23dfd634cce05c128f8f9967895193"},
- {file = "watchdog-2.1.5-py3-none-manylinux2014_x86_64.whl", hash = "sha256:51af09ae937ada0e9a10cc16988ec03c649754a91526170b6839b89fc56d6acb"},
- {file = "watchdog-2.1.5-py3-none-win32.whl", hash = "sha256:9391003635aa783957b9b11175d9802d3272ed67e69ef2e3394c0b6d9d24fa9a"},
- {file = "watchdog-2.1.5-py3-none-win_amd64.whl", hash = "sha256:eab14adfc417c2c983fbcb2c73ef3f28ba6990d1fff45d1180bf7e38bda0d98d"},
- {file = "watchdog-2.1.5-py3-none-win_ia64.whl", hash = "sha256:a2888a788893c4ef7e562861ec5433875b7915f930a5a7ed3d32c048158f1be5"},
- {file = "watchdog-2.1.5.tar.gz", hash = "sha256:5563b005907613430ef3d4aaac9c78600dd5704e84764cb6deda4b3d72807f09"},
+ {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9693f35162dc6208d10b10ddf0458cc09ad70c30ba689d9206e02cd836ce28a3"},
+ {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aba5c812f8ee8a3ff3be51887ca2d55fb8e268439ed44110d3846e4229eb0e8b"},
+ {file = "watchdog-2.1.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ae38bf8ba6f39d5b83f78661273216e7db5b00f08be7592062cb1fc8b8ba542"},
+ {file = "watchdog-2.1.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ad6f1796e37db2223d2a3f302f586f74c72c630b48a9872c1e7ae8e92e0ab669"},
+ {file = "watchdog-2.1.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:922a69fa533cb0c793b483becaaa0845f655151e7256ec73630a1b2e9ebcb660"},
+ {file = "watchdog-2.1.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b2fcf9402fde2672545b139694284dc3b665fd1be660d73eca6805197ef776a3"},
+ {file = "watchdog-2.1.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3386b367e950a11b0568062b70cc026c6f645428a698d33d39e013aaeda4cc04"},
+ {file = "watchdog-2.1.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f1c00aa35f504197561060ca4c21d3cc079ba29cf6dd2fe61024c70160c990b"},
+ {file = "watchdog-2.1.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b52b88021b9541a60531142b0a451baca08d28b74a723d0c99b13c8c8d48d604"},
+ {file = "watchdog-2.1.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8047da932432aa32c515ec1447ea79ce578d0559362ca3605f8e9568f844e3c6"},
+ {file = "watchdog-2.1.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e92c2d33858c8f560671b448205a268096e17870dcf60a9bb3ac7bfbafb7f5f9"},
+ {file = "watchdog-2.1.6-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b7d336912853d7b77f9b2c24eeed6a5065d0a0cc0d3b6a5a45ad6d1d05fb8cd8"},
+ {file = "watchdog-2.1.6-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cca7741c0fcc765568350cb139e92b7f9f3c9a08c4f32591d18ab0a6ac9e71b6"},
+ {file = "watchdog-2.1.6-py3-none-manylinux2014_armv7l.whl", hash = "sha256:25fb5240b195d17de949588628fdf93032ebf163524ef08933db0ea1f99bd685"},
+ {file = "watchdog-2.1.6-py3-none-manylinux2014_i686.whl", hash = "sha256:be9be735f827820a06340dff2ddea1fb7234561fa5e6300a62fe7f54d40546a0"},
+ {file = "watchdog-2.1.6-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0d19fb2441947b58fbf91336638c2b9f4cc98e05e1045404d7a4cb7cddc7a65"},
+ {file = "watchdog-2.1.6-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:3becdb380d8916c873ad512f1701f8a92ce79ec6978ffde92919fd18d41da7fb"},
+ {file = "watchdog-2.1.6-py3-none-manylinux2014_s390x.whl", hash = "sha256:ae67501c95606072aafa865b6ed47343ac6484472a2f95490ba151f6347acfc2"},
+ {file = "watchdog-2.1.6-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e0f30db709c939cabf64a6dc5babb276e6d823fd84464ab916f9b9ba5623ca15"},
+ {file = "watchdog-2.1.6-py3-none-win32.whl", hash = "sha256:e02794ac791662a5eafc6ffeaf9bcc149035a0e48eb0a9d40a8feb4622605a3d"},
+ {file = "watchdog-2.1.6-py3-none-win_amd64.whl", hash = "sha256:bd9ba4f332cf57b2c1f698be0728c020399ef3040577cde2939f2e045b39c1e5"},
+ {file = "watchdog-2.1.6-py3-none-win_ia64.whl", hash = "sha256:a0f1c7edf116a12f7245be06120b1852275f9506a7d90227648b250755a03923"},
+ {file = "watchdog-2.1.6.tar.gz", hash = "sha256:a36e75df6c767cbf46f61a91c70b3ba71811dfa0aca4a324d9407a06a8b7a2e7"},
]
wemake-python-styleguide = [
- {file = "wemake-python-styleguide-0.15.3.tar.gz", hash = "sha256:8b89aedabae67b7b915908ed06c178b702068137c0d8afe1fb59cdc829cd2143"},
- {file = "wemake_python_styleguide-0.15.3-py3-none-any.whl", hash = "sha256:a382f6c9ec87d56daa08a11e47cab019c99b384f1393b32564ebc74c6da80441"},
+ {file = "wemake-python-styleguide-0.16.0.tar.gz", hash = "sha256:3bf0a4962404e6fd6fa479e72e2ba3fb75d5920ea6c44b72b45240c9e519543c"},
+ {file = "wemake_python_styleguide-0.16.0-py3-none-any.whl", hash = "sha256:8caa92b4aa77b08a505d718553238812d1b612b1036bc171ca3aa18345efe0b4"},
]
win32-setctime = [
- {file = "win32_setctime-1.0.3-py3-none-any.whl", hash = "sha256:dc925662de0a6eb987f0b01f599c01a8236cb8c62831c22d9cada09ad958243e"},
- {file = "win32_setctime-1.0.3.tar.gz", hash = "sha256:4e88556c32fdf47f64165a2180ba4552f8bb32c1103a2fafd05723a0bd42bd4b"},
+ {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
+ {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
]
zipp = [
- {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"},
- {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"},
+ {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"},
+ {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"},
]
diff --git a/pyproject.toml b/pyproject.toml
index b858a205..6bfb5769 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,86 +1,52 @@
[tool.poetry]
name = "botx"
-version = "0.28.0"
-description = "A little python framework for building bots for eXpress"
-license = "MIT"
+version = "0.30.0"
+description = "A python library for interacting with eXpress BotX API"
authors = [
"Sidnev Nikolay ",
"Maxim Gorbachev ",
- "Alexander Samoylenko "
+ "Alexander Samoylenko ",
+ "Arseniy Zhiltsov "
]
-readme = "README.md"
documentation = "https://expressapp.github.io/pybotx"
repository = "https://github.com/ExpressApp/pybotx"
+
[tool.poetry.dependencies]
-python = "^3.7"
+python = ">=3.8,<3.11"
-base64io = "^1.0.3"
-httpx = "^0.19.0"
-loguru = "^0.5.0"
-pydantic = "^1.0.0"
-typing-extensions = { version = "^3.7.4", python = "<3.8" }
+aiofiles = ">=0.7.0,<0.9.0"
+httpx = ">=0.18.0,<0.22.0"
+loguru = ">=0.6.0,<0.7.0"
+mypy-extensions = ">=0.2.0,<0.5.0"
+pydantic = ">=1.6.0,<1.9.0"
+typing-extensions = ">=3.7.4,<5.0.0"
-# for testing by users
-aiofiles = { version = "^0.7.0", optional = true }
-molten = { version = "^1.0.1", optional = true }
-python-multipart = { version = "^0.0.5", optional = true }
[tool.poetry.dev-dependencies]
-# tasks
-nox = "^2021.0.0"
-# formatters
-black = "^20.8b1"
-isort = "^5.9"
-autoflake = "^1.4"
-add-trailing-comma = "^2.0.1"
-# linters
-mypy = "^0.812"
-wemake-python-styleguide = "^0.15"
-flake8-pytest-style = "^1.1.1"
-# tests
-pytest = "^6.0.0"
-pytest-asyncio = "^0.15.0"
-pytest-cov = "^2.8.1"
-pytest-clarity = "^0.3.0-alpha.0"
-coverage-conditional-plugin = "^0.4.0"
-starlette = "^0.16.0"
-# docs
-mkdocs = "^1.1"
-mkdocs-material = "^7.0.0"
-markdown-include = "^0.6.0"
-mkdocstrings = "^0.15.0"
-livereload = "^2.6.3"
+add-trailing-comma = "2.2.1"
+autoflake = "1.4.0"
+black = "21.12b0"
+isort = "5.10.1"
+mypy = "0.910.0"
+wemake-python-styleguide = "0.16.0"
+bandit = "1.7.2" # https://github.com/PyCQA/bandit/issues/837
+
+pytest = "6.2.5"
+pytest-asyncio = "0.16.0"
+pytest-cov = "3.0.0"
+python-dotenv = "0.19.2"
+requests = "2.26.0"
+respx = "0.19.0"
-[tool.poetry.extras]
-tests = ["aiofiles", "molten", "python-multipart"]
+mkdocs = "1.2.3"
+mkdocs-material = "8.1.9"
+markdown = "3.3.6" # https://github.com/python-poetry/poetry/issues/4777
-[tool.black]
-target_version = ['py37', 'py38']
-include = '\.pyi?$'
-exclude = '''
-/(\.git/
- |\.eggs
- |\.hg
- |__pycache__
- |\.cache
- |\.ipynb_checkpoints
- |\.mypy_cache
- |\.pytest_cache
- |\.tox
- |\.venv
- |node_modules
- |_build
- |buck-out
- |build
- |dist
- |media
- |infrastructure
- |templates
- |locale
-)/
-'''
+fastapi = "0.73.0"
+starlette = "0.17.1" # TODO: Drop dependency after updating end-to-end test
+uvicorn = "0.16.0"
[build-system]
-requires = ["poetry>=0.1.0"]
+requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"
diff --git a/scripts/docs-format b/scripts/docs-format
new file mode 100755
index 00000000..7acc651f
--- /dev/null
+++ b/scripts/docs-format
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+
+set -ex
+
+autoflake --recursive --in-place \
+ --remove-all-unused-imports \
+ --ignore-init-module-imports \
+ docs/snippets
+isort --profile black docs/snippets
+black docs/snippets
+
+find docs/snippets -type f -name "*.py" | xargs add-trailing-comma --py36-plus --exit-zero-even-if-changed
+
+# This `black` is needed again in order to transfer parameters/arguments to new lines
+# after inserting commas.
+# The first `black` won't be able to transfer parameters/arguments to new lines because
+# there is no comma at the end of the line.
+# Inserting commas must be after the first `black`, so that there is one new line break,
+# if the line is out of max-line-length.
+black botx tests > /dev/null
diff --git a/scripts/docs-lint b/scripts/docs-lint
new file mode 100755
index 00000000..c73e32d6
--- /dev/null
+++ b/scripts/docs-lint
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+
+set -ex
+
+black --check --diff docs/snippets
+isort --profile black --check-only docs/snippets
+
+mypy docs/snippets
+flake8 docs/snippets
diff --git a/scripts/format b/scripts/format
new file mode 100755
index 00000000..dd2b08e8
--- /dev/null
+++ b/scripts/format
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+
+set -ex
+
+autoflake --recursive --in-place \
+ --remove-all-unused-imports \
+ --ignore-init-module-imports \
+ botx tests
+isort --profile black botx tests
+black botx tests
+
+find botx -type f -name "*.py" | xargs add-trailing-comma --py36-plus --exit-zero-even-if-changed
+find tests -type f -name "*.py" | xargs add-trailing-comma --py36-plus --exit-zero-even-if-changed
+
+# This `black` is needed again in order to transfer parameters/arguments to new lines
+# after inserting commas.
+# The first `black` won't be able to transfer parameters/arguments to new lines because
+# there is no comma at the end of the line.
+# Inserting commas must be after the first `black`, so that there is one new line break,
+# if the line is out of max-line-length.
+black botx tests > /dev/null
diff --git a/scripts/lint b/scripts/lint
new file mode 100755
index 00000000..c2da4b1f
--- /dev/null
+++ b/scripts/lint
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+
+set -ex
+
+black --check --diff botx tests
+isort --profile black --check-only botx tests
+
+mypy botx tests
+flake8 botx tests
+
+./scripts/wip_marks
diff --git a/scripts/test b/scripts/test
new file mode 100755
index 00000000..743b0a6b
--- /dev/null
+++ b/scripts/test
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+set -ex
+
+pytest ${@}
diff --git a/scripts/wip_marks b/scripts/wip_marks
new file mode 100755
index 00000000..daddf39e
--- /dev/null
+++ b/scripts/wip_marks
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+
+wip_markers=$(find . -name "*.py" -print0 | xargs -0 grep -n "@pytest.mark.wip")
+
+if [[ "$wip_markers" ]]
+then
+ printf "Some wip marks found:\n%s\n" "$wip_markers" >&2
+ exit 1
+fi
diff --git a/setup.cfg b/setup.cfg
index 7639e7a4..eed2b5d5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,206 +1,153 @@
-# All configuration for plugins and other utils is defined here.
-# Read more about `setup.cfg`:
-# https://docs.python.org/3/distutils/configfile.html
-
-
-[isort]
-# isort configuration:
-# https://github.com/timothycrosley/isort/wiki/isort-Settings
-include_trailing_comma = true
-# See https://github.com/timothycrosley/isort#multi-line-output-modes
-multi_line_output = 3
-line_length = 88
-force_grid_wrap = 0
-combine_as_imports = True
-
-
-[darglint]
-# darglint configuration:
-# https://github.com/terrencepreilly/darglint
-strictness = long
-
-
-[tool:pytest]
-# Directories that are not visited by pytest collector:
-norecursedirs = *.egg .eggs dist build docs .tox .git __pycache__
-
-# You will need to measure your tests speed with `-n auto` and without it,
-# so you can see whether it gives you any performance gain, or just gives
-# you an overhead. See `docs/template/development-process.rst`.
-addopts =
- --strict-markers
- --tb=short
- --cov=botx
- --cov=tests
- --cov-branch
- --cov-report=term-missing
- --cov-report=html
- --cov-report=xml
- --no-cov-on-fail
- --cov-fail-under=100
-
-
-[coverage:run]
-# Here we specify plugins for coverage to be used:
-plugins =
- coverage_conditional_plugin
-
-omit =
-
-
-[coverage:report]
-precision = 2
-exclude_lines =
- pragma: no cover
- raise NotImplementedError
- if TYPE_CHECKING:
- except ImportError:
-
-
-[coverage:coverage_conditional_plugin]
-# Here we specify our pragma rules:
-rules =
- "sys_version_info < (3, 8)": py-lt-38
-
-
[mypy]
-# Mypy configuration:
-# https://mypy.readthedocs.io/en/latest/config_file.html
plugins = pydantic.mypy
+warn_unused_configs = True
+disallow_any_generics = True
+disallow_subclassing_any = True
+disallow_untyped_calls = True
disallow_untyped_defs = True
-strict_optional = True
+disallow_incomplete_defs = True
+check_untyped_defs = True
+disallow_untyped_decorators = True
+no_implicit_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
+warn_return_any = True
+no_implicit_reexport = True
+strict_equality = True
show_error_codes = True
+[mypy-tests.*]
+# https://github.com/python/mypy/issues/9689
+disallow_untyped_decorators = False
-[pydantic-mypy]
-init_forbid_extra = True
-init_typed = True
-warn_required_dynamic_aliases = True
-
+[mypy-aiofiles.*]
+ignore_missing_imports = True
-[mypy-noxfile]
-# Nox decorators return untyped callables
-disallow_untyped_decorators = false
+[mypy-pytest.*]
+ignore_missing_imports = True
+[mypy-respx.*]
+ignore_missing_imports = True
-[mypy-tests.*]
-# ignore mypy on tests package
-ignore_errors = true
+[isort]
+profile = black
+multi_line_output = 3
-[mypy-base64io.*]
-ignore_missing_imports = True
+[darglint]
+# darglint configuration:
+# https://github.com/terrencepreilly/darglint
+strictness = short
+docstring_style = sphinx
[flake8]
format = wemake
-show-source = True
-statistics = False
+show-source = true
-# Flake plugins:
max-line-length = 88
inline-quotes = double
-i-control-code = False
-# currently 13 imports are used for Bot definition
-max-imports = 13
-nested-classes-whitelist = Config
-allowed-domain-names =
- # handler is something similar to "views" from common framework, but for bot:
- handler,
-
- # BotX API is built with similar to json-rpc approach and use "result" field for responses:
- result,
-
- # file is field that is used in BotX API and it's an entity provided by library:
- file,
-pytest-raises-require-match-for =
-
-# Excluding some directories:
-exclude = .git,__pycache__,.venv,.eggs,*.egg
-
-# Docs: https://github.com/snoack/flake8-per-file-ignores
-# You can completely or partially disable our custom checks,
-# to do so you have to ignore `WPS` letters for all python files:
-per-file-ignores =
- # WPS:
- # "validated_values" is required name for pydantic validator in case it receives validated query_params:
- botx/collecting/handlers/validators.py: WPS110,
-
- # re-exports from library using __all__:
- botx/__init__.py: WPS201, WPS203, WPS235, WPS410
-
- # allow inheritance from builtin, since there are enums for pydantic, module members, OverusedStringViolation:
- botx/models/enums.py: WPS600, WPS202, WPS226
-
- # TODO: simplify test utils
- botx/testing/testing_client/base.py: WPS201
- botx/testing/botx_mock/asgi/routes/chats.py: WPS202, WPS204
- botx/testing/botx_mock/wsgi/routes/chats.py: WPS202, WPS204
- botx/testing/botx_mock/asgi/routes/stickers.py: WPS202, WPS204, WPS226
- botx/testing/botx_mock/wsgi/routes/stickers.py: WPS202, WPS204, WPS226
-
- # magic method(__root__)
- botx/testing/building/attachments.py: WPS609
- botx/testing/building/entites.py: WPS609
-
- # E800 for disable formatter
- botx/models/attachments.py: WPS110, WPS125, WPS202, E800
-
- # allow noqa overuse and many imports (we should reconsider WPS ignores)
- botx/models/messages/sending/message.py: WPS402, WPS201
+nested_classes_whitelist = Config
+allowed_domain_names = data, handler, result, content, file
- # cls to pydantic validators
- botx/models/*.py: N805
+per-file-ignores =
+ botx/bot/bot.py:WPS203,
+ botx/constants.py:WPS432,
+ botx/__init__.py:WPS203,WPS410,WPS412,F401,
+ # https://github.com/wemake-services/wemake-python-styleguide/issues/2172
+ botx/bot/handler_collector.py:WPS437,
+ botx/client/notifications_api/internal_bot_notification.py:WPS202,
+ # Complex model converting
+ botx/models/message/incoming_message.py:WPS232,
+ # WPS reacts at using `}` in f-strings
+ botx/models/message/mentions.py:WPS226,
+ # Protected attr usage is OK with async_files
+ botx/models/async_files.py:WPS437,
+ botx/models/api_base.py:WPS232,WPS231,WPS110,WPS440
+ # This ignores make logger code cleaner
+ botx/logger.py:WPS219,WPS226
+
+ tests/*:DAR101,E501,WPS110,WPS114,WPS116,WPS118,WPS202,WPS221,WPS226,WPS237,WPS402,WPS420,WPS428,WPS430,WPS432,WPS441,WPS442,WPS520,PT011,S105,S106
- # disable most linting issues for tests:
- # TODO: configure linting for tests more strictly
- tests/*.py: D, S101, S106, WPS, B015
+ignore =
+ # This project uses google style docstring
+ RST,
+ # Upper-case constant in class
+ WPS115,
+ # Too many module members
+ WPS202,
+ # Too many arguments
+ WPS211,
+ # f-strings
+ WPS305,
+ # Class without base class
+ WPS306,
+ # Implicit string concatenation
+ WPS326,
+ # Explicit string concatenation
+ WPS336,
+ # Module docstring
+ D100,
+ # Class docstring
+ D101,
+ # Method docstring
+ D102,
+ # Function docstring
+ D103,
+ # Package docstring
+ D104,
+ # Magic method docstring
+ D105,
+ # Nested class docstring
+ D106,
+ # __init__ docstring
+ D107,
+ # Allow empty line after docstring
+ D202,
+ # Line break before binary operator
+ W503,
+ # Too many methods
+ WPS214,
+ # Too many imports
+ WPS201,
+ # Overused expression
+ WPS204,
+ # Too many local vars
+ WPS210,
+ # Too many imported names from module
+ WPS235,
+ # Multiple conditions
+ WPS337,
+ # Nested imports (often used with ImportError)
+ WPS433,
+ # Forbidden `@staticmethod`
+ WPS602,
+ # Forbidden `assert`
+ S101,
- # module with content examplest
- botx/testing/content.py: E501
- # imports into module with collector model is ok
- botx/bots/bots.py: WPS201
+[tool:pytest]
+testpaths = tests
- # many chat methods
- botx/bots/mixins/requests/chats.py: WPS201, WPS214
+addopts =
+ --strict-markers
+ --tb=short
+ --cov=botx
+ --cov-report=term-missing
+ --cov-branch
+ --no-cov-on-fail
+ --cov-fail-under=100
- botx/exceptions.py: WPS202
- botx/models/events.py: WPS202
+markers =
+ wip: "Work in progress"
+ mock_authorization: "Mock authorization"
- # too many module members
- # too many imported names from a module
- # found overused expression
- botx/bots/mixins/requests/mixin.py: WPS235
- botx/testing/botx_mock/asgi/application.py: WPS235
- botx/testing/botx_mock/asgi/routes/chats.py: WPS202,WPS204,WPS235
- botx/testing/botx_mock/wsgi/application.py: WPS235
- botx/testing/botx_mock/wsgi/routes/chats.py: WPS202,WPS204,WPS235
-# Disable some checks:
-ignore =
- # Docs:
- # Disable nested classes documentation, since only Config for pydantic is allowed:
- D106,
- # This project uses google style and mkdocs for docs:
- RST,
-
- # WPS:
- # 3xx
- # Disable required inheritance from object:
- WPS306,
- # Allow implicit string concatenation
- WPS326,
-
- # 6xx
- # A lot of functionality in this lib is build around async __call__:
- WPS610,
-
- # TODO:
- # WPS bugs:
- WPS601,
-
- # Asserts in code is OK
- S101,
+[coverage:report]
+exclude_lines =
+ pragma: no cover
+ if TYPE_CHECKING:
+ raise NotImplementedError
+ except ImportError:
+ ... # noqa: WPS428
+ def __repr__
diff --git a/tests/test_bots/test_bots/test_decorators/__init__.py b/tests/client/__init__.py
similarity index 100%
rename from tests/test_bots/test_bots/test_decorators/__init__.py
rename to tests/client/__init__.py
diff --git a/tests/test_bots/test_mixins/__init__.py b/tests/client/bots_api/__init__.py
similarity index 100%
rename from tests/test_bots/test_mixins/__init__.py
rename to tests/client/bots_api/__init__.py
diff --git a/tests/client/bots_api/test_get_token.py b/tests/client/bots_api/test_get_token.py
new file mode 100644
index 00000000..f371e84e
--- /dev/null
+++ b/tests/client/bots_api/test_get_token.py
@@ -0,0 +1,78 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ InvalidBotAccountError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__get_token__invalid_bot_account_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_signature: str,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v2/botx/bots/{bot_id}/token",
+ params={"signature": bot_signature},
+ ).mock(
+ return_value=httpx.Response(HTTPStatus.UNAUTHORIZED),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ with pytest.raises(InvalidBotAccountError) as exc:
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.get_token(bot_id=bot_id)
+
+ # - Assert -
+ assert "failed with code 401" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__get_token__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_signature: str,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v2/botx/bots/{bot_id}/token",
+ params={"signature": bot_signature},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": "token",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ token = await bot.get_token(bot_id=bot_id)
+
+ # - Assert -
+ assert token == "token"
+ assert endpoint.called
diff --git a/tests/test_bots/test_mixins/test_requests/__init__.py b/tests/client/chats_api/__init__.py
similarity index 100%
rename from tests/test_bots/test_mixins/test_requests/__init__.py
rename to tests/client/chats_api/__init__.py
diff --git a/tests/client/chats_api/test_add_admin.py b/tests/client/chats_api/test_add_admin.py
new file mode 100644
index 00000000..14fd58f9
--- /dev/null
+++ b/tests/client/chats_api/test_add_admin.py
@@ -0,0 +1,278 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ CantUpdatePersonalChatError,
+ ChatNotFoundError,
+ HandlerCollector,
+ InvalidBotXStatusCodeError,
+ InvalidUsersListError,
+ PermissionDeniedError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__promote_to_chat_admins__unexpected_bad_request_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/add_admin",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.BAD_REQUEST,
+ json={
+ "status": "error",
+ "reason": "some_reason",
+ "errors": [],
+ "error_data": {},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(InvalidBotXStatusCodeError) as exc:
+ await bot.promote_to_chat_admins(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert "some_reason" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__promote_to_chat_admins__cant_update_personal_chat_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/add_admin",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.BAD_REQUEST,
+ json={
+ "status": "error",
+ "reason": "chat_members_not_modifiable",
+ "errors": [],
+ "error_data": {},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(CantUpdatePersonalChatError) as exc:
+ await bot.promote_to_chat_admins(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert "chat_members_not_modifiable" in str(exc.value)
+ assert "Personal chat couldn't have admins" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__promote_to_chat_admins__invalid_users_list_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/add_admin",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.BAD_REQUEST,
+ json={
+ "status": "error",
+ "reason": "admins_not_changed",
+ "errors": ["Admins have not changed"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(InvalidUsersListError) as exc:
+ await bot.promote_to_chat_admins(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert "admins_not_changed" in str(exc.value)
+ assert "Specified users are already admins or missing from chat" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__promote_to_chat_admins__permission_denied_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/add_admin",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.FORBIDDEN,
+ json={
+ "status": "error",
+ "reason": "no_permission_for_operation",
+ "errors": ["Sender is not chat admin"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ "sender": "a465f0f3-1354-491c-8f11-f400164295cb",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(PermissionDeniedError) as exc:
+ await bot.promote_to_chat_admins(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert "no_permission_for_operation" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__promote_to_chat_admins__chat_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/add_admin",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "chat_not_found",
+ "errors": ["Chat not found"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatNotFoundError) as exc:
+ await bot.promote_to_chat_admins(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__promote_to_chat_admins__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/add_admin",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={"status": "ok", "result": True},
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.promote_to_chat_admins(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/chats_api/test_add_user.py b/tests/client/chats_api/test_add_user.py
new file mode 100644
index 00000000..eaaed589
--- /dev/null
+++ b/tests/client/chats_api/test_add_user.py
@@ -0,0 +1,145 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatNotFoundError,
+ HandlerCollector,
+ PermissionDeniedError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__add_users_to_chat__chat_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/add_user",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "chat_not_found",
+ "errors": ["Chat not found"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatNotFoundError) as exc:
+ await bot.add_users_to_chat(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__add_users_to_chat__permission_denied_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/add_user",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.FORBIDDEN,
+ json={
+ "status": "error",
+ "reason": "no_permission_for_operation",
+ "errors": ["Sender is not chat admin"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ "sender": "a465f0f3-1354-491c-8f11-f400164295cb",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(PermissionDeniedError) as exc:
+ await bot.add_users_to_chat(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert "no_permission_for_operation" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__add_users_to_chat__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/add_user",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={"status": "ok", "result": True},
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.add_users_to_chat(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/chats_api/test_chat_info.py b/tests/client/chats_api/test_chat_info.py
new file mode 100644
index 00000000..be9979f1
--- /dev/null
+++ b/tests/client/chats_api/test_chat_info.py
@@ -0,0 +1,142 @@
+from datetime import datetime as dt
+from http import HTTPStatus
+from typing import Callable
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatInfo,
+ ChatInfoMember,
+ ChatNotFoundError,
+ ChatTypes,
+ HandlerCollector,
+ UserKinds,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__chat_info__chat_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/chats/info",
+ headers={"Authorization": "Bearer token"},
+ params={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "chat_not_found",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ "error_description": "Chat with id dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4 not found",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatNotFoundError) as exc:
+ await bot.chat_info(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__chat_info__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ datetime_formatter: Callable[[str], dt],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/chats/info",
+ headers={"Authorization": "Bearer token"},
+ params={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "chat_type": "group_chat",
+ "creator": "6fafda2c-6505-57a5-a088-25ea5d1d0364",
+ "description": None,
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "inserted_at": "2019-08-29T11:22:48.358586Z",
+ "members": [
+ {
+ "admin": True,
+ "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364",
+ "user_kind": "user",
+ },
+ {
+ "admin": False,
+ "user_huid": "705df263-6bfd-536a-9d51-13524afaab5c",
+ "user_kind": "botx",
+ },
+ ],
+ "name": "Group Chat Example",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ chat_info = await bot.chat_info(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert chat_info == ChatInfo(
+ chat_type=ChatTypes.GROUP_CHAT,
+ creator_id=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"),
+ description=None,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"),
+ members=[
+ ChatInfoMember(
+ is_admin=True,
+ huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"),
+ kind=UserKinds.RTS_USER,
+ ),
+ ChatInfoMember(
+ is_admin=False,
+ huid=UUID("705df263-6bfd-536a-9d51-13524afaab5c"),
+ kind=UserKinds.BOT,
+ ),
+ ],
+ name="Group Chat Example",
+ )
+
+ assert endpoint.called
diff --git a/tests/client/chats_api/test_create_chat.py b/tests/client/chats_api/test_create_chat.py
new file mode 100644
index 00000000..63d242a1
--- /dev/null
+++ b/tests/client/chats_api/test_create_chat.py
@@ -0,0 +1,159 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatCreationError,
+ ChatCreationProhibitedError,
+ ChatTypes,
+ HandlerCollector,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__create_chat__bot_have_no_permissions_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/create",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "name": "Test chat name",
+ "description": None,
+ "chat_type": "group_chat",
+ "members": [],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.FORBIDDEN,
+ json={
+ "status": "error",
+ "reason": "chat_creation_is_prohibited",
+ "errors": ["This bot is not allowed to create chats"],
+ "error_data": {
+ "bot_id": "a465f0f3-1354-491c-8f11-f400164295cb",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatCreationProhibitedError) as exc:
+ await bot.create_chat(
+ bot_id=bot_id,
+ name="Test chat name",
+ chat_type=ChatTypes.GROUP_CHAT,
+ huids=[],
+ )
+
+ # - Assert -
+ assert endpoint.called
+ assert "chat_creation_is_prohibited" in str(exc.value)
+
+
+async def test__create_chat__botx_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/create",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "name": "Test chat name",
+ "description": None,
+ "chat_type": "group_chat",
+ "members": [],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.UNPROCESSABLE_ENTITY,
+ json={
+ "status": "error",
+ "reason": "|specified reason|",
+ "errors": ["|specified errors|"],
+ "error_data": {},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatCreationError) as exc:
+ await bot.create_chat(
+ bot_id=bot_id,
+ name="Test chat name",
+ chat_type=ChatTypes.GROUP_CHAT,
+ huids=[],
+ )
+
+ # - Assert -
+ assert endpoint.called
+ assert "specified reason" in str(exc.value)
+
+
+async def test__create_chat__maximum_filled_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/create",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "name": "Test chat name",
+ "description": "Test description",
+ "chat_type": "group_chat",
+ "members": ["2fc83441-366a-49ba-81fc-6c39f065bb58"],
+ "shared_history": True,
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {"chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ created_chat_id = await bot.create_chat(
+ bot_id=bot_id,
+ name="Test chat name",
+ chat_type=ChatTypes.GROUP_CHAT,
+ huids=[UUID("2fc83441-366a-49ba-81fc-6c39f065bb58")],
+ description="Test description",
+ shared_history=True,
+ )
+
+ # - Assert -
+ assert created_chat_id == UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa")
+ assert endpoint.called
diff --git a/tests/client/chats_api/test_disable_stealth.py b/tests/client/chats_api/test_disable_stealth.py
new file mode 100644
index 00000000..d8993c9a
--- /dev/null
+++ b/tests/client/chats_api/test_disable_stealth.py
@@ -0,0 +1,133 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatNotFoundError,
+ HandlerCollector,
+ PermissionDeniedError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__disable_stealth__permission_denied_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/stealth_disable",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.FORBIDDEN,
+ json={
+ "status": "error",
+ "reason": "no_permission_for_operation",
+ "errors": ["Sender is not chat admin"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ "sender": "a465f0f3-1354-491c-8f11-f400164295cb",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(PermissionDeniedError) as exc:
+ await bot.disable_stealth(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert "no_permission_for_operation" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__disable_stealth__chat_not_found_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/stealth_disable",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "chat_not_found",
+ "errors": ["Chat not found"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatNotFoundError) as exc:
+ await bot.disable_stealth(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__disable_stealth__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/stealth_disable",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={"status": "ok", "result": True},
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.disable_stealth(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/chats_api/test_list_chats.py b/tests/client/chats_api/test_list_chats.py
new file mode 100644
index 00000000..ad53b76c
--- /dev/null
+++ b/tests/client/chats_api/test_list_chats.py
@@ -0,0 +1,81 @@
+from datetime import datetime
+from http import HTTPStatus
+from typing import Callable
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatListItem,
+ ChatTypes,
+ HandlerCollector,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__list_chats__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ datetime_formatter: Callable[[str], datetime],
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/chats/list",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": [
+ {
+ "group_chat_id": "740cf331-d833-5250-b5a5-5b5cbc697ff5",
+ "chat_type": "group_chat",
+ "name": "Chat Name",
+ "description": "Desc",
+ "members": [
+ "6fafda2c-6505-57a5-a088-25ea5d1d0364",
+ "705df263-6bfd-536a-9d51-13524afaab5c",
+ ],
+ "inserted_at": "2019-08-29T11:22:48.358586Z",
+ "updated_at": "2019-08-30T21:02:10.453786Z",
+ },
+ ],
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ chats = await bot.list_chats(bot_id=bot_id)
+
+ # - Assert -
+ assert chats == [
+ ChatListItem(
+ chat_id=UUID("740cf331-d833-5250-b5a5-5b5cbc697ff5"),
+ chat_type=ChatTypes.GROUP_CHAT,
+ name="Chat Name",
+ description="Desc",
+ members=[
+ UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"),
+ UUID("705df263-6bfd-536a-9d51-13524afaab5c"),
+ ],
+ created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"),
+ updated_at=datetime_formatter("2019-08-30T21:02:10.453786Z"),
+ ),
+ ]
+ assert endpoint.called
diff --git a/tests/client/chats_api/test_pin_message.py b/tests/client/chats_api/test_pin_message.py
new file mode 100644
index 00000000..46cd40cc
--- /dev/null
+++ b/tests/client/chats_api/test_pin_message.py
@@ -0,0 +1,147 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatNotFoundError,
+ HandlerCollector,
+ PermissionDeniedError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__pin_message__permission_denied_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/pin_message",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.FORBIDDEN,
+ json={
+ "error_data": {
+ "bot_id": "f9e1c958-bf81-564e-bff2-a2943869af15",
+ "error_description": "Bot doesn't have permission for this operation in current chat",
+ "group_chat_id": "5680c26a-07a5-5b40-a6ff-f5e7e68fed25",
+ },
+ "errors": [],
+ "reason": "no_permission_for_operation",
+ "status": "error",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(PermissionDeniedError) as exc:
+ await bot.pin_message(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ sync_id=UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"),
+ )
+
+ # - Assert -
+ assert "no_permission_for_operation" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__pin_message__chat_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/pin_message",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "chat_not_found",
+ "errors": [],
+ "error_data": {
+ "error_description": "Chat with specified id not found",
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatNotFoundError) as exc:
+ await bot.pin_message(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ sync_id=UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"),
+ )
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__pin_message__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/pin_message",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={"status": "ok", "result": "message_pinned"},
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.pin_message(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ sync_id=UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"),
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/chats_api/test_remove_user.py b/tests/client/chats_api/test_remove_user.py
new file mode 100644
index 00000000..e39906cc
--- /dev/null
+++ b/tests/client/chats_api/test_remove_user.py
@@ -0,0 +1,145 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatNotFoundError,
+ HandlerCollector,
+ PermissionDeniedError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__remove_users_from_chat__permission_denied_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/remove_user",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.FORBIDDEN,
+ json={
+ "status": "error",
+ "reason": "no_permission_for_operation",
+ "errors": ["Sender is not chat admin"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ "sender": "a465f0f3-1354-491c-8f11-f400164295cb",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(PermissionDeniedError) as exc:
+ await bot.remove_users_from_chat(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert "no_permission_for_operation" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__remove_users_from_chat__chat_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/remove_user",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "chat_not_found",
+ "errors": ["Chat not found"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatNotFoundError) as exc:
+ await bot.remove_users_from_chat(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__remove_users_from_chat__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/remove_user",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={"status": "ok", "result": True},
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.remove_users_from_chat(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")],
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/chats_api/test_set_stealth.py b/tests/client/chats_api/test_set_stealth.py
new file mode 100644
index 00000000..22addd5f
--- /dev/null
+++ b/tests/client/chats_api/test_set_stealth.py
@@ -0,0 +1,148 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatNotFoundError,
+ HandlerCollector,
+ PermissionDeniedError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__enable_stealth__permission_denied_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/stealth_set",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.FORBIDDEN,
+ json={
+ "status": "error",
+ "reason": "no_permission_for_operation",
+ "errors": ["Sender is not chat admin"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ "sender": "a465f0f3-1354-491c-8f11-f400164295cb",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(PermissionDeniedError) as exc:
+ await bot.enable_stealth(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert "no_permission_for_operation" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__enable_stealth__chat_not_found_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/stealth_set",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "chat_not_found",
+ "errors": ["Chat not found"],
+ "error_data": {
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatNotFoundError) as exc:
+ await bot.enable_stealth(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__enable_stealth__maximum_filled_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/stealth_set",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "disable_web": True,
+ "burn_in": 100,
+ "expire_in": 1000,
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": True,
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.enable_stealth(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ disable_web_client=True,
+ ttl_after_read=100,
+ total_ttl=1000,
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/chats_api/test_unpin_message.py b/tests/client/chats_api/test_unpin_message.py
new file mode 100644
index 00000000..30429f90
--- /dev/null
+++ b/tests/client/chats_api/test_unpin_message.py
@@ -0,0 +1,141 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatNotFoundError,
+ HandlerCollector,
+ PermissionDeniedError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__unpin_message__permission_denied_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/unpin_message",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.FORBIDDEN,
+ json={
+ "error_data": {
+ "bot_id": "f9e1c958-bf81-564e-bff2-a2943869af15",
+ "error_description": "Bot doesn't have permission for this operation in current chat",
+ "group_chat_id": "5680c26a-07a5-5b40-a6ff-f5e7e68fed25",
+ },
+ "errors": [],
+ "reason": "no_permission_for_operation",
+ "status": "error",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(PermissionDeniedError) as exc:
+ await bot.unpin_message(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert "no_permission_for_operation" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__unpin_message__chat_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/unpin_message",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "chat_not_found",
+ "errors": [],
+ "error_data": {
+ "error_description": "Chat with specified id not found",
+ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatNotFoundError) as exc:
+ await bot.unpin_message(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__unpin_message__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/chats/unpin_message",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={"status": "ok", "result": "message_unpinned"},
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.unpin_message(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/test_bots/test_mixins/test_sending/__init__.py b/tests/client/events_api/__init__.py
similarity index 100%
rename from tests/test_bots/test_mixins/test_sending/__init__.py
rename to tests/client/events_api/__init__.py
diff --git a/tests/client/events_api/test_edit_event.py b/tests/client/events_api/test_edit_event.py
new file mode 100644
index 00000000..7f0e55a9
--- /dev/null
+++ b/tests/client/events_api/test_edit_event.py
@@ -0,0 +1,304 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from aiofiles.tempfile import NamedTemporaryFile
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ BubbleMarkup,
+ EditMessage,
+ HandlerCollector,
+ KeyboardMarkup,
+ Mention,
+ OutgoingAttachment,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__edit_message__minimal_edit_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/events/edit_event",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "bot_command_result_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.edit_message(
+ bot_id=bot_id,
+ sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"),
+ )
+
+ # - Assert -
+ assert endpoint.called
+
+
+async def test__edit_message__maximum_edit_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # - Arrange -
+ monkeypatch.setattr(
+ "botx.models.message.mentions.uuid4",
+ lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"),
+ )
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/events/edit_event",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9",
+ "payload": {
+ "body": "@{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!",
+ "metadata": {"message": "metadata"},
+ "bubble": [
+ [
+ {
+ "command": "/bubble-button",
+ "data": {},
+ "label": "Bubble button",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "keyboard": [
+ [
+ {
+ "command": "/keyboard-button",
+ "data": {},
+ "label": "Keyboard button",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "mentions": [
+ {
+ "mention_type": "user",
+ "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e",
+ "mention_data": {
+ "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24",
+ },
+ },
+ ],
+ },
+ "file": {
+ "file_name": "test.txt",
+ "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=",
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "bot_command_result_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ bubbles = BubbleMarkup()
+ bubbles.add_button(command="/bubble-button", label="Bubble button")
+
+ keyboard = KeyboardMarkup()
+ keyboard.add_button(command="/keyboard-button", label="Keyboard button")
+
+ async with NamedTemporaryFile("wb+") as async_buffer:
+ await async_buffer.write(b"Hello, world!\n")
+ await async_buffer.seek(0)
+
+ file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt")
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.edit_message(
+ bot_id=bot_id,
+ sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"),
+ body=f"{Mention.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!",
+ metadata={"message": "metadata"},
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ )
+
+ # - Assert -
+ assert endpoint.called
+
+
+async def test__edit_message__clean_message_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/events/edit_event",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "payload": {
+ "body": "",
+ "metadata": {},
+ "bubble": [],
+ "keyboard": [],
+ "mentions": [],
+ },
+ "sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9",
+ "file": None,
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "bot_command_result_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.edit_message(
+ bot_id=bot_id,
+ sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"),
+ body="",
+ metadata={},
+ bubbles=BubbleMarkup(),
+ keyboard=KeyboardMarkup(),
+ file=None,
+ )
+
+ # - Assert -
+ assert endpoint.called
+
+
+async def test__edit__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # - Arrange -
+ monkeypatch.setattr(
+ "botx.models.message.mentions.uuid4",
+ lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"),
+ )
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/events/edit_event",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9",
+ "payload": {
+ "body": "@{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!",
+ "metadata": {"message": "metadata"},
+ "bubble": [
+ [
+ {
+ "command": "/bubble-button",
+ "data": {},
+ "label": "Bubble button",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "keyboard": [
+ [
+ {
+ "command": "/keyboard-button",
+ "data": {},
+ "label": "Keyboard button",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "mentions": [
+ {
+ "mention_type": "user",
+ "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e",
+ "mention_data": {
+ "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24",
+ },
+ },
+ ],
+ },
+ "file": {
+ "file_name": "test.txt",
+ "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=",
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "bot_command_result_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ bubbles = BubbleMarkup()
+ bubbles.add_button(command="/bubble-button", label="Bubble button")
+
+ keyboard = KeyboardMarkup()
+ keyboard.add_button(command="/keyboard-button", label="Keyboard button")
+
+ async with NamedTemporaryFile("wb+") as async_buffer:
+ await async_buffer.write(b"Hello, world!\n")
+ await async_buffer.seek(0)
+
+ file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt")
+
+ message = EditMessage(
+ bot_id=bot_id,
+ sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"),
+ body=f"{Mention.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!",
+ metadata={"message": "metadata"},
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ )
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.edit(message=message)
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/events_api/test_message_status_event.py b/tests/client/events_api/test_message_status_event.py
new file mode 100644
index 00000000..7bd5f2d5
--- /dev/null
+++ b/tests/client/events_api/test_message_status_event.py
@@ -0,0 +1,123 @@
+from datetime import datetime
+from http import HTTPStatus
+from typing import Callable
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ EventNotFoundError,
+ HandlerCollector,
+ MessageStatus,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__get_message_status__event_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/events/fe1f285c-073e-4231-b190-2959f28168cc/status",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "event_not_found",
+ "errors": [],
+ "error_data": {"sync_id": "fe1f285c-073e-4231-b190-2959f28168cc"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(EventNotFoundError) as exc:
+ await bot.get_message_status(
+ bot_id=bot_id,
+ sync_id=UUID("fe1f285c-073e-4231-b190-2959f28168cc"),
+ )
+
+ # - Assert -
+ assert "event_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__get_message_status__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ datetime_formatter: Callable[[str], datetime],
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/events/fe1f285c-073e-4231-b190-2959f28168cc/status",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {
+ "group_chat_id": "740cf331-d833-5250-b5a5-5b5cbc697ff5",
+ "sent_to": ["32bb051e-cee9-5c5c-9c35-f213ec18d11e"],
+ "read_by": [
+ {
+ "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364",
+ "read_at": "2019-08-29T11:22:48.358586Z",
+ },
+ ],
+ "received_by": [
+ {
+ "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364",
+ "received_at": "2019-08-29T11:22:48.358586Z",
+ },
+ ],
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ message_status = await bot.get_message_status(
+ bot_id=bot_id,
+ sync_id=UUID("fe1f285c-073e-4231-b190-2959f28168cc"),
+ )
+
+ # - Assert -
+ assert message_status == MessageStatus(
+ group_chat_id=UUID("740cf331-d833-5250-b5a5-5b5cbc697ff5"),
+ sent_to=[UUID("32bb051e-cee9-5c5c-9c35-f213ec18d11e")],
+ read_by={
+ UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"): datetime_formatter(
+ "2019-08-29T11:22:48.358586Z",
+ ),
+ },
+ received_by={
+ UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"): datetime_formatter(
+ "2019-08-29T11:22:48.358586Z",
+ ),
+ },
+ )
+ assert endpoint.called
diff --git a/tests/client/events_api/test_reply_event.py b/tests/client/events_api/test_reply_event.py
new file mode 100644
index 00000000..5d1cb8af
--- /dev/null
+++ b/tests/client/events_api/test_reply_event.py
@@ -0,0 +1,289 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from aiofiles.tempfile import NamedTemporaryFile
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ BubbleMarkup,
+ HandlerCollector,
+ KeyboardMarkup,
+ Mention,
+ OutgoingAttachment,
+ ReplyMessage,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__reply_message__minimal_filled_reply_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/events/reply_event",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "source_sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9",
+ "reply": {
+ "status": "ok",
+ "body": "Replied",
+ },
+ "opts": {"raw_mentions": True},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "bot_reply_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.reply_message(
+ bot_id=bot_id,
+ sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"),
+ body="Replied",
+ )
+
+ # - Assert -
+ assert endpoint.called
+
+
+async def test__reply_message__maximum_filled_reply_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # - Arrange -
+ monkeypatch.setattr(
+ "botx.models.message.mentions.uuid4",
+ lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"),
+ )
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/events/reply_event",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "source_sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9",
+ "reply": {
+ "status": "ok",
+ "body": "@{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!",
+ "metadata": {"message": "metadata"},
+ "opts": {
+ "buttons_auto_adjust": True,
+ "silent_response": True,
+ },
+ "bubble": [
+ [
+ {
+ "command": "/bubble-button",
+ "label": "Bubble button",
+ "data": {},
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "keyboard": [
+ [
+ {
+ "command": "/keyboard-button",
+ "label": "Keyboard button",
+ "data": {},
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "mentions": [
+ {
+ "mention_type": "user",
+ "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e",
+ "mention_data": {
+ "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24",
+ },
+ },
+ ],
+ },
+ "file": {
+ "file_name": "test.txt",
+ "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=",
+ },
+ "opts": {
+ "raw_mentions": True,
+ "stealth_mode": True,
+ "notification_opts": {"send": True, "force_dnd": True},
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "bot_reply_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ bubbles = BubbleMarkup()
+ bubbles.add_button(command="/bubble-button", label="Bubble button")
+
+ keyboard = KeyboardMarkup()
+ keyboard.add_button(command="/keyboard-button", label="Keyboard button")
+
+ async with NamedTemporaryFile("wb+") as async_buffer:
+ await async_buffer.write(b"Hello, world!\n")
+ await async_buffer.seek(0)
+
+ file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt")
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.reply_message(
+ bot_id=bot_id,
+ sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"),
+ body=f"{Mention.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!",
+ metadata={"message": "metadata"},
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ silent_response=True,
+ markup_auto_adjust=True,
+ stealth_mode=True,
+ send_push=True,
+ ignore_mute=True,
+ )
+
+ # - Assert -
+ assert endpoint.called
+
+
+async def test__reply__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # - Arrange -
+ monkeypatch.setattr(
+ "botx.models.message.mentions.uuid4",
+ lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"),
+ )
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/events/reply_event",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "source_sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9",
+ "reply": {
+ "status": "ok",
+ "body": "@{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!",
+ "metadata": {"message": "metadata"},
+ "opts": {
+ "buttons_auto_adjust": True,
+ "silent_response": True,
+ },
+ "bubble": [
+ [
+ {
+ "command": "/bubble-button",
+ "label": "Bubble button",
+ "data": {},
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "keyboard": [
+ [
+ {
+ "command": "/keyboard-button",
+ "label": "Keyboard button",
+ "data": {},
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "mentions": [
+ {
+ "mention_type": "user",
+ "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e",
+ "mention_data": {
+ "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24",
+ },
+ },
+ ],
+ },
+ "file": {
+ "file_name": "test.txt",
+ "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=",
+ },
+ "opts": {
+ "raw_mentions": True,
+ "stealth_mode": True,
+ "notification_opts": {"send": True, "force_dnd": True},
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "bot_reply_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ bubbles = BubbleMarkup()
+ bubbles.add_button(command="/bubble-button", label="Bubble button")
+
+ keyboard = KeyboardMarkup()
+ keyboard.add_button(command="/keyboard-button", label="Keyboard button")
+
+ async with NamedTemporaryFile("wb+") as async_buffer:
+ await async_buffer.write(b"Hello, world!\n")
+ await async_buffer.seek(0)
+
+ file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt")
+
+ message = ReplyMessage(
+ bot_id=bot_id,
+ sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"),
+ body=f"{Mention.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!",
+ metadata={"message": "metadata"},
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ silent_response=True,
+ markup_auto_adjust=True,
+ stealth_mode=True,
+ send_push=True,
+ ignore_mute=True,
+ )
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.reply(message=message)
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/events_api/test_stop_typing_event.py b/tests/client/events_api/test_stop_typing_event.py
new file mode 100644
index 00000000..6568eec4
--- /dev/null
+++ b/tests/client/events_api/test_stop_typing_event.py
@@ -0,0 +1,47 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import Bot, BotAccountWithSecret, HandlerCollector, lifespan_wrapper
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__stop_typing__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/events/stop_typing",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={"status": "ok", "result": "stop_typing_event_pushed"},
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.stop_typing(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/events_api/test_typing_event.py b/tests/client/events_api/test_typing_event.py
new file mode 100644
index 00000000..31b6a00e
--- /dev/null
+++ b/tests/client/events_api/test_typing_event.py
@@ -0,0 +1,47 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import Bot, BotAccountWithSecret, HandlerCollector, lifespan_wrapper
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__start_typing__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/events/typing",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={"status": "ok", "result": "typing_event_pushed"},
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.start_typing(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/test_clients/__init__.py b/tests/client/files_api/__init__.py
similarity index 100%
rename from tests/test_clients/__init__.py
rename to tests/client/files_api/__init__.py
diff --git a/tests/client/files_api/test_download_file.py b/tests/client/files_api/test_download_file.py
new file mode 100644
index 00000000..9a68aa65
--- /dev/null
+++ b/tests/client/files_api/test_download_file.py
@@ -0,0 +1,251 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from aiofiles.tempfile import NamedTemporaryFile
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatNotFoundError,
+ FileDeletedError,
+ FileMetadataNotFound,
+ HandlerCollector,
+ InvalidBotXStatusCodeError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__download_file__unexpected_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/files/download",
+ params={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80",
+ },
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "anything_not_found",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "84a12e71-3efc-5c34-87d5-84e3d9ad64fd",
+ "error_description": "42",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(InvalidBotXStatusCodeError) as exc:
+ await bot.download_file(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ file_id=UUID("c3b9def2-b2c8-4732-b61f-99b9b110fa80"),
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert "anything_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__download_file__file_metadata_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/files/download",
+ params={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80",
+ },
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "file_metadata_not_found",
+ "errors": [],
+ "error_data": {
+ "file_id": "e48c5612-b94f-4264-adc2-1bc36445a226",
+ "group_chat_id": "84a12e71-3efc-5c34-87d5-84e3d9ad64fd",
+ "error_description": "File with specified file_id and group_chat_id not found in file service",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(FileMetadataNotFound) as exc:
+ await bot.download_file(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ file_id=UUID("c3b9def2-b2c8-4732-b61f-99b9b110fa80"),
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert "file_metadata_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__download_file__file_deleted_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/files/download",
+ params={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80",
+ },
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NO_CONTENT,
+ json={
+ "status": "error",
+ "reason": "file_deleted",
+ "errors": [],
+ "error_data": {
+ "link": "/example/file.jpeg",
+ "error_description": "File at the specified link has been deleted",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(FileDeletedError) as exc:
+ await bot.download_file(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ file_id=UUID("c3b9def2-b2c8-4732-b61f-99b9b110fa80"),
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert "file_deleted" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__download_file__chat_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/files/download",
+ params={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80",
+ },
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "chat_not_found",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "84a12e71-3efc-5c34-87d5-84e3d9ad64fd",
+ "error_description": "Chat with id 84a12e71-3efc-5c34-87d5-84e3d9ad64fd not found",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatNotFoundError) as exc:
+ await bot.download_file(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ file_id=UUID("c3b9def2-b2c8-4732-b61f-99b9b110fa80"),
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__download_file__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/files/download",
+ params={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80",
+ },
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ content=b"Hello, world!\n",
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.download_file(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ file_id=UUID("c3b9def2-b2c8-4732-b61f-99b9b110fa80"),
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert await async_buffer.read() == b"Hello, world!\n"
+ assert endpoint.called
diff --git a/tests/client/files_api/test_upload_file.py b/tests/client/files_api/test_upload_file.py
new file mode 100644
index 00000000..17df5dae
--- /dev/null
+++ b/tests/client/files_api/test_upload_file.py
@@ -0,0 +1,125 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from aiofiles.tempfile import NamedTemporaryFile
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatNotFoundError,
+ HandlerCollector,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__download_file__chat_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ await async_buffer.write(b"Hello, world!\n")
+ await async_buffer.seek(0)
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/files/upload",
+ # TODO: check data too, when files pattern will be ready
+ # https://github.com/lundberg/respx/issues/115
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "chat_not_found",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "84a12e71-3efc-5c34-87d5-84e3d9ad64fd",
+ "error_description": "Chat with id 84a12e71-3efc-5c34-87d5-84e3d9ad64fd not found",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ChatNotFoundError) as exc:
+ await bot.upload_file(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ async_buffer=async_buffer,
+ filename="test.txt",
+ )
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__download_file__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ await async_buffer.write(b"Hello, world!\n")
+ await async_buffer.seek(0)
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/files/upload",
+ # TODO: check data too, when files pattern will be ready
+ # https://github.com/lundberg/respx/issues/115
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "type": "image",
+ "file": "https://link.to/file",
+ "file_mime_type": "image/png",
+ "file_name": "pass.png",
+ "file_preview": "https://link.to/preview",
+ "file_preview_height": 300,
+ "file_preview_width": 300,
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_encryption_algo": "stream",
+ "chunk_size": 2097152,
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ "caption": "текст",
+ "duration": None,
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.upload_file(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ async_buffer=async_buffer,
+ filename="test.txt",
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/test_clients/test_clients/__init__.py b/tests/client/notifications_api/__init__.py
similarity index 100%
rename from tests/test_clients/test_clients/__init__.py
rename to tests/client/notifications_api/__init__.py
diff --git a/tests/client/notifications_api/test_direct_notification.py b/tests/client/notifications_api/test_direct_notification.py
new file mode 100644
index 00000000..4e90d9df
--- /dev/null
+++ b/tests/client/notifications_api/test_direct_notification.py
@@ -0,0 +1,974 @@
+import asyncio
+from http import HTTPStatus
+from typing import Any, Callable, Dict
+from uuid import UUID
+
+import httpx
+import pytest
+from aiofiles.tempfile import NamedTemporaryFile
+from respx.router import MockRouter
+
+from botx import (
+ AnswerDestinationLookupError,
+ Bot,
+ BotAccountWithSecret,
+ BotIsNotChatMemberError,
+ BubbleMarkup,
+ ChatNotFoundError,
+ FinalRecipientsListEmptyError,
+ HandlerCollector,
+ IncomingMessage,
+ KeyboardMarkup,
+ Mention,
+ OutgoingAttachment,
+ OutgoingMessage,
+ StealthModeDisabledError,
+ UnknownBotAccountError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__send_message__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_account: BotAccountWithSecret,
+ bot_id: UUID,
+ api_incoming_message_factory: Callable[..., Dict[str, Any]],
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {
+ "opts": {
+ "silent_response": True,
+ "buttons_auto_adjust": True,
+ },
+ "status": "ok",
+ "body": "Hi!",
+ "metadata": {"foo": "bar"},
+ "bubble": [
+ [
+ {
+ "command": "/bubble-button",
+ "data": {},
+ "label": "Bubble button",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "keyboard": [
+ [
+ {
+ "command": "/keyboard-button",
+ "data": {},
+ "label": "Keyboard button",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ },
+ "file": {
+ "file_name": "test.txt",
+ "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=",
+ },
+ "recipients": ["0a462a79-d9a2-4fad-8a96-7074f59daba9"],
+ "opts": {
+ "stealth_mode": True,
+ "notification_opts": {
+ "send": True,
+ "force_dnd": True,
+ },
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ payload = api_incoming_message_factory(
+ bot_id=bot_id,
+ host=host,
+ group_chat_id="054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ )
+
+ bubbles = BubbleMarkup()
+ bubbles.add_button(
+ command="/bubble-button",
+ label="Bubble button",
+ )
+
+ keyboard = KeyboardMarkup()
+ keyboard.add_button(
+ command="/keyboard-button",
+ label="Keyboard button",
+ )
+
+ async with NamedTemporaryFile("wb+") as async_buffer:
+ await async_buffer.write(b"Hello, world!\n")
+ await async_buffer.seek(0)
+
+ file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt")
+
+ collector = HandlerCollector()
+
+ outgoing_message = OutgoingMessage(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ body="Hi!",
+ metadata={"foo": "bar"},
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ silent_response=True,
+ markup_auto_adjust=True,
+ recipients=[UUID("0a462a79-d9a2-4fad-8a96-7074f59daba9")],
+ stealth_mode=True,
+ send_push=True,
+ ignore_mute=True,
+ )
+
+ @collector.command("/hello", description="Hello command")
+ async def hello_handler(message: IncomingMessage, bot: Bot) -> None:
+ await bot.send(message=outgoing_message)
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ },
+ )
+
+ # - Assert -
+ assert endpoint.called
+
+
+async def test__answer_message__no_incoming_message_error_raised(
+ host: str,
+ bot_account: BotAccountWithSecret,
+ bot_id: UUID,
+) -> None:
+ # - Arrange -
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(AnswerDestinationLookupError) as exc:
+ await bot.answer_message("Hi!")
+
+ # - Assert -
+ assert "No IncomingMessage received" in str(exc.value)
+
+
+async def test__answer_message__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_account: BotAccountWithSecret,
+ bot_id: UUID,
+ api_incoming_message_factory: Callable[..., Dict[str, Any]],
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {
+ "status": "ok",
+ "body": "Hi!",
+ "metadata": {"foo": "bar"},
+ "bubble": [
+ [
+ {
+ "command": "/bubble-button",
+ "data": {},
+ "label": "Bubble button",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "keyboard": [
+ [
+ {
+ "command": "/keyboard-button",
+ "data": {},
+ "label": "Keyboard button",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ },
+ "file": {
+ "file_name": "test.txt",
+ "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=",
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ payload = api_incoming_message_factory(
+ bot_id=bot_id,
+ host=host,
+ group_chat_id="054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ )
+
+ bubbles = BubbleMarkup()
+ bubbles.add_button(
+ command="/bubble-button",
+ label="Bubble button",
+ )
+
+ keyboard = KeyboardMarkup()
+ keyboard.add_button(
+ command="/keyboard-button",
+ label="Keyboard button",
+ )
+
+ async with NamedTemporaryFile("wb+") as async_buffer:
+ await async_buffer.write(b"Hello, world!\n")
+ await async_buffer.seek(0)
+
+ file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt")
+
+ collector = HandlerCollector()
+
+ @collector.command("/hello", description="Hello command")
+ async def hello_handler(message: IncomingMessage, bot: Bot) -> None:
+ await bot.answer_message(
+ "Hi!",
+ metadata={"foo": "bar"},
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ )
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ },
+ )
+
+ # - Assert -
+ assert endpoint.called
+
+
+async def test__send_message__unknown_bot_account_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ unknown_bot_id = UUID("51550ccc-dfd1-4d22-9b6f-a330145192b0")
+ direct_notification_endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(UnknownBotAccountError) as exc:
+ await bot.send_message(
+ body="Hi!",
+ bot_id=unknown_bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert not direct_notification_endpoint.called
+ assert str(unknown_bot_id) in str(exc.value)
+
+
+async def test__send_message__chat_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {"status": "ok", "body": "Hi!"},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_message(
+ body="Hi!",
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ ),
+ )
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "chat_not_found",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "error_description": "Chat with specified id not found",
+ },
+ },
+ )
+
+ # - Assert -
+ with pytest.raises(ChatNotFoundError) as exc:
+ await task
+
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__send_message__bot_is_not_a_chat_member_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {"status": "ok", "body": "Hi!"},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_message(
+ body="Hi!",
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ ),
+ )
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "bot_is_not_a_chat_member",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "bot_id": "b165f00f-3154-412c-7f11-c120164257da",
+ "error_description": "Bot is not a chat member",
+ },
+ },
+ )
+
+ # - Assert -
+ with pytest.raises(BotIsNotChatMemberError) as exc:
+ await task
+
+ assert "bot_is_not_a_chat_member" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__send_message__event_recipients_list_is_empty_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {"status": "ok", "body": "Hi!"},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_message(
+ body="Hi!",
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ ),
+ )
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "event_recipients_list_is_empty",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "bot_id": "b165f00f-3154-412c-7f11-c120164257da",
+ "recipients_param": ["b165f00f-3154-412c-7f11-c120164257da"],
+ "error_description": "Event recipients list is empty",
+ },
+ },
+ )
+
+ # - Assert -
+ with pytest.raises(FinalRecipientsListEmptyError) as exc:
+ await task
+
+ assert "event_recipients_list_is_empty" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__send_message__stealth_mode_disabled_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {"status": "ok", "body": "Hi!"},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_message(
+ body="Hi!",
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ ),
+ )
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "stealth_mode_disabled",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "bot_id": "b165f00f-3154-412c-7f11-c120164257da",
+ "error_description": "Stealth mode disabled in specified chat",
+ },
+ },
+ )
+
+ # - Assert -
+ with pytest.raises(StealthModeDisabledError) as exc:
+ await task
+
+ assert "stealth_mode_disabled" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__send_message__miminally_filled_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {"status": "ok", "body": "Hi!"},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_message(
+ body="Hi!",
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ ),
+ )
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ },
+ )
+
+ # - Assert -
+ assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
+
+
+async def test__send_message__maximum_filled_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # - Arrange -
+ monkeypatch.setattr(
+ "botx.models.message.mentions.uuid4",
+ lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"),
+ )
+
+ body = f"Hi, {Mention.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!"
+ formatted_body = "Hi, @{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!"
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {
+ "opts": {
+ "silent_response": True,
+ "buttons_auto_adjust": True,
+ },
+ "status": "ok",
+ "body": formatted_body,
+ "metadata": {"foo": "bar"},
+ "bubble": [
+ [
+ {
+ "command": "/bubble-button",
+ "label": "Bubble button",
+ "data": {"foo": "bar"},
+ "opts": {
+ "silent": False,
+ "h_size": 1,
+ "alert_text": "Alert text 1",
+ "show_alert": True,
+ "handler": "client",
+ },
+ },
+ ],
+ ],
+ "keyboard": [
+ [
+ {
+ "command": "/keyboard-button",
+ "label": "Keyboard button",
+ "data": {"baz": "quux"},
+ "opts": {
+ "silent": True,
+ "h_size": 2,
+ "alert_text": "Alert text 2",
+ "show_alert": True,
+ },
+ },
+ ],
+ ],
+ "mentions": [
+ {
+ "mention_type": "user",
+ "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e",
+ "mention_data": {
+ "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24",
+ },
+ },
+ ],
+ },
+ "file": {
+ "file_name": "test.txt",
+ "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=",
+ },
+ "recipients": ["41af5a7b-04c1-465e-8383-e3b1d9e76126"],
+ "opts": {
+ "stealth_mode": True,
+ "notification_opts": {
+ "send": True,
+ "force_dnd": True,
+ },
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ async with NamedTemporaryFile("wb+") as async_buffer:
+ await async_buffer.write(b"Hello, world!\n")
+ await async_buffer.seek(0)
+
+ file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt")
+
+ bubbles = BubbleMarkup()
+ bubbles.add_button(
+ command="/bubble-button",
+ label="Bubble button",
+ data={"foo": "bar"},
+ silent=False,
+ width_ratio=1,
+ alert="Alert text 1",
+ process_on_client=True,
+ )
+
+ keyboard = KeyboardMarkup()
+ keyboard.add_button(
+ command="/keyboard-button",
+ label="Keyboard button",
+ data={"baz": "quux"},
+ silent=True,
+ width_ratio=2,
+ alert="Alert text 2",
+ process_on_client=False,
+ )
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_message(
+ body=body,
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ metadata={"foo": "bar"},
+ bubbles=bubbles,
+ keyboard=keyboard,
+ file=file,
+ silent_response=True,
+ markup_auto_adjust=True,
+ recipients=[UUID("41af5a7b-04c1-465e-8383-e3b1d9e76126")],
+ stealth_mode=True,
+ send_push=True,
+ ignore_mute=True,
+ ),
+ )
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ },
+ )
+
+ # - Assert -
+ assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
+
+
+async def test__send_message__all_mentions_types_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # - Arrange -
+ monkeypatch.setattr(
+ "botx.models.message.mentions.uuid4",
+ lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"),
+ )
+
+ mentioned_user_huid = UUID("8f3abcc8-ba00-4c89-88e0-b786beb8ec24")
+ user_mention = Mention.user(mentioned_user_huid)
+ mentioned_contact_huid = UUID("1e0529fd-f091-4be9-93cc-6704a8957432")
+ contact_mention = Mention.contact(mentioned_contact_huid)
+ mentioned_chat_huid = UUID("454d73ad-1d32-4939-a708-e14b77414e86")
+ chat_mention = Mention.chat(mentioned_chat_huid, "Our chat")
+ mentioned_channel_huid = UUID("78198bec-3285-48d0-9fe2-c0eb3afaffd7")
+ channel_mention = Mention.channel(mentioned_channel_huid)
+ all_mention = Mention.all()
+
+ body = (
+ f"Hi, {user_mention}, want you to know, "
+ f"that I and {contact_mention} are getting married in a week. "
+ f"Here's a chat for all the invitees: {chat_mention}. "
+ f"And here is the news channel just in case: {channel_mention}. "
+ "In case of something incredible, "
+ f"I will notify you with {all_mention}, so you won't miss it."
+ )
+
+ formatted_body = (
+ "Hi, @{mention:f3e176d5-ff46-4b18-b260-25008338c06e}, want you to know, "
+ "that I and @@{mention:f3e176d5-ff46-4b18-b260-25008338c06e} are getting married in a week. "
+ "Here's a chat for all the invitees: ##{mention:f3e176d5-ff46-4b18-b260-25008338c06e}. "
+ "And here is the news channel just in case: ##{mention:f3e176d5-ff46-4b18-b260-25008338c06e}. "
+ "In case of something incredible, "
+ "I will notify you with @{mention:f3e176d5-ff46-4b18-b260-25008338c06e}, so you won't miss it."
+ )
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {
+ "status": "ok",
+ "body": formatted_body,
+ "mentions": [
+ {
+ "mention_type": "user",
+ "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e",
+ "mention_data": {
+ "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24",
+ },
+ },
+ {
+ "mention_type": "contact",
+ "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e",
+ "mention_data": {
+ "user_huid": "1e0529fd-f091-4be9-93cc-6704a8957432",
+ },
+ },
+ {
+ "mention_type": "chat",
+ "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e",
+ "mention_data": {
+ "group_chat_id": "454d73ad-1d32-4939-a708-e14b77414e86",
+ "name": "Our chat",
+ },
+ },
+ {
+ "mention_type": "channel",
+ "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e",
+ "mention_data": {
+ "group_chat_id": "78198bec-3285-48d0-9fe2-c0eb3afaffd7",
+ },
+ },
+ {
+ "mention_type": "all",
+ "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e",
+ },
+ ],
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_message(
+ body=body,
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ ),
+ )
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ },
+ )
+
+ # - Assert -
+ assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
+
+
+async def test__send_message__message_body_max_length_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ too_long_body = "1" * 4097
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ValueError) as exc:
+ await bot.send_message(
+ body=too_long_body,
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ )
+
+ # - Assert -
+ assert "Message body length exceeds 4096 symbols" in str(exc.value)
+ assert not endpoint.called
+
+
+async def test__send_message__message_body_max_length_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ max_long_body = "1" * 4096
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_message(
+ body=max_long_body,
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ ),
+ )
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ },
+ )
+
+ # - Assert -
+ assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
diff --git a/tests/client/notifications_api/test_internal_bot_notification.py b/tests/client/notifications_api/test_internal_bot_notification.py
new file mode 100644
index 00000000..cfb18357
--- /dev/null
+++ b/tests/client/notifications_api/test_internal_bot_notification.py
@@ -0,0 +1,297 @@
+import asyncio
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ BotIsNotChatMemberError,
+ ChatNotFoundError,
+ FinalRecipientsListEmptyError,
+ HandlerCollector,
+ RateLimitReachedError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__send_internal_bot_notification__rate_limit_reached_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/internal",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "data": {"foo": "bar"},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.TOO_MANY_REQUESTS,
+ json={
+ "status": "error",
+ "reason": "too_many_requests",
+ "errors": [],
+ "error_data": {
+ "bot_id": "b165f00f-3154-412c-7f11-c120164257da",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(RateLimitReachedError) as exc:
+ await bot.send_internal_bot_notification(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ data={"foo": "bar"},
+ )
+
+ # - Assert -
+ assert "too_many_requests" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__send_internal_bot_notification__chat_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/internal",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "data": {"foo": "bar"},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_internal_bot_notification(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ data={"foo": "bar"},
+ ),
+ )
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "chat_not_found",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "error_description": (
+ "Chat with id 21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3 not found"
+ ),
+ },
+ },
+ )
+
+ with pytest.raises(ChatNotFoundError) as exc:
+ await task
+
+ # - Assert -
+ assert "chat_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__send_internal_bot_notification__bot_is_not_chat_member_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/internal",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "data": {"foo": "bar"},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_internal_bot_notification(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ data={"foo": "bar"},
+ ),
+ )
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "bot_is_not_a_chat_member",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "bot_id": str(bot_id),
+ "error_description": "Bot is not a chat member",
+ },
+ },
+ )
+
+ with pytest.raises(BotIsNotChatMemberError) as exc:
+ await task
+
+ # - Assert -
+ assert "bot_is_not_a_chat_member" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__send_internal_bot_notification__final_recipients_list_empty_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/internal",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "data": {"foo": "bar"},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_internal_bot_notification(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ data={"foo": "bar"},
+ ),
+ )
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "event_recipients_list_is_empty",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "bot_id": str(bot_id),
+ "recipients_param": ["b165f00f-3154-412c-7f11-c120164257da"],
+ "error_description": "Event recipients list is empty",
+ },
+ },
+ )
+
+ with pytest.raises(FinalRecipientsListEmptyError) as exc:
+ await task
+
+ # - Assert -
+ assert "event_recipients_list_is_empty" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__send_internal_bot_notification__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/internal",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "data": {"foo": "bar"},
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_internal_bot_notification(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ data={"foo": "bar"},
+ ),
+ )
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ },
+ )
+
+ # - Assert -
+ assert await task == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
diff --git a/tests/client/notifications_api/test_markup.py b/tests/client/notifications_api/test_markup.py
new file mode 100644
index 00000000..3421381b
--- /dev/null
+++ b/tests/client/notifications_api/test_markup.py
@@ -0,0 +1,239 @@
+import asyncio
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ BubbleMarkup,
+ Button,
+ HandlerCollector,
+ KeyboardMarkup,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__markup__defaults_filled(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {
+ "status": "ok",
+ "body": "Hi!",
+ "bubble": [
+ [
+ {
+ "command": "/bubble-button",
+ "data": {},
+ "label": "Bubble button",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ "keyboard": [
+ [
+ {
+ "command": "/keyboard-button",
+ "data": {},
+ "label": "Keyboard button",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ bubbles = BubbleMarkup()
+ bubbles.add_button(
+ command="/bubble-button",
+ label="Bubble button",
+ )
+
+ keyboard = KeyboardMarkup()
+ keyboard.add_button(
+ command="/keyboard-button",
+ label="Keyboard button",
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_message(
+ body="Hi!",
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ bubbles=bubbles,
+ keyboard=keyboard,
+ ),
+ )
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ },
+ )
+
+ # - Assert -
+ assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
+
+
+async def test__markup__correctly_built(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {
+ "status": "ok",
+ "body": "Hi!",
+ "bubble": [
+ [
+ {
+ "command": "/bubble-button-1",
+ "data": {},
+ "label": "Bubble button 1",
+ "opts": {"silent": True},
+ },
+ {
+ "command": "/bubble-button-2",
+ "data": {},
+ "label": "Bubble button 2",
+ "opts": {"silent": True},
+ },
+ ],
+ [
+ {
+ "command": "/bubble-button-3",
+ "data": {},
+ "label": "Bubble button 3",
+ "opts": {"silent": True},
+ },
+ ],
+ [
+ {
+ "command": "/bubble-button-4",
+ "data": {},
+ "label": "Bubble button 4",
+ "opts": {"silent": True},
+ },
+ {
+ "command": "/bubble-button-5",
+ "data": {},
+ "label": "Bubble button 5",
+ "opts": {"silent": True},
+ },
+ ],
+ ],
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ bubbles = BubbleMarkup()
+
+ bubbles.add_button(
+ command="/bubble-button-1",
+ label="Bubble button 1",
+ new_row=False,
+ )
+ bubbles.add_button(
+ command="/bubble-button-2",
+ label="Bubble button 2",
+ new_row=False,
+ )
+ bubbles.add_button(
+ command="/bubble-button-3",
+ label="Bubble button 3",
+ )
+
+ button_4 = Button(
+ command="/bubble-button-4",
+ label="Bubble button 4",
+ )
+ button_5 = Button(
+ command="/bubble-button-5",
+ label="Bubble button 5",
+ )
+ bubbles.add_row([button_4, button_5])
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.send_message(
+ body="Hi!",
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ bubbles=bubbles,
+ ),
+ )
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ },
+ )
+
+ # - Assert -
+ assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
+
+
+def test__markup__comparison() -> None:
+ # - Arrange -
+ button = Button("/test", "test")
+
+ # - Assert -
+ assert BubbleMarkup([[button]]) == BubbleMarkup([[button]])
diff --git a/tests/test_clients/test_clients/test_async_client/__init__.py b/tests/client/smartapps_api/__init__.py
similarity index 100%
rename from tests/test_clients/test_clients/test_async_client/__init__.py
rename to tests/client/smartapps_api/__init__.py
diff --git a/tests/client/smartapps_api/test_smartapp_event.py b/tests/client/smartapps_api/test_smartapp_event.py
new file mode 100644
index 00000000..67b4126c
--- /dev/null
+++ b/tests/client/smartapps_api/test_smartapp_event.py
@@ -0,0 +1,187 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import Bot, BotAccountWithSecret, HandlerCollector, lifespan_wrapper
+from botx.models.async_files import Document, Image, Video, Voice
+from botx.models.enums import AttachmentTypes
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__send_smartapp_event__miminally_filled_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/smartapps/event",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "ref": "921763b3-77e8-4f37-b97e-20f4517949b8",
+ "smartapp_id": str(bot_id),
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "data": {"key": "value"},
+ "opts": {},
+ "smartapp_api_version": 1,
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "smartapp_event_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.send_smartapp_event(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ data={"key": "value"},
+ ref=UUID("921763b3-77e8-4f37-b97e-20f4517949b8"),
+ )
+
+ # - Assert -
+ assert endpoint.called
+
+
+async def test__send_smartapp_event__maximum_filled_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/smartapps/event",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "ref": "921763b3-77e8-4f37-b97e-20f4517949b8",
+ "smartapp_id": str(bot_id),
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "data": {"key": "value"},
+ "opts": {"option": True},
+ "smartapp_api_version": 1,
+ "async_files": [
+ {
+ "type": "image",
+ "file": "https://link.to/file",
+ "file_mime_type": "image/png",
+ "file_name": "pass.png",
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ },
+ {
+ "type": "video",
+ "file": "https://link.to/file",
+ "file_mime_type": "video/mp4",
+ "file_name": "pass.mp4",
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ "duration": 10,
+ },
+ {
+ "type": "document",
+ "file": "https://link.to/file",
+ "file_mime_type": "plain/text",
+ "file_name": "pass.txt",
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ },
+ {
+ "type": "voice",
+ "file": "https://link.to/file",
+ "file_mime_type": "audio/mp3",
+ "file_name": "pass.mp3",
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ "duration": 10,
+ },
+ ],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "smartapp_event_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.send_smartapp_event(
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ ref=UUID("921763b3-77e8-4f37-b97e-20f4517949b8"),
+ data={"key": "value"},
+ opts={"option": True},
+ files=[
+ Image(
+ type=AttachmentTypes.IMAGE,
+ filename="pass.png",
+ size=1502345,
+ is_async_file=True,
+ _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ _file_url="https://link.to/file",
+ _file_mimetype="image/png",
+ _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ ),
+ Video(
+ type=AttachmentTypes.VIDEO,
+ filename="pass.mp4",
+ size=1502345,
+ is_async_file=True,
+ duration=10,
+ _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ _file_url="https://link.to/file",
+ _file_mimetype="video/mp4",
+ _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ ),
+ Document(
+ type=AttachmentTypes.DOCUMENT,
+ filename="pass.txt",
+ size=1502345,
+ is_async_file=True,
+ _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ _file_url="https://link.to/file",
+ _file_mimetype="plain/text",
+ _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ ),
+ Voice(
+ type=AttachmentTypes.VOICE,
+ filename="pass.mp3",
+ size=1502345,
+ is_async_file=True,
+ duration=10,
+ _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ _file_url="https://link.to/file",
+ _file_mimetype="audio/mp3",
+ _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ ),
+ ],
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/smartapps_api/test_smartapp_notification.py b/tests/client/smartapps_api/test_smartapp_notification.py
new file mode 100644
index 00000000..aaf5ea32
--- /dev/null
+++ b/tests/client/smartapps_api/test_smartapp_notification.py
@@ -0,0 +1,55 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import Bot, BotAccountWithSecret, HandlerCollector, lifespan_wrapper
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__send_smartapp_notification__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/smartapps/notification",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c",
+ "smartapp_counter": 42,
+ "opts": {"message": "ping"},
+ "smartapp_api_version": 1,
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "smartapp_notification_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.send_smartapp_notification(
+ bot_id=bot_id,
+ chat_id=UUID("705df263-6bfd-536a-9d51-13524afaab5c"),
+ smartapp_counter=42,
+ opts={"message": "ping"},
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/test_clients/test_clients/test_sync_client/__init__.py b/tests/client/stickers_api/__init__.py
similarity index 100%
rename from tests/test_clients/test_clients/test_sync_client/__init__.py
rename to tests/client/stickers_api/__init__.py
diff --git a/tests/client/stickers_api/test_add_sticker.py b/tests/client/stickers_api/test_add_sticker.py
new file mode 100644
index 00000000..c8ea1e52
--- /dev/null
+++ b/tests/client/stickers_api/test_add_sticker.py
@@ -0,0 +1,318 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from aiofiles.tempfile import NamedTemporaryFile
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ InvalidBotXStatusCodeError,
+ InvalidEmojiError,
+ InvalidImageError,
+ Sticker,
+ StickerPackOrStickerNotFoundError,
+ lifespan_wrapper,
+)
+
+PNG_IMAGE = (
+ b"\x89PNG\r\n\x1a\n"
+ b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x01\x03\x00"
+ b"\x00\x00%\xdbV\xca\x00\x00\x00\x03PLTE\x00\x00\x00\xa7z=\xda\x00"
+ b"\x00\x00\x01tRNS\x00@\xe6\xd8f\x00\x00\x00\nIDAT\x08\xd7c`\x00"
+ b"\x00\x00\x02\x00\x01\xe2!\xbc3\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+PNG_IMAGE_B64 = (
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3a"
+ "AAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__add_sticker__is_not_png_error_raised(
+ respx_mock: MockRouter,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ await async_buffer.write(b"Hello, world!\n")
+ await async_buffer.seek(0)
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ValueError) as exc:
+ await bot.add_sticker(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ emoji="🤔",
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert "Passed file is not PNG" in str(exc.value)
+
+
+async def test__add_sticker__bad_file_size_error_raised(
+ respx_mock: MockRouter,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ await async_buffer.write(PNG_IMAGE + b"\x00" * (512 * 1024 + 1))
+ await async_buffer.seek(0)
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ValueError) as exc:
+ await bot.add_sticker(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ emoji="🤔",
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert "Passed file size is greater than 0.5 Mb" in str(exc.value)
+
+
+async def test__add_sticker__unexpected_bad_request_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ await async_buffer.write(PNG_IMAGE)
+ await async_buffer.seek(0)
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/stickers",
+ headers={"Authorization": "Bearer token"},
+ json={"emoji": "🤔", "image": f"data:image/png;base64,{PNG_IMAGE_B64}"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.BAD_REQUEST,
+ json={
+ "status": "error",
+ "reason": "some_reason",
+ "errors": [],
+ "error_data": {},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(InvalidBotXStatusCodeError) as exc:
+ await bot.add_sticker(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ emoji="🤔",
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert "some_reason" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__add_sticker__sticker_pack_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ await async_buffer.write(PNG_IMAGE)
+ await async_buffer.seek(0)
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/stickers",
+ headers={"Authorization": "Bearer token"},
+ json={"emoji": "🤔", "image": f"data:image/png;base64,{PNG_IMAGE_B64}"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.BAD_REQUEST,
+ json={
+ "error_data": {"pack_id": "26080153-a57d-5a8c-af0e-fdecee3c4435"},
+ "errors": ["Failed to add sticker because pack not found."],
+ "reason": "pack_not_found",
+ "status": "error",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(StickerPackOrStickerNotFoundError) as exc:
+ await bot.add_sticker(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ emoji="🤔",
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert "pack_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__add_sticker__invalid_emoji_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ await async_buffer.write(PNG_IMAGE)
+ await async_buffer.seek(0)
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/stickers",
+ headers={"Authorization": "Bearer token"},
+ json={"emoji": "🤔", "image": f"data:image/png;base64,{PNG_IMAGE_B64}"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.BAD_REQUEST,
+ json={
+ "error_data": {"emoji": "invalid"},
+ "errors": [],
+ "reason": "malformed_request",
+ "status": "error",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(InvalidEmojiError) as exc:
+ await bot.add_sticker(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ emoji="🤔",
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert "malformed_request" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__add_sticker__invalid_image_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ await async_buffer.write(PNG_IMAGE)
+ await async_buffer.seek(0)
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/stickers",
+ headers={"Authorization": "Bearer token"},
+ json={"emoji": "🤔", "image": f"data:image/png;base64,{PNG_IMAGE_B64}"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.BAD_REQUEST,
+ json={
+ "error_data": {"image": "invalid"},
+ "errors": [],
+ "reason": "malformed_request",
+ "status": "error",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(InvalidImageError) as exc:
+ await bot.add_sticker(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ emoji="🤔",
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert "malformed_request" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__add_sticker__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ await async_buffer.write(PNG_IMAGE)
+ await async_buffer.seek(0)
+
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/stickers",
+ headers={"Authorization": "Bearer token"},
+ json={"emoji": "🤔", "image": f"data:image/png;base64,{PNG_IMAGE_B64}"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "id": "75bb24c9-7c08-5db0-ae3e-085929e80c54",
+ "emoji": "🤔",
+ "link": "http://cts-domain/uploads/sticker_pack/26080153-a57d-5a8c-af0e-fdecee3c4435/b4577728162f4d9ea2b35f25f9f0dcde.png?v=1626137130775",
+ "inserted_at": "2020-12-28T12:56:43.672163Z",
+ "updated_at": "2020-12-28T12:56:43.672163Z",
+ "deleted_at": None,
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ sticker = await bot.add_sticker(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ emoji="🤔",
+ async_buffer=async_buffer,
+ )
+
+ # - Assert -
+ assert sticker == Sticker(
+ id=UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"),
+ emoji="🤔",
+ image_link="http://cts-domain/uploads/sticker_pack/26080153-a57d-5a8c-af0e-fdecee3c4435/b4577728162f4d9ea2b35f25f9f0dcde.png?v=1626137130775",
+ )
+
+ assert endpoint.called
diff --git a/tests/client/stickers_api/test_create_sticker_pack.py b/tests/client/stickers_api/test_create_sticker_pack.py
new file mode 100644
index 00000000..b6a72d37
--- /dev/null
+++ b/tests/client/stickers_api/test_create_sticker_pack.py
@@ -0,0 +1,68 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ StickerPack,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__create_sticker_pack__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v3/botx/stickers/packs",
+ headers={"Authorization": "Bearer token"},
+ json={"name": "Sticker Pack"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "id": "26080153-a57d-5a8c-af0e-fdecee3c4435",
+ "name": "Sticker Pack",
+ "public": False,
+ "preview": None,
+ "stickers": [],
+ "stickers_order": [],
+ "inserted_at": "2021-07-10T00:27:55.616703Z",
+ "updated_at": "2021-07-10T00:27:55.616703Z",
+ "deleted_at": None,
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ sticker_pack = await bot.create_sticker_pack(bot_id=bot_id, name="Sticker Pack")
+
+ # - Assert -
+ assert sticker_pack == StickerPack(
+ id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ name="Sticker Pack",
+ is_public=False,
+ stickers=[],
+ )
+
+ assert endpoint.called
diff --git a/tests/client/stickers_api/test_delete_sticker.py b/tests/client/stickers_api/test_delete_sticker.py
new file mode 100644
index 00000000..be3c7f13
--- /dev/null
+++ b/tests/client/stickers_api/test_delete_sticker.py
@@ -0,0 +1,99 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ StickerPackOrStickerNotFoundError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__delete_sticker__sticker_or_pack_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.delete(
+ f"https://{host}/api/v3/botx/stickers/"
+ f"packs/78f9743c-8b24-4e97-8059-70908604a252/"
+ f"stickers/6ead1e00-f788-4ce6-9e1a-95abe219414e",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "error_data": {
+ "pack_id": "78f9743c-8b24-4e97-8059-70908604a252",
+ "sticker_id": "6ead1e00-f788-4ce6-9e1a-95abe219414e",
+ },
+ "errors": ["Sticker or sticker pack not found."],
+ "reason": "not_found",
+ "status": "error",
+ },
+ ),
+ )
+
+ build_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(build_bot) as bot:
+ with pytest.raises(StickerPackOrStickerNotFoundError) as exc:
+ await bot.delete_sticker(
+ bot_id=bot_id,
+ sticker_id=UUID("6ead1e00-f788-4ce6-9e1a-95abe219414e"),
+ sticker_pack_id=UUID("78f9743c-8b24-4e97-8059-70908604a252"),
+ )
+
+ # - Assert -
+ assert "Sticker or sticker pack not found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__delete_sticker__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.delete(
+ f"https://{host}/api/v3/botx/stickers/"
+ f"packs/78f9743c-8b24-4e97-8059-70908604a252/"
+ f"stickers/6ead1e00-f788-4ce6-9e1a-95abe219414e",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "delete_sticker_from_pack_pushed",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.delete_sticker(
+ bot_id=bot_id,
+ sticker_id=UUID("6ead1e00-f788-4ce6-9e1a-95abe219414e"),
+ sticker_pack_id=UUID("78f9743c-8b24-4e97-8059-70908604a252"),
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/stickers_api/test_delete_sticker_pack.py b/tests/client/stickers_api/test_delete_sticker_pack.py
new file mode 100644
index 00000000..998d6f18
--- /dev/null
+++ b/tests/client/stickers_api/test_delete_sticker_pack.py
@@ -0,0 +1,92 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ StickerPackOrStickerNotFoundError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__delete_sticker_pack__sticker_pack_not_found(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.delete(
+ f"https://{host}/api/v3/botx/stickers/"
+ f"packs/26080153-a57d-5a8c-af0e-fdecee3c4435",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "error_data": {"pack_id": "26080153-a57d-5a8c-af0e-fdecee3c4435"},
+ "errors": ["Sticker pack not found."],
+ "reason": "pack_not_found",
+ "status": "error",
+ },
+ ),
+ )
+
+ build_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(build_bot) as bot:
+ with pytest.raises(StickerPackOrStickerNotFoundError) as exc:
+ await bot.delete_sticker_pack(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ )
+
+ # - Assert -
+ assert "pack_not_found" in str(exc)
+ assert endpoint.called
+
+
+async def test__delete_sticker_pack__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.delete(
+ f"https://{host}/api/v3/botx/stickers/"
+ f"packs/26080153-a57d-5a8c-af0e-fdecee3c4435",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": "delete_sticker_pack_pushed",
+ },
+ ),
+ )
+
+ build_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(build_bot) as bot:
+ await bot.delete_sticker_pack(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ )
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/client/stickers_api/test_edit_sticker_pack.py b/tests/client/stickers_api/test_edit_sticker_pack.py
new file mode 100644
index 00000000..67ffb807
--- /dev/null
+++ b/tests/client/stickers_api/test_edit_sticker_pack.py
@@ -0,0 +1,159 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ StickerPackOrStickerNotFoundError,
+ lifespan_wrapper,
+)
+from botx.models.stickers import Sticker, StickerPack
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__edit_sticker_pack__sticker_pack_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.put(
+ f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "error_data": {"pack_id": "26080153-a57d-5a8c-af0e-fdecee3c4435"},
+ "errors": ["Sticker pack not found."],
+ "reason": "pack_not_found",
+ "status": "error",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(StickerPackOrStickerNotFoundError) as exc:
+ await bot.edit_sticker_pack(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ name="Sticker Pack 2.0",
+ preview=UUID("528c3953-5842-5a30-b2cb-8a09218497bc"),
+ stickers_order=[
+ UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"),
+ UUID("528c3953-5842-5a30-b2cb-8a09218497bc"),
+ ],
+ )
+
+ # - Assert -
+ assert "pack_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__edit_sticker__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.put(
+ f"https://{host}/api/v3/botx/stickers/packs/d881f83a-db30-4cff-b60e-f24ac53deecf",
+ headers={"Authorization": "Bearer token"},
+ json={
+ "name": "Sticker Pack 2.0",
+ "preview": "528c3953-5842-5a30-b2cb-8a09218497bc",
+ "stickers_order": [
+ "75bb24c9-7c08-5db0-ae3e-085929e80c54",
+ "528c3953-5842-5a30-b2cb-8a09218497bc",
+ ],
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "id": "d881f83a-db30-4cff-b60e-f24ac53deecf",
+ "name": "Sticker Pack 2.0",
+ "public": True,
+ "preview": "528c3953-5842-5a30-b2cb-8a09218497bc",
+ "stickers_order": [
+ "75bb24c9-7c08-5db0-ae3e-085929e80c54",
+ "528c3953-5842-5a30-b2cb-8a09218497bc",
+ ],
+ "stickers": [
+ {
+ "id": "75bb24c9-7c08-5db0-ae3e-085929e80c54",
+ "emoji": "🤔",
+ "link": "https://cts-host/uploads/sticker_pack/image.png",
+ "inserted_at": "2020-12-28T12:56:43.672163Z",
+ "updated_at": "2020-12-28T12:56:43.672163Z",
+ "deleted_at": None,
+ },
+ {
+ "id": "528c3953-5842-5a30-b2cb-8a09218497bc",
+ "emoji": "😀",
+ "link": "https://cts-host/uploads/sticker_pack/image.png",
+ "inserted_at": "2020-12-28T12:56:43.672163Z",
+ "updated_at": "2020-12-28T12:56:43.672163Z",
+ "deleted_at": None,
+ },
+ ],
+ "inserted_at": "2020-12-28T12:56:43.672163Z",
+ "updated_at": "2021-07-22T13:26:41.562143Z",
+ "deleted_at": None,
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ sticker_pack = await bot.edit_sticker_pack(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"),
+ name="Sticker Pack 2.0",
+ preview=UUID("528c3953-5842-5a30-b2cb-8a09218497bc"),
+ stickers_order=[
+ UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"),
+ UUID("528c3953-5842-5a30-b2cb-8a09218497bc"),
+ ],
+ )
+
+ # - Assert -
+ assert sticker_pack == StickerPack(
+ id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"),
+ name="Sticker Pack 2.0",
+ is_public=True,
+ stickers=[
+ Sticker(
+ id=UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"),
+ emoji="🤔",
+ image_link="https://cts-host/uploads/sticker_pack/image.png",
+ ),
+ Sticker(
+ id=UUID("528c3953-5842-5a30-b2cb-8a09218497bc"),
+ emoji="😀",
+ image_link="https://cts-host/uploads/sticker_pack/image.png",
+ ),
+ ],
+ )
+
+ assert endpoint.called
diff --git a/tests/client/stickers_api/test_get_sticker.py b/tests/client/stickers_api/test_get_sticker.py
new file mode 100644
index 00000000..5092f35e
--- /dev/null
+++ b/tests/client/stickers_api/test_get_sticker.py
@@ -0,0 +1,109 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ StickerPackOrStickerNotFoundError,
+ lifespan_wrapper,
+)
+from botx.models.stickers import Sticker
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__get_sticker__sticker_pack_or_sticker_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/"
+ f"stickers/75bb24c9-7c08-5db0-ae3e-085929e80c54",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "error_data": {
+ "pack_id": "26080153-a57d-5a8c-af0e-fdecee3c4435",
+ "sticker_id": "75bb24c9-7c08-5db0-ae3e-085929e80c54",
+ },
+ "errors": ["Sticker or sticker pack not found."],
+ "reason": "not_found",
+ "status": "error",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(StickerPackOrStickerNotFoundError) as exc:
+ await bot.get_sticker(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ sticker_id=UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"),
+ )
+
+ # - Assert -
+ assert "not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__get_sticker__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/"
+ f"stickers/75bb24c9-7c08-5db0-ae3e-085929e80c54",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "id": "75bb24c9-7c08-5db0-ae3e-085929e80c54",
+ "emoji": "🤔",
+ "link": "https://cts-host/uploads/sticker_pack/image.png",
+ "preview": "https://cts-host/uploads/sticker_pack/image.png",
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ sticker = await bot.get_sticker(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ sticker_id=UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"),
+ )
+
+ # - Assert -
+ assert sticker == Sticker(
+ id=UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"),
+ emoji="🤔",
+ image_link="https://cts-host/uploads/sticker_pack/image.png",
+ )
+
+ assert endpoint.called
diff --git a/tests/client/stickers_api/test_get_sticker_pack.py b/tests/client/stickers_api/test_get_sticker_pack.py
new file mode 100644
index 00000000..d344b0c4
--- /dev/null
+++ b/tests/client/stickers_api/test_get_sticker_pack.py
@@ -0,0 +1,202 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ StickerPackOrStickerNotFoundError,
+ lifespan_wrapper,
+)
+from botx.models.stickers import Sticker, StickerPack
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__get_sticker__sticker_pack_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "error_data": {"pack_id": "26080153-a57d-5a8c-af0e-fdecee3c4435"},
+ "errors": ["Sticker pack not found."],
+ "reason": "pack_not_found",
+ "status": "error",
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(StickerPackOrStickerNotFoundError) as exc:
+ await bot.get_sticker_pack(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ )
+
+ # - Assert -
+ assert "pack_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__get_sticker_pack__stickers_in_right_order_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/stickers/packs/d881f83a-db30-4cff-b60e-f24ac53deecf",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "id": "d881f83a-db30-4cff-b60e-f24ac53deecf",
+ "name": "Sticker Pack",
+ "public": True,
+ "preview": "https://cts-host/uploads/sticker_pack/image.png",
+ "stickers_order": [
+ "528c3953-5842-5a30-b2cb-8a09218497bc",
+ "750bb400-bb37-4ff9-aa92-cc293f09cafa",
+ ],
+ "stickers": [
+ {
+ "id": "750bb400-bb37-4ff9-aa92-cc293f09cafa",
+ "emoji": "🤔",
+ "link": "https://cts-host/uploads/sticker_pack/image.png",
+ "inserted_at": "2020-12-28T12:56:43.672163Z",
+ "updated_at": "2020-12-28T12:56:43.672163Z",
+ "deleted_at": None,
+ },
+ {
+ "id": "528c3953-5842-5a30-b2cb-8a09218497bc",
+ "emoji": "🤔",
+ "link": "https://cts-host/uploads/sticker_pack/image.png",
+ "inserted_at": "2020-12-28T12:56:43.672163Z",
+ "updated_at": "2020-12-28T12:56:43.672163Z",
+ "deleted_at": None,
+ },
+ ],
+ "inserted_at": "2020-12-28T12:56:43.672163Z",
+ "updated_at": "2020-12-28T12:56:43.672163Z",
+ "deleted_at": None,
+ },
+ },
+ ),
+ )
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ sticker_pack = await bot.get_sticker_pack(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"),
+ )
+
+ # - Assert -
+ assert sticker_pack == StickerPack(
+ id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"),
+ name="Sticker Pack",
+ is_public=True,
+ stickers=[
+ Sticker(
+ id=UUID("528c3953-5842-5a30-b2cb-8a09218497bc"),
+ emoji="🤔",
+ image_link="https://cts-host/uploads/sticker_pack/image.png",
+ ),
+ Sticker(
+ id=UUID("750bb400-bb37-4ff9-aa92-cc293f09cafa"),
+ emoji="🤔",
+ image_link="https://cts-host/uploads/sticker_pack/image.png",
+ ),
+ ],
+ )
+
+ assert endpoint.called
+
+
+async def test__get_sticker_pack__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/stickers/packs/d881f83a-db30-4cff-b60e-f24ac53deecf",
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "id": "d881f83a-db30-4cff-b60e-f24ac53deecf",
+ "name": "Sticker Pack",
+ "public": True,
+ "preview": "https://cts-host/uploads/sticker_pack/image.png",
+ "stickers_order": [],
+ "stickers": [
+ {
+ "id": "528c3953-5842-5a30-b2cb-8a09218497bc",
+ "emoji": "🤔",
+ "link": "https://cts-host/uploads/sticker_pack/image.png",
+ "inserted_at": "2020-12-28T12:56:43.672163Z",
+ "updated_at": "2020-12-28T12:56:43.672163Z",
+ "deleted_at": None,
+ },
+ ],
+ "inserted_at": "2020-12-28T12:56:43.672163Z",
+ "updated_at": "2020-12-28T12:56:43.672163Z",
+ "deleted_at": None,
+ },
+ },
+ ),
+ )
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ sticker_pack = await bot.get_sticker_pack(
+ bot_id=bot_id,
+ sticker_pack_id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"),
+ )
+
+ # - Assert -
+ assert sticker_pack == StickerPack(
+ id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"),
+ name="Sticker Pack",
+ is_public=True,
+ stickers=[
+ Sticker(
+ id=UUID("528c3953-5842-5a30-b2cb-8a09218497bc"),
+ emoji="🤔",
+ image_link="https://cts-host/uploads/sticker_pack/image.png",
+ ),
+ ],
+ )
+
+ assert endpoint.called
diff --git a/tests/client/stickers_api/test_get_sticker_packs.py b/tests/client/stickers_api/test_get_sticker_packs.py
new file mode 100644
index 00000000..297650c0
--- /dev/null
+++ b/tests/client/stickers_api/test_get_sticker_packs.py
@@ -0,0 +1,228 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import Bot, BotAccountWithSecret, HandlerCollector, lifespan_wrapper
+from botx.models.stickers import StickerPackFromList
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__iterate_by_sticker_packs__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/stickers/packs",
+ headers={"Authorization": "Bearer token"},
+ params={"user_huid": "d881f83a-db30-4cff-b60e-f24ac53deecf"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "packs": [
+ {
+ "id": "26080153-a57d-5a8c-af0e-fdecee3c4435",
+ "name": "Sticker Pack",
+ "preview": "https://cts-host/uploads/sticker_pack/image.png",
+ "public": True,
+ "stickers_count": 2,
+ "stickers_order": [
+ "a998f599-d7ac-5e04-9fdb-2d98224ce4ff",
+ "25054ac4-8be2-5a4b-ae00-9efd38c73fb7",
+ ],
+ "inserted_at": "2020-11-28T12:56:43.672163Z",
+ "updated_at": "2021-02-18T12:52:31.571133Z",
+ "deleted_at": None,
+ },
+ ],
+ "pagination": {
+ "after": None,
+ },
+ },
+ },
+ ),
+ )
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+ sticker_pack_list = []
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ sticker_pack_pages = bot.iterate_by_sticker_packs(
+ bot_id=bot_id,
+ user_huid=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"),
+ )
+ async for sticker_packs in sticker_pack_pages:
+ sticker_pack_list.append(sticker_packs)
+
+ # - Assert -
+ assert sticker_pack_list == [
+ StickerPackFromList(
+ id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ name="Sticker Pack",
+ is_public=True,
+ stickers_count=2,
+ sticker_ids=[
+ UUID("a998f599-d7ac-5e04-9fdb-2d98224ce4ff"),
+ UUID("25054ac4-8be2-5a4b-ae00-9efd38c73fb7"),
+ ],
+ ),
+ ]
+ assert endpoint.called
+
+
+async def test__iterate_by_sticker_packs__iterate_by_pages_succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # - Arrange -
+ monkeypatch.setattr("botx.bot.bot.STICKER_PACKS_PER_PAGE", 2)
+
+ # Mock order matters
+ # https://lundberg.github.io/respx/guide/#routing-requests
+ second_sticker_endpoint_call = respx_mock.get(
+ f"https://{host}/api/v3/botx/stickers/packs",
+ headers={"Authorization": "Bearer token"},
+ params={
+ "user_huid": "d881f83a-db30-4cff-b60e-f24ac53deecf",
+ "limit": 2,
+ "after": "base64string",
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "packs": [
+ {
+ "id": "750bb400-bb37-4ff9-aa92-cc293f09cafa",
+ "name": "Sticker Pack 3",
+ "preview": "https://cts-host/uploads/sticker_pack/image.png",
+ "public": True,
+ "stickers_count": 2,
+ "stickers_order": [
+ "a998f599-d7ac-5e04-9fdb-2d98224ce4ff",
+ "25054ac4-8be2-5a4b-ae00-9efd38c73fb7",
+ ],
+ "inserted_at": "2020-11-28T12:56:43.672163Z",
+ "updated_at": "2021-02-18T12:52:31.571133Z",
+ "deleted_at": None,
+ },
+ ],
+ "pagination": {
+ "after": None,
+ },
+ },
+ },
+ ),
+ )
+ first_sticker_endpoint_call = respx_mock.get(
+ f"https://{host}/api/v3/botx/stickers/packs",
+ headers={"Authorization": "Bearer token"},
+ params={"user_huid": "d881f83a-db30-4cff-b60e-f24ac53deecf", "limit": 2},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "packs": [
+ {
+ "id": "26080153-a57d-5a8c-af0e-fdecee3c4435",
+ "name": "Sticker Pack 1",
+ "preview": "https://cts-host/uploads/sticker_pack/image.png",
+ "public": True,
+ "stickers_count": 2,
+ "stickers_order": [
+ "a998f599-d7ac-5e04-9fdb-2d98224ce4ff",
+ "25054ac4-8be2-5a4b-ae00-9efd38c73fb7",
+ ],
+ "inserted_at": "2020-11-28T12:56:43.672163Z",
+ "updated_at": "2021-02-18T12:52:31.571133Z",
+ "deleted_at": None,
+ },
+ {
+ "id": "89152263-2484-4e00-bc6c-90003027e39e",
+ "name": "Sticker Pack 2",
+ "preview": "https://cts-host/uploads/sticker_pack/image.png",
+ "public": True,
+ "stickers_count": 1,
+ "stickers_order": [
+ "a998f599-d7ac-5e04-9fdb-2d98224ce4ff",
+ ],
+ "inserted_at": "2020-11-28T12:56:43.672163Z",
+ "updated_at": "2021-02-18T12:52:31.571133Z",
+ "deleted_at": None,
+ },
+ ],
+ "pagination": {
+ "after": "base64string",
+ },
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ sticker_pack_list = []
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ sticker_pack_pages = bot.iterate_by_sticker_packs(
+ bot_id=bot_id,
+ user_huid=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"),
+ )
+ async for sticker_packs in sticker_pack_pages:
+ sticker_pack_list.append(sticker_packs)
+
+ # - Assert -
+ assert sticker_pack_list == [
+ StickerPackFromList(
+ id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"),
+ name="Sticker Pack 1",
+ is_public=True,
+ stickers_count=2,
+ sticker_ids=[
+ UUID("a998f599-d7ac-5e04-9fdb-2d98224ce4ff"),
+ UUID("25054ac4-8be2-5a4b-ae00-9efd38c73fb7"),
+ ],
+ ),
+ StickerPackFromList(
+ id=UUID("89152263-2484-4e00-bc6c-90003027e39e"),
+ name="Sticker Pack 2",
+ is_public=True,
+ stickers_count=1,
+ sticker_ids=[
+ UUID("a998f599-d7ac-5e04-9fdb-2d98224ce4ff"),
+ ],
+ ),
+ StickerPackFromList(
+ id=UUID("750bb400-bb37-4ff9-aa92-cc293f09cafa"),
+ name="Sticker Pack 3",
+ is_public=True,
+ stickers_count=2,
+ sticker_ids=[
+ UUID("a998f599-d7ac-5e04-9fdb-2d98224ce4ff"),
+ UUID("25054ac4-8be2-5a4b-ae00-9efd38c73fb7"),
+ ],
+ ),
+ ]
+ assert first_sticker_endpoint_call.called
+ assert second_sticker_endpoint_call.called
diff --git a/tests/client/test_authorized_botx_method.py b/tests/client/test_authorized_botx_method.py
new file mode 100644
index 00000000..9da35c8a
--- /dev/null
+++ b/tests/client/test_authorized_botx_method.py
@@ -0,0 +1,176 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import BotAccountWithSecret, InvalidBotAccountError
+from botx.bot.bot_accounts_storage import BotAccountsStorage
+from botx.client.authorized_botx_method import AuthorizedBotXMethod
+from tests.client.test_botx_method import (
+ BotXAPIFooBarRequestPayload,
+ BotXAPIFooBarResponsePayload,
+)
+
+
+class FooBarMethod(AuthorizedBotXMethod):
+ async def execute(
+ self,
+ payload: BotXAPIFooBarRequestPayload,
+ ) -> BotXAPIFooBarResponsePayload:
+ path = "/foo/bar"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIFooBarResponsePayload,
+ response,
+ )
+
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__authorized_botx_method__unauthorized(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_signature: str,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ token_endpoint = respx_mock.get(
+ f"https://{host}/api/v2/botx/bots/{bot_id}/token",
+ params={"signature": bot_signature},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": "token",
+ },
+ ),
+ )
+
+ foo_bar_endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(HTTPStatus.UNAUTHORIZED),
+ )
+
+ method = FooBarMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ with pytest.raises(InvalidBotAccountError) as exc:
+ await method.execute(payload)
+
+ # - Assert -
+ assert "failed with code 401" in str(exc.value)
+ assert token_endpoint.called
+ assert foo_bar_endpoint.called
+
+
+async def test__authorized_botx_method__succeed(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_signature: str,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ token_endpoint = respx_mock.get(
+ f"https://{host}/api/v2/botx/bots/{bot_id}/token",
+ params={"signature": bot_signature},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": "token",
+ },
+ ),
+ )
+
+ foo_bar_endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ method = FooBarMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ botx_api_foo_bar = await method.execute(payload)
+
+ # - Assert -
+ assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert token_endpoint.called
+ assert foo_bar_endpoint.called
+
+
+async def test__authorized_botx_method__with_prepared_token(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ prepared_bot_accounts_storage: BotAccountsStorage,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ method = FooBarMethod(
+ bot_id,
+ httpx_client,
+ prepared_bot_accounts_storage,
+ )
+
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ botx_api_foo_bar = await method.execute(payload)
+
+ # - Assert -
+ assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
diff --git a/tests/client/test_botx_method.py b/tests/client/test_botx_method.py
new file mode 100644
index 00000000..f87a48aa
--- /dev/null
+++ b/tests/client/test_botx_method.py
@@ -0,0 +1,242 @@
+from http import HTTPStatus
+from typing import Literal
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ BotAccountWithSecret,
+ InvalidBotXResponsePayloadError,
+ InvalidBotXStatusCodeError,
+)
+from botx.bot.bot_accounts_storage import BotAccountsStorage
+from botx.client.botx_method import BotXMethod, response_exception_thrower
+from botx.client.exceptions.base import BaseClientError
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class FooBarError(BaseClientError):
+ """Test exception."""
+
+
+class BotXAPIFooBarRequestPayload(UnverifiedPayloadBaseModel):
+ baz: int
+
+ @classmethod
+ def from_domain(cls, baz: int) -> "BotXAPIFooBarRequestPayload":
+ return cls(baz=baz)
+
+
+class BotXAPISyncIdResult(VerifiedPayloadBaseModel):
+ sync_id: UUID
+
+
+class BotXAPIFooBarResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+ result: BotXAPISyncIdResult
+
+ def to_domain(self) -> UUID:
+ return self.result.sync_id
+
+
+class FooBarMethod(BotXMethod):
+ status_handlers = {
+ 403: response_exception_thrower(FooBarError, "FooBar comment"),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIFooBarRequestPayload,
+ ) -> BotXAPIFooBarResponsePayload:
+ path = "/foo/bar"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ return self._verify_and_extract_api_model(
+ BotXAPIFooBarResponsePayload,
+ response,
+ )
+
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__botx_method__invalid_botx_status_code_error_raised(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(HTTPStatus.METHOD_NOT_ALLOWED),
+ )
+
+ method = FooBarMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ with pytest.raises(InvalidBotXStatusCodeError) as exc:
+ await method.execute(payload)
+
+ # - Assert -
+ assert "failed with code 405" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__botx_method__invalid_json_raises_invalid_botx_response_payload_error(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ content='{"invalid": "json',
+ ),
+ )
+
+ method = FooBarMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ with pytest.raises(InvalidBotXResponsePayloadError) as exc:
+ await method.execute(payload)
+
+ # - Assert -
+ assert '{"invalid": "json' in str(exc.value)
+ assert endpoint.called
+
+
+async def test__botx_method__invalid_schema_raises_invalid_botx_response_payload_error(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={"invalid": "schema"},
+ ),
+ )
+
+ method = FooBarMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ with pytest.raises(InvalidBotXResponsePayloadError) as exc:
+ await method.execute(payload)
+
+ # - Assert -
+ assert '{"invalid": "schema"}' in str(exc.value)
+ assert endpoint.called
+
+
+async def test__botx_method__status_handler_called(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(HTTPStatus.FORBIDDEN),
+ )
+
+ method = FooBarMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ with pytest.raises(FooBarError) as exc:
+ await method.execute(payload)
+
+ # - Assert -
+ assert "403" in str(exc.value)
+ assert "FooBar comment" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__botx_method__succeed(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ method = FooBarMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ botx_api_foo_bar = await method.execute(payload)
+
+ # - Assert -
+ assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
diff --git a/tests/client/test_botx_method_callback.py b/tests/client/test_botx_method_callback.py
new file mode 100644
index 00000000..7cee4c52
--- /dev/null
+++ b/tests/client/test_botx_method_callback.py
@@ -0,0 +1,500 @@
+# type: ignore [attr-defined]
+
+import asyncio
+import types
+from http import HTTPStatus
+from typing import Optional
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ BotShuttingDownError,
+ BotXMethodCallbackNotFoundError,
+ BotXMethodFailedCallbackReceivedError,
+ CallbackNotReceivedError,
+ HandlerCollector,
+ lifespan_wrapper,
+)
+from botx.client.botx_method import (
+ BotXMethod,
+ ErrorCallbackHandlers,
+ callback_exception_thrower,
+)
+from botx.client.exceptions.base import BaseClientError
+from botx.missing import MissingOptional, Undefined, not_undefined
+from botx.models.method_callbacks import BotAPIMethodSuccessfulCallback
+from tests.client.test_botx_method import (
+ BotXAPIFooBarRequestPayload,
+ BotXAPIFooBarResponsePayload,
+)
+
+
+class FooBarError(BaseClientError):
+ """Test exception."""
+
+
+class FooBarCallbackMethod(BotXMethod):
+ error_callback_handlers: ErrorCallbackHandlers = {
+ "foo_bar_error": callback_exception_thrower(
+ FooBarError,
+ "FooBar comment",
+ ),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIFooBarRequestPayload,
+ wait_callback: bool,
+ callback_timeout: MissingOptional[int] = Undefined,
+ ) -> BotXAPIFooBarResponsePayload:
+ path = "/foo/bar"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+ api_model = self._verify_and_extract_api_model(
+ BotXAPIFooBarResponsePayload,
+ response,
+ )
+
+ await self._process_callback(
+ api_model.result.sync_id,
+ wait_callback,
+ callback_timeout,
+ )
+
+ return api_model
+
+
+async def call_foo_bar(
+ self: Bot,
+ bot_id: UUID,
+ baz: int,
+ wait_callback: bool = True,
+ callback_timeout: Optional[int] = None,
+) -> UUID:
+ method = FooBarCallbackMethod(
+ bot_id,
+ self._httpx_client,
+ self._bot_accounts_storage,
+ self._callback_manager,
+ )
+
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=baz)
+ botx_api_foo_bar = await method.execute(
+ payload,
+ wait_callback,
+ not_undefined(callback_timeout, self.default_callback_timeout),
+ )
+
+ return botx_api_foo_bar.to_domain()
+
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__botx_method_callback__callback_not_found(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(BotXMethodCallbackNotFoundError) as exc:
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "chat_not_found",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c",
+ "error_description": (
+ "Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found"
+ ),
+ },
+ },
+ )
+
+ # - Assert -
+ assert "No callback found" in str(exc.value)
+ assert "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" in str(exc.value)
+
+
+async def test__botx_method_callback__error_callback_error_handler_called(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot)
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.call_foo_bar(bot_id, baz=1),
+ )
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "foo_bar_error",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c",
+ "error_description": (
+ "Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found"
+ ),
+ },
+ },
+ )
+
+ with pytest.raises(FooBarError) as exc:
+ await task
+
+ # - Assert -
+ assert "foo_bar_error" in str(exc.value)
+ assert "FooBar comment" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__botx_method_callback__error_callback_received(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot)
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.call_foo_bar(bot_id, baz=1),
+ )
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "quux_error",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c",
+ "error_description": (
+ "Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found"
+ ),
+ },
+ },
+ )
+
+ with pytest.raises(BotXMethodFailedCallbackReceivedError) as exc:
+ await task
+
+ # - Assert -
+ assert "failed with" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__botx_method_callback__cancelled_callback_future_during_shutdown(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot)
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(CallbackNotReceivedError) as exc:
+ await bot.call_foo_bar(bot_id, baz=1, callback_timeout=0)
+
+ # - Assert -
+ assert "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__botx_method_callback__callback_received_after_timeout(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ loguru_caplog: pytest.LogCaptureFixture,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot)
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(CallbackNotReceivedError) as exc:
+ await bot.call_foo_bar(bot_id, baz=1, callback_timeout=0)
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "error",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "reason": "quux_error",
+ "errors": [],
+ "error_data": {
+ "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c",
+ "error_description": (
+ "Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found"
+ ),
+ },
+ },
+ )
+
+ # - Assert -
+ assert "hasn't been received" in str(exc.value)
+ assert "don't wait callback" in loguru_caplog.text
+ assert "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" in loguru_caplog.text
+ assert endpoint.called
+
+
+async def test__botx_method_callback__dont_wait_for_callback(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot)
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ foo_bar = await bot.call_foo_bar(bot_id, baz=1, wait_callback=False)
+
+ # - Assert -
+ assert foo_bar == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
+
+
+async def test__botx_method_callback__pending_callback_future_during_shutdown(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot)
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.call_foo_bar(bot_id, baz=1),
+ )
+ await asyncio.sleep(0) # Return control to event loop
+
+ with pytest.raises(BotShuttingDownError) as exc:
+ await task
+
+ # - Assert -
+ assert "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__botx_method_callback__callback_successful_received(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot)
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ task = asyncio.create_task(
+ bot.call_foo_bar(bot_id, baz=1),
+ )
+ await asyncio.sleep(0) # Return control to event loop
+
+ bot.set_raw_botx_method_result(
+ {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ },
+ )
+
+ # - Assert -
+ assert await task == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")
+ assert endpoint.called
+
+
+async def test__botx_method_callback__bot_wait_callback(
+ respx_mock: MockRouter,
+ httpx_client: httpx.AsyncClient,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={"baz": 1},
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+ built_bot = Bot(
+ collectors=[HandlerCollector()],
+ bot_accounts=[bot_account],
+ httpx_client=httpx_client,
+ )
+
+ built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot)
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ foo_bar = await bot.call_foo_bar(bot_id, baz=1, wait_callback=False)
+ task = asyncio.create_task(bot.wait_botx_method_callback(foo_bar, None))
+
+ # Return control to event loop
+ await asyncio.sleep(0)
+
+ bot.set_raw_botx_method_result(
+ {
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "status": "ok",
+ "result": {},
+ },
+ )
+
+ callback = await task
+
+ # - Assert -
+ assert callback == BotAPIMethodSuccessfulCallback(
+ sync_id=foo_bar,
+ status="ok",
+ result={},
+ )
+ assert endpoint.called
diff --git a/tests/client/test_botx_method_stream.py b/tests/client/test_botx_method_stream.py
new file mode 100644
index 00000000..829939a2
--- /dev/null
+++ b/tests/client/test_botx_method_stream.py
@@ -0,0 +1,137 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from aiofiles.tempfile import NamedTemporaryFile
+from respx.router import MockRouter
+
+from botx import BotAccountWithSecret, InvalidBotXStatusCodeError
+from botx.async_buffer import AsyncBufferWritable
+from botx.bot.bot_accounts_storage import BotAccountsStorage
+from botx.client.botx_method import BotXMethod, response_exception_thrower
+from botx.client.exceptions.base import BaseClientError
+from tests.client.test_botx_method import BotXAPIFooBarRequestPayload
+
+
+class FooBarError(BaseClientError):
+ """Test exception."""
+
+
+class FooBarStreamMethod(BotXMethod):
+ status_handlers = {
+ 403: response_exception_thrower(FooBarError),
+ }
+
+ async def execute(
+ self,
+ payload: BotXAPIFooBarRequestPayload,
+ async_buffer: AsyncBufferWritable,
+ ) -> None:
+ path = "/foo/bar"
+
+ async with self._botx_method_stream(
+ "GET",
+ self._build_url(path),
+ params=payload.jsonable_dict(),
+ ) as response:
+ async for chunk in response.aiter_bytes():
+ await async_buffer.write(chunk)
+
+ await async_buffer.seek(0)
+
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__botx_method_stream__invalid_botx_status_code_error_raised(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(f"https://{host}/foo/bar", params={"baz": 1}).mock(
+ return_value=httpx.Response(HTTPStatus.METHOD_NOT_ALLOWED),
+ )
+
+ method = FooBarStreamMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ with pytest.raises(InvalidBotXStatusCodeError) as exc:
+ await method.execute(payload, async_buffer)
+
+ # - Assert -
+ assert "failed with code 405" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__botx_method_stream__status_handler_called(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(f"https://{host}/foo/bar", params={"baz": 1}).mock(
+ return_value=httpx.Response(HTTPStatus.FORBIDDEN),
+ )
+
+ method = FooBarStreamMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ with pytest.raises(FooBarError) as exc:
+ await method.execute(payload, async_buffer)
+
+ # - Assert -
+ assert "403" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__botx_method_stream__succeed(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(f"https://{host}/foo/bar", params={"baz": 1}).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ content=b"Hello, world!\n",
+ ),
+ )
+
+ method = FooBarStreamMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(baz=1)
+
+ # - Act -
+ await method.execute(payload, async_buffer)
+
+ # - Assert -
+ assert await async_buffer.read() == b"Hello, world!\n"
+ assert endpoint.called
diff --git a/tests/client/test_botx_method_undefined_cleaned.py b/tests/client/test_botx_method_undefined_cleaned.py
new file mode 100644
index 00000000..ed4b418c
--- /dev/null
+++ b/tests/client/test_botx_method_undefined_cleaned.py
@@ -0,0 +1,113 @@
+from http import HTTPStatus
+from typing import Any, Dict, Literal
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import BotAccountWithSecret
+from botx.bot.bot_accounts_storage import BotAccountsStorage
+from botx.client.botx_method import BotXMethod
+from botx.missing import Undefined
+from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
+
+
+class BotXAPIFooBarRequestPayload(UnverifiedPayloadBaseModel):
+ baz: Dict[str, Any]
+
+ @classmethod
+ def from_domain(cls, baz: Dict[str, Any]) -> "BotXAPIFooBarRequestPayload":
+ return cls(baz=baz)
+
+
+class BotXAPIFooBarResponsePayload(VerifiedPayloadBaseModel):
+ status: Literal["ok"]
+
+
+class FooBarMethod(BotXMethod):
+ async def execute(
+ self,
+ payload: BotXAPIFooBarRequestPayload,
+ ) -> None:
+ path = "/foo/bar"
+
+ response = await self._botx_method_call(
+ "POST",
+ self._build_url(path),
+ json=payload.jsonable_dict(),
+ )
+
+ self._verify_and_extract_api_model(
+ BotXAPIFooBarResponsePayload,
+ response,
+ )
+
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__botx_method__undefined_cleaned(
+ httpx_client: httpx.AsyncClient,
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/foo/bar",
+ json={
+ "baz": {
+ "key": [
+ {
+ "key1": "value",
+ "key3": [1, 2, 3],
+ "key4": {"key1": "value"},
+ "key7": {},
+ "key8": [],
+ },
+ ],
+ },
+ },
+ headers={"Content-Type": "application/json"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={"status": "ok"},
+ ),
+ )
+
+ method = FooBarMethod(
+ bot_id,
+ httpx_client,
+ BotAccountsStorage([bot_account]),
+ )
+ payload = BotXAPIFooBarRequestPayload.from_domain(
+ baz={
+ "key": [
+ {
+ "key1": "value",
+ "key2": Undefined,
+ "key3": [Undefined, 1, 2, Undefined, 3],
+ "key4": {"key1": "value", "key2": Undefined},
+ "key5": [Undefined, Undefined],
+ "key6": {"key1": Undefined, "key2": Undefined},
+ "key7": {},
+ "key8": [],
+ },
+ {
+ "key": Undefined,
+ },
+ ],
+ },
+ )
+
+ # - Act -
+ await method.execute(payload)
+
+ # - Assert -
+ assert endpoint.called
diff --git a/tests/test_clients/test_methods/__init__.py b/tests/client/users_api/__init__.py
similarity index 100%
rename from tests/test_clients/test_methods/__init__.py
rename to tests/client/users_api/__init__.py
diff --git a/tests/client/users_api/test_search_user_by_email.py b/tests/client/users_api/test_search_user_by_email.py
new file mode 100644
index 00000000..ccaf71ba
--- /dev/null
+++ b/tests/client/users_api/test_search_user_by_email.py
@@ -0,0 +1,113 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ UserFromSearch,
+ UserNotFoundError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__search_user_by_email__user_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/users/by_email",
+ headers={"Authorization": "Bearer token"},
+ params={"email": "ad_user@cts.com"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "user_not_found",
+ "errors": [],
+ "error_data": {},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(UserNotFoundError) as exc:
+ await bot.search_user_by_email(
+ bot_id=bot_id,
+ email="ad_user@cts.com",
+ )
+
+ # - Assert -
+ assert "user_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__search_user_by_email__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/users/by_email",
+ headers={"Authorization": "Bearer token"},
+ params={"email": "ad_user@cts.com"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364",
+ "ad_login": "ad_user_login",
+ "ad_domain": "cts.com",
+ "name": "Bob",
+ "company": "Bobs Co",
+ "company_position": "Director",
+ "department": "Owners",
+ "emails": ["ad_user@cts.com"],
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ user = await bot.search_user_by_email(
+ bot_id=bot_id,
+ email="ad_user@cts.com",
+ )
+
+ # - Assert -
+ assert user == UserFromSearch(
+ huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"),
+ ad_login="ad_user_login",
+ ad_domain="cts.com",
+ username="Bob",
+ company="Bobs Co",
+ company_position="Director",
+ department="Owners",
+ emails=["ad_user@cts.com"],
+ )
+
+ assert endpoint.called
diff --git a/tests/client/users_api/test_search_user_by_huid.py b/tests/client/users_api/test_search_user_by_huid.py
new file mode 100644
index 00000000..aebcca8f
--- /dev/null
+++ b/tests/client/users_api/test_search_user_by_huid.py
@@ -0,0 +1,113 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ UserFromSearch,
+ UserNotFoundError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__search_user_by_huid__user_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/users/by_huid",
+ headers={"Authorization": "Bearer token"},
+ params={"user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "user_not_found",
+ "errors": [],
+ "error_data": {},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(UserNotFoundError) as exc:
+ await bot.search_user_by_huid(
+ bot_id=bot_id,
+ huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"),
+ )
+
+ # - Assert -
+ assert "user_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__search_user_by_huid__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/users/by_huid",
+ headers={"Authorization": "Bearer token"},
+ params={"user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1",
+ "ad_login": "ad_user_login",
+ "ad_domain": "cts.com",
+ "name": "Bob",
+ "company": "Bobs Co",
+ "company_position": "Director",
+ "department": "Owners",
+ "emails": ["ad_user@cts.com"],
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ user = await bot.search_user_by_huid(
+ bot_id=bot_id,
+ huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"),
+ )
+
+ # - Assert -
+ assert user == UserFromSearch(
+ huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"),
+ ad_login="ad_user_login",
+ ad_domain="cts.com",
+ username="Bob",
+ company="Bobs Co",
+ company_position="Director",
+ department="Owners",
+ emails=["ad_user@cts.com"],
+ )
+
+ assert endpoint.called
diff --git a/tests/client/users_api/test_search_user_by_login.py b/tests/client/users_api/test_search_user_by_login.py
new file mode 100644
index 00000000..3a2ec0e2
--- /dev/null
+++ b/tests/client/users_api/test_search_user_by_login.py
@@ -0,0 +1,115 @@
+from http import HTTPStatus
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ UserFromSearch,
+ UserNotFoundError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__search_user_by_ad__user_not_found_error_raised(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/users/by_login",
+ headers={"Authorization": "Bearer token"},
+ params={"ad_login": "ad_user_login", "ad_domain": "cts.com"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.NOT_FOUND,
+ json={
+ "status": "error",
+ "reason": "user_not_found",
+ "errors": [],
+ "error_data": {},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(UserNotFoundError) as exc:
+ await bot.search_user_by_ad(
+ bot_id=bot_id,
+ ad_login="ad_user_login",
+ ad_domain="cts.com",
+ )
+
+ # - Assert -
+ assert "user_not_found" in str(exc.value)
+ assert endpoint.called
+
+
+async def test__search_user_by_ad__succeed(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/users/by_login",
+ headers={"Authorization": "Bearer token"},
+ params={"ad_login": "ad_user_login", "ad_domain": "cts.com"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": {
+ "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364",
+ "ad_login": "ad_user_login",
+ "ad_domain": "cts.com",
+ "name": "Bob",
+ "company": "Bobs Co",
+ "company_position": "Director",
+ "department": "Owners",
+ "emails": ["ad_user@cts.com"],
+ },
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ user = await bot.search_user_by_ad(
+ bot_id=bot_id,
+ ad_login="ad_user_login",
+ ad_domain="cts.com",
+ )
+
+ # - Assert -
+ assert user == UserFromSearch(
+ huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"),
+ ad_login="ad_user_login",
+ ad_domain="cts.com",
+ username="Bob",
+ company="Bobs Co",
+ company_position="Director",
+ department="Owners",
+ emails=["ad_user@cts.com"],
+ )
+
+ assert endpoint.called
diff --git a/tests/conftest.py b/tests/conftest.py
index 71bbddc9..5ec584e1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,10 +1,252 @@
-pytest_plugins = (
- "tests.fixtures.bot",
- "tests.fixtures.collector",
- "tests.fixtures.handlers",
- "tests.fixtures.storage",
- "tests.fixtures.credentials",
- "tests.fixtures.logging",
- "tests.fixtures.messages",
- "tests.fixtures.errors",
+import logging
+from datetime import datetime
+from http import HTTPStatus
+from typing import Any, AsyncGenerator, Callable, Dict, Generator, List, Optional
+from unittest.mock import Mock
+from uuid import UUID, uuid4
+
+import httpx
+import pytest
+from aiofiles.tempfile import NamedTemporaryFile
+from pydantic import BaseModel
+from respx.router import MockRouter
+
+from botx import (
+ BotAccount,
+ BotAccountWithSecret,
+ Chat,
+ ChatTypes,
+ IncomingMessage,
+ UserDevice,
+ UserSender,
)
+from botx.bot.bot_accounts_storage import BotAccountsStorage
+from botx.logger import logger
+
+
+@pytest.fixture(autouse=True)
+def enable_logger() -> None:
+ logger.enable("botx")
+
+
+@pytest.fixture
+def prepared_bot_accounts_storage(
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+) -> BotAccountsStorage:
+ bot_accounts_storage = BotAccountsStorage([bot_account])
+ bot_accounts_storage.set_token(bot_id, "token")
+
+ return bot_accounts_storage
+
+
+@pytest.fixture
+def datetime_formatter() -> Callable[[str], datetime]:
+ class DateTimeFormatter(BaseModel): # noqa: WPS431
+ value: datetime
+
+ def factory(dt_str: str) -> datetime:
+ return DateTimeFormatter(value=dt_str).value
+
+ return factory
+
+
+@pytest.fixture
+def host() -> str:
+ return "cts.example.com"
+
+
+@pytest.fixture
+def bot_id() -> UUID:
+ return UUID("24348246-6791-4ac0-9d86-b948cd6a0e46")
+
+
+@pytest.fixture
+def bot_account(host: str, bot_id: UUID) -> BotAccountWithSecret:
+ return BotAccountWithSecret(
+ id=bot_id,
+ host=host,
+ secret_key="bee001",
+ )
+
+
+@pytest.fixture
+def bot_signature() -> str:
+ return "E050AEEA197E0EF0A6E1653E18B7D41C7FDEC0FCFBA44C44FCCD2A88CEABD130"
+
+
+@pytest.fixture
+def mock_authorization(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_signature: str,
+) -> None:
+ """Fixture should be used as a marker."""
+ respx_mock.get(
+ f"https://{host}/api/v2/botx/bots/{bot_id}/token",
+ params={"signature": bot_signature},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ json={
+ "status": "ok",
+ "result": "token",
+ },
+ ),
+ )
+
+
+@pytest.hookimpl(trylast=True)
+def pytest_collection_modifyitems(items: List[pytest.Function]) -> None:
+ for item in items:
+ if item.get_closest_marker("mock_authorization"):
+ item.fixturenames.append("mock_authorization")
+
+
+@pytest.fixture()
+def loguru_caplog(
+ caplog: pytest.LogCaptureFixture,
+) -> Generator[pytest.LogCaptureFixture, None, None]:
+ # https://github.com/Delgan/loguru/issues/59
+
+ class PropogateHandler(logging.Handler): # noqa: WPS431
+ def emit(self, record: logging.LogRecord) -> None:
+ logging.getLogger(record.name).handle(record)
+
+ handler_id = logger.add(PropogateHandler(), format="{message}")
+ yield caplog
+ logger.remove(handler_id)
+
+
+@pytest.fixture
+async def httpx_client() -> AsyncGenerator[httpx.AsyncClient, None]:
+ async with httpx.AsyncClient() as client:
+ yield client
+
+
+@pytest.fixture
+async def async_buffer() -> AsyncGenerator[NamedTemporaryFile, None]:
+ async with NamedTemporaryFile("wb+") as async_buffer:
+ yield async_buffer
+
+
+@pytest.fixture
+def api_incoming_message_factory() -> Callable[..., Dict[str, Any]]:
+ def decorator(
+ *,
+ bot_id: Optional[UUID] = None,
+ group_chat_id: Optional[UUID] = None,
+ user_huid: Optional[UUID] = None,
+ host: Optional[str] = None,
+ attachment: Optional[Dict[str, Any]] = None,
+ async_file: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ return {
+ "bot_id": str(bot_id) if bot_id else "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "command": {
+ "body": "/hello",
+ "command_type": "user",
+ "data": {},
+ "metadata": {},
+ },
+ "attachments": [attachment] if attachment else [],
+ "async_files": [async_file] if async_file else [],
+ "source_sync_id": None,
+ "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": "chat",
+ "device": None,
+ "device_meta": {
+ "permissions": None,
+ "pushes": False,
+ "timezone": "Europe/Moscow",
+ },
+ "device_software": None,
+ "group_chat_id": (
+ str(group_chat_id)
+ if group_chat_id
+ else "30dc1980-643a-00ad-37fc-7cc10d74e935"
+ ),
+ "host": host or "cts.example.com",
+ "is_admin": True,
+ "is_creator": True,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": (
+ str(user_huid)
+ if user_huid
+ else "f16cdc5f-6366-5552-9ecd-c36290ab3d11"
+ ),
+ "username": None,
+ },
+ "proto_version": 4,
+ "entities": [],
+ }
+
+ return decorator
+
+
+@pytest.fixture
+def incoming_message_factory(
+ bot_id: UUID,
+) -> Callable[..., IncomingMessage]:
+ def decorator(
+ *,
+ body: str = "",
+ ad_login: Optional[str] = None,
+ ad_domain: Optional[str] = None,
+ ) -> IncomingMessage:
+ return IncomingMessage(
+ bot=BotAccount(
+ id=bot_id,
+ host="cts.example.com",
+ ),
+ sync_id=uuid4(),
+ source_sync_id=None,
+ body=body,
+ data={},
+ metadata={},
+ sender=UserSender(
+ huid=uuid4(),
+ ad_login=ad_login,
+ ad_domain=ad_domain,
+ username=None,
+ is_chat_admin=True,
+ is_chat_creator=True,
+ device=UserDevice(
+ manufacturer=None,
+ device_name=None,
+ os=None,
+ pushes=None,
+ timezone=None,
+ permissions=None,
+ platform=None,
+ platform_package_id=None,
+ app_version=None,
+ locale=None,
+ ),
+ ),
+ chat=Chat(
+ id=uuid4(),
+ type=ChatTypes.PERSONAL_CHAT,
+ ),
+ raw_command=None,
+ )
+
+ return decorator
+
+
+@pytest.fixture
+def correct_handler_trigger() -> Mock:
+ return Mock()
+
+
+@pytest.fixture
+def incorrect_handler_trigger() -> Mock:
+ return Mock()
diff --git a/tests/fixtures/bot.py b/tests/fixtures/bot.py
deleted file mode 100644
index e164dd8e..00000000
--- a/tests/fixtures/bot.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import pytest
-
-from botx import Bot, BotXCredentials, TestClient
-
-
-@pytest.fixture()
-def bot(host, secret_key, bot_id, token):
- accounts = BotXCredentials(
- host=host,
- secret_key=secret_key,
- bot_id=bot_id,
- token=token,
- )
-
- return Bot(bot_accounts=[accounts])
-
-
-@pytest.fixture()
-def client(bot):
- with TestClient(bot) as client:
- yield client
diff --git a/tests/fixtures/collector.py b/tests/fixtures/collector.py
deleted file mode 100644
index 851be7a3..00000000
--- a/tests/fixtures/collector.py
+++ /dev/null
@@ -1,75 +0,0 @@
-from typing import Callable, Optional
-
-import pytest
-
-from botx import Collector, Depends, Message
-
-
-def build_botx_handler(name: Optional[str] = None) -> Callable[[], None]:
- def factory(_message: Message):
- """Just do nothing."""
-
- factory.__name__ = name or factory.__name__
- return factory
-
-
-@pytest.fixture()
-def build_handler_for_collector():
- return build_botx_handler
-
-
-@pytest.fixture()
-def collector_with_handlers(build_handler_for_collector):
- collector = Collector()
- collector.handler(build_handler_for_collector("regular_handler"))
- collector.handler(
- build_handler_for_collector("regular_handler_with_command"),
- command="/handler-command",
- )
- collector.handler(
- build_handler_for_collector("regular_handler_with_command_aliases"),
- commands=["/handler-command1", "/handler-command2"],
- )
- collector.handler(
- build_handler_for_collector("regular_handler_with_command_and_command_aliases"),
- command="/handler-command3",
- commands=["/handler-command4", "/handler-command5"],
- )
- collector.handler(
- build_handler_for_collector("regular_handler_with_custom_name"),
- name="regular-handler-with-name",
- )
- collector.handler(
- build_handler_for_collector("regular_handler_with_background_dependencies"),
- dependencies=[Depends(build_handler_for_collector("background_dependency"))],
- )
- collector.handler(
- build_handler_for_collector(
- "regular_handler_that_excluded_from_status_and_auto_body",
- ),
- include_in_status=False,
- )
- collector.handler(
- build_handler_for_collector(
- "regular_handler_that_excluded_from_status_and_passed_body",
- ),
- command="regular-handler-with-excluding-from-status",
- include_in_status=False,
- )
- collector.handler(
- build_handler_for_collector(
- "regular_handler_that_included_in_status_by_callable_function",
- ),
- include_in_status=lambda *_: True,
- )
- collector.handler(
- build_handler_for_collector(
- "regular_handler_that_excluded_from_status_by_callable_function",
- ),
- include_in_status=lambda *_: False,
- )
- collector.default(build_handler_for_collector("default_handler"))
- collector.hidden(build_handler_for_collector("hidden_handler"))
- collector.chat_created(build_handler_for_collector("chat_created_handler"))
- collector.file_transfer(build_handler_for_collector("file_transfer_handler"))
- return collector
diff --git a/tests/fixtures/credentials.py b/tests/fixtures/credentials.py
deleted file mode 100644
index 69e5bf08..00000000
--- a/tests/fixtures/credentials.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import uuid
-
-import pytest
-
-
-@pytest.fixture()
-def host():
- return "cts.example.com"
-
-
-@pytest.fixture()
-def secret_key():
- return "secret-key-for-token"
-
-
-@pytest.fixture()
-def token():
- return "generated-token-for-bot"
-
-
-@pytest.fixture()
-def bot_id():
- return uuid.uuid4()
diff --git a/tests/fixtures/errors.py b/tests/fixtures/errors.py
deleted file mode 100644
index 840f6067..00000000
--- a/tests/fixtures/errors.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import threading
-
-import pytest
-
-from botx import Message
-
-
-@pytest.fixture()
-def build_exception_catcher(storage):
- def factory(event: threading.Event):
- def decorator(exc: Exception, msg: Message):
- event.set()
- storage.exception = exc
- storage.message = msg
-
- return decorator
-
- return factory
diff --git a/tests/fixtures/handlers.py b/tests/fixtures/handlers.py
deleted file mode 100644
index ec916dbc..00000000
--- a/tests/fixtures/handlers.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import threading
-from typing import Callable
-
-import pytest
-
-
-def build_botx_handler(event: threading.Event) -> Callable[[], None]:
- def factory():
- event.set()
-
- return factory
-
-
-@pytest.fixture()
-def build_handler():
- return build_botx_handler
-
-
-@pytest.fixture()
-def build_failed_handler():
- def factory(exception: Exception, event: threading.Event):
- def decorator():
- event.set()
- raise exception
-
- return decorator
-
- return factory
diff --git a/tests/fixtures/logging.py b/tests/fixtures/logging.py
deleted file mode 100644
index 03769385..00000000
--- a/tests/fixtures/logging.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import logging
-
-import pytest
-from loguru import logger
-
-
-@pytest.fixture()
-def _enable_logger():
- logger.enable("botx")
-
-
-@pytest.fixture()
-def loguru_caplog(caplog, _enable_logger):
- class PropogateHandler(logging.Handler):
- def emit(self, record: logging.LogRecord) -> None:
- logging.getLogger(record.name).handle(record)
-
- handler_id = logger.add(PropogateHandler(), format="{message}")
- yield caplog
- logger.remove(handler_id)
diff --git a/tests/fixtures/messages.py b/tests/fixtures/messages.py
deleted file mode 100644
index 21152cfd..00000000
--- a/tests/fixtures/messages.py
+++ /dev/null
@@ -1,72 +0,0 @@
-import pytest
-
-from botx import (
- ChatCreatedEvent,
- InternalBotNotificationEvent,
- InternalBotNotificationPayload,
- Message,
- MessageBuilder,
- UserKinds,
-)
-from botx.models.events import UserInChatCreated
-
-
-@pytest.fixture()
-def incoming_message(host, bot_id):
- builder = MessageBuilder()
- builder.bot_id = bot_id
- builder.user.host = host
- return builder.message
-
-
-@pytest.fixture()
-def message(incoming_message, bot):
- return Message.from_dict(incoming_message.dict(), bot)
-
-
-@pytest.fixture()
-def chat_created_message(host, bot_id):
- builder = MessageBuilder()
- builder.bot_id = bot_id
- builder.command_data = ChatCreatedEvent(
- group_chat_id=builder.user.group_chat_id,
- chat_type=builder.user.chat_type,
- name="chat",
- creator=builder.user.user_huid,
- members=[
- UserInChatCreated(
- huid=builder.user.user_huid,
- user_kind=UserKinds.user,
- name=builder.user.username,
- admin=True,
- ),
- UserInChatCreated(
- huid=builder.bot_id,
- user_kind=UserKinds.bot,
- name="bot",
- admin=False,
- ),
- ],
- )
- builder.user.user_huid = None
- builder.user.ad_login = None
- builder.user.ad_domain = None
- builder.user.username = None
-
- builder.body = "system:chat_created"
- builder.system_command = True
-
- return builder.message
-
-
-@pytest.fixture()
-def internal_bot_notification_message(host, bot_id, bot):
- builder = MessageBuilder()
- builder.bot_id = bot_id
- builder.command_data = InternalBotNotificationEvent(
- data=InternalBotNotificationPayload(message="ping"), # noqa: WPS110
- opts={},
- )
- builder.body = "system:internal_bot_notification"
- builder.system_command = True
- return Message.from_dict(builder.message.dict(), bot)
diff --git a/tests/fixtures/smartapps.py b/tests/fixtures/smartapps.py
deleted file mode 100644
index 00763dae..00000000
--- a/tests/fixtures/smartapps.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from typing import Dict
-from uuid import UUID, uuid4
-
-import pytest
-
-
-@pytest.fixture()
-def smartapp_api_version() -> int:
- return 1
-
-
-@pytest.fixture()
-def smartapp_counter() -> int:
- return 42
-
-
-@pytest.fixture()
-def smartapp_id() -> UUID:
- return uuid4()
-
-
-@pytest.fixture()
-def group_chat_id() -> UUID:
- return uuid4()
-
-
-@pytest.fixture()
-def ref() -> UUID:
- return uuid4()
-
-
-@pytest.fixture()
-def smartapp_data() -> Dict[str, str]:
- return {"key": "value"}
diff --git a/tests/fixtures/storage.py b/tests/fixtures/storage.py
deleted file mode 100644
index bfb2ef67..00000000
--- a/tests/fixtures/storage.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import pytest
-
-from botx.models.datastructures import State
-
-
-@pytest.fixture()
-def storage():
- return State()
diff --git a/tests/test_clients/test_methods/test_base/__init__.py b/tests/models/__init__.py
similarity index 100%
rename from tests/test_clients/test_methods/test_base/__init__.py
rename to tests/models/__init__.py
diff --git a/tests/models/test_incoming_message.py b/tests/models/test_incoming_message.py
new file mode 100644
index 00000000..081848aa
--- /dev/null
+++ b/tests/models/test_incoming_message.py
@@ -0,0 +1,101 @@
+from typing import Callable, Tuple
+
+import pytest
+
+from botx import IncomingMessage
+
+
+def test__upn_property__not_filled(
+ incoming_message_factory: Callable[..., IncomingMessage],
+) -> None:
+ # - Arrange -
+ message = incoming_message_factory()
+
+ # - Assert -
+ assert message.sender.upn is None
+
+
+def test__upn_property__filled(
+ incoming_message_factory: Callable[..., IncomingMessage],
+) -> None:
+ # - Arrange -
+ message = incoming_message_factory(ad_login="login", ad_domain="domain")
+
+ # - Assert -
+ assert message.sender.upn == "login@domain"
+
+
+@pytest.mark.parametrize(
+ "body,argument_answer",
+ [
+ ("", ""),
+ ("/command", ""),
+ ],
+)
+def test__argument__not_filled(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ body: str,
+ argument_answer: str,
+) -> None:
+ # - Arrange -
+ message = incoming_message_factory(body=body)
+
+ # - Assert -
+ assert message.argument == argument_answer
+
+
+@pytest.mark.parametrize(
+ "body,argument_answer",
+ [
+ ("/command arg1 ", "arg1"),
+ ("/command arg1 arg2", "arg1 arg2"),
+ ],
+)
+def test__argument__filled(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ body: str,
+ argument_answer: str,
+) -> None:
+ # - Arrange -
+ message = incoming_message_factory(body=body)
+
+ # - Assert -
+ assert message.argument == argument_answer
+
+
+@pytest.mark.parametrize(
+ "body,argument_answer",
+ [
+ ("", ()),
+ ("/command", ()),
+ ],
+)
+def test__arguments__not_filled(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ body: str,
+ argument_answer: Tuple[str, ...],
+) -> None:
+ # - Arrange -
+ message = incoming_message_factory(body=body)
+
+ # - Assert -
+ assert message.arguments == argument_answer
+
+
+@pytest.mark.parametrize(
+ "body,argument_answer",
+ [
+ ("/command arg1 ", ("arg1",)),
+ ("/command arg1 arg2", ("arg1", "arg2")),
+ ],
+)
+def test__arguments__filled(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ body: str,
+ argument_answer: Tuple[str, ...],
+) -> None:
+ # - Arrange -
+ message = incoming_message_factory(body=body)
+
+ # - Assert -
+ assert message.arguments == argument_answer
diff --git a/tests/models/test_mentions_list.py b/tests/models/test_mentions_list.py
new file mode 100644
index 00000000..17cf5707
--- /dev/null
+++ b/tests/models/test_mentions_list.py
@@ -0,0 +1,88 @@
+from typing import Callable, Optional
+from uuid import UUID, uuid4
+
+import pytest
+
+from botx import Mention, MentionList, MentionTypes
+
+
+@pytest.fixture
+def mention_factory() -> Callable[..., Mention]:
+ def factory(
+ mention_type: MentionTypes,
+ huid: UUID,
+ name: Optional[str] = None,
+ ) -> Mention:
+ return Mention(
+ type=mention_type,
+ entity_id=huid,
+ name=name,
+ )
+
+ return factory
+
+
+def test__mentions_list_properties__filled(
+ mention_factory: Callable[..., Mention],
+) -> None:
+ # - Arrange -
+ contacts = [
+ mention_factory(
+ mention_type=MentionTypes.CONTACT,
+ huid=uuid4(),
+ name=str(name),
+ )
+ for name in range(2)
+ ]
+ chats = [
+ mention_factory(
+ mention_type=MentionTypes.CHAT,
+ huid=uuid4(),
+ name=str(name),
+ )
+ for name in range(2)
+ ]
+ channels = [
+ mention_factory(
+ mention_type=MentionTypes.CHANNEL,
+ huid=uuid4(),
+ name=str(name),
+ )
+ for name in range(2)
+ ]
+ users = [
+ mention_factory(
+ mention_type=MentionTypes.USER,
+ huid=uuid4(),
+ name=str(name),
+ )
+ for name in range(2)
+ ]
+
+ mentions = MentionList([*contacts, *chats, *channels, *users])
+
+ # - Assert -
+ assert mentions.contacts == contacts
+ assert mentions.chats == chats
+ assert mentions.channels == channels
+ assert mentions.users == users
+
+
+def test__mentions_list_all_users_mentioned__filled(
+ mention_factory: Callable[..., Mention],
+) -> None:
+ # - Arrange -
+ user_mention = mention_factory(
+ mention_type=MentionTypes.CONTACT,
+ huid=uuid4(),
+ )
+ all_mention = Mention(type=MentionTypes.ALL)
+
+ one_all_mention = MentionList([user_mention, all_mention])
+ two_all_mentions = MentionList([all_mention, all_mention])
+
+ # - Assert -
+ assert one_all_mention.all_users_mentioned
+ assert two_all_mentions.all_users_mentioned
+
+ assert not MentionList([]).all_users_mentioned
diff --git a/tests/models/test_status_recipient.py b/tests/models/test_status_recipient.py
new file mode 100644
index 00000000..5d3f8c2a
--- /dev/null
+++ b/tests/models/test_status_recipient.py
@@ -0,0 +1,24 @@
+from typing import Callable
+
+from botx import IncomingMessage, StatusRecipient
+
+
+def test__status_recipient__from_message(
+ incoming_message_factory: Callable[..., IncomingMessage],
+) -> None:
+ # - Arrange -
+ incoming_message = incoming_message_factory(
+ ad_login="test_login",
+ ad_domain="test_domain",
+ )
+ status_recipient = StatusRecipient.from_incoming_message(incoming_message)
+
+ # - Assert -
+ assert status_recipient == StatusRecipient(
+ bot_id=incoming_message.bot.id,
+ huid=incoming_message.sender.huid,
+ ad_login=incoming_message.sender.ad_login,
+ ad_domain=incoming_message.sender.ad_domain,
+ is_admin=incoming_message.sender.is_chat_admin,
+ chat_type=incoming_message.chat.type,
+ )
diff --git a/tests/system_events/test_added_to_chat.py b/tests/system_events/test_added_to_chat.py
new file mode 100644
index 00000000..6788e60e
--- /dev/null
+++ b/tests/system_events/test_added_to_chat.py
@@ -0,0 +1,100 @@
+from typing import Optional
+from uuid import UUID
+
+import pytest
+
+from botx import (
+ AddedToChatEvent,
+ Bot,
+ BotAccount,
+ BotAccountWithSecret,
+ Chat,
+ ChatTypes,
+ HandlerCollector,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__added_to_chat__succeed(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "command": {
+ "body": "system:added_to_chat",
+ "command_type": "system",
+ "data": {
+ "added_members": [
+ "ab103983-6001-44e9-889e-d55feb295494",
+ "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ ],
+ },
+ "metadata": {},
+ },
+ "source_sync_id": None,
+ "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": "group_chat",
+ "device": None,
+ "device_meta": {
+ "permissions": None,
+ "pushes": None,
+ "timezone": None,
+ },
+ "device_software": None,
+ "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517",
+ "host": "cts.example.com",
+ "is_admin": None,
+ "is_creator": None,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": None,
+ "username": None,
+ },
+ "proto_version": 4,
+ }
+
+ collector = HandlerCollector()
+ added_to_chat: Optional[AddedToChatEvent] = None
+
+ @collector.added_to_chat
+ async def added_to_chat_handler(event: AddedToChatEvent, bot: Bot) -> None:
+ nonlocal added_to_chat
+ added_to_chat = event
+ # Drop `raw_command` from asserting
+ added_to_chat.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert added_to_chat == AddedToChatEvent(
+ bot=BotAccount(
+ id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ host="cts.example.com",
+ ),
+ raw_command=None,
+ huids=[
+ UUID("ab103983-6001-44e9-889e-d55feb295494"),
+ UUID("dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4"),
+ ],
+ chat=Chat(
+ id=UUID("dea55ee4-7a9f-5da0-8c73-079f400ee517"),
+ type=ChatTypes.GROUP_CHAT,
+ ),
+ )
diff --git a/tests/system_events/test_chat_created.py b/tests/system_events/test_chat_created.py
new file mode 100644
index 00000000..806d3338
--- /dev/null
+++ b/tests/system_events/test_chat_created.py
@@ -0,0 +1,129 @@
+from typing import Optional
+from uuid import UUID
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccount,
+ BotAccountWithSecret,
+ Chat,
+ ChatCreatedEvent,
+ ChatCreatedMember,
+ ChatTypes,
+ HandlerCollector,
+ UserKinds,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__chat_created__succeed(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "command": {
+ "body": "system:chat_created",
+ "command_type": "system",
+ "data": {
+ "chat_type": "group_chat",
+ "creator": "83fbf1c7-f14b-5176-bd32-ca15cf00d4b7",
+ "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517",
+ "members": [
+ {
+ "admin": True,
+ "huid": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "name": "Feature bot",
+ "user_kind": "botx",
+ },
+ {
+ "admin": False,
+ "huid": "83fbf1c7-f14b-5176-bd32-ca15cf00d4b7",
+ "name": "Ivanov Ivan Ivanovich",
+ "user_kind": "cts_user",
+ },
+ ],
+ "name": "Feature-party",
+ },
+ "metadata": {},
+ },
+ "source_sync_id": None,
+ "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": "group_chat",
+ "device": None,
+ "device_meta": {
+ "permissions": None,
+ "pushes": None,
+ "timezone": None,
+ },
+ "device_software": None,
+ "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517",
+ "host": "cts.example.com",
+ "is_admin": None,
+ "is_creator": None,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": None,
+ "username": None,
+ },
+ "proto_version": 4,
+ }
+
+ collector = HandlerCollector()
+ chat_created: Optional[ChatCreatedEvent] = None
+
+ @collector.chat_created
+ async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None:
+ nonlocal chat_created
+ chat_created = event
+ # Drop `raw_command` from asserting
+ chat_created.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert chat_created == ChatCreatedEvent(
+ sync_id=UUID("2c1a31d6-f47f-5f54-aee2-d0c526bb1d54"),
+ bot=BotAccount(
+ id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ host="cts.example.com",
+ ),
+ chat_name="Feature-party",
+ chat=Chat(
+ id=UUID("dea55ee4-7a9f-5da0-8c73-079f400ee517"),
+ type=ChatTypes.GROUP_CHAT,
+ ),
+ creator_id=UUID("83fbf1c7-f14b-5176-bd32-ca15cf00d4b7"),
+ members=[
+ ChatCreatedMember(
+ is_admin=True,
+ huid=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ username="Feature bot",
+ kind=UserKinds.BOT,
+ ),
+ ChatCreatedMember(
+ is_admin=False,
+ huid=UUID("83fbf1c7-f14b-5176-bd32-ca15cf00d4b7"),
+ username="Ivanov Ivan Ivanovich",
+ kind=UserKinds.CTS_USER,
+ ),
+ ],
+ raw_command=None,
+ )
diff --git a/tests/system_events/test_cts_login.py b/tests/system_events/test_cts_login.py
new file mode 100644
index 00000000..5c3effef
--- /dev/null
+++ b/tests/system_events/test_cts_login.py
@@ -0,0 +1,89 @@
+from typing import Optional
+from uuid import UUID
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccount,
+ BotAccountWithSecret,
+ CTSLoginEvent,
+ HandlerCollector,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__cts_login__succeed(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "command": {
+ "body": "system:cts_login",
+ "data": {
+ "user_huid": "b9197d3a-d855-5d34-ba8a-eff3a975ab20",
+ "cts_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ },
+ "command_type": "system",
+ "metadata": {},
+ },
+ "source_sync_id": None,
+ "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": None,
+ "device": None,
+ "device_meta": {
+ "permissions": None,
+ "pushes": None,
+ "timezone": None,
+ },
+ "device_software": None,
+ "group_chat_id": None,
+ "host": "cts.example.com",
+ "is_admin": None,
+ "is_creator": None,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": None,
+ "username": None,
+ },
+ "proto_version": 4,
+ }
+
+ collector = HandlerCollector()
+ cts_login: Optional[CTSLoginEvent] = None
+
+ @collector.cts_login
+ async def cts_login_handler(event: CTSLoginEvent, bot: Bot) -> None:
+ nonlocal cts_login
+ cts_login = event
+ # Drop `raw_command` from asserting
+ cts_login.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert cts_login == CTSLoginEvent(
+ bot=BotAccount(
+ id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ host="cts.example.com",
+ ),
+ raw_command=None,
+ huid=UUID("b9197d3a-d855-5d34-ba8a-eff3a975ab20"),
+ )
diff --git a/tests/system_events/test_cts_logout.py b/tests/system_events/test_cts_logout.py
new file mode 100644
index 00000000..13363bdc
--- /dev/null
+++ b/tests/system_events/test_cts_logout.py
@@ -0,0 +1,89 @@
+from typing import Optional
+from uuid import UUID
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccount,
+ BotAccountWithSecret,
+ CTSLogoutEvent,
+ HandlerCollector,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__cts_logout__succeed(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "command": {
+ "body": "system:cts_logout",
+ "data": {
+ "user_huid": "b9197d3a-d855-5d34-ba8a-eff3a975ab20",
+ "cts_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ },
+ "command_type": "system",
+ "metadata": {},
+ },
+ "source_sync_id": None,
+ "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": None,
+ "device": None,
+ "device_meta": {
+ "permissions": None,
+ "pushes": None,
+ "timezone": None,
+ },
+ "device_software": None,
+ "group_chat_id": None,
+ "host": "cts.example.com",
+ "is_admin": None,
+ "is_creator": None,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": None,
+ "username": None,
+ },
+ "proto_version": 4,
+ }
+
+ collector = HandlerCollector()
+ cts_logout: Optional[CTSLogoutEvent] = None
+
+ @collector.cts_logout
+ async def cts_logout_handler(event: CTSLogoutEvent, bot: Bot) -> None:
+ nonlocal cts_logout
+ cts_logout = event
+ # Drop `raw_command` from asserting
+ cts_logout.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert cts_logout == CTSLogoutEvent(
+ bot=BotAccount(
+ id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ host="cts.example.com",
+ ),
+ raw_command=None,
+ huid=UUID("b9197d3a-d855-5d34-ba8a-eff3a975ab20"),
+ )
diff --git a/tests/system_events/test_deleted_from_chat.py b/tests/system_events/test_deleted_from_chat.py
new file mode 100644
index 00000000..4f93fa30
--- /dev/null
+++ b/tests/system_events/test_deleted_from_chat.py
@@ -0,0 +1,99 @@
+from typing import Optional
+from uuid import UUID
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccount,
+ BotAccountWithSecret,
+ Chat,
+ ChatTypes,
+ DeletedFromChatEvent,
+ HandlerCollector,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__deleted_from_chat__succeed(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "command": {
+ "body": "system:deleted_from_chat",
+ "command_type": "system",
+ "data": {
+ "deleted_members": [
+ "ab103983-6001-44e9-889e-d55feb295494",
+ "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ ],
+ },
+ },
+ "source_sync_id": None,
+ "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": "group_chat",
+ "device": None,
+ "device_meta": {
+ "permissions": None,
+ "pushes": None,
+ "timezone": None,
+ },
+ "device_software": None,
+ "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517",
+ "host": "cts.example.com",
+ "is_admin": None,
+ "is_creator": None,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": None,
+ "username": None,
+ },
+ "proto_version": 4,
+ }
+
+ collector = HandlerCollector()
+ deleted_from_chat: Optional[DeletedFromChatEvent] = None
+
+ @collector.deleted_from_chat
+ async def deleted_from_chat_handler(event: DeletedFromChatEvent, bot: Bot) -> None:
+ nonlocal deleted_from_chat
+ deleted_from_chat = event
+ # Drop `raw_command` from asserting
+ deleted_from_chat.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert deleted_from_chat == DeletedFromChatEvent(
+ bot=BotAccount(
+ id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ host="cts.example.com",
+ ),
+ raw_command=None,
+ huids=[
+ UUID("ab103983-6001-44e9-889e-d55feb295494"),
+ UUID("dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4"),
+ ],
+ chat=Chat(
+ id=UUID("dea55ee4-7a9f-5da0-8c73-079f400ee517"),
+ type=ChatTypes.GROUP_CHAT,
+ ),
+ )
diff --git a/tests/system_events/test_internal_bot_notification.py b/tests/system_events/test_internal_bot_notification.py
new file mode 100644
index 00000000..f39b636f
--- /dev/null
+++ b/tests/system_events/test_internal_bot_notification.py
@@ -0,0 +1,108 @@
+from typing import Optional
+from uuid import UUID
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccount,
+ BotAccountWithSecret,
+ BotSender,
+ Chat,
+ ChatTypes,
+ HandlerCollector,
+ InternalBotNotificationEvent,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__internal_bot_notification__succeed(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "sync_id": "a465f0f3-1354-491c-8f11-f400164295cb",
+ "command": {
+ "body": "system:internal_bot_notification",
+ "data": {
+ "data": {
+ "message": "ping",
+ },
+ "opts": {
+ "internal_token": "KyKfLJD1zMjNSJ1cQ4+8Lz",
+ },
+ },
+ "command_type": "system",
+ "metadata": {},
+ },
+ "async_files": [],
+ "attachments": [],
+ "entities": [],
+ "from": {
+ "user_huid": "b9197d3a-d855-5d34-ba8a-eff3a975ab20",
+ "group_chat_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ "ad_login": None,
+ "ad_domain": None,
+ "username": None,
+ "chat_type": "group_chat",
+ "manufacturer": None,
+ "device": None,
+ "device_software": None,
+ "device_meta": {},
+ "platform": None,
+ "platform_package_id": None,
+ "is_admin": False,
+ "is_creator": False,
+ "app_version": None,
+ "locale": "en",
+ "host": "cts.example.com",
+ },
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "proto_version": 4,
+ "source_sync_id": None,
+ }
+
+ collector = HandlerCollector()
+ internal_bot_notification: Optional[InternalBotNotificationEvent] = None
+
+ @collector.internal_bot_notification
+ async def internal_bot_notification_handler(
+ event: InternalBotNotificationEvent,
+ bot: Bot,
+ ) -> None:
+ nonlocal internal_bot_notification
+ internal_bot_notification = event
+ # Drop `raw_command` from asserting
+ internal_bot_notification.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert internal_bot_notification == InternalBotNotificationEvent(
+ bot=BotAccount(
+ id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ host="cts.example.com",
+ ),
+ raw_command=None,
+ data={"message": "ping"},
+ opts={"internal_token": "KyKfLJD1zMjNSJ1cQ4+8Lz"},
+ chat=Chat(
+ id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ type=ChatTypes.GROUP_CHAT,
+ ),
+ sender=BotSender(
+ huid=UUID("b9197d3a-d855-5d34-ba8a-eff3a975ab20"),
+ is_chat_admin=False,
+ is_chat_creator=False,
+ ),
+ )
diff --git a/tests/system_events/test_left_from_chat.py b/tests/system_events/test_left_from_chat.py
new file mode 100644
index 00000000..918ce34b
--- /dev/null
+++ b/tests/system_events/test_left_from_chat.py
@@ -0,0 +1,99 @@
+from typing import Optional
+from uuid import UUID
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccount,
+ BotAccountWithSecret,
+ Chat,
+ ChatTypes,
+ HandlerCollector,
+ LeftFromChatEvent,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__left_from_chat__succeed(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "command": {
+ "body": "system:left_from_chat",
+ "command_type": "system",
+ "data": {
+ "left_members": [
+ "ab103983-6001-44e9-889e-d55feb295494",
+ "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
+ ],
+ },
+ },
+ "source_sync_id": None,
+ "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": "group_chat",
+ "device": None,
+ "device_meta": {
+ "permissions": None,
+ "pushes": None,
+ "timezone": None,
+ },
+ "device_software": None,
+ "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517",
+ "host": "cts.example.com",
+ "is_admin": None,
+ "is_creator": None,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": None,
+ "username": None,
+ },
+ "proto_version": 4,
+ }
+
+ collector = HandlerCollector()
+ left_from_chat: Optional[LeftFromChatEvent] = None
+
+ @collector.left_from_chat
+ async def left_from_chat_handler(event: LeftFromChatEvent, bot: Bot) -> None:
+ nonlocal left_from_chat
+ left_from_chat = event
+ # Drop `raw_command` from asserting
+ left_from_chat.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert left_from_chat == LeftFromChatEvent(
+ bot=BotAccount(
+ id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ host="cts.example.com",
+ ),
+ raw_command=None,
+ huids=[
+ UUID("ab103983-6001-44e9-889e-d55feb295494"),
+ UUID("dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4"),
+ ],
+ chat=Chat(
+ id=UUID("dea55ee4-7a9f-5da0-8c73-079f400ee517"),
+ type=ChatTypes.GROUP_CHAT,
+ ),
+ )
diff --git a/tests/system_events/test_smartapp_event.py b/tests/system_events/test_smartapp_event.py
new file mode 100644
index 00000000..72dcabb4
--- /dev/null
+++ b/tests/system_events/test_smartapp_event.py
@@ -0,0 +1,163 @@
+from typing import Optional
+from uuid import UUID
+
+import pytest
+
+from botx import (
+ AttachmentTypes,
+ Bot,
+ BotAccount,
+ BotAccountWithSecret,
+ HandlerCollector,
+ Image,
+ SmartAppEvent,
+ lifespan_wrapper,
+)
+from botx.models.chats import Chat
+from botx.models.enums import ChatTypes
+from botx.models.message.incoming_message import UserDevice, UserSender
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__smartapp__succeed(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "sync_id": "a465f0f3-1354-491c-8f11-f400164295cb",
+ "command": {
+ "body": "system:smartapp_event",
+ "data": {
+ "ref": "6fafda2c-6505-57a5-a088-25ea5d1d0364",
+ "smartapp_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ "data": {
+ "type": "smartapp_rpc",
+ "method": "folders.get",
+ "params": {
+ "q": 1,
+ },
+ },
+ "opts": {"option": "test_option"},
+ "smartapp_api_version": 1,
+ },
+ "command_type": "system",
+ "metadata": {},
+ },
+ "async_files": [
+ {
+ "type": "image",
+ "file": "https://link.to/file",
+ "file_mime_type": "image/png",
+ "file_name": "pass.png",
+ "file_preview": "https://link.to/preview",
+ "file_preview_height": 300,
+ "file_preview_width": 300,
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_encryption_algo": "stream",
+ "chunk_size": 2097152,
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ },
+ ],
+ "attachments": [],
+ "entities": [],
+ "from": {
+ "user_huid": "b9197d3a-d855-5d34-ba8a-eff3a975ab20",
+ "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517",
+ "host": "cts.example.com",
+ "ad_login": None,
+ "ad_domain": None,
+ "username": None,
+ "chat_type": "group_chat",
+ "manufacturer": None,
+ "device": None,
+ "device_software": None,
+ "device_meta": {},
+ "platform": None,
+ "platform_package_id": None,
+ "is_admin": False,
+ "is_creator": False,
+ "app_version": None,
+ "locale": "en",
+ },
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "proto_version": 4,
+ "source_sync_id": None,
+ }
+
+ collector = HandlerCollector()
+ smartapp: Optional[SmartAppEvent] = None
+
+ @collector.smartapp_event
+ async def smartapp_handler(event: SmartAppEvent, bot: Bot) -> None:
+ nonlocal smartapp
+ smartapp = event
+ # Drop `raw_command` from asserting
+ smartapp.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert smartapp == SmartAppEvent(
+ ref=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"),
+ smartapp_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ bot=BotAccount(
+ id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ host="cts.example.com",
+ ),
+ data={
+ "type": "smartapp_rpc",
+ "method": "folders.get",
+ "params": {
+ "q": 1,
+ },
+ },
+ opts={"option": "test_option"},
+ smartapp_api_version=1,
+ files=[
+ Image(
+ type=AttachmentTypes.IMAGE,
+ filename="pass.png",
+ size=1502345,
+ is_async_file=True,
+ _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ _file_url="https://link.to/file",
+ _file_mimetype="image/png",
+ _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ ),
+ ],
+ chat=Chat(
+ id=UUID("dea55ee4-7a9f-5da0-8c73-079f400ee517"),
+ type=ChatTypes.GROUP_CHAT,
+ ),
+ sender=UserSender(
+ huid=UUID("b9197d3a-d855-5d34-ba8a-eff3a975ab20"),
+ ad_login=None,
+ ad_domain=None,
+ username=None,
+ is_chat_admin=False,
+ is_chat_creator=False,
+ device=UserDevice(
+ manufacturer=None,
+ device_name=None,
+ os=None,
+ pushes=None,
+ timezone=None,
+ permissions=None,
+ platform=None,
+ platform_package_id=None,
+ app_version=None,
+ locale="en",
+ ),
+ ),
+ raw_command=None,
+ )
diff --git a/tests/test_attachments.py b/tests/test_attachments.py
new file mode 100644
index 00000000..add65590
--- /dev/null
+++ b/tests/test_attachments.py
@@ -0,0 +1,272 @@
+import asyncio
+from typing import Any, Callable, Dict, Optional
+from uuid import UUID
+
+import pytest
+
+from botx import (
+ AttachmentTypes,
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ IncomingMessage,
+ lifespan_wrapper,
+)
+from botx.models.attachments import (
+ AttachmentContact,
+ AttachmentDocument,
+ AttachmentImage,
+ AttachmentLink,
+ AttachmentLocation,
+ AttachmentVideo,
+ AttachmentVoice,
+ IncomingAttachment,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__attachment__open(
+ host: str,
+ bot_account: BotAccountWithSecret,
+ bot_id: UUID,
+ api_incoming_message_factory: Callable[..., Dict[str, Any]],
+) -> None:
+ # - Arrange -
+ payload = api_incoming_message_factory(
+ bot_id=bot_id,
+ attachment={
+ "data": {
+ "content": "data:image/jpg;base64,SGVsbG8sIHdvcmxkIQo=",
+ "file_name": "test_file.jpg",
+ },
+ "type": "image",
+ },
+ group_chat_id="054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ host=host,
+ )
+ collector = HandlerCollector()
+ incoming_message: Optional[IncomingMessage] = None
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ nonlocal incoming_message
+ incoming_message = message
+
+ # Drop `raw_command` from asserting
+ incoming_message.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ await asyncio.sleep(0) # Return control to event loop
+
+ assert incoming_message and incoming_message.file
+ async with incoming_message.file.open() as fo:
+ read_content = await fo.read()
+
+ # - Assert -
+ assert read_content == b"Hello, world!\n"
+
+
+API_AND_DOMAIN_NON_FILE_ATTACHMENTS = (
+ (
+ {
+ "type": "location",
+ "data": {
+ "location_name": "Центр вселенной",
+ "location_address": "Россия, Тверская область",
+ "location_lat": 58.04861,
+ "location_lng": 34.28833,
+ },
+ },
+ AttachmentLocation(
+ type=AttachmentTypes.LOCATION,
+ name="Центр вселенной",
+ address="Россия, Тверская область",
+ latitude="58.04861",
+ longitude="34.28833",
+ ),
+ "location",
+ ),
+ (
+ {
+ "type": "contact",
+ "data": {
+ "file_name": "Контакт",
+ "contact_name": "Иванов Иван",
+ "content": "data:text/vcard;base64,eDnXAc1FEUB0VFEFctII3lRlRBcetROeFfduPmXxE/8=",
+ },
+ },
+ AttachmentContact(
+ type=AttachmentTypes.CONTACT,
+ name="Иванов Иван",
+ ),
+ "contact",
+ ),
+ (
+ {
+ "type": "link",
+ "data": {
+ "url": "http://ya.ru/xxx",
+ "url_title": "Header in link",
+ "url_preview": "http://ya.ru/xxx.jpg",
+ "url_text": "Some text in link",
+ },
+ },
+ AttachmentLink(
+ type=AttachmentTypes.LINK,
+ url="http://ya.ru/xxx",
+ title="Header in link",
+ preview="http://ya.ru/xxx.jpg",
+ text="Some text in link",
+ ),
+ "link",
+ ),
+)
+
+
+@pytest.mark.parametrize(
+ "api_attachment,domain_attachment,attr_name",
+ API_AND_DOMAIN_NON_FILE_ATTACHMENTS,
+)
+async def test__async_execute_raw_bot_command__non_file_attachments_types(
+ api_attachment: Dict[str, Any],
+ domain_attachment: IncomingAttachment,
+ attr_name: str,
+ api_incoming_message_factory: Callable[..., Dict[str, Any]],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = api_incoming_message_factory(attachment=api_attachment)
+
+ collector = HandlerCollector()
+ incoming_message: Optional[IncomingMessage] = None
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ nonlocal incoming_message
+ incoming_message = message
+ # Drop `raw_command` from asserting
+ incoming_message.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert getattr(incoming_message, attr_name) == domain_attachment
+
+
+API_AND_DOMAIN_FILE_ATTACHMENTS = (
+ (
+ {
+ "data": {
+ "content": "data:image/jpg;base64,SGVsbG8sIHdvcmxkIQo=",
+ "file_name": "test_file.jpg",
+ },
+ "type": "image",
+ },
+ AttachmentImage(
+ type=AttachmentTypes.IMAGE,
+ filename="test_file.jpg",
+ size=len(b"Hello, world!\n"),
+ is_async_file=False,
+ content=b"Hello, world!\n",
+ ),
+ ),
+ (
+ {
+ "data": {
+ "content": "data:video/mp4;base64,SGVsbG8sIHdvcmxkIQo=",
+ "file_name": "test_file.mp4",
+ "duration": 10,
+ },
+ "type": "video",
+ },
+ AttachmentVideo(
+ type=AttachmentTypes.VIDEO,
+ filename="test_file.mp4",
+ size=len(b"Hello, world!\n"),
+ is_async_file=False,
+ content=b"Hello, world!\n",
+ duration=10,
+ ),
+ ),
+ (
+ {
+ "data": {
+ "content": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=",
+ "file_name": "test_file.txt",
+ },
+ "type": "document",
+ },
+ AttachmentDocument(
+ type=AttachmentTypes.DOCUMENT,
+ filename="test_file.txt",
+ size=len(b"Hello, world!\n"),
+ is_async_file=False,
+ content=b"Hello, world!\n",
+ ),
+ ),
+ (
+ {
+ "data": {
+ "content": "data:audio/mpeg3;base64,SGVsbG8sIHdvcmxkIQo=",
+ "duration": 10,
+ },
+ "type": "voice",
+ },
+ AttachmentVoice(
+ type=AttachmentTypes.VOICE,
+ filename="record.mp3",
+ size=len(b"Hello, world!\n"),
+ is_async_file=False,
+ content=b"Hello, world!\n",
+ duration=10,
+ ),
+ ),
+)
+
+
+@pytest.mark.parametrize(
+ "api_attachment,domain_attachment",
+ API_AND_DOMAIN_FILE_ATTACHMENTS,
+)
+async def test__async_execute_raw_bot_command__file_attachments_types(
+ api_attachment: Dict[str, Any],
+ domain_attachment: IncomingAttachment,
+ api_incoming_message_factory: Callable[..., Dict[str, Any]],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = api_incoming_message_factory(attachment=api_attachment)
+
+ collector = HandlerCollector()
+ incoming_message: Optional[IncomingMessage] = None
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ nonlocal incoming_message
+ incoming_message = message
+ # Drop `raw_command` from asserting
+ incoming_message.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert incoming_message
+ assert incoming_message.file == domain_attachment
diff --git a/tests/test_base_command.py b/tests/test_base_command.py
new file mode 100644
index 00000000..2e7086f6
--- /dev/null
+++ b/tests/test_base_command.py
@@ -0,0 +1,38 @@
+import pytest
+
+from botx import Bot, HandlerCollector, UnsupportedBotAPIVersionError, lifespan_wrapper
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__async_execute_raw_bot_command__invalid_payload_value_error_raised() -> None:
+ # - Arrange -
+ payload = {"invalid": "command"}
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(ValueError) as exc:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert "validation" in str(exc.value)
+
+
+async def test__async_execute_raw_bot_command__unsupported_bot_api_version_error_raised() -> None:
+ # - Arrange -
+ payload = {"proto_version": "3"}
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(UnsupportedBotAPIVersionError) as exc:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert "Unsupported" in str(exc.value)
+ assert "expected `4`" in str(exc.value)
diff --git a/tests/test_bot_constructing.py b/tests/test_bot_constructing.py
new file mode 100644
index 00000000..41ebedd0
--- /dev/null
+++ b/tests/test_bot_constructing.py
@@ -0,0 +1,24 @@
+import pytest
+
+from botx import Bot, BotAccountWithSecret, HandlerCollector
+
+
+def test__bot__empty_collectors_warning(
+ loguru_caplog: pytest.LogCaptureFixture,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Act -
+ Bot(collectors=[], bot_accounts=[bot_account])
+
+ # - Assert -
+ assert "Bot has no connected collectors" in loguru_caplog.text
+
+
+def test__bot__empty_bot_accounts_warning(
+ loguru_caplog: pytest.LogCaptureFixture,
+) -> None:
+ # - Act -
+ Bot(collectors=[HandlerCollector()], bot_accounts=[])
+
+ # - Assert -
+ assert "Bot has no bot accounts" in loguru_caplog.text
diff --git a/tests/test_bots/test_bots/test_decorators/test_exception_handler.py b/tests/test_bots/test_bots/test_decorators/test_exception_handler.py
deleted file mode 100644
index ff8ecdad..00000000
--- a/tests/test_bots/test_bots/test_decorators/test_exception_handler.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import threading
-
-import pytest
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_register_middleware_through_decorator(
- bot,
- client,
- incoming_message,
- build_failed_handler,
- build_exception_catcher,
-):
- exception_event = threading.Event()
- bot.exception_handler(Exception)(build_exception_catcher(exception_event))
- bot.default(build_failed_handler(Exception(), threading.Event()))
-
- await client.send_command(incoming_message)
-
- assert exception_event.is_set()
diff --git a/tests/test_bots/test_bots/test_decorators/test_middleware.py b/tests/test_bots/test_bots/test_decorators/test_middleware.py
deleted file mode 100644
index 390fde05..00000000
--- a/tests/test_bots/test_bots/test_decorators/test_middleware.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import pytest
-
-from botx import Message
-from botx.typing import SyncExecutor
-
-
-@pytest.fixture()
-def middleware_function():
- def factory(_message: Message, _call_next: SyncExecutor):
- """Do nothing."""
-
- return factory
-
-
-def test_register_middleware_through_decorator(bot, middleware_function):
- bot.middleware(middleware_function)
- assert bot.exception_middleware.executor.dispatch_func == middleware_function
diff --git a/tests/test_bots/test_bots/test_execution.py b/tests/test_bots/test_bots/test_execution.py
deleted file mode 100644
index db0f9f38..00000000
--- a/tests/test_bots/test_bots/test_execution.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import threading
-
-import pytest
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_bot_process_message_by_sorted_handlers(
- bot,
- client,
- incoming_message,
- build_handler,
-):
- event1 = threading.Event()
- event2 = threading.Event()
-
- bot.handler(build_handler(event1), command="/body")
- bot.handler(build_handler(event2), command="/body-v2")
-
- incoming_message.command.body = "/body-v2 args"
- await client.send_command(incoming_message)
-
- assert not event1.is_set()
- assert event2.is_set()
diff --git a/tests/test_bots/test_bots/test_execution_errors.py b/tests/test_bots/test_bots/test_execution_errors.py
deleted file mode 100644
index d93c6d35..00000000
--- a/tests/test_bots/test_bots/test_execution_errors.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import uuid
-
-import pytest
-
-from botx import BotXCredentials, UnknownBotError
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_error_when_execution_for_unknown_host(
- bot,
- client,
- incoming_message,
- bot_id,
-):
- bot.bot_accounts = [
- BotXCredentials(
- host="cts.unknown1.com",
- secret_key="secret",
- bot_id=uuid.uuid4(),
- ),
- BotXCredentials(
- host="cts.unknown2.com",
- secret_key="secret",
- bot_id=uuid.uuid4(),
- ),
- ]
- with pytest.raises(UnknownBotError):
- await client.send_command(incoming_message)
diff --git a/tests/test_bots/test_bots/test_lifespan.py b/tests/test_bots/test_bots/test_lifespan.py
deleted file mode 100644
index ee04fd83..00000000
--- a/tests/test_bots/test_bots/test_lifespan.py
+++ /dev/null
@@ -1,38 +0,0 @@
-import threading
-from typing import Callable
-
-import pytest
-
-from botx import Bot
-
-pytestmark = pytest.mark.asyncio
-
-
-def build_lifespan_event(event: threading.Event) -> Callable[[Bot], None]:
- def factory(_bot):
- event.set()
-
- return factory
-
-
-@pytest.fixture()
-def build_lifespan():
- return build_lifespan_event
-
-
-async def test_lifespan_events(bot, build_lifespan):
- startup_event = threading.Event()
- shutdown_event = threading.Event()
-
- bot.startup_events = [build_lifespan(startup_event)]
- bot.shutdown_events = [build_lifespan(shutdown_event)]
-
- await bot.start()
- assert startup_event.is_set()
-
- await bot.shutdown()
- assert shutdown_event.is_set()
-
-
-async def test_no_error_when_stopping_bot_with_no_tasks(bot):
- await bot.shutdown()
diff --git a/tests/test_bots/test_bots/test_status.py b/tests/test_bots/test_bots/test_status.py
deleted file mode 100644
index 2bbef91d..00000000
--- a/tests/test_bots/test_bots/test_status.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import pytest
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_returning_bot_commands_status(bot, collector_with_handlers):
- bot.include_collector(collector_with_handlers)
- status = await bot.status()
- commands = [command.body for command in status.result.commands]
- assert commands == [
- "/regular-handler",
- "/handler-command",
- "/handler-command1",
- "/handler-command2",
- "/handler-command3",
- "/handler-command4",
- "/handler-command5",
- "/regular-handler-with-name",
- "/regular-handler-with-background-dependencies",
- "/regular-handler-that-included-in-status-by-callable-function",
- ]
diff --git a/tests/test_bots/test_mixins/test_clients.py b/tests/test_bots/test_mixins/test_clients.py
deleted file mode 100644
index d918ad0f..00000000
--- a/tests/test_bots/test_mixins/test_clients.py
+++ /dev/null
@@ -1,38 +0,0 @@
-import uuid
-
-import pytest
-
-from botx import BotXCredentials
-from botx.exceptions import TokenError, UnknownBotError
-
-pytestmark = pytest.mark.asyncio
-
-
-def test_raising_error_if_token_was_not_found(client, incoming_message):
- account = client.bot.get_account_by_bot_id(incoming_message.bot_id)
- account.token = None
- with pytest.raises(TokenError):
- client.bot.get_token_for_bot(incoming_message.bot_id)
-
-
-def test_get_token_to_bot(client, incoming_message):
- account = client.bot.get_account_by_bot_id(incoming_message.bot_id)
- account.token = "token"
- assert client.bot.get_token_for_bot(incoming_message.bot_id) is not None
-
-
-def test_raising_error_if_cts_not_found(bot, incoming_message):
- bot.bot_accounts = [
- BotXCredentials(
- host="cts.unknown1.com",
- secret_key="secret",
- bot_id=uuid.uuid4(),
- ),
- BotXCredentials(
- host="cts.unknown2.com",
- secret_key="secret",
- bot_id=uuid.uuid4(),
- ),
- ]
- with pytest.raises(UnknownBotError):
- bot.get_account_by_bot_id(incoming_message.bot_id)
diff --git a/tests/test_bots/test_mixins/test_requests/test_chats.py b/tests/test_bots/test_mixins/test_requests/test_chats.py
deleted file mode 100644
index 71244fa7..00000000
--- a/tests/test_bots/test_mixins/test_requests/test_chats.py
+++ /dev/null
@@ -1,121 +0,0 @@
-import uuid
-
-import pytest
-
-from botx import ChatTypes
-from botx.clients.methods.v3.chats.chat_list import ChatList
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_creating_chat(client, message):
- await client.bot.create_chat(
- message.credentials,
- name="test",
- members=[message.user_huid],
- chat_type=ChatTypes.group_chat,
- )
-
- assert client.requests[0].name == "test"
-
-
-async def test_enable_stealth_mode(bot, client, message):
- await bot.enable_stealth_mode(
- message.credentials,
- chat_id=message.group_chat_id,
- burn_in=60,
- )
-
- assert client.requests[0].burn_in == 60
-
-
-async def test_disable_stealth_mode(bot, client, message):
- await bot.disable_stealth_mode(
- message.credentials,
- chat_id=message.group_chat_id,
- )
-
- assert client.requests[0].group_chat_id == message.group_chat_id
-
-
-async def test_adding_user_to_chat(bot, client, message):
- users = [uuid.uuid4()]
- await bot.add_users(
- message.credentials,
- chat_id=message.group_chat_id,
- user_huids=users,
- )
- request = client.requests[0]
-
- assert request.group_chat_id == message.group_chat_id
-
- assert request.user_huids == users
-
-
-async def test_remove_user(bot, client, message):
- users = [uuid.uuid4()]
- await bot.remove_users(
- message.credentials,
- chat_id=message.group_chat_id,
- user_huids=users,
- )
- request = client.requests[0]
-
- assert request.group_chat_id == message.group_chat_id
-
- assert request.user_huids == users
-
-
-async def test_retrieving_chat_info(bot, client, message):
- chat_id = uuid.uuid4()
- info = await bot.get_chat_info(message.credentials, chat_id=chat_id)
-
- assert info.group_chat_id == chat_id
-
-
-async def test_retrieving_bot_chats(bot, client, message):
- await bot.get_bot_chats(message.credentials)
- request = client.requests[0]
-
- assert isinstance(request, ChatList)
-
-
-async def test_promoting_users_to_admins(bot, client, message):
- users = [uuid.uuid4()]
- await bot.add_admin_roles(
- message.credentials,
- chat_id=message.group_chat_id,
- user_huids=users,
- )
- request = client.requests[0]
-
- assert request.group_chat_id == message.group_chat_id
-
- assert request.user_huids == users
-
-
-async def test_pinning_message(bot, client, message):
- chat_id = uuid.uuid4()
- sync_id = uuid.uuid4()
-
- await bot.pin_message(
- message.credentials,
- chat_id=chat_id,
- sync_id=sync_id,
- )
- request = client.requests[0]
-
- assert request.chat_id == chat_id
- assert request.sync_id == sync_id
-
-
-async def test_unpinning_message(bot, client, message):
- chat_id = uuid.uuid4()
-
- await bot.unpin_message(
- message.credentials,
- chat_id=chat_id,
- )
- request = client.requests[0]
-
- assert request.chat_id == chat_id
diff --git a/tests/test_bots/test_mixins/test_requests/test_command.py b/tests/test_bots/test_mixins/test_requests/test_command.py
deleted file mode 100644
index ce94e37c..00000000
--- a/tests/test_bots/test_mixins/test_requests/test_command.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import pytest
-
-from botx import MessagePayload
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_command_result(client, message):
- await client.bot.send_command_result(
- credentials=message.credentials,
- payload=MessagePayload(text="some text"),
- )
-
- assert client.command_results[0].result.body == "some text"
diff --git a/tests/test_bots/test_mixins/test_requests/test_events.py b/tests/test_bots/test_mixins/test_requests/test_events.py
deleted file mode 100644
index 7c70aea0..00000000
--- a/tests/test_bots/test_mixins/test_requests/test_events.py
+++ /dev/null
@@ -1,69 +0,0 @@
-import pytest
-
-from botx import SendingMessage, UpdatePayload
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_updating_message_through_bot(bot, client, message):
- sync_id = await bot.answer_message("some text", message)
-
- await bot.update_message(
- message.credentials.copy(update={"sync_id": sync_id}),
- UpdatePayload(text="new text"),
- )
-
- update = client.message_updates[0].result
- assert update.body == "new text"
-
-
-async def test_update_metadata(bot, client, message):
- msg = SendingMessage.from_message(text="some text", message=message)
- msg.metadata = {"hello": "world"}
- await bot.send(msg)
-
- upd = UpdatePayload.from_sending_payload(msg.payload)
- upd.metadata = {"foo": "bar"}
-
- await bot.update_message(message.credentials, upd)
-
- update = client.message_updates[0].result
- assert update.metadata == {"foo": "bar"}
-
-
-async def test_cant_update_without_sync_id(bot, client, message):
- credentials = message.credentials.copy(update={"sync_id": None})
-
- with pytest.raises(ValueError) as exc:
- await bot.update_message(credentials, UpdatePayload(text="new text"))
-
- assert "sync_id is required" in str(exc.value)
-
-
-async def test_reply(bot, client, message):
- await bot.reply(
- text="foo",
- source_sync_id=message.sync_id,
- credentials=message.credentials,
- )
-
- reply = client.replies[0]
- assert reply.result.body == "foo"
- assert reply.source_sync_id == message.sync_id
-
-
-async def test_reply_on_message_empty_text_error(bot, message):
- with pytest.raises(ValueError):
- await bot.reply(
- text="",
- source_sync_id=message.sync_id,
- credentials=message.credentials,
- )
-
-
-async def test_reply_arguments_error(bot, message):
- with pytest.raises(ValueError):
- await bot.reply(
- source_sync_id=message.sync_id,
- credentials=message.credentials,
- )
diff --git a/tests/test_bots/test_mixins/test_requests/test_files.py b/tests/test_bots/test_mixins/test_requests/test_files.py
deleted file mode 100644
index cccdda76..00000000
--- a/tests/test_bots/test_mixins/test_requests/test_files.py
+++ /dev/null
@@ -1,38 +0,0 @@
-from uuid import uuid4
-
-import pytest
-
-from botx.clients.methods.v3.files.download import DownloadFile
-from botx.clients.methods.v3.files.upload import UploadFile
-from botx.models.files import File
-from botx.testing.content import PNG_DATA
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_upload_file(client, message):
- image = File(file_name="image.png", data=PNG_DATA)
- await client.bot.upload_file(message.credentials, image, group_chat_id=uuid4())
-
- assert isinstance(client.requests[0], UploadFile)
-
-
-async def test_download_file(client, message):
- await client.bot.download_file(
- message.credentials,
- file_id=uuid4(),
- group_chat_id=uuid4(),
- )
-
- assert isinstance(client.requests[0], DownloadFile)
-
-
-async def test_custom_filename(client, message):
- file = await client.bot.download_file(
- message.credentials,
- file_id=uuid4(),
- group_chat_id=uuid4(),
- file_name="myname",
- )
-
- assert file.file_name == "myname.txt"
diff --git a/tests/test_bots/test_mixins/test_requests/test_internal_bot_notification.py b/tests/test_bots/test_mixins/test_requests/test_internal_bot_notification.py
deleted file mode 100644
index 92cab4cf..00000000
--- a/tests/test_bots/test_mixins/test_requests/test_internal_bot_notification.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import uuid
-
-import pytest
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_internal_bot_notification(client, message):
- await client.bot.internal_bot_notification(
- credentials=message.credentials,
- group_chat_id=uuid.uuid4(),
- text="ping",
- sender=None,
- recipients=None,
- opts=None,
- )
-
- assert client.messages[0].data.message == "ping"
diff --git a/tests/test_bots/test_mixins/test_requests/test_notification.py b/tests/test_bots/test_mixins/test_requests/test_notification.py
deleted file mode 100644
index 78b1e444..00000000
--- a/tests/test_bots/test_mixins/test_requests/test_notification.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import pytest
-
-from botx import MessagePayload
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_filling_with_chat_id_from_credentials(client, message):
- await client.bot.send_notification(
- credentials=message.credentials,
- payload=MessagePayload(text="some text"),
- )
-
- assert client.notifications[0].result.body == "some text"
-
-
-async def test_filling_with_ids_if_passed(client, message):
- await client.bot.send_notification(
- message.credentials,
- group_chat_ids=[message.user.group_chat_id],
- payload=MessagePayload(text="some text"),
- )
-
- assert client.notifications[0].result.body == "some text"
-
-
-async def test_send_to_all_if_ids_omitted(client, message):
- text = "some text"
- credentials = message.credentials.copy(update={"chat_id": None})
-
- await client.bot.send_notification(credentials, MessagePayload(text=text))
-
- assert client.notifications[0].result.body == text
-
-
-async def test_direct_notification_chat_id_required(client, message):
- credentials = message.credentials.copy(update={"chat_id": None})
-
- with pytest.raises(ValueError) as exc:
- await client.bot.send_direct_notification(
- credentials,
- payload=MessagePayload(text="some text"),
- )
-
- assert "chat_id is required" in str(exc.value)
diff --git a/tests/test_bots/test_mixins/test_requests/test_smartapps.py b/tests/test_bots/test_mixins/test_requests/test_smartapps.py
deleted file mode 100644
index 1cde5887..00000000
--- a/tests/test_bots/test_mixins/test_requests/test_smartapps.py
+++ /dev/null
@@ -1,64 +0,0 @@
-from typing import Any, Dict
-from uuid import UUID
-
-import pytest
-
-from botx import Message, TestClient
-from botx.clients.methods.v3.smartapps.smartapp_event import SmartAppEvent
-from botx.clients.methods.v3.smartapps.smartapp_notification import SmartAppNotification
-from botx.models.smartapps import SendingSmartAppEvent
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures", "tests.fixtures.smartapps")
-
-
-async def test_smartapp_event(
- client: TestClient,
- message: Message,
- ref: UUID,
- smartapp_id: UUID,
- smartapp_api_version: int,
- group_chat_id: UUID,
- smartapp_data: Dict[str, Any],
-):
- await client.bot.send_smartapp_event(
- credentials=message.credentials,
- smartapp_event=SendingSmartAppEvent(
- ref=ref,
- smartapp_id=smartapp_id,
- smartapp_api_version=smartapp_api_version,
- group_chat_id=group_chat_id,
- data=smartapp_data,
- ),
- )
-
- assert client.requests[0] == SmartAppEvent(
- ref=ref,
- smartapp_id=smartapp_id,
- smartapp_api_version=smartapp_api_version,
- group_chat_id=group_chat_id,
- data=smartapp_data,
- )
-
-
-async def test_smartapp_notification(
- client: TestClient,
- message: Message,
- smartapp_api_version: int,
- group_chat_id: UUID,
- smartapp_counter: int,
-):
- await client.bot.send_smartapp_notification(
- credentials=message.credentials,
- smartapp_notification=SmartAppNotification(
- smartapp_api_version=smartapp_api_version,
- group_chat_id=group_chat_id,
- smartapp_counter=smartapp_counter,
- ),
- )
-
- assert client.requests[0] == SmartAppNotification(
- smartapp_api_version=smartapp_api_version,
- group_chat_id=group_chat_id,
- smartapp_counter=smartapp_counter,
- )
diff --git a/tests/test_bots/test_mixins/test_requests/test_stickers.py b/tests/test_bots/test_mixins/test_requests/test_stickers.py
deleted file mode 100644
index 0b6ba33c..00000000
--- a/tests/test_bots/test_mixins/test_requests/test_stickers.py
+++ /dev/null
@@ -1,76 +0,0 @@
-from uuid import uuid4
-
-import pytest
-
-from botx.clients.methods.v3.stickers.add_sticker import AddSticker
-from botx.clients.methods.v3.stickers.create_sticker_pack import CreateStickerPack
-from botx.clients.methods.v3.stickers.delete_sticker import DeleteSticker
-from botx.clients.methods.v3.stickers.delete_sticker_pack import DeleteStickerPack
-from botx.clients.methods.v3.stickers.edit_sticker_pack import EditStickerPack
-from botx.clients.methods.v3.stickers.sticker import GetSticker
-from botx.clients.methods.v3.stickers.sticker_pack import GetStickerPack
-from botx.clients.methods.v3.stickers.sticker_pack_list import GetStickerPackList
-from botx.testing.content import PNG_DATA
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_get_sticker_pack_list(client, message):
- await client.bot.get_sticker_pack_list(message.credentials)
- assert isinstance(client.requests[0], GetStickerPackList)
-
-
-async def test_get_sticker_pack(client, message):
- await client.bot.get_sticker_pack(message.credentials, pack_id=uuid4())
- assert isinstance(client.requests[0], GetStickerPack)
-
-
-async def test_get_sticker_from_pack(client, message):
- await client.bot.get_sticker_from_pack(
- message.credentials,
- pack_id=uuid4(),
- sticker_id=uuid4(),
- )
- assert isinstance(client.requests[0], GetSticker)
-
-
-async def test_create_sticker_pack(client, message):
- await client.bot.create_sticker_pack(
- message.credentials,
- name="Test sticker pack",
- user_huid=uuid4(),
- )
- assert isinstance(client.requests[0], CreateStickerPack)
-
-
-async def test_add_sticker_into_pack(client, message):
- await client.bot.add_sticker(
- message.credentials,
- pack_id=uuid4(),
- emoji="🐢",
- image=PNG_DATA,
- )
- assert isinstance(client.requests[0], AddSticker)
-
-
-async def test_edit_sticker_pack(client, message):
- await client.bot.edit_sticker_pack(
- message.credentials,
- pack_id=uuid4(),
- name="New test sticker pack",
- )
- assert isinstance(client.requests[0], EditStickerPack)
-
-
-async def test_delete_sticker_pack(client, message):
- await client.bot.delete_sticker_pack(message.credentials, pack_id=uuid4())
- assert isinstance(client.requests[0], DeleteStickerPack)
-
-
-async def test_delete_sticker_from_pack(client, message):
- await client.bot.delete_sticker(
- message.credentials,
- pack_id=uuid4(),
- sticker_id=uuid4(),
- )
- assert isinstance(client.requests[0], DeleteSticker)
diff --git a/tests/test_bots/test_mixins/test_requests/test_users.py b/tests/test_bots/test_mixins/test_requests/test_users.py
deleted file mode 100644
index 15a9a58b..00000000
--- a/tests/test_bots/test_mixins/test_requests/test_users.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import pytest
-
-from botx.clients.methods.v3.users.by_email import ByEmail
-from botx.clients.methods.v3.users.by_huid import ByHUID
-from botx.clients.methods.v3.users.by_login import ByLogin
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_search_requires_one_of_params(client, message):
- with pytest.raises(ValueError):
- await client.bot.search_user(message.credentials)
-
-
-async def test_search_using_huid_method(client, message):
- await client.bot.search_user(message.credentials, user_huid=message.user_huid)
-
- assert isinstance(client.requests[0], ByHUID)
-
-
-async def test_search_using_email_method(client, message):
- await client.bot.search_user(message.credentials, email=message.user.upn)
-
- assert isinstance(client.requests[0], ByEmail)
-
-
-async def test_search_using_ad_method(client, message):
- await client.bot.search_user(
- message.credentials,
- ad=(message.user.ad_login, message.user.ad_domain),
- )
-
- assert isinstance(client.requests[0], ByLogin)
diff --git a/tests/test_bots/test_mixins/test_sending/test_answer_message.py b/tests/test_bots/test_mixins/test_sending/test_answer_message.py
deleted file mode 100644
index d22f39d7..00000000
--- a/tests/test_bots/test_mixins/test_sending/test_answer_message.py
+++ /dev/null
@@ -1,32 +0,0 @@
-import pytest
-
-from botx import File
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_answer_message_is_notification(bot, client, message):
- await bot.answer_message("some text", message)
-
- message = client.notifications[0]
- assert message.result.body == "some text"
-
-
-async def test_answer_message_with_file_is_notification(bot, client, message):
- file = File.from_string("some content", "file.txt")
- await bot.answer_message(
- "some text",
- message,
- file=file,
- )
-
- message = client.notifications[0]
- assert message.result.body == "some text"
- assert message.file == file
-
-
-async def test_answer_message_with_metadata(bot, client, message):
- await bot.answer_message("some text", message, metadata={"foo": "bar"})
-
- message = client.notifications[0]
- assert message.result.metadata == {"foo": "bar"}
diff --git a/tests/test_bots/test_mixins/test_sending/test_errors.py b/tests/test_bots/test_mixins/test_sending/test_errors.py
deleted file mode 100644
index d8a0b863..00000000
--- a/tests/test_bots/test_mixins/test_sending/test_errors.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import pytest
-
-from botx.exceptions import UnknownBotError
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_error_for_sending_to_unknown_host(bot, message):
- bot.bot_accounts = []
- with pytest.raises(UnknownBotError):
- await bot.answer_message("some text", message)
diff --git a/tests/test_bots/test_mixins/test_sending/test_send.py b/tests/test_bots/test_mixins/test_sending/test_send.py
deleted file mode 100644
index b49bc7eb..00000000
--- a/tests/test_bots/test_mixins/test_sending/test_send.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import uuid
-
-import pytest
-
-from botx import File, SendingMessage
-
-pytestmark = pytest.mark.asyncio
-
-
-@pytest.fixture()
-def sending_file():
- return File.from_string("some content", "file.txt")
-
-
-@pytest.fixture()
-def metadata():
- return {"account_id": 94}
-
-
-@pytest.fixture()
-def sending_message(message, metadata, sending_file):
- sending_message = SendingMessage.from_message(
- text="some text",
- file=sending_file,
- message=message,
- )
- sending_message.add_keyboard_button(command="/command", label="keyboard")
- sending_message.add_bubble(command="/command", label="bubble")
- sending_message.metadata = metadata
- return sending_message
-
-
-async def test_using_notification_route(bot, client, sending_message):
- await bot.send(sending_message)
-
- assert client.notifications[0]
-
-
-async def test_sending_notification_using_send(bot, client, sending_message, metadata):
- sending_message.credentials.sync_id = None
-
- await bot.send(sending_message)
-
- assert len(client.notifications)
- notification = client.notifications[0]
-
- assert notification.result.metadata == metadata
-
-
-async def test_sending_update_using_send(bot, client, sending_message):
- sending_message.credentials.message_id = uuid.uuid4()
-
- await bot.send(sending_message, update=True)
-
- assert client.message_updates[0].sync_id == sending_message.credentials.message_id
-
-
-async def test_returning_event_id_from_notification(bot, client, sending_message):
- sending_message.credentials.sync_id = None
- assert await bot.send(sending_message)
-
-
-async def test_setting_custom_id_for_notification(bot, client, sending_message):
- message_id = uuid.uuid4()
- sending_message.credentials.message_id = message_id
- notification_id = await bot.send(sending_message)
- assert notification_id == message_id
diff --git a/tests/test_bots/test_mixins/test_sending/test_send_file.py b/tests/test_bots/test_mixins/test_sending/test_send_file.py
deleted file mode 100644
index d4d22ac9..00000000
--- a/tests/test_bots/test_mixins/test_sending/test_send_file.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import pytest
-
-from botx import File
-
-pytestmark = pytest.mark.asyncio
-
-
-@pytest.fixture()
-def sending_file():
- return File.from_string("some content", "file.txt")
-
-
-async def test_send_file_is_notification(bot, client, message, sending_file):
- await bot.send_file(sending_file, message.credentials)
-
- message = client.notifications[0]
- assert message.file == sending_file
-
-
-async def test_using_notification(bot, client, message, sending_file):
- await bot.send_file(
- sending_file,
- message.credentials.copy(update={"sync_id": None}),
- )
-
- message = client.notifications[0]
- assert message.file == sending_file
diff --git a/tests/test_bots/test_mixins/test_sending/test_send_message.py b/tests/test_bots/test_mixins/test_sending/test_send_message.py
deleted file mode 100644
index 34c51cf2..00000000
--- a/tests/test_bots/test_mixins/test_sending/test_send_message.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import pytest
-
-from botx import File
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_sending_command_result(bot, client, message):
- await bot.send_message(
- "some text",
- message.credentials,
- )
-
- assert client.notifications[0]
-
-
-async def test_sending_notification_using_send_message(bot, client, message):
- await bot.send_message(
- "some text",
- message.credentials.copy(update={"sync_id": None}),
- )
-
- assert client.notifications[0]
-
-
-async def test_adding_file(bot, client, message):
- sending_file = File.from_string("some content", "file.txt")
- await bot.send_message(
- "some text",
- message.credentials,
- file=sending_file.file,
- )
-
- command_result = client.notifications[0]
- assert command_result.file == sending_file
diff --git a/tests/test_clients/fixtures.py b/tests/test_clients/fixtures.py
deleted file mode 100644
index cd2837d8..00000000
--- a/tests/test_clients/fixtures.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import pytest
-
-from botx import AsyncClient, Client
-
-
-@pytest.fixture(params=(AsyncClient, Client))
-def requests_client(request, client):
- if issubclass(request.param, AsyncClient):
- return client.bot.client
-
- return client.bot.sync_client
diff --git a/tests/test_clients/test_clients/test_async_client/test_execute.py b/tests/test_clients/test_clients/test_async_client/test_execute.py
deleted file mode 100644
index cd0a3281..00000000
--- a/tests/test_clients/test_clients/test_async_client/test_execute.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import uuid
-
-import pytest
-from httpx import ConnectError, Request, Response
-
-from botx.clients.methods.v2.bots.token import Token
-from botx.exceptions import BotXConnectError, BotXJSONDecodeError
-
-try:
- from unittest.mock import AsyncMock
-except ImportError:
- from unittest.mock import MagicMock
-
- # Used for compatibility with python 3.7
- class AsyncMock(MagicMock):
- async def __call__(self, *args, **kwargs):
- return super(AsyncMock, self).__call__(*args, **kwargs)
-
-
-@pytest.fixture()
-def token_method():
- return Token(host="example.cts", bot_id=uuid.uuid4(), signature="signature")
-
-
-@pytest.fixture()
-def mock_http_client():
- return AsyncMock()
-
-
-@pytest.mark.asyncio()
-async def test_raising_connection_error(client, token_method, mock_http_client):
- request = Request(token_method.http_method, token_method.url)
- mock_http_client.request.side_effect = ConnectError("Test error", request=request)
-
- client.bot.client.http_client = mock_http_client
- botx_request = client.bot.client.build_request(token_method)
-
- with pytest.raises(BotXConnectError):
- await client.bot.client.execute(botx_request)
-
-
-@pytest.mark.asyncio()
-async def test_raising_decode_error(client, token_method, mock_http_client):
- response = Response(status_code=418, text="Wrong json")
- mock_http_client.request.return_value = response
-
- client.bot.client.http_client = mock_http_client
- botx_request = client.bot.client.build_request(token_method)
-
- with pytest.raises(BotXJSONDecodeError):
- await client.bot.client.execute(botx_request)
diff --git a/tests/test_clients/test_clients/test_sync_client/test_execute.py b/tests/test_clients/test_clients/test_sync_client/test_execute.py
deleted file mode 100644
index 5e90e9ef..00000000
--- a/tests/test_clients/test_clients/test_sync_client/test_execute.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import uuid
-from unittest.mock import Mock
-
-import pytest
-from httpx import ConnectError, Request, Response
-
-from botx.clients.methods.v2.bots.token import Token
-from botx.exceptions import BotXConnectError, BotXJSONDecodeError
-
-
-@pytest.fixture()
-def token_method():
- return Token(host="example.cts", bot_id=uuid.uuid4(), signature="signature")
-
-
-@pytest.fixture()
-def mock_http_client():
- return Mock()
-
-
-def test_execute_without_explicit_host(client, token_method):
- request = client.bot.sync_client.build_request(token_method)
-
- assert client.bot.sync_client.execute(request)
-
-
-def test_raising_connection_error(client, token_method, mock_http_client):
- request = Request(token_method.http_method, token_method.url)
- mock_http_client.request.side_effect = ConnectError("Test error", request=request)
-
- client.bot.sync_client.http_client = mock_http_client
- botx_request = client.bot.sync_client.build_request(token_method)
-
- with pytest.raises(BotXConnectError):
- client.bot.sync_client.execute(botx_request)
-
-
-def test_raising_decode_error(client, token_method, mock_http_client):
- response = Response(status_code=418, text="Wrong json")
- mock_http_client.request.return_value = response
-
- client.bot.sync_client.http_client = mock_http_client
- botx_request = client.bot.sync_client.build_request(token_method)
-
- with pytest.raises(BotXJSONDecodeError):
- client.bot.sync_client.execute(botx_request)
diff --git a/tests/test_clients/test_methods/test_base/test_empty_error_handlers.py b/tests/test_clients/test_methods/test_base/test_empty_error_handlers.py
deleted file mode 100644
index 5106ff1d..00000000
--- a/tests/test_clients/test_methods/test_base/test_empty_error_handlers.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from botx.clients.methods.base import BotXMethod
-
-
-class TestMethod(BotXMethod):
- __test__ = False
-
- __url__ = "/path/to/example"
- __method__ = "GET"
- __returning__ = str
-
-
-def test_method_empty_error_handlers():
- test_method = TestMethod()
-
- assert test_method.__errors_handlers__ == {}
diff --git a/tests/test_clients/test_methods/test_base/test_unhandled_error.py b/tests/test_clients/test_methods/test_base/test_unhandled_error.py
deleted file mode 100644
index 8f22e8ee..00000000
--- a/tests/test_clients/test_methods/test_base/test_unhandled_error.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import uuid
-
-import pytest
-
-from botx import BotXAPIError, ChatTypes
-from botx.clients.methods.errors.bot_is_not_admin import BotIsNotAdminData
-from botx.clients.methods.v2.bots.token import Token
-from botx.clients.methods.v3.chats.create import Create
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-IM_A_TEAPOT = 418 # This status code added in python 3.9
-
-
-async def test_raising_base_api_error_if_empty_handlers(client, requests_client):
- method = Token(host="example.com", bot_id=uuid.uuid4(), signature="signature")
-
- errors_to_raise = {
- Token: (
- IM_A_TEAPOT,
- BotIsNotAdminData(sender=uuid.uuid4(), group_chat_id=uuid.uuid4()),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(BotXAPIError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
-
-async def test_raising_base_api_error_if_unhandled(client, requests_client):
- method = Create(
- host="example.com",
- name="test name",
- members=[uuid.uuid4()],
- chat_type=ChatTypes.group_chat,
- shared_history=False,
- )
-
- method.__errors_handlers__[IM_A_TEAPOT] = []
-
- errors_to_raise = {
- Create: (
- IM_A_TEAPOT,
- BotIsNotAdminData(sender=uuid.uuid4(), group_chat_id=uuid.uuid4()),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(BotXAPIError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/__init__.py b/tests/test_clients/test_methods/test_errors/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_errors/test_bot_is_not_admin.py b/tests/test_clients/test_methods/test_errors/test_bot_is_not_admin.py
deleted file mode 100644
index 569296e4..00000000
--- a/tests/test_clients/test_methods/test_errors/test_bot_is_not_admin.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.bot_is_not_admin import (
- BotIsNotAdminData,
- BotIsNotAdminError,
-)
-from botx.clients.methods.v3.chats.add_user import AddUser
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_bot_is_not_admin(client, requests_client):
- method = AddUser(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- user_huids=[uuid.uuid4() for _ in range(10)],
- )
- errors_to_raise = {
- AddUser: (
- HTTPStatus.FORBIDDEN,
- BotIsNotAdminData(sender=uuid.uuid4(), group_chat_id=method.group_chat_id),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(BotIsNotAdminError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_bot_not_found.py b/tests/test_clients/test_methods/test_errors/test_bot_not_found.py
deleted file mode 100644
index 1ae420f1..00000000
--- a/tests/test_clients/test_methods/test_errors/test_bot_not_found.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.bot_not_found import BotNotFoundError
-from botx.clients.methods.v2.bots.token import Token
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_bot_not_found_error(client, requests_client):
- method = Token(host="example.com", bot_id=uuid.uuid4(), signature="signature")
-
- errors_to_raise = {Token: (HTTPStatus.NOT_FOUND, {})}
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(BotNotFoundError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_chat_creation_disallowed.py b/tests/test_clients/test_methods/test_errors/test_chat_creation_disallowed.py
deleted file mode 100644
index 4ffe31cf..00000000
--- a/tests/test_clients/test_methods/test_errors/test_chat_creation_disallowed.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx import ChatTypes
-from botx.clients.methods.errors.chat_creation_disallowed import (
- ChatCreationDisallowedData,
- ChatCreationDisallowedError,
-)
-from botx.clients.methods.v3.chats.create import Create
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_chat_creation_disallowed(client, requests_client):
- method = Create(
- host="example.com",
- name="test name",
- members=[uuid.uuid4()],
- chat_type=ChatTypes.group_chat,
- shared_history=False,
- )
-
- errors_to_raise = {
- Create: (
- HTTPStatus.FORBIDDEN,
- ChatCreationDisallowedData(bot_id=uuid.uuid4()),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(ChatCreationDisallowedError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_chat_creation_error.py b/tests/test_clients/test_methods/test_errors/test_chat_creation_error.py
deleted file mode 100644
index 1d238028..00000000
--- a/tests/test_clients/test_methods/test_errors/test_chat_creation_error.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx import ChatTypes
-from botx.clients.methods.errors.chat_creation_error import ChatCreationError
-from botx.clients.methods.v3.chats.create import Create
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_chat_creation_error(client, requests_client):
- method = Create(
- host="example.com",
- name="test name",
- members=[uuid.uuid4()],
- chat_type=ChatTypes.group_chat,
- shared_history=False,
- )
-
- errors_to_raise = {Create: (HTTPStatus.UNPROCESSABLE_ENTITY, {})}
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(ChatCreationError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_chat_is_not_modifiable.py b/tests/test_clients/test_methods/test_errors/test_chat_is_not_modifiable.py
deleted file mode 100644
index 3904fbda..00000000
--- a/tests/test_clients/test_methods/test_errors/test_chat_is_not_modifiable.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.chat_is_not_modifiable import (
- PersonalChatIsNotModifiableData,
- PersonalChatIsNotModifiableError,
-)
-from botx.clients.methods.v3.chats.add_user import AddUser
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_chat_is_not_modifiable(client, requests_client):
- method = AddUser(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- user_huids=[uuid.uuid4()],
- )
-
- errors_to_raise = {
- AddUser: (
- HTTPStatus.FORBIDDEN,
- PersonalChatIsNotModifiableData(group_chat_id=method.group_chat_id),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(PersonalChatIsNotModifiableError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_chat_not_found.py b/tests/test_clients/test_methods/test_errors/test_chat_not_found.py
deleted file mode 100644
index 790d258c..00000000
--- a/tests/test_clients/test_methods/test_errors/test_chat_not_found.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.chat_not_found import (
- ChatNotFoundData,
- ChatNotFoundError,
-)
-from botx.clients.methods.v3.chats.add_user import AddUser
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_chat_not_found(client, requests_client):
- method = AddUser(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- user_huids=[uuid.uuid4()],
- )
-
- errors_to_raise = {
- AddUser: (
- HTTPStatus.NOT_FOUND,
- ChatNotFoundData(group_chat_id=method.group_chat_id),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(ChatNotFoundError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_files/__init__.py b/tests/test_clients/test_methods/test_errors/test_files/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_errors/test_files/test_chat_not_found.py b/tests/test_clients/test_methods/test_errors/test_files/test_chat_not_found.py
deleted file mode 100644
index 3f691fb0..00000000
--- a/tests/test_clients/test_methods/test_errors/test_files/test_chat_not_found.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.files.chat_not_found import (
- ChatNotFoundData,
- ChatNotFoundError,
-)
-from botx.clients.methods.v3.files.download import DownloadFile
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_chat_not_found(client, requests_client):
- method = DownloadFile(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- file_id=uuid.uuid4(),
- is_preview=False,
- )
-
- errors_to_raise = {
- DownloadFile: (
- HTTPStatus.NOT_FOUND,
- ChatNotFoundData(
- group_chat_id=method.group_chat_id,
- error_description="test",
- ),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(ChatNotFoundError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_files/test_file_deleted.py b/tests/test_clients/test_methods/test_errors/test_files/test_file_deleted.py
deleted file mode 100644
index 4cdeeb10..00000000
--- a/tests/test_clients/test_methods/test_errors/test_files/test_file_deleted.py
+++ /dev/null
@@ -1,41 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.files.file_deleted import (
- FileDeletedError,
- FileDeletedErrorData,
-)
-from botx.clients.methods.v3.files.download import DownloadFile
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_file_deleted(client, requests_client):
- method = DownloadFile(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- file_id=uuid.uuid4(),
- is_preview=False,
- )
-
- errors_to_raise = {
- DownloadFile: (
- HTTPStatus.NO_CONTENT,
- FileDeletedErrorData(link="/path/to/file", error_description="test"),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(FileDeletedError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_files/test_metadata_not_found.py b/tests/test_clients/test_methods/test_errors/test_files/test_metadata_not_found.py
deleted file mode 100644
index 6899c51b..00000000
--- a/tests/test_clients/test_methods/test_errors/test_files/test_metadata_not_found.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.files.metadata_not_found import (
- MetadataNotFoundData,
- MetadataNotFoundError,
-)
-from botx.clients.methods.v3.files.download import DownloadFile
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_metadata_found(client, requests_client):
- method = DownloadFile(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- file_id=uuid.uuid4(),
- is_preview=False,
- )
-
- errors_to_raise = {
- DownloadFile: (
- HTTPStatus.NOT_FOUND,
- MetadataNotFoundData(
- file_id=method.file_id,
- group_chat_id=method.group_chat_id,
- error_description="test",
- ),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(MetadataNotFoundError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_files/test_without_preview.py b/tests/test_clients/test_methods/test_errors/test_files/test_without_preview.py
deleted file mode 100644
index 5d583c12..00000000
--- a/tests/test_clients/test_methods/test_errors/test_files/test_without_preview.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.files.without_preview import (
- WithoutPreviewData,
- WithoutPreviewError,
-)
-from botx.clients.methods.v3.files.download import DownloadFile
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_without_preview(client, requests_client):
- method = DownloadFile(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- file_id=uuid.uuid4(),
- is_preview=True,
- )
-
- errors_to_raise = {
- DownloadFile: (
- HTTPStatus.BAD_REQUEST,
- WithoutPreviewData(
- file_id=method.file_id,
- group_chat_id=method.group_chat_id,
- error_description="test",
- ),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(WithoutPreviewError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_messaging.py b/tests/test_clients/test_methods/test_errors/test_messaging.py
deleted file mode 100644
index bda8c716..00000000
--- a/tests/test_clients/test_methods/test_errors/test_messaging.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.messaging import MessagingError
-from botx.clients.methods.v3.chats.info import Info
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_messaging_error(client, requests_client):
- method = Info(host="example.com", group_chat_id=uuid.uuid4())
-
- errors_to_raise = {Info: (HTTPStatus.BAD_REQUEST, {})}
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(MessagingError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_permissions.py b/tests/test_clients/test_methods/test_errors/test_permissions.py
deleted file mode 100644
index c2b77fcd..00000000
--- a/tests/test_clients/test_methods/test_errors/test_permissions.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.permissions import (
- NoPermissionError,
- NoPermissionErrorData,
-)
-from botx.clients.methods.v3.chats.pin_message import PinMessage
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_no_permission(client, requests_client):
- method = PinMessage(
- host="example.com",
- chat_id=uuid.uuid4(),
- sync_id=uuid.uuid4(),
- )
-
- errors_to_raise = {
- PinMessage: (
- HTTPStatus.FORBIDDEN,
- NoPermissionErrorData(group_chat_id=method.chat_id),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(NoPermissionError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_stickers/__init__.py b/tests/test_clients/test_methods/test_errors/test_stickers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_errors/test_stickers/test_image_not_valid.py b/tests/test_clients/test_methods/test_errors/test_stickers/test_image_not_valid.py
deleted file mode 100644
index 8b5da31f..00000000
--- a/tests/test_clients/test_methods/test_errors/test_stickers/test_image_not_valid.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.stickers.image_not_valid import ImageNotValidError
-from botx.clients.methods.v3.stickers.add_sticker import AddSticker
-from botx.concurrency import callable_to_coroutine
-from botx.testing.content import PNG_DATA
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_image_not_valid(client, requests_client):
- method = AddSticker(
- host="example.com",
- pack_id=uuid.uuid4(),
- emoji="🐢",
- image=PNG_DATA,
- )
-
- errors_to_raise = {
- AddSticker: (
- HTTPStatus.BAD_REQUEST,
- {},
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(ImageNotValidError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_not_found.py b/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_not_found.py
deleted file mode 100644
index a781fd48..00000000
--- a/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_not_found.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.stickers.sticker_pack_or_sticker_not_found import (
- StickerPackOrStickerNotFoundData,
- StickerPackOrStickerNotFoundError,
-)
-from botx.clients.methods.v3.stickers.sticker import GetSticker
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_sticker_not_found(client, requests_client):
- method = GetSticker(
- host="example.com",
- pack_id=uuid.uuid4(),
- sticker_id=uuid.uuid4(),
- )
-
- errors_to_raise = {
- GetSticker: (
- HTTPStatus.NOT_FOUND,
- StickerPackOrStickerNotFoundData(
- pack_id=method.pack_id,
- sticker_id=method.sticker_id,
- ),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(StickerPackOrStickerNotFoundError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_pack_not_found.py b/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_pack_not_found.py
deleted file mode 100644
index 942fb839..00000000
--- a/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_pack_not_found.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.stickers.sticker_pack_not_found import (
- StickerPackNotFoundData,
- StickerPackNotFoundError,
-)
-from botx.clients.methods.v3.stickers.sticker_pack import GetStickerPack
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_sticker_pack_not_found(client, requests_client):
- method = GetStickerPack(
- host="example.com",
- pack_id=uuid.uuid4(),
- )
-
- errors_to_raise = {
- GetStickerPack: (
- HTTPStatus.NOT_FOUND,
- StickerPackNotFoundData(pack_id=method.pack_id),
- ),
- }
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(StickerPackNotFoundError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_unauthorized_bot.py b/tests/test_clients/test_methods/test_errors/test_unauthorized_bot.py
deleted file mode 100644
index 27105221..00000000
--- a/tests/test_clients/test_methods/test_errors/test_unauthorized_bot.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.unauthorized_bot import InvalidBotCredentials
-from botx.clients.methods.v2.bots.token import Token
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_unauthorized_bot_error(client, requests_client):
- method = Token(host="example.com", bot_id=uuid.uuid4(), signature="signature")
-
- errors_to_raise = {Token: (HTTPStatus.UNAUTHORIZED, {})}
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(InvalidBotCredentials):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_errors/test_user_not_found.py b/tests/test_clients/test_methods/test_errors/test_user_not_found.py
deleted file mode 100644
index 41f36d00..00000000
--- a/tests/test_clients/test_methods/test_errors/test_user_not_found.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import uuid
-from http import HTTPStatus
-
-import pytest
-
-from botx.clients.methods.errors.user_not_found import UserNotFoundError
-from botx.clients.methods.v3.users.by_huid import ByHUID
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_user_not_found(client, requests_client):
- method = ByHUID(host="example.com", user_huid=uuid.uuid4())
-
- errors_to_raise = {ByHUID: (HTTPStatus.NOT_FOUND, {})}
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(UserNotFoundError):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_clients/test_methods/test_v2/__init__.py b/tests/test_clients/test_methods/test_v2/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v2/test_bots/__init__.py b/tests/test_clients/test_methods/test_v2/test_bots/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v2/test_bots/test_token.py b/tests/test_clients/test_methods/test_v2/test_bots/test_token.py
deleted file mode 100644
index e1bea697..00000000
--- a/tests/test_clients/test_methods/test_v2/test_bots/test_token.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v2.bots.token import Token
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_obtaining_token(client, requests_client):
- method = Token(host="example.com", bot_id=uuid.uuid4(), signature="signature")
-
- request = requests_client.build_request(method)
- await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].bot_id == method.bot_id
- assert client.requests[0].signature == method.signature
diff --git a/tests/test_clients/test_methods/test_v3/__init__.py b/tests/test_clients/test_methods/test_v3/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/__init__.py b/tests/test_clients/test_methods/test_v3/test_chats/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_add_admin_role.py b/tests/test_clients/test_methods/test_v3/test_chats/test_add_admin_role.py
deleted file mode 100644
index 6d39273b..00000000
--- a/tests/test_clients/test_methods/test_v3/test_chats/test_add_admin_role.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.chats.add_admin_role import AddAdminRole
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_adding_users(client, requests_client):
- method = AddAdminRole(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- user_huids=[uuid.uuid4() for _ in range(10)],
- )
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].group_chat_id == method.group_chat_id
- assert client.requests[0].user_huids == method.user_huids
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_add_user.py b/tests/test_clients/test_methods/test_v3/test_chats/test_add_user.py
deleted file mode 100644
index 102083b1..00000000
--- a/tests/test_clients/test_methods/test_v3/test_chats/test_add_user.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.chats.add_user import AddUser
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_adding_users(client, requests_client):
- method = AddUser(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- user_huids=[uuid.uuid4() for _ in range(10)],
- )
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].group_chat_id == method.group_chat_id
- assert client.requests[0].user_huids == method.user_huids
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_chat_list.py b/tests/test_clients/test_methods/test_v3/test_chats/test_chat_list.py
deleted file mode 100644
index 5e706bd4..00000000
--- a/tests/test_clients/test_methods/test_v3/test_chats/test_chat_list.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import pytest
-
-from botx.clients.methods.v3.chats.chat_list import ChatList
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_retrieving_bot_chats(client, requests_client):
- method = ChatList(host="example.com")
-
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
- bot_chats = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert isinstance(list(bot_chats), list)
-
- assert len(bot_chats) == 1
-
- assert len(bot_chats[0].members) == 1
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_create.py b/tests/test_clients/test_methods/test_v3/test_chats/test_create.py
deleted file mode 100644
index 0328cb1d..00000000
--- a/tests/test_clients/test_methods/test_v3/test_chats/test_create.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import uuid
-
-import pytest
-
-from botx import ChatTypes
-from botx.clients.methods.v3.chats.create import Create
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_chat_creation(client, requests_client):
- method = Create(
- host="example.com",
- name="test name",
- members=[uuid.uuid4()],
- chat_type=ChatTypes.group_chat,
- shared_history=False,
- )
-
- request = requests_client.build_request(method)
- await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].name == method.name
- assert client.requests[0].members == method.members
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_info.py b/tests/test_clients/test_methods/test_v3/test_chats/test_info.py
deleted file mode 100644
index 4c9a88d4..00000000
--- a/tests/test_clients/test_methods/test_v3/test_chats/test_info.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.chats.info import Info
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_retrieving_info(client, requests_client):
- method = Info(host="example.com", group_chat_id=uuid.uuid4())
-
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
- info = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert info.members
-
- assert client.requests[0].group_chat_id == method.group_chat_id
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_pin_message.py b/tests/test_clients/test_methods/test_v3/test_chats/test_pin_message.py
deleted file mode 100644
index 8b634e59..00000000
--- a/tests/test_clients/test_methods/test_v3/test_chats/test_pin_message.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.chats.pin_message import PinMessage
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_pinning_message(client, requests_client):
- chat_id = uuid.uuid4()
- sync_id = uuid.uuid4()
- method = PinMessage(host="example.com", chat_id=chat_id, sync_id=sync_id)
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].chat_id == method.chat_id
- assert client.requests[0].sync_id == method.sync_id
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_remove_user.py b/tests/test_clients/test_methods/test_v3/test_chats/test_remove_user.py
deleted file mode 100644
index f87ef12a..00000000
--- a/tests/test_clients/test_methods/test_v3/test_chats/test_remove_user.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.chats.remove_user import RemoveUser
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_removing_users(client, requests_client):
- method = RemoveUser(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- user_huids=[uuid.uuid4() for _ in range(10)],
- )
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].group_chat_id == method.group_chat_id
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_disable.py b/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_disable.py
deleted file mode 100644
index f50b0307..00000000
--- a/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_disable.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.chats.stealth_disable import StealthDisable
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_disabling_stealth(client, requests_client):
- method = StealthDisable(host="example.com", group_chat_id=uuid.uuid4())
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].group_chat_id == method.group_chat_id
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_set.py b/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_set.py
deleted file mode 100644
index 980e5bf5..00000000
--- a/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_set.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.chats.stealth_set import StealthSet
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_enabling_stealth(client, requests_client):
- method = StealthSet(host="example.com", group_chat_id=uuid.uuid4())
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].group_chat_id == method.group_chat_id
diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_unpin_message.py b/tests/test_clients/test_methods/test_v3/test_chats/test_unpin_message.py
deleted file mode 100644
index 8d192f29..00000000
--- a/tests/test_clients/test_methods/test_v3/test_chats/test_unpin_message.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.chats.unpin_message import UnpinMessage
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_unpinning_message(client, requests_client):
- chat_id = uuid.uuid4()
- method = UnpinMessage(host="example.com", chat_id=chat_id)
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].chat_id == method.chat_id
diff --git a/tests/test_clients/test_methods/test_v3/test_command/__init__.py b/tests/test_clients/test_methods/test_v3/test_command/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v3/test_command/test_command_result.py b/tests/test_clients/test_methods/test_v3/test_command/test_command_result.py
deleted file mode 100644
index 8eb42a59..00000000
--- a/tests/test_clients/test_methods/test_v3/test_command/test_command_result.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.command.command_result import CommandResult
-from botx.clients.types.message_payload import ResultPayload
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_sending_command_result(client, requests_client):
- method = CommandResult(
- host="example.com",
- sync_id=uuid.uuid4(),
- bot_id=uuid.uuid4(),
- result=ResultPayload(body="test"),
- )
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].result.body == method.result.body
diff --git a/tests/test_clients/test_methods/test_v3/test_events/__init__.py b/tests/test_clients/test_methods/test_v3/test_events/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v3/test_events/test_edit_event.py b/tests/test_clients/test_methods/test_v3/test_events/test_edit_event.py
deleted file mode 100644
index 41265103..00000000
--- a/tests/test_clients/test_methods/test_v3/test_events/test_edit_event.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.events.edit_event import EditEvent
-from botx.clients.types.message_payload import UpdatePayload
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_sending_edit_event(client, requests_client):
- method = EditEvent(
- host="example.com",
- sync_id=uuid.uuid4(),
- result=UpdatePayload(body="test"),
- )
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].result.body == method.result.body
diff --git a/tests/test_clients/test_methods/test_v3/test_files/__init__.py b/tests/test_clients/test_methods/test_v3/test_files/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v3/test_files/test_download.py b/tests/test_clients/test_methods/test_v3/test_files/test_download.py
deleted file mode 100644
index 2eedce1a..00000000
--- a/tests/test_clients/test_methods/test_v3/test_files/test_download.py
+++ /dev/null
@@ -1,32 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.files.download import DownloadFile
-from botx.concurrency import callable_to_coroutine
-from botx.models.files import File
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_download_file(client, requests_client):
- file_id = uuid.uuid4()
- method = DownloadFile(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- file_id=file_id,
- is_preview=False,
- )
-
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
- file = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert isinstance(file, File)
-
- assert client.requests[0].file_id == file_id
diff --git a/tests/test_clients/test_methods/test_v3/test_files/test_upload.py b/tests/test_clients/test_methods/test_v3/test_files/test_upload.py
deleted file mode 100644
index d1c1da45..00000000
--- a/tests/test_clients/test_methods/test_v3/test_files/test_upload.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.files.upload import UploadFile
-from botx.concurrency import callable_to_coroutine
-from botx.models.files import File
-from botx.testing.content import PNG_DATA
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_upload_file(client, requests_client):
- file_name = "image.png"
-
- image = File(file_name=file_name, data=PNG_DATA)
- method = UploadFile(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- file=image,
- meta={},
- )
-
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
- meta_file = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert meta_file.file_name == file_name
-
- assert client.requests[0].file.file_name == file_name
diff --git a/tests/test_clients/test_methods/test_v3/test_notification/__init__.py b/tests/test_clients/test_methods/test_v3/test_notification/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v3/test_notification/test_notification.py b/tests/test_clients/test_methods/test_v3/test_notification/test_notification.py
deleted file mode 100644
index f0791863..00000000
--- a/tests/test_clients/test_methods/test_v3/test_notification/test_notification.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.notification.notification import Notification
-from botx.clients.types.message_payload import ResultPayload
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_sending_notification(client, requests_client):
- method = Notification(
- host="example.com",
- group_chat_ids=[uuid.uuid4()],
- bot_id=uuid.uuid4(),
- result=ResultPayload(body="test"),
- )
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].result.body == method.result.body
diff --git a/tests/test_clients/test_methods/test_v3/test_notification/test_notification_direct.py b/tests/test_clients/test_methods/test_v3/test_notification/test_notification_direct.py
deleted file mode 100644
index b67a6fff..00000000
--- a/tests/test_clients/test_methods/test_v3/test_notification/test_notification_direct.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.notification.direct_notification import NotificationDirect
-from botx.clients.types.message_payload import ResultPayload
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_sending_direct_notification(client, requests_client):
- method = NotificationDirect(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- bot_id=uuid.uuid4(),
- result=ResultPayload(body="test"),
- )
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].result.body == method.result.body
diff --git a/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_event.py b/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_event.py
deleted file mode 100644
index decc32ee..00000000
--- a/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_event.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from typing import Any, Dict, Union
-from uuid import UUID
-
-import pytest
-
-from botx import AsyncClient, Client, TestClient
-from botx.clients.methods.v3.smartapps.smartapp_event import SmartAppEvent
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures", "tests.fixtures.smartapps")
-
-
-async def test_smartapp_event(
- client: TestClient,
- requests_client: Union[AsyncClient, Client],
- ref: UUID,
- smartapp_id: UUID,
- smartapp_api_version: int,
- group_chat_id: UUID,
- smartapp_data: Dict[str, Any],
-) -> None:
- method = SmartAppEvent(
- host="example.com", # type: ignore [call-arg]
- ref=ref,
- smartapp_id=smartapp_id,
- data=smartapp_data,
- smartapp_api_version=smartapp_api_version,
- group_chat_id=group_chat_id,
- )
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert isinstance(client.requests[0], SmartAppEvent)
- smartapp_event = client.requests[0]
-
- assert smartapp_event.ref == ref
- assert smartapp_event.smartapp_id == smartapp_id
- assert smartapp_event.smartapp_api_version == smartapp_api_version
- assert smartapp_event.group_chat_id == group_chat_id
- assert smartapp_event.data == smartapp_data
diff --git a/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_notification.py b/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_notification.py
deleted file mode 100644
index 33ec0d25..00000000
--- a/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_notification.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from typing import Union
-from uuid import UUID
-
-import pytest
-
-from botx import AsyncClient, Client, TestClient
-from botx.clients.methods.v3.smartapps.smartapp_notification import SmartAppNotification
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures", "tests.fixtures.smartapps")
-
-
-async def test_smartapp_event(
- client: TestClient,
- requests_client: Union[AsyncClient, Client],
- smartapp_api_version: int,
- group_chat_id: UUID,
- smartapp_counter: int,
-) -> None:
- method = SmartAppNotification(
- host="example.com", # type: ignore [call-arg]
- group_chat_id=group_chat_id,
- smartapp_counter=smartapp_counter,
- smartapp_api_version=smartapp_api_version,
- )
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert isinstance(client.requests[0], SmartAppNotification)
- client.requests[0]
-
- assert client.requests[0].smartapp_counter == smartapp_counter
- assert client.requests[0].smartapp_api_version == smartapp_api_version
- assert client.requests[0].group_chat_id == group_chat_id
diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/__init__.py b/tests/test_clients/test_methods/test_v3/test_stickers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_add_sticker.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_add_sticker.py
deleted file mode 100644
index c485bcdf..00000000
--- a/tests/test_clients/test_methods/test_v3/test_stickers/test_add_sticker.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.stickers.add_sticker import AddSticker
-from botx.concurrency import callable_to_coroutine
-from botx.testing.content import PNG_DATA
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_add_sticker_into_sticker_pack(client, requests_client):
- emoji = "🐢"
-
- method = AddSticker(
- pack_id=uuid.uuid4(),
- emoji=emoji,
- image=PNG_DATA,
- host="example.com",
- )
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- sticker = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert sticker.emoji == emoji
diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_create_sticker_pack.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_create_sticker_pack.py
deleted file mode 100644
index ea8702b2..00000000
--- a/tests/test_clients/test_methods/test_v3/test_stickers/test_create_sticker_pack.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.stickers.create_sticker_pack import CreateStickerPack
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_create_sticker_pack(client, requests_client):
- sticker_pack_name = "Test sticker pack"
-
- method = CreateStickerPack(
- name=sticker_pack_name,
- host="example.com",
- user_huid=uuid.uuid4(),
- )
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- sticker_pack = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert sticker_pack.name == sticker_pack_name
diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker.py
deleted file mode 100644
index c80aa0bb..00000000
--- a/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.stickers.delete_sticker import DeleteSticker
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_delete_sticker(client, requests_client):
-
- method = DeleteSticker(
- pack_id=uuid.uuid4(),
- sticker_id=uuid.uuid4(),
- host="example.com",
- )
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- result = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert result == "sticker_deleted"
diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker_pack.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker_pack.py
deleted file mode 100644
index 6cb89d10..00000000
--- a/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker_pack.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.stickers.delete_sticker_pack import DeleteStickerPack
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_delete_sticker_pack(client, requests_client):
-
- method = DeleteStickerPack(pack_id=uuid.uuid4(), host="example.com")
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- result = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert result == "sticker_pack_deleted"
diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_edit_sticker_pack.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_edit_sticker_pack.py
deleted file mode 100644
index af5dc62d..00000000
--- a/tests/test_clients/test_methods/test_v3/test_stickers/test_edit_sticker_pack.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.stickers.edit_sticker_pack import EditStickerPack
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_edit_sticker_pack(client, requests_client):
- sticker_pack_name = "Test sticker pack"
-
- method = EditStickerPack(
- pack_id=uuid.uuid4(),
- name=sticker_pack_name,
- host="example.com",
- )
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- sticker_pack = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert sticker_pack.name == sticker_pack_name
diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_from_sticker_pack.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_from_sticker_pack.py
deleted file mode 100644
index a21003a9..00000000
--- a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_from_sticker_pack.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.stickers.sticker import GetSticker
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_get_sticker_from_sticker_pack(client, requests_client):
- emoji = "🐢"
-
- method = GetSticker(
- pack_id=uuid.uuid4(),
- sticker_id=uuid.uuid4(),
- host="example.com",
- )
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- sticker = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert sticker.emoji == emoji
diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack.py
deleted file mode 100644
index ef36c34c..00000000
--- a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.stickers.sticker_pack import GetStickerPack
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_get_sticker_pack(client, requests_client):
- sticker_pack_name = "Test sticker pack"
-
- method = GetStickerPack(pack_id=uuid.uuid4(), host="example.com")
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- sticker_pack = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert sticker_pack.name == sticker_pack_name
diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack_list.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack_list.py
deleted file mode 100644
index 1f6be276..00000000
--- a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack_list.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import pytest
-
-from botx.clients.methods.v3.stickers.sticker_pack_list import GetStickerPackList
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_get_sticker_pack_list(client, requests_client):
- sticker_pack_name = "Test sticker pack"
-
- method = GetStickerPackList(host="example.com", limit=1)
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- sticker_pack_list = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert sticker_pack_list.packs[0].name == sticker_pack_name
diff --git a/tests/test_clients/test_methods/test_v3/test_users/__init__.py b/tests/test_clients/test_methods/test_v3/test_users/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v3/test_users/test_by_email.py b/tests/test_clients/test_methods/test_v3/test_users/test_by_email.py
deleted file mode 100644
index 2504b01c..00000000
--- a/tests/test_clients/test_methods/test_v3/test_users/test_by_email.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import pytest
-
-from botx.clients.methods.v3.users.by_email import ByEmail
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_search_by_huid(client, requests_client):
- method = ByEmail(host="example.com", email="test@example.com")
-
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
- user = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert user.emails == [method.email]
-
- assert client.requests[0].email == method.email
diff --git a/tests/test_clients/test_methods/test_v3/test_users/test_by_huid.py b/tests/test_clients/test_methods/test_v3/test_users/test_by_huid.py
deleted file mode 100644
index 842cbc0d..00000000
--- a/tests/test_clients/test_methods/test_v3/test_users/test_by_huid.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v3.users.by_huid import ByHUID
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_search_by_huid(client, requests_client):
- method = ByHUID(host="example.com", user_huid=uuid.uuid4())
-
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
- user = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert user.user_huid == method.user_huid
-
- assert client.requests[0].user_huid == method.user_huid
diff --git a/tests/test_clients/test_methods/test_v3/test_users/test_by_login.py b/tests/test_clients/test_methods/test_v3/test_users/test_by_login.py
deleted file mode 100644
index 096fadfc..00000000
--- a/tests/test_clients/test_methods/test_v3/test_users/test_by_login.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import pytest
-
-from botx.clients.methods.v3.users.by_login import ByLogin
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_search_by_huid(client, requests_client):
- method = ByLogin(host="example.com", ad_login="test", ad_domain="example.com")
-
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
- user = await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
-
- assert user.ad_login == method.ad_login
- assert user.ad_domain == method.ad_domain
-
- assert client.requests[0].ad_login == method.ad_login
- assert client.requests[0].ad_domain == method.ad_domain
diff --git a/tests/test_clients/test_methods/test_v4/__init__.py b/tests/test_clients/test_methods/test_v4/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v4/test_notifications/__init__.py b/tests/test_clients/test_methods/test_v4/test_notifications/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_methods/test_v4/test_notifications/test_internal_bot_notification.py b/tests/test_clients/test_methods/test_v4/test_notifications/test_internal_bot_notification.py
deleted file mode 100644
index be7b01ac..00000000
--- a/tests/test_clients/test_methods/test_v4/test_notifications/test_internal_bot_notification.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import uuid
-
-import pytest
-
-from botx.clients.methods.v4.notifications.internal_bot_notification import (
- InternalBotNotification,
-)
-from botx.clients.types.message_payload import InternalBotNotificationPayload
-from botx.concurrency import callable_to_coroutine
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_sending_internal_bot_notification(client, requests_client):
- method = InternalBotNotification(
- host="example.com",
- group_chat_id=uuid.uuid4(),
- bot_id=uuid.uuid4(),
- data=InternalBotNotificationPayload(message="test"),
- )
-
- request = requests_client.build_request(method)
- assert await callable_to_coroutine(requests_client.execute, request)
-
- assert client.requests[0].data.message == "test"
diff --git a/tests/test_clients/test_types/__init__.py b/tests/test_clients/test_types/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_clients/test_types/test_http.py b/tests/test_clients/test_types/test_http.py
deleted file mode 100644
index d3f1fd69..00000000
--- a/tests/test_clients/test_types/test_http.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import pytest
-from pydantic import ValidationError
-
-from botx.clients.types.http import HTTPResponse
-
-
-def test_response_validation():
- with pytest.raises(ValidationError):
- HTTPResponse(
- headers={},
- status_code=200,
- json_body={"status": "ok"},
- raw_data=b"content",
- )
diff --git a/tests/test_collecting/__init__.py b/tests/test_collecting/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_collecting/fixtures.py b/tests/test_collecting/fixtures.py
deleted file mode 100644
index ec71704c..00000000
--- a/tests/test_collecting/fixtures.py
+++ /dev/null
@@ -1,69 +0,0 @@
-import pytest
-
-from botx import Bot, Collector
-from botx.collecting.handlers.handler import Handler
-
-
-class HandlerClass:
- def handler_method_snake_case(self) -> None:
- """Handler with name in snake case."""
-
- def handlerMethodCamelCase(self) -> None: # noqa: N802
- """Handler with name in camel case."""
-
- def HandlerMethodPascalCase(self) -> None: # noqa: N802
- """Handler with name in pascal case."""
-
- def __call__(self) -> None:
- """Handler that is callable class."""
-
-
-@pytest.fixture()
-def handler_as_function(build_handler_for_collector):
- return build_handler_for_collector("handler_function")
-
-
-@pytest.fixture()
-def handler_as_class():
- return HandlerClass
-
-
-@pytest.fixture()
-def handler_as_callable_object():
- return HandlerClass()
-
-
-@pytest.fixture()
-def handler_as_normal_method():
- return HandlerClass().handler_method_snake_case
-
-
-@pytest.fixture()
-def handler_as_pascal_case_method():
- return HandlerClass().HandlerMethodPascalCase
-
-
-@pytest.fixture()
-def handler_as_camel_case_method():
- return HandlerClass().handlerMethodCamelCase
-
-
-@pytest.fixture()
-def default_handler(handler_as_function):
- return Handler(handler=handler_as_function, body="/default-handler")
-
-
-@pytest.fixture()
-def extract_collector():
- def factory(collector_instance):
- if isinstance(collector_instance, Bot):
- return collector_instance.collector
-
- return collector_instance
-
- return factory
-
-
-@pytest.fixture(params=(Collector, Bot))
-def collector_cls(request):
- return request.param
diff --git a/tests/test_collecting/test_collector/__init__.py b/tests/test_collecting/test_collector/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_collecting/test_collector/test_bodies_generating.py b/tests/test_collecting/test_collector/test_bodies_generating.py
deleted file mode 100644
index f534f715..00000000
--- a/tests/test_collecting/test_collector/test_bodies_generating.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from botx import Collector
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_generating_body_from_snake_case(handler_as_normal_method):
- collector = Collector()
- collector.add_handler(handler=handler_as_normal_method)
- handler = collector.handler_for("handler_method_snake_case")
- assert handler.body == "/handler-method-snake-case"
-
-
-def test_generating_body_from_pascal_case(handler_as_pascal_case_method):
- collector = Collector()
- collector.add_handler(handler=handler_as_pascal_case_method)
- handler = collector.handler_for("HandlerMethodPascalCase")
- assert handler.body == "/handler-method-pascal-case"
-
-
-def test_generating_body_from_camel_case(handler_as_camel_case_method):
- collector = Collector()
- collector.add_handler(handler=handler_as_camel_case_method)
- handler = collector.handler_for("handlerMethodCamelCase")
- assert handler.body == "/handler-method-camel-case"
diff --git a/tests/test_collecting/test_collector/test_collectors_merging/__init__.py b/tests/test_collecting/test_collector/test_collectors_merging/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_collecting/test_collector/test_collectors_merging/test_default_handler_adding.py b/tests/test_collecting/test_collector/test_collectors_merging/test_default_handler_adding.py
deleted file mode 100644
index 4874c57a..00000000
--- a/tests/test_collecting/test_collector/test_collectors_merging/test_default_handler_adding.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from botx import Collector
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_default_handler_after_including_into_another_collector(
- default_handler,
- handler_as_function,
-):
- collector1 = Collector()
- collector2 = Collector(default=default_handler)
-
- collector1.include_collector(collector2)
-
- assert collector1.default_message_handler == collector2.default_message_handler
diff --git a/tests/test_collecting/test_collector/test_collectors_merging/test_dependencies_order.py b/tests/test_collecting/test_collector/test_collectors_merging/test_dependencies_order.py
deleted file mode 100644
index 25c23cc3..00000000
--- a/tests/test_collecting/test_collector/test_collectors_merging/test_dependencies_order.py
+++ /dev/null
@@ -1,93 +0,0 @@
-from typing import Callable
-
-import pytest
-
-from botx import Collector, Depends
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def build_botx_dependency(number: int) -> Callable[[], None]:
- def factory():
- """Just do nothing."""
-
- factory.number = number
- return factory
-
-
-@pytest.fixture()
-def build_dependency():
- return build_botx_dependency
-
-
-def test_preserving_order_after_merging(message, handler_as_function, build_dependency):
- message.command.body = "/command"
-
- collector1 = Collector(dependencies=[Depends(build_dependency(1))])
- collector2 = Collector(dependencies=[Depends(build_dependency(2))])
-
- collector2.add_handler(
- handler=handler_as_function,
- body=message.command.command,
- dependencies=[Depends(build_dependency(3))],
- )
-
- collector1.include_collector(collector2)
-
- handler = collector1.handler_for("handler_function")
-
- numbers = [dep.dependency.number for dep in handler.dependencies]
-
- assert numbers == [1, 2, 3]
-
-
-def test_preserving_order_after_merging_for_default_handler(
- message,
- default_handler,
- build_dependency,
-):
- message.command.body = "/command"
-
- default_handler.dependencies = [Depends(build_dependency(3))]
-
- collector1 = Collector(dependencies=[Depends(build_dependency(1))])
- collector2 = Collector(
- dependencies=[Depends(build_dependency(2))],
- default=default_handler,
- )
-
- collector1.include_collector(collector2)
-
- handler = collector1.default_message_handler
-
- numbers = [dep.dependency.number for dep in handler.dependencies]
-
- assert numbers == [1, 2, 3]
-
-
-def test_dependencies_order_in_include_collector(
- message,
- handler_as_function,
- build_dependency,
-):
- message.command.body = "/command"
-
- collector1 = Collector()
- collector2 = Collector()
-
- collector2.add_handler(
- handler=handler_as_function,
- body=message.command.command,
- dependencies=[Depends(build_dependency(2))],
- )
-
- collector1.include_collector(
- collector2,
- dependencies=[Depends(build_dependency(1))],
- )
-
- handler = collector1.handler_for("handler_function")
-
- numbers = [dep.dependency.number for dep in handler.dependencies]
-
- assert numbers == [1, 2]
diff --git a/tests/test_collecting/test_collector/test_collectors_merging/test_errors.py b/tests/test_collecting/test_collector/test_collectors_merging/test_errors.py
deleted file mode 100644
index 35bc1655..00000000
--- a/tests/test_collecting/test_collector/test_collectors_merging/test_errors.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import pytest
-
-from botx import Collector
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_error_when_merging_handlers_with_equal_bodies(build_handler):
- collector1 = Collector()
- collector1.add_handler(
- handler=build_handler("handler1"),
- body="/body",
- name="handler1",
- )
-
- collector2 = Collector()
- collector2.add_handler(
- handler=build_handler("handler2"),
- body="/body",
- name="handler2",
- )
-
- with pytest.raises(AssertionError):
- collector1.include_collector(collector2)
-
-
-def test_error_when_merging_handlers_with_equal_names(build_handler):
- collector1 = Collector()
- collector1.add_handler(
- handler=build_handler("handler1"),
- body="/body1",
- name="handler",
- )
-
- collector2 = Collector()
- collector2.add_handler(
- handler=build_handler("handler2"),
- body="/body2",
- name="handler",
- )
-
- with pytest.raises(AssertionError):
- collector1.include_collector(collector2)
-
-
-def test_only_single_default_handler_can_defined_in_collector(default_handler):
- collector1 = Collector(default=default_handler)
- collector2 = Collector(default=default_handler)
-
- with pytest.raises(AssertionError):
- collector1.include_collector(collector2)
diff --git a/tests/test_collecting/test_collector/test_commands_generation.py b/tests/test_collecting/test_collector/test_commands_generation.py
deleted file mode 100644
index 4e515739..00000000
--- a/tests/test_collecting/test_collector/test_commands_generation.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import pytest
-
-from botx.exceptions import NoMatchFound
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_error_when_no_args_passed(collector_cls):
- collector = collector_cls()
- with pytest.raises(TypeError):
- collector.command_for()
-
-
-def test_building_command_with_arguments(handler_as_function, collector_cls):
- collector = collector_cls()
- collector.add_handler(handler=handler_as_function, name="handler", body="/handler")
- built_command = collector.command_for("handler", "arg1", 1, True)
- assert built_command == "/handler arg1 1 True"
-
-
-def test_raising_exception_when_generating_command_and_not_found(
- build_handler_for_collector,
- collector_cls,
-):
- collector = collector_cls()
- collector.handler(build_handler_for_collector("handler1"))
- collector.handler(build_handler_for_collector("handler2"))
- with pytest.raises(NoMatchFound):
- collector.command_for("not-existing-handler")
diff --git a/tests/test_collecting/test_collector/test_decorators/__init__.py b/tests/test_collecting/test_collector/test_decorators/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_collecting/test_collector/test_decorators/test_default.py b/tests/test_collecting/test_collector/test_decorators/test_default.py
deleted file mode 100644
index 966510c7..00000000
--- a/tests/test_collecting/test_collector/test_decorators/test_default.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import pytest
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_defining_default_handler_in_collector_as_decorator(
- handler_as_function,
- extract_collector,
- collector_cls,
-):
- collector = collector_cls()
- collector.default()(handler_as_function)
- assert extract_collector(collector).default_message_handler
-
-
-def test_error_when_default_already_exists(
- handler_as_function,
- extract_collector,
- collector_cls,
-):
- collector = collector_cls()
- collector.default()(handler_as_function)
-
- with pytest.raises(AssertionError):
- collector.default()(handler_as_function)
diff --git a/tests/test_collecting/test_collector/test_decorators/test_handler.py b/tests/test_collecting/test_collector/test_decorators/test_handler.py
deleted file mode 100644
index eba5d870..00000000
--- a/tests/test_collecting/test_collector/test_decorators/test_handler.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from botx import Collector
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_defining_handler_in_collector_as_decorator(
- handler_as_function,
- extract_collector,
- collector_cls,
-):
- collector = Collector()
- collector.handler()(handler_as_function)
- handlers = [collector.handler_for("handler_function")]
- assert handlers
diff --git a/tests/test_collecting/test_collector/test_decorators/test_hidden.py b/tests/test_collecting/test_collector/test_decorators/test_hidden.py
deleted file mode 100644
index 613debb1..00000000
--- a/tests/test_collecting/test_collector/test_decorators/test_hidden.py
+++ /dev/null
@@ -1,11 +0,0 @@
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_defining_hidden_handler_in_collector_as_decorator(
- handler_as_function,
- extract_collector,
- collector_cls,
-):
- collector = collector_cls()
- collector.hidden()(handler_as_function)
- assert not collector.handlers[0].include_in_status
diff --git a/tests/test_collecting/test_collector/test_decorators/test_system_event.py b/tests/test_collecting/test_collector/test_decorators/test_system_event.py
deleted file mode 100644
index c72edcba..00000000
--- a/tests/test_collecting/test_collector/test_decorators/test_system_event.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import pytest
-
-from botx import SystemEvents
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_registration_handler_for_several_system_events(
- handler_as_function,
- extract_collector,
- collector_cls,
-):
- system_events = {
- SystemEvents.chat_created,
- SystemEvents.file_transfer,
- SystemEvents.added_to_chat,
- SystemEvents.deleted_from_chat,
- SystemEvents.left_from_chat,
- SystemEvents.internal_bot_notification,
- SystemEvents.cts_login,
- SystemEvents.cts_logout,
- SystemEvents.smartapp_event,
- }
- collector = collector_cls()
- collector.system_event(
- handler=handler_as_function,
- events=list(system_events),
- )
- handlers = [SystemEvents(handler.body) for handler in collector.handlers]
- assert handlers
-
-
-@pytest.mark.parametrize(
- "event",
- [
- SystemEvents.added_to_chat,
- SystemEvents.deleted_from_chat,
- SystemEvents.chat_created,
- SystemEvents.file_transfer,
- SystemEvents.left_from_chat,
- SystemEvents.cts_login,
- SystemEvents.cts_logout,
- SystemEvents.smartapp_event,
- ],
-)
-def test_defining_system_handler_in_collector_as_decorator(
- handler_as_function,
- extract_collector,
- collector_cls,
- event,
-):
- collector = collector_cls()
- getattr(collector, event.name)()(handler_as_function)
- assert SystemEvents(collector.handlers[0].body) == event
-
-
-def test_error_when_no_event_was_passed(
- handler_as_function,
- extract_collector,
- collector_cls,
-):
- collector = collector_cls()
- with pytest.raises(AssertionError):
- collector.system_event(handler=handler_as_function)
diff --git a/tests/test_collecting/test_collector/test_execution.py b/tests/test_collecting/test_collector/test_execution.py
deleted file mode 100644
index 1f04359b..00000000
--- a/tests/test_collecting/test_collector/test_execution.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import threading
-
-import pytest
-
-from botx import Collector
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-pytestmark = pytest.mark.asyncio
-
-
-async def test_execution_when_full_match(message, build_handler):
- event = threading.Event()
- message.command.body = "/command"
-
- collector = Collector()
- collector.add_handler(build_handler(event), body=message.command.body)
-
- await collector.handle_message(message)
-
- assert event.is_set()
-
-
-async def test_executing_handler_when_partial_match(message, build_handler):
- event = threading.Event()
- message.command.body = "/command with arguments"
-
- collector = Collector()
- collector.add_handler(build_handler(event), body=message.command.command)
-
- await collector.handle_message(message)
-
- assert event.is_set()
-
-
-async def test_execution_internal_bot_notification(
- internal_bot_notification_message,
- build_handler,
-):
- event = threading.Event()
- collector = Collector()
- collector.internal_bot_notification(build_handler(event))
-
- await collector.handle_message(internal_bot_notification_message)
-
- assert event.is_set()
diff --git a/tests/test_collecting/test_collector/test_handler_definition_errors.py b/tests/test_collecting/test_collector/test_handler_definition_errors.py
deleted file mode 100644
index b07dc764..00000000
--- a/tests/test_collecting/test_collector/test_handler_definition_errors.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import pytest
-from pydantic import ValidationError
-
-from botx import Collector
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_handler_can_not_consist_from_slashes_only(handler_as_function):
- collector = Collector()
- with pytest.raises(ValidationError):
- collector.add_handler(handler_as_function, body="////")
diff --git a/tests/test_collecting/test_collector/test_handlers_definition.py b/tests/test_collecting/test_collector/test_handlers_definition.py
deleted file mode 100644
index 896313bb..00000000
--- a/tests/test_collecting/test_collector/test_handlers_definition.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from botx import Collector
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_collector_default_handler_generating(default_handler):
- collector = Collector(default=default_handler)
-
- assert collector.default_message_handler == default_handler
diff --git a/tests/test_collecting/test_collector/test_handlers_order.py b/tests/test_collecting/test_collector/test_handlers_order.py
deleted file mode 100644
index 92f2c6c3..00000000
--- a/tests/test_collecting/test_collector/test_handlers_order.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import pytest
-
-from botx import Collector
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-@pytest.fixture()
-def collector_with_handlers(handler_as_function):
- collector = Collector()
- for index in range(1, 31):
- body = "/{0}".format("a" * index)
- collector.add_handler(handler=handler_as_function, body=body, name=str(index))
-
- return collector
-
-
-def test_sorting_handlers_in_collector_by_body_length(collector_with_handlers):
- added_handlers = collector_with_handlers.sorted_handlers
- assert added_handlers == sorted(
- added_handlers,
- key=lambda handler: len(handler.body),
- reverse=True,
- )
-
-
-def test_preserve_length_sort_when_merging_collectors(
- collector_with_handlers,
- handler_as_function,
-):
- collector = Collector()
- collector.add_handler(handler=handler_as_function, body="/{0}".format("a" * 1000))
-
- collector_with_handlers.include_collector(collector)
-
- added_handlers = collector_with_handlers.sorted_handlers
- assert added_handlers[0] == collector.handlers[0]
diff --git a/tests/test_collecting/test_collector/test_handlers_search.py b/tests/test_collecting/test_collector/test_handlers_search.py
deleted file mode 100644
index cad298d6..00000000
--- a/tests/test_collecting/test_collector/test_handlers_search.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import pytest
-
-from botx.exceptions import NoMatchFound
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_raising_exception_when_searching_for_handler_and_no_found(collector_cls):
- collector = collector_cls()
- with pytest.raises(NoMatchFound):
- collector.handler_for("not-existing-handler")
diff --git a/tests/test_collecting/test_handler/__init__.py b/tests/test_collecting/test_handler/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_collecting/test_handler/test_attributes.py b/tests/test_collecting/test_handler/test_attributes.py
deleted file mode 100644
index a86267e2..00000000
--- a/tests/test_collecting/test_handler/test_attributes.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from botx.collecting.handlers.handler import Handler
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_handler_docstring_stored_as_full_description(handler_as_function):
- handler = Handler(body="/command", handler=handler_as_function)
- assert handler.full_description == handler_as_function.__doc__
-
-
-def test_handler_from_function(handler_as_function):
- handler = Handler(body="/command", handler=handler_as_function)
- assert handler.name == "handler_function"
-
-
-def test_name_when_name_was_passed_explicitly(handler_as_function):
- handler = Handler(handler=handler_as_function, body="/command", name="my_handler")
- assert handler.name == "my_handler"
-
-
-def test_any_body_for_hidden_handler(handler_as_function):
- Handler(handler=handler_as_function, body="any text!", include_in_status=False)
diff --git a/tests/test_collecting/test_handler/test_commands_generation.py b/tests/test_collecting/test_handler/test_commands_generation.py
deleted file mode 100644
index 1ee5ed8b..00000000
--- a/tests/test_collecting/test_handler/test_commands_generation.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from botx.collecting.handlers.handler import Handler
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_no_extra_space_on_command_built_through_command_for(handler_as_function):
- handler = Handler(body="/command", handler=handler_as_function)
-
- assert handler.command_for() == "/command"
-
- built_command_with_args = handler.command_for(None, 1, "some string", True)
-
- assert built_command_with_args == "/command 1 some string True"
diff --git a/tests/test_collecting/test_handler/test_constructing_errors.py b/tests/test_collecting/test_handler/test_constructing_errors.py
deleted file mode 100644
index 3dbfd50b..00000000
--- a/tests/test_collecting/test_handler/test_constructing_errors.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import pytest
-from pydantic import ValidationError
-
-from botx.collecting.handlers.handler import Handler
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_error_handler_from_class(handler_as_class):
- with pytest.raises(ValidationError):
- Handler(body="/command", handler=handler_as_class)
-
-
-def test_error_from_callable(handler_as_callable_object):
- with pytest.raises(ValidationError):
- Handler(body="/command", handler=handler_as_callable_object)
-
-
-def test_slash_in_command_for_public_command(handler_as_function):
- with pytest.raises(ValidationError):
- Handler(body="command", handler=handler_as_function)
-
-
-def test_only_one_slash_in_public_command(handler_as_function):
- with pytest.raises(ValidationError):
- Handler(body="//command", handler=handler_as_function)
-
-
-def test_that_menu_command_contain_only_single_word(handler_as_function):
- with pytest.raises(ValidationError):
- Handler(body="/many words handler", handler=handler_as_function)
diff --git a/tests/test_collecting/test_handler/test_equeality.py b/tests/test_collecting/test_handler/test_equeality.py
deleted file mode 100644
index e6893a1d..00000000
--- a/tests/test_collecting/test_handler/test_equeality.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from botx.collecting.handlers.handler import Handler
-
-pytest_plugins = ("tests.test_collecting.fixtures",)
-
-
-def test_equality_is_false_if_not_handler_passed(handler_as_function):
- handler = Handler(body="/command", handler=handler_as_function)
- assert handler != ""
-
-
-def test_equality_is_false_if_handlers_are_different(handler_as_function):
- handler1 = Handler(body="/command1", handler=handler_as_function)
- handler2 = Handler(body="/command2", handler=handler_as_function)
- assert handler1 != handler2
-
-
-def test_equality_if_handlers_are_similar(handler_as_function):
- handler1 = Handler(body="/command", handler=handler_as_function)
- handler2 = Handler(body="/command", handler=handler_as_function)
- assert handler1 == handler2
diff --git a/tests/test_dependencies/__init__.py b/tests/test_dependencies/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_dependencies/test_default_handler_dependencies.py b/tests/test_dependencies/test_default_handler_dependencies.py
deleted file mode 100644
index 3c5aa631..00000000
--- a/tests/test_dependencies/test_default_handler_dependencies.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import threading
-
-import pytest
-
-from botx import Collector, Depends
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_dependencies_from_bot_on_default_handler(
- bot,
- incoming_message,
- client,
- build_handler,
-):
- event1 = threading.Event()
- event2 = threading.Event()
-
- bot.collector = bot.exception_middleware.executor = Collector(
- dependencies=[Depends(build_handler(event1))],
- )
-
- bot.default(build_handler(event2))
-
- await client.send_command(incoming_message)
-
- assert event1.is_set()
- assert event2.is_set()
-
-
-async def test_dependency_saved_after_include_collector(
- bot,
- incoming_message,
- client,
- build_handler,
-):
- event1 = threading.Event()
- event2 = threading.Event()
-
- collector = Collector()
- collector.default(build_handler(event1))
-
- bot.collector = bot.exception_middleware.executor = Collector(
- dependencies=[Depends(build_handler(event2))],
- )
- bot.include_collector(collector)
-
- await client.send_command(incoming_message)
-
- assert event1.is_set()
- assert event2.is_set()
diff --git a/tests/test_dependencies/test_dependencies_cache.py b/tests/test_dependencies/test_dependencies_cache.py
deleted file mode 100644
index 60895ac8..00000000
--- a/tests/test_dependencies/test_dependencies_cache.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import threading
-from typing import Callable
-
-import pytest
-
-from botx import Depends
-
-pytestmark = pytest.mark.asyncio
-
-
-def build_botx_dependency(lock: threading.Lock) -> Callable[[], None]:
- def factory():
- lock.acquire()
-
- return factory
-
-
-@pytest.fixture()
-def build_dependency():
- return build_botx_dependency
-
-
-async def test_dependency_executed_only_once_per_message(
- bot,
- client,
- incoming_message,
- build_dependency,
- build_handler,
-):
- event = threading.Event()
- lock = threading.Lock()
-
- dependency_function = build_dependency(lock)
-
- bot.default(
- build_handler(event),
- dependencies=[Depends(dependency_function), Depends(dependency_function)],
- )
-
- await client.send_command(incoming_message)
-
- assert event.is_set()
diff --git a/tests/test_dependencies/test_errors.py b/tests/test_dependencies/test_errors.py
deleted file mode 100644
index 13a80dce..00000000
--- a/tests/test_dependencies/test_errors.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import pytest
-
-from botx.collecting.handlers.handler import Handler
-from botx.dependencies.solving import get_executor
-
-
-def test_error_when_creating_executor_without_call(build_handler):
- handler = Handler(build_handler(...), body="/body")
- dependant = handler.dependant
- dependant.call = None
- with pytest.raises(AssertionError):
- get_executor(dependant)
diff --git a/tests/test_dependencies/test_flow_control.py b/tests/test_dependencies/test_flow_control.py
deleted file mode 100644
index 3ddcf012..00000000
--- a/tests/test_dependencies/test_flow_control.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import threading
-from typing import Callable
-
-import pytest
-
-from botx import DependencyFailure, Depends
-
-pytestmark = pytest.mark.asyncio
-
-
-def build_botx_fail_dependency(event: threading.Event) -> Callable[[], None]:
- def factory():
- event.set()
- raise DependencyFailure
-
- return factory
-
-
-@pytest.fixture()
-def build_fail_dependency():
- return build_botx_fail_dependency
-
-
-async def test_flow_stop_if_error_raised(
- bot,
- client,
- incoming_message,
- build_handler,
- build_fail_dependency,
-):
- handler_event = threading.Event()
-
- dependency_event1 = threading.Event()
- dependency_event2 = threading.Event()
- dependency_event3 = threading.Event()
-
- bot.default(
- handler=build_handler(handler_event),
- dependencies=[
- Depends(build_handler(dependency_event1)),
- Depends(build_botx_fail_dependency(dependency_event2)),
- Depends(build_handler(dependency_event3)),
- ],
- )
-
- await client.send_command(incoming_message)
-
- assert dependency_event1.is_set()
- assert dependency_event2.is_set()
- assert not dependency_event3.is_set()
- assert not handler_event.is_set()
diff --git a/tests/test_dependencies/test_overrides.py b/tests/test_dependencies/test_overrides.py
deleted file mode 100644
index ecb8fa38..00000000
--- a/tests/test_dependencies/test_overrides.py
+++ /dev/null
@@ -1,81 +0,0 @@
-import threading
-
-import pytest
-
-from botx import Depends
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_that_dependency_can_be_overriden(
- bot,
- client,
- incoming_message,
- build_handler,
-):
- handler_event = threading.Event()
- original_dependency_event = threading.Event()
- fake_dependency_event = threading.Event()
-
- dependency = build_handler(original_dependency_event)
- bot.default(build_handler(handler_event), dependencies=[Depends(dependency)])
-
- bot.dependency_overrides[dependency] = build_handler(fake_dependency_event)
-
- await client.send_command(incoming_message)
-
- assert handler_event.is_set()
- assert not original_dependency_event.is_set()
- assert fake_dependency_event.is_set()
-
-
-async def test_bot_is_used_as_default_provider(
- bot,
- client,
- incoming_message,
- build_handler,
-):
- handler_event = threading.Event()
- original_dependency_event = threading.Event()
- fake_dependency_event = threading.Event()
-
- dependency = build_handler(original_dependency_event)
- bot.default(
- build_handler(handler_event),
- dependencies=[Depends(dependency)],
- dependency_overrides_provider=None,
- )
-
- bot.dependency_overrides[dependency] = build_handler(fake_dependency_event)
-
- await client.send_command(incoming_message)
-
- assert handler_event.is_set()
- assert not original_dependency_event.is_set()
- assert fake_dependency_event.is_set()
-
-
-async def test_overrider_is_used_if_not_none(
- bot,
- client,
- incoming_message,
- build_handler,
-):
- handler_event = threading.Event()
- original_dependency_event = threading.Event()
- fake_dependency_event = threading.Event()
-
- dependency = build_handler(original_dependency_event)
- bot.default(
- build_handler(handler_event),
- dependencies=[Depends(dependency)],
- dependency_overrides_provider={},
- )
-
- bot.dependency_overrides[dependency] = build_handler(fake_dependency_event)
-
- await client.send_command(incoming_message)
-
- assert handler_event.is_set()
- assert not fake_dependency_event.is_set()
- assert original_dependency_event.is_set()
diff --git a/tests/test_dependencies/test_special_params/__init__.py b/tests/test_dependencies/test_special_params/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_dependencies/test_special_params/test_definition/__init__.py b/tests/test_dependencies/test_special_params/test_definition/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_dependencies/test_special_params/test_definition/test_async_client_param.py b/tests/test_dependencies/test_special_params/test_definition/test_async_client_param.py
deleted file mode 100644
index cc912690..00000000
--- a/tests/test_dependencies/test_special_params/test_definition/test_async_client_param.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import pytest
-
-from botx import AsyncClient
-
-pytestmark = pytest.mark.asyncio
-
-
-@pytest.fixture()
-def handler_with_dependency(storage):
- def factory(client: AsyncClient) -> None:
- storage.client = client
-
- return factory
-
-
-async def test_passing_async_client_as_dependency(
- bot,
- client,
- incoming_message,
- handler_with_dependency,
- storage,
-):
- bot.default(handler_with_dependency)
- await client.send_command(incoming_message)
- assert storage.client == bot.client
diff --git a/tests/test_dependencies/test_special_params/test_definition/test_bot_param.py b/tests/test_dependencies/test_special_params/test_definition/test_bot_param.py
deleted file mode 100644
index eb18cd1b..00000000
--- a/tests/test_dependencies/test_special_params/test_definition/test_bot_param.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import pytest
-
-from botx import Bot
-
-pytestmark = pytest.mark.asyncio
-
-
-@pytest.fixture()
-def handler_with_dependency(storage):
- def factory(bot: Bot) -> None:
- storage.bot = bot
-
- return factory
-
-
-async def test_passing_async_client_as_dependency(
- bot,
- client,
- incoming_message,
- handler_with_dependency,
- storage,
-):
- bot.default(handler_with_dependency)
- await client.send_command(incoming_message)
- assert storage.bot == bot
diff --git a/tests/test_dependencies/test_special_params/test_definition/test_depends_param.py b/tests/test_dependencies/test_special_params/test_definition/test_depends_param.py
deleted file mode 100644
index 1db4167c..00000000
--- a/tests/test_dependencies/test_special_params/test_definition/test_depends_param.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import pytest
-
-from botx import Depends
-
-pytestmark = pytest.mark.asyncio
-
-
-def dependency():
- return 42
-
-
-@pytest.fixture()
-def handler_with_dependency(storage):
- def factory(dep: int = Depends(dependency)) -> None: # noqa: B008
- storage.dep = dep
-
- return factory
-
-
-async def test_passing_async_client_as_dependency(
- bot,
- client,
- incoming_message,
- handler_with_dependency,
- storage,
-):
- bot.default(handler_with_dependency)
- await client.send_command(incoming_message)
- assert storage.dep == 42
diff --git a/tests/test_dependencies/test_special_params/test_definition/test_message_param.py b/tests/test_dependencies/test_special_params/test_definition/test_message_param.py
deleted file mode 100644
index 83aaac69..00000000
--- a/tests/test_dependencies/test_special_params/test_definition/test_message_param.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import pytest
-
-from botx import Message
-
-pytestmark = pytest.mark.asyncio
-
-
-@pytest.fixture()
-def handler_with_dependency(storage):
- def factory(message: Message) -> None:
- storage.message = message
-
- return factory
-
-
-async def test_passing_async_client_as_dependency(
- bot,
- client,
- incoming_message,
- handler_with_dependency,
- storage,
-):
- bot.default(handler_with_dependency)
- await client.send_command(incoming_message)
- assert storage.message.incoming_message == incoming_message
diff --git a/tests/test_dependencies/test_special_params/test_definition/test_sync_client_param.py b/tests/test_dependencies/test_special_params/test_definition/test_sync_client_param.py
deleted file mode 100644
index 7bb3cd15..00000000
--- a/tests/test_dependencies/test_special_params/test_definition/test_sync_client_param.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import pytest
-
-from botx import Client
-
-pytestmark = pytest.mark.asyncio
-
-
-@pytest.fixture()
-def handler_with_dependency(storage):
- def factory(client: Client) -> None:
- storage.client = client
-
- return factory
-
-
-async def test_passing_async_client_as_dependency(
- bot,
- client,
- incoming_message,
- handler_with_dependency,
- storage,
-):
- bot.default(handler_with_dependency)
- await client.send_command(incoming_message)
- assert storage.client == bot.sync_client
diff --git a/tests/test_dependencies/test_special_params/test_errors.py b/tests/test_dependencies/test_special_params/test_errors.py
deleted file mode 100644
index 0c618520..00000000
--- a/tests/test_dependencies/test_special_params/test_errors.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import pytest
-
-
-@pytest.fixture()
-def handler_with_dependency(storage):
- def factory(_: int) -> None:
- """Just do nothing"""
-
- return factory
-
-
-def test_error_for_wrong_param(bot, handler_with_dependency) -> None:
- with pytest.raises(ValueError):
- bot.default(handler_with_dependency)
diff --git a/tests/test_dependencies/test_special_params/test_forward_references_solving.py b/tests/test_dependencies/test_special_params/test_forward_references_solving.py
deleted file mode 100644
index 3fbfd5bc..00000000
--- a/tests/test_dependencies/test_special_params/test_forward_references_solving.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import pytest
-
-from botx import Message
-
-pytestmark = pytest.mark.asyncio
-
-
-@pytest.fixture()
-def handler_with_dependency(storage):
- def factory(message: "Message") -> None:
- storage.message = message
-
- return factory
-
-
-async def test_solving_forward_references_for_special_dependencies(
- bot,
- client,
- incoming_message,
- handler_with_dependency,
- storage,
-):
- bot.default(handler_with_dependency)
-
- await client.send_command(incoming_message)
-
- assert storage.message.incoming_message == incoming_message
diff --git a/tests/test_docs/__init__.py b/tests/test_docs/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py
new file mode 100644
index 00000000..27abf5c5
--- /dev/null
+++ b/tests/test_end_to_end.py
@@ -0,0 +1,345 @@
+import os
+from http import HTTPStatus
+from typing import List
+from uuid import UUID
+
+import httpx
+import pytest
+from dotenv import load_dotenv
+from fastapi import APIRouter, Depends, FastAPI, Request
+from fastapi.responses import JSONResponse
+from fastapi.testclient import TestClient
+from loguru import logger
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ IncomingMessage,
+ UnknownBotAccountError,
+ build_bot_disabled_response,
+ build_command_accepted_response,
+)
+
+# - Test utils -
+load_dotenv()
+
+
+def get_bot_accounts() -> List[BotAccountWithSecret]:
+ raw_credentials_list = os.getenv("BOT_CREDENTIALS")
+ if not raw_credentials_list:
+ raise RuntimeError("BOT_CREDENTIALS env not set")
+
+ bot_accounts = []
+ for raw_credentials in raw_credentials_list.split(","):
+ host, secret_key, raw_bot_id = raw_credentials.replace("|", "@").split("@")
+ bot_accounts.append(
+ BotAccountWithSecret(
+ id=UUID(raw_bot_id),
+ host=host,
+ secret_key=secret_key,
+ ),
+ )
+
+ return bot_accounts
+
+
+# - Bot setup -
+collector = HandlerCollector()
+
+
+@collector.command("/debug", description="Simple debug command")
+async def debug_handler(message: IncomingMessage, bot: Bot) -> None:
+ await bot.answer_message("Hi!")
+
+
+def bot_factory(
+ bot_accounts: List[BotAccountWithSecret],
+) -> Bot:
+ return Bot(collectors=[collector], bot_accounts=bot_accounts)
+
+
+# - FastAPI integration -
+def get_bot(request: Request) -> Bot:
+ assert isinstance(request.app.state.bot, Bot)
+
+ return request.app.state.bot
+
+
+bot_dependency = Depends(get_bot)
+
+router = APIRouter()
+
+
+@router.post("/command")
+async def command_handler(
+ request: Request,
+ bot: Bot = bot_dependency,
+) -> JSONResponse:
+ try:
+ bot.async_execute_raw_bot_command(await request.json())
+ except ValueError:
+ error_label = "Bot command validation error"
+ logger.exception(error_label)
+
+ return JSONResponse(
+ build_bot_disabled_response(error_label),
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE,
+ )
+ except UnknownBotAccountError as exc:
+ error_label = f"No credentials for bot {exc.bot_id}"
+ logger.warning(error_label)
+
+ return JSONResponse(
+ build_bot_disabled_response(error_label),
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE,
+ )
+
+ return JSONResponse(
+ build_command_accepted_response(),
+ status_code=HTTPStatus.ACCEPTED,
+ )
+
+
+@router.get("/status")
+async def status_handler(request: Request, bot: Bot = bot_dependency) -> JSONResponse:
+ status = await bot.raw_get_status(dict(request.query_params))
+ return JSONResponse(status)
+
+
+@router.post("/notification/callback")
+async def callback_handler(
+ request: Request,
+ bot: Bot = bot_dependency,
+) -> JSONResponse:
+ bot.set_raw_botx_method_result(await request.json())
+ return JSONResponse(
+ build_command_accepted_response(),
+ status_code=HTTPStatus.ACCEPTED,
+ )
+
+
+def fastapi_factory(bot: Bot) -> FastAPI:
+ application = FastAPI()
+ application.state.bot = bot
+
+ application.add_event_handler("startup", bot.startup)
+ application.add_event_handler("shutdown", bot.shutdown)
+
+ application.include_router(router)
+
+ return application
+
+
+# https://www.uvicorn.org/#application-factories
+def asgi_factory() -> FastAPI:
+ bot_accounts = get_bot_accounts()
+ bot = bot_factory(bot_accounts)
+ return fastapi_factory(bot)
+
+
+# - Tests -
+@pytest.fixture
+def bot(bot_account: BotAccountWithSecret) -> Bot:
+ return bot_factory(bot_accounts=[bot_account])
+
+
+pytestmark = [
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+def test__web_app__bot_status(
+ bot_id: UUID,
+ bot: Bot,
+) -> None:
+ # - Arrange -
+ query_params = {
+ "bot_id": str(bot_id),
+ "chat_type": "chat",
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ }
+
+ # - Act -
+ with TestClient(fastapi_factory(bot)) as test_client:
+ response = test_client.get(
+ "/status",
+ params=query_params,
+ )
+
+ # - Assert -
+ assert response.status_code == HTTPStatus.OK
+ assert response.json() == {
+ "result": {
+ "commands": [
+ {
+ "body": "/debug",
+ "description": "Simple debug command",
+ "name": "/debug",
+ },
+ ],
+ "enabled": True,
+ "status_message": "Bot is working",
+ },
+ "status": "ok",
+ }
+
+
+def test__web_app__bot_command(
+ respx_mock: MockRouter,
+ bot_id: UUID,
+ host: str,
+ bot: Bot,
+) -> None:
+ # - Arrange -
+ direct_notification_endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ command_payload = {
+ "bot_id": str(bot_id),
+ "command": {
+ "body": "/debug",
+ "command_type": "user",
+ "data": {},
+ "metadata": {},
+ },
+ "attachments": [],
+ "async_files": [],
+ "entities": [],
+ "source_sync_id": None,
+ "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": "chat",
+ "device": None,
+ "device_meta": {
+ "permissions": None,
+ "pushes": False,
+ "timezone": "Europe/Moscow",
+ },
+ "device_software": None,
+ "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935",
+ "host": "cts.example.com",
+ "is_admin": True,
+ "is_creator": True,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ "username": None,
+ },
+ "proto_version": 4,
+ }
+
+ callback_payload = {
+ "status": "ok",
+ "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
+ "result": {},
+ }
+
+ # - Act -
+ with TestClient(fastapi_factory(bot)) as test_client:
+ command_response = test_client.post(
+ "/command",
+ json=command_payload,
+ )
+
+ callback_response = test_client.post(
+ "/notification/callback",
+ json=callback_payload,
+ )
+
+ # - Assert -
+ assert command_response.status_code == HTTPStatus.ACCEPTED
+ assert direct_notification_endpoint.called
+ assert callback_response.status_code == HTTPStatus.ACCEPTED
+
+
+def test__web_app__unknown_bot_response(
+ bot: Bot,
+) -> None:
+ # - Arrange -
+ payload = {
+ "bot_id": "c755e147-30a5-45df-b46a-c75aa6089c8f",
+ "command": {
+ "body": "/debug",
+ "command_type": "user",
+ "data": {},
+ "metadata": {},
+ },
+ "attachments": [],
+ "async_files": [],
+ "entities": [],
+ "source_sync_id": None,
+ "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": "chat",
+ "device": None,
+ "device_meta": {
+ "permissions": None,
+ "pushes": False,
+ "timezone": "Europe/Moscow",
+ },
+ "device_software": None,
+ "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935",
+ "host": "cts.example.com",
+ "is_admin": True,
+ "is_creator": True,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ "username": None,
+ },
+ "proto_version": 4,
+ }
+
+ # - Act -
+ with TestClient(fastapi_factory(bot)) as test_client:
+ response = test_client.post(
+ "/command",
+ json=payload,
+ )
+
+ # - Assert -
+ assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE
+
+
+def test__web_app__disabled_bot_response(
+ bot: Bot,
+) -> None:
+ # - Arrange -
+ payload = {"incorrect": "request"}
+
+ # - Act -
+ with TestClient(fastapi_factory(bot)) as test_client:
+ response = test_client.post(
+ "/command",
+ json=payload,
+ )
+
+ # - Assert -
+ assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE
+ assert response.json() == {
+ "error_data": {"status_message": "Bot command validation error"},
+ "errors": [],
+ "reason": "bot_disabled",
+ }
diff --git a/tests/test_exception_handlers/__init__.py b/tests/test_exception_handlers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_exception_handlers/test_logging.py b/tests/test_exception_handlers/test_logging.py
deleted file mode 100644
index 7e2b9e88..00000000
--- a/tests/test_exception_handlers/test_logging.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import pytest
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_logging_that_handler_was_not_found(
- bot,
- client,
- incoming_message,
- loguru_caplog,
-) -> None:
- await client.send_command(incoming_message)
-
- error_message = "handler for {0!r} was not found".format(
- incoming_message.command.body,
- )
-
- assert error_message in loguru_caplog.text
diff --git a/tests/test_exception_middleware.py b/tests/test_exception_middleware.py
new file mode 100644
index 00000000..ede3fe51
--- /dev/null
+++ b/tests/test_exception_middleware.py
@@ -0,0 +1,112 @@
+import asyncio
+from typing import Callable
+from unittest.mock import MagicMock, call
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ IncomingMessage,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__exception_middleware__handler_called(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ exc = ValueError("test_error")
+ value_error_handler = MagicMock(asyncio.Future())
+
+ user_command = incoming_message_factory(body="/command")
+
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ raise exc
+
+ built_bot = Bot(
+ collectors=[collector],
+ bot_accounts=[bot_account],
+ exception_handlers={ValueError: value_error_handler},
+ )
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ assert len(value_error_handler.mock_calls) == 1
+ assert value_error_handler.mock_calls[0] == call(user_command, built_bot, exc)
+
+
+async def test__exception_middleware__without_handler_logs(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ loguru_caplog: pytest.LogCaptureFixture,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ raise ValueError("Testing exception middleware")
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ assert "Uncaught exception ValueError" in loguru_caplog.text
+ assert "Testing exception middleware" in loguru_caplog.text
+
+
+async def test__exception_middleware__error_in_handler_logs(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ loguru_caplog: pytest.LogCaptureFixture,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+
+ async def exception_handler(
+ message: IncomingMessage,
+ bot: Bot,
+ exc: Exception,
+ ) -> None:
+ raise ValueError("Nested error")
+
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ raise ValueError("Testing exception middleware")
+
+ built_bot = Bot(
+ collectors=[collector],
+ bot_accounts=[bot_account],
+ exception_handlers={Exception: exception_handler},
+ )
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ assert "Uncaught exception ValueError in exception handler" in loguru_caplog.text
+ assert "Testing exception middleware" in loguru_caplog.text
+ assert "Nested error" in loguru_caplog.text
diff --git a/tests/test_exceptions/__init__.py b/tests/test_exceptions/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_exceptions/test_botx_exception.py b/tests/test_exceptions/test_botx_exception.py
deleted file mode 100644
index e51034a0..00000000
--- a/tests/test_exceptions/test_botx_exception.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from botx.exceptions import BotXException
-
-
-def test_to_string_fills_template():
- exc = BotXException(arg=42)
- exc.message_template = "template with {arg}"
-
- assert str(exc) == "template with 42"
diff --git a/tests/test_exceptions/test_route_deprecated_error.py b/tests/test_exceptions/test_route_deprecated_error.py
deleted file mode 100644
index 2b2bb6c9..00000000
--- a/tests/test_exceptions/test_route_deprecated_error.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import uuid
-
-import httpx
-import pytest
-
-from botx.clients.methods.v3.chats.info import Info
-from botx.concurrency import callable_to_coroutine
-from botx.exceptions import BotXAPIRouteDeprecated
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_clients.fixtures",)
-
-
-async def test_raising_route_deprecated_error(client, requests_client):
- method = Info(host="example.com", group_chat_id=uuid.uuid4())
- errors_to_raise = {Info: (httpx.codes.GONE, {})}
-
- with client.error_client(errors=errors_to_raise):
- request = requests_client.build_request(method)
- response = await callable_to_coroutine(requests_client.execute, request)
-
- with pytest.raises(BotXAPIRouteDeprecated):
- await callable_to_coroutine(
- requests_client.process_response,
- method,
- response,
- )
diff --git a/tests/test_exceptions/test_routing_match_error.py b/tests/test_exceptions/test_routing_match_error.py
deleted file mode 100644
index 5184653e..00000000
--- a/tests/test_exceptions/test_routing_match_error.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import pytest
-
-from botx.exceptions import NoMatchFound
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_search_param_in_matching_error(bot, message, client):
- with pytest.raises(NoMatchFound) as err_info:
- await bot.collector.handle_message(message)
-
- error = err_info.value
- assert error.search_param == message.command.body
diff --git a/tests/test_exceptions/test_unknown_server.py b/tests/test_exceptions/test_unknown_server.py
deleted file mode 100644
index cff8d424..00000000
--- a/tests/test_exceptions/test_unknown_server.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import pytest
-
-from botx import UnknownBotError
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_host_in_server_error(bot, incoming_message, client):
- bot.bot_accounts = []
-
- with pytest.raises(UnknownBotError) as err_info:
- await client.send_command(incoming_message)
-
- error = err_info.value
- assert error.bot_id == incoming_message.bot_id
diff --git a/tests/test_files.py b/tests/test_files.py
new file mode 100644
index 00000000..1274e43d
--- /dev/null
+++ b/tests/test_files.py
@@ -0,0 +1,237 @@
+from http import HTTPStatus
+from typing import Any, Callable, Dict, Optional
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import (
+ AttachmentTypes,
+ Bot,
+ BotAccountWithSecret,
+ Document,
+ File,
+ HandlerCollector,
+ Image,
+ IncomingMessage,
+ Video,
+ Voice,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__async_file__open(
+ respx_mock: MockRouter,
+ host: str,
+ bot_account: BotAccountWithSecret,
+ bot_id: UUID,
+ api_incoming_message_factory: Callable[..., Dict[str, Any]],
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.get(
+ f"https://{host}/api/v3/botx/files/download",
+ params={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80",
+ },
+ headers={"Authorization": "Bearer token"},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ content=b"Hello, world!\n",
+ ),
+ )
+
+ payload = api_incoming_message_factory(
+ bot_id=bot_id,
+ async_file={
+ "type": "image",
+ "file": "https://link.to/file",
+ "file_mime_type": "image/png",
+ "file_name": "pass.png",
+ "file_preview": "https://link.to/preview",
+ "file_preview_height": 300,
+ "file_preview_width": 300,
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_encryption_algo": "stream",
+ "chunk_size": 2097152,
+ "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80",
+ },
+ group_chat_id="054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ host=host,
+ )
+
+ collector = HandlerCollector()
+ read_content: Optional[bytes] = None
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ nonlocal read_content
+
+ assert message.file
+ async with message.file.open() as fo:
+ read_content = await fo.read()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert read_content == b"Hello, world!\n"
+ assert endpoint.called
+
+
+API_AND_DOMAIN_FILES = (
+ (
+ {
+ "type": "image",
+ "file": "https://link.to/file",
+ "file_mime_type": "image/png",
+ "file_name": "pass.png",
+ "file_preview": "https://link.to/preview",
+ "file_preview_height": 300,
+ "file_preview_width": 300,
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_encryption_algo": "stream",
+ "chunk_size": 2097152,
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ },
+ Image(
+ type=AttachmentTypes.IMAGE,
+ filename="pass.png",
+ size=1502345,
+ is_async_file=True,
+ _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ _file_url="https://link.to/file",
+ _file_mimetype="image/png",
+ _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ ),
+ ),
+ (
+ {
+ "type": "video",
+ "file": "https://link.to/file",
+ "file_mime_type": "video/mp4",
+ "file_name": "pass.mp4",
+ "file_preview": "https://link.to/preview",
+ "file_preview_height": 300,
+ "file_preview_width": 300,
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_encryption_algo": "stream",
+ "chunk_size": 2097152,
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ "duration": 10,
+ },
+ Video(
+ type=AttachmentTypes.VIDEO,
+ filename="pass.mp4",
+ size=1502345,
+ is_async_file=True,
+ duration=10,
+ _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ _file_url="https://link.to/file",
+ _file_mimetype="video/mp4",
+ _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ ),
+ ),
+ (
+ {
+ "type": "document",
+ "file": "https://link.to/file",
+ "file_mime_type": "plain/text",
+ "file_name": "pass.txt",
+ "file_preview": "https://link.to/preview",
+ "file_preview_height": 300,
+ "file_preview_width": 300,
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_encryption_algo": "stream",
+ "chunk_size": 2097152,
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ },
+ Document(
+ type=AttachmentTypes.DOCUMENT,
+ filename="pass.txt",
+ size=1502345,
+ is_async_file=True,
+ _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ _file_url="https://link.to/file",
+ _file_mimetype="plain/text",
+ _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ ),
+ ),
+ (
+ {
+ "type": "voice",
+ "file": "https://link.to/file",
+ "file_mime_type": "audio/mp3",
+ "file_name": "pass.mp3",
+ "file_preview": "https://link.to/preview",
+ "file_preview_height": 300,
+ "file_preview_width": 300,
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_encryption_algo": "stream",
+ "chunk_size": 2097152,
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ "duration": 10,
+ },
+ Voice(
+ type=AttachmentTypes.VOICE,
+ filename="pass.mp3",
+ size=1502345,
+ is_async_file=True,
+ duration=10,
+ _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ _file_url="https://link.to/file",
+ _file_mimetype="audio/mp3",
+ _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ ),
+ ),
+)
+
+
+@pytest.mark.parametrize(
+ "api_async_file,domain_async_file",
+ API_AND_DOMAIN_FILES,
+)
+async def test__async_execute_raw_bot_command__different_file_types(
+ api_async_file: Dict[str, Any],
+ domain_async_file: File,
+ api_incoming_message_factory: Callable[..., Dict[str, Any]],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = api_incoming_message_factory(async_file=api_async_file)
+
+ collector = HandlerCollector()
+ incoming_message: Optional[IncomingMessage] = None
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ nonlocal incoming_message
+ incoming_message = message
+ # Drop `raw_command` from asserting
+ incoming_message.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert incoming_message
+ assert incoming_message.file == domain_async_file
diff --git a/tests/test_handler_collector.py b/tests/test_handler_collector.py
new file mode 100644
index 00000000..3177318e
--- /dev/null
+++ b/tests/test_handler_collector.py
@@ -0,0 +1,509 @@
+from typing import Callable
+from unittest.mock import Mock
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ ChatCreatedEvent,
+ HandlerCollector,
+ IncomingMessage,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+def test__handler_collector__command_with_space_error_raised() -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ with pytest.raises(ValueError) as exc:
+
+ @collector.command("/ command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ # - Assert -
+ assert "include space" in str(exc.value)
+
+
+def test__handler_collector__command_without_leading_slash_error_raised() -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ with pytest.raises(ValueError) as exc:
+
+ @collector.command("command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ # - Assert -
+ assert "should start with '/'" in str(exc.value)
+
+
+def test__handler_collector__visible_command_without_description_error_raised() -> None:
+ # - Act -
+ collector = HandlerCollector()
+
+ with pytest.raises(ValueError) as exc:
+
+ @collector.command("/command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ # - Assert -
+ assert "Description is required" in str(exc.value)
+
+
+def test__handler_collector__two_same_commands_error_raised() -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler_1(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ # - Act -
+ with pytest.raises(ValueError) as exc:
+
+ @collector.command("/command", description="My command")
+ async def handler_2(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ # - Assert -
+ assert "already registered" in str(exc.value)
+ assert "/command" in str(exc.value)
+
+
+def test__handler_collector__two_default_handlers_error_raised() -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ @collector.default_message_handler
+ async def handler_1(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ # - Act -
+ with pytest.raises(ValueError) as exc:
+
+ @collector.default_message_handler
+ async def handler_2(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ # - Assert -
+ assert "already registered" in str(exc.value)
+ assert "Default" in str(exc.value)
+
+
+def test__handler_collector__two_same_system_events_handlers_error_raised() -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ @collector.chat_created
+ async def handler_1(message: ChatCreatedEvent, bot: Bot) -> None:
+ pass
+
+ # - Act -
+ with pytest.raises(ValueError) as exc:
+
+ @collector.chat_created
+ async def handler_2(message: ChatCreatedEvent, bot: Bot) -> None:
+ pass
+
+ # - Assert -
+ assert "already registered" in str(exc.value)
+ assert "Event" in str(exc.value)
+
+
+def test___handler_collector__merge_collectors_with_same_command_error_raised() -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler_1(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ other_collector = HandlerCollector()
+
+ @other_collector.command("/command", description="My command")
+ async def handler_2(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ # - Act -
+ with pytest.raises(ValueError) as exc:
+ collector.include(other_collector)
+
+ # - Assert -
+ assert "already registered" in str(exc.value)
+ assert "/command" in str(exc.value)
+
+
+def test__handler_collector__merge_collectors_with_default_handlers_error_raised() -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ @collector.default_message_handler
+ async def handler_1(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ other_collector = HandlerCollector()
+
+ @other_collector.default_message_handler
+ async def handler_2(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ # - Act -
+ with pytest.raises(ValueError) as exc:
+ collector.include(other_collector)
+
+ # - Assert -
+ assert "already registered" in str(exc.value)
+ assert "Default" in str(exc.value)
+
+
+def test__handler_collector__merge_collectors_with_same_system_events_handlers_error_raised() -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ @collector.chat_created
+ async def handler_1(message: ChatCreatedEvent, bot: Bot) -> None:
+ pass
+
+ other_collector = HandlerCollector()
+
+ @other_collector.chat_created
+ async def handler_2(message: ChatCreatedEvent, bot: Bot) -> None:
+ pass
+
+ # - Act -
+ with pytest.raises(ValueError) as exc:
+ collector.include(other_collector)
+
+ # - Assert -
+ assert "already registered" in str(exc.value)
+ assert "event" in str(exc.value)
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__command_handler_called(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__unicode_command_error_raised(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ russian_command = incoming_message_factory(body="/команда")
+ collector = HandlerCollector()
+
+ @collector.command("/команда", description="Моя команда")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(russian_command)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__correct_command_handler_called(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ incorrect_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def correct_handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ @collector.command("/other", description="My command")
+ async def incorrect_handler(message: IncomingMessage, bot: Bot) -> None:
+ incorrect_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+ incorrect_handler_trigger.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__correct_command_handler_called_in_merged_collectors(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ incorrect_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+
+ collector_1 = HandlerCollector()
+ collector_2 = HandlerCollector()
+
+ @collector_1.command("/command", description="My command")
+ async def correct_handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ @collector_2.command("/command-two", description="My command")
+ async def incorrect_handler(message: IncomingMessage, bot: Bot) -> None:
+ incorrect_handler_trigger()
+
+ built_bot = Bot(collectors=[collector_1, collector_2], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+ incorrect_handler_trigger.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__default_handler_called(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+ collector = HandlerCollector()
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__empty_command_goes_to_default_handler(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ empty_command = incoming_message_factory(body="")
+ collector = HandlerCollector()
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(empty_command)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__invalid_command_goes_to_default_handler(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ empty_command = incoming_message_factory(body="/")
+ collector = HandlerCollector()
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(empty_command)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__handler_not_found_logged(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ bot_account: BotAccountWithSecret,
+ loguru_caplog: pytest.LogCaptureFixture,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+ collector = HandlerCollector()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ assert "`/command` not found" in loguru_caplog.text
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__default_handler_in_first_collector_called(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+
+ collector_1 = HandlerCollector()
+ collector_2 = HandlerCollector()
+
+ @collector_1.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector_1, collector_2], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__default_handler_in_second_collector_called(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+
+ collector_1 = HandlerCollector()
+ collector_2 = HandlerCollector()
+
+ @collector_2.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector_1, collector_2], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__handler_not_found_exception_logged(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ bot_account: BotAccountWithSecret,
+ loguru_caplog: pytest.LogCaptureFixture,
+) -> None:
+ # - Arrange -
+ bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ bot.async_execute_bot_command(incoming_message_factory(body="/command"))
+ await bot.shutdown()
+
+ # - Assert -
+ assert "`/command` not found" in loguru_caplog.text
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__handle_incoming_message_by_command_handler_not_found_exception_logged(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ bot_account: BotAccountWithSecret,
+ loguru_caplog: pytest.LogCaptureFixture,
+) -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await collector.handle_incoming_message_by_command(
+ incoming_message_factory(body="Text"),
+ bot,
+ command="/command",
+ )
+
+ # - Assert -
+ assert "`/command` not found" in loguru_caplog.text
+
+
+@pytest.mark.asyncio
+async def test__handler_collector__handle_incoming_message_by_command_succeed(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ bot_account: BotAccountWithSecret,
+ correct_handler_trigger: Mock,
+) -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await collector.handle_incoming_message_by_command(
+ incoming_message_factory(body="Text"),
+ bot,
+ command="/command",
+ )
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
diff --git a/tests/test_incoming_message.py b/tests/test_incoming_message.py
new file mode 100644
index 00000000..0284146a
--- /dev/null
+++ b/tests/test_incoming_message.py
@@ -0,0 +1,475 @@
+from datetime import datetime
+from typing import Callable, Optional
+from uuid import UUID
+
+import pytest
+
+from botx import (
+ AttachmentTypes,
+ Bot,
+ BotAccount,
+ BotAccountWithSecret,
+ Chat,
+ ChatTypes,
+ ClientPlatforms,
+ Forward,
+ HandlerCollector,
+ Image,
+ IncomingMessage,
+ Mention,
+ MentionList,
+ MentionTypes,
+ Reply,
+ UserDevice,
+ UserSender,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__async_execute_raw_bot_command__minimally_filled_incoming_message(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "command": {
+ "body": "/hello",
+ "command_type": "user",
+ "data": {},
+ "metadata": {},
+ },
+ "attachments": [],
+ "async_files": [],
+ "entities": [],
+ "source_sync_id": None,
+ "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": "chat",
+ "device": None,
+ "device_meta": None,
+ "device_software": None,
+ "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935",
+ "host": "cts.example.com",
+ "is_admin": True,
+ "is_creator": True,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ "username": None,
+ },
+ "proto_version": 4,
+ }
+
+ collector = HandlerCollector()
+ incoming_message: Optional[IncomingMessage] = None
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ nonlocal incoming_message
+ incoming_message = message
+ # Drop `raw_command` from asserting
+ incoming_message.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert incoming_message == IncomingMessage(
+ bot=BotAccount(
+ id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ host="cts.example.com",
+ ),
+ sync_id=UUID("6f40a492-4b5f-54f3-87ee-77126d825b51"),
+ source_sync_id=None,
+ body="/hello",
+ data={},
+ metadata={},
+ sender=UserSender(
+ huid=UUID("f16cdc5f-6366-5552-9ecd-c36290ab3d11"),
+ ad_login=None,
+ ad_domain=None,
+ username=None,
+ is_chat_admin=True,
+ is_chat_creator=True,
+ device=UserDevice(
+ manufacturer=None,
+ device_name=None,
+ os=None,
+ pushes=None,
+ timezone=None,
+ permissions=None,
+ platform=None,
+ platform_package_id=None,
+ app_version=None,
+ locale="en",
+ ),
+ ),
+ chat=Chat(
+ id=UUID("30dc1980-643a-00ad-37fc-7cc10d74e935"),
+ type=ChatTypes.PERSONAL_CHAT,
+ ),
+ raw_command=None,
+ )
+
+
+async def test__async_execute_raw_bot_command__maximum_filled_incoming_message(
+ datetime_formatter: Callable[[str], datetime],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "command": {
+ "body": "/hello",
+ "command_type": "user",
+ "data": {"message": "data"},
+ "metadata": {"message": "metadata"},
+ },
+ "attachments": [],
+ "async_files": [
+ {
+ "type": "image",
+ "file": "https://link.to/file",
+ "file_mime_type": "image/png",
+ "file_name": "pass.png",
+ "file_preview": "https://link.to/preview",
+ "file_preview_height": 300,
+ "file_preview_width": 300,
+ "file_size": 1502345,
+ "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ "file_encryption_algo": "stream",
+ "chunk_size": 2097152,
+ "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
+ },
+ ],
+ "entities": [
+ {
+ "type": "reply",
+ "data": {
+ "source_sync_id": "a7ffba12-8d0a-534e-8896-a0aa2d93a434",
+ "sender": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f",
+ "body": "все равно документацию никто не читает...",
+ "mentions": [
+ {
+ "mention_type": "contact",
+ "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f",
+ "mention_data": {
+ "user_huid": "ab103983-6001-44e9-889e-d55feb295494",
+ "name": "Вася Иванов",
+ "conn_type": "cts",
+ },
+ },
+ ],
+ "attachment": None,
+ "reply_type": "chat",
+ "source_group_chat_id": "918da23a-1c9a-506e-8a6f-1328f1499ee8",
+ "source_chat_name": "Serious Dev Chat",
+ },
+ },
+ {
+ "type": "forward",
+ "data": {
+ "group_chat_id": "918da23a-1c9a-506e-8a6f-1328f1499ee8",
+ "sender_huid": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f",
+ "forward_type": "chat",
+ "source_chat_name": "Simple Chat",
+ "source_sync_id": "a7ffba12-8d0a-534e-8896-a0aa2d93a434",
+ "source_inserted_at": "2020-04-21T22:09:32.178Z",
+ },
+ },
+ {
+ "type": "mention",
+ "data": {
+ "mention_type": "contact",
+ "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f",
+ "mention_data": {
+ "user_huid": "ab103983-6001-44e9-889e-d55feb295494",
+ "name": "Вася Иванов",
+ "conn_type": "cts",
+ },
+ },
+ },
+ ],
+ "source_sync_id": "bc3d06ed-7b2e-41ad-99f9-ca28adc2c88d",
+ "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51",
+ "from": {
+ "ad_domain": "domain",
+ "ad_login": "login",
+ "app_version": "1.21.9",
+ "chat_type": "chat",
+ "device": "Firefox 91.0",
+ "device_meta": {
+ "permissions": {
+ "microphone": True,
+ "notifications": False,
+ },
+ "pushes": False,
+ "timezone": "Europe/Moscow",
+ },
+ "device_software": "Linux",
+ "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935",
+ "host": "cts.example.com",
+ "is_admin": True,
+ "is_creator": True,
+ "locale": "en",
+ "manufacturer": "Mozilla",
+ "platform": "web",
+ "platform_package_id": "ru.unlimitedtech.express",
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ "username": "Ivanov Ivan Ivanovich",
+ },
+ "proto_version": 4,
+ }
+
+ collector = HandlerCollector()
+ incoming_message: Optional[IncomingMessage] = None
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ nonlocal incoming_message
+ incoming_message = message
+ # Drop `raw_command` from asserting
+ incoming_message.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert incoming_message == IncomingMessage(
+ bot=BotAccount(
+ id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"),
+ host="cts.example.com",
+ ),
+ sync_id=UUID("6f40a492-4b5f-54f3-87ee-77126d825b51"),
+ source_sync_id=UUID("bc3d06ed-7b2e-41ad-99f9-ca28adc2c88d"),
+ body="/hello",
+ data={"message": "data"},
+ metadata={"message": "metadata"},
+ sender=UserSender(
+ huid=UUID("f16cdc5f-6366-5552-9ecd-c36290ab3d11"),
+ ad_login="login",
+ ad_domain="domain",
+ username="Ivanov Ivan Ivanovich",
+ is_chat_admin=True,
+ is_chat_creator=True,
+ device=UserDevice(
+ manufacturer="Mozilla",
+ device_name="Firefox 91.0",
+ os="Linux",
+ pushes=False,
+ timezone="Europe/Moscow",
+ permissions={"microphone": True, "notifications": False},
+ platform=ClientPlatforms.WEB,
+ platform_package_id="ru.unlimitedtech.express",
+ app_version="1.21.9",
+ locale="en",
+ ),
+ ),
+ chat=Chat(
+ id=UUID("30dc1980-643a-00ad-37fc-7cc10d74e935"),
+ type=ChatTypes.PERSONAL_CHAT,
+ ),
+ raw_command=None,
+ file=Image(
+ type=AttachmentTypes.IMAGE,
+ filename="pass.png",
+ size=1502345,
+ is_async_file=True,
+ _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"),
+ _file_url="https://link.to/file",
+ _file_mimetype="image/png",
+ _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=",
+ ),
+ mentions=MentionList(
+ [
+ Mention(
+ type=MentionTypes.CONTACT,
+ entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"),
+ name="Вася Иванов",
+ ),
+ ],
+ ),
+ forward=Forward(
+ chat_id=UUID("918da23a-1c9a-506e-8a6f-1328f1499ee8"),
+ author_id=UUID("c06a96fa-7881-0bb6-0e0b-0af72fe3683f"),
+ sync_id=UUID("a7ffba12-8d0a-534e-8896-a0aa2d93a434"),
+ ),
+ reply=Reply(
+ author_id=UUID("c06a96fa-7881-0bb6-0e0b-0af72fe3683f"),
+ sync_id=UUID("a7ffba12-8d0a-534e-8896-a0aa2d93a434"),
+ body="все равно документацию никто не читает...",
+ mentions=MentionList(
+ [
+ Mention(
+ type=MentionTypes.CONTACT,
+ entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"),
+ name="Вася Иванов",
+ ),
+ ],
+ ),
+ ),
+ )
+
+
+async def test__async_execute_raw_bot_command__all_mention_types(
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ payload = {
+ "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46",
+ "command": {
+ "body": "/hello",
+ "command_type": "user",
+ "data": {},
+ "metadata": {},
+ },
+ "attachments": [],
+ "async_files": [],
+ "entities": [
+ {
+ "type": "mention",
+ "data": {
+ "mention_type": "contact",
+ "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f",
+ "mention_data": {
+ "user_huid": "ab103983-6001-44e9-889e-d55feb295494",
+ "name": "Вася Иванов",
+ "conn_type": "cts",
+ },
+ },
+ },
+ {
+ "type": "mention",
+ "data": {
+ "mention_type": "user",
+ "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f",
+ "mention_data": {
+ "user_huid": "ab103983-6001-44e9-889e-d55feb295494",
+ "name": "Вася Иванов",
+ "conn_type": "cts",
+ },
+ },
+ },
+ {
+ "type": "mention",
+ "data": {
+ "mention_type": "channel",
+ "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f",
+ "mention_data": {
+ "group_chat_id": "ab103983-6001-44e9-889e-d55feb295494",
+ "name": "Вася Иванов",
+ },
+ },
+ },
+ {
+ "type": "mention",
+ "data": {
+ "mention_type": "chat",
+ "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f",
+ "mention_data": {
+ "group_chat_id": "ab103983-6001-44e9-889e-d55feb295494",
+ "name": "Вася Иванов",
+ },
+ },
+ },
+ {
+ "type": "mention",
+ "data": {
+ "mention_type": "all",
+ "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f",
+ "mention_data": {},
+ },
+ },
+ ],
+ "source_sync_id": None,
+ "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51",
+ "from": {
+ "ad_domain": None,
+ "ad_login": None,
+ "app_version": None,
+ "chat_type": "chat",
+ "device": None,
+ "device_meta": {
+ "permissions": None,
+ "pushes": False,
+ "timezone": "Europe/Moscow",
+ },
+ "device_software": None,
+ "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935",
+ "host": "cts.example.com",
+ "is_admin": True,
+ "is_creator": True,
+ "locale": "en",
+ "manufacturer": None,
+ "platform": None,
+ "platform_package_id": None,
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ "username": None,
+ },
+ "proto_version": 4,
+ }
+
+ collector = HandlerCollector()
+ incoming_message: Optional[IncomingMessage] = None
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ nonlocal incoming_message
+ incoming_message = message
+ # Drop `raw_command` from asserting
+ incoming_message.raw_command = None
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert incoming_message
+ assert incoming_message.mentions == MentionList(
+ [
+ Mention(
+ type=MentionTypes.CONTACT,
+ entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"),
+ name="Вася Иванов",
+ ),
+ Mention(
+ type=MentionTypes.USER,
+ entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"),
+ name="Вася Иванов",
+ ),
+ Mention(
+ type=MentionTypes.CHANNEL,
+ entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"),
+ name="Вася Иванов",
+ ),
+ Mention(
+ type=MentionTypes.CHAT,
+ entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"),
+ name="Вася Иванов",
+ ),
+ Mention(
+ type=MentionTypes.ALL,
+ entity_id=None,
+ name=None,
+ ),
+ ],
+ )
diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py
new file mode 100644
index 00000000..13197747
--- /dev/null
+++ b/tests/test_lifespan.py
@@ -0,0 +1,76 @@
+from http import HTTPStatus
+from typing import Callable
+from unittest.mock import Mock
+from uuid import UUID
+
+import httpx
+import pytest
+from respx.router import MockRouter
+
+from botx import Bot, BotAccountWithSecret, HandlerCollector, IncomingMessage
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__shutdown__wait_for_active_handlers(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ bot.async_execute_bot_command(user_command)
+ await bot.shutdown()
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+
+async def test__startup__authorize_cant_get_token(
+ respx_mock: MockRouter,
+ loguru_caplog: pytest.LogCaptureFixture,
+ bot_account: BotAccountWithSecret,
+ host: str,
+ bot_id: UUID,
+ bot_signature: str,
+) -> None:
+ # - Arrange -
+ token_endpoint = respx_mock.get(
+ f"https://{host}/api/v2/botx/bots/{bot_id}/token",
+ params={"signature": bot_signature},
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.UNAUTHORIZED,
+ json={
+ "status": "error",
+ },
+ ),
+ )
+
+ collector = HandlerCollector()
+
+ bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ await bot.startup()
+
+ # - Assert -
+ assert token_endpoint.called
+
+ assert "Can't get token for bot account: " in loguru_caplog.text
+ assert f"host - {host}, bot_id - {bot_id}" in loguru_caplog.text
+
+ await bot.shutdown()
diff --git a/tests/test_logs.py b/tests/test_logs.py
new file mode 100644
index 00000000..56093595
--- /dev/null
+++ b/tests/test_logs.py
@@ -0,0 +1,128 @@
+from http import HTTPStatus
+from typing import Any, Callable, Dict, Optional, cast
+from uuid import UUID
+
+import httpx
+import pytest
+from aiofiles.tempfile import NamedTemporaryFile
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ IncomingMessage,
+ lifespan_wrapper,
+)
+from botx.models.attachments import AttachmentDocument, OutgoingAttachment
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__attachment__trimmed_in_incoming_message(
+ bot_account: BotAccountWithSecret,
+ api_incoming_message_factory: Callable[..., Dict[str, Any]],
+ loguru_caplog: pytest.LogCaptureFixture,
+) -> None:
+ payload = api_incoming_message_factory(
+ attachment={
+ "data": {
+ "content": (
+ "data:text/plain;base64,"
+ "SGVsbG8sIGFtYXppbmcgd29ybGQhIFZlcnkgdmVyeSB2ZXJ5IHZlcnkgdm"
+ "VyeSB2ZXJ5IGxvbmcgdGV4dCB0byB0ZXN0IHRoYXQgdHJpbW1pbmcgY29u"
+ "dGVudCBkb2Vzbid0IGFmZmVjdCBmaWxlIGluIGluY29taW5nIG1lc3NhZ2U="
+ ),
+ "file_name": "test_file.jpg",
+ },
+ "type": "image",
+ },
+ )
+ collector = HandlerCollector()
+ file_data: Optional[bytes] = None
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ nonlocal file_data
+ file = cast(AttachmentDocument, message.file)
+ file_data = file.content
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert "..." in loguru_caplog.text
+ assert file_data == (
+ b"Hello, amazing world! Very very very very very very long text to"
+ b" test that trimming content doesn't affect file in incoming message"
+ )
+
+
+async def test__attachment__trimmed_in_outgoing_message(
+ respx_mock: MockRouter,
+ host: str,
+ bot_id: UUID,
+ bot_account: BotAccountWithSecret,
+ loguru_caplog: pytest.LogCaptureFixture,
+) -> None:
+ # - Arrange -
+ endpoint = respx_mock.post(
+ f"https://{host}/api/v4/botx/notifications/direct",
+ headers={"Authorization": "Bearer token", "Content-Type": "application/json"},
+ json={
+ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa",
+ "notification": {"status": "ok", "body": "Hi!"},
+ "file": {
+ "file_name": "test.txt",
+ "data": (
+ "data:text/plain;base64,"
+ "SGVsbG8sIGFtYXppbmcgd29ybGQhIFZlcnkgdmVyeSB2ZXJ5IHZlcnkgdm"
+ "VyeSB2ZXJ5IGxvbmcgdGV4dCB0byB0ZXN0IHRoYXQgdHJpbW1pbmcgY29u"
+ "dGVudCBkb2Vzbid0IGFmZmVjdCBmaWxlIGluIGluY29taW5nIG1lc3NhZ2U="
+ ),
+ },
+ },
+ ).mock(
+ return_value=httpx.Response(
+ HTTPStatus.ACCEPTED,
+ json={
+ "status": "ok",
+ "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
+ },
+ ),
+ )
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ async with NamedTemporaryFile("wb+") as async_buffer:
+ await async_buffer.write(
+ b"Hello, amazing world! Very very very very very very long text to"
+ b" test that trimming content doesn't affect file in incoming message",
+ )
+ await async_buffer.seek(0)
+
+ file = await OutgoingAttachment.from_async_buffer(
+ async_buffer,
+ "test.txt",
+ )
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.send_message(
+ body="Hi!",
+ bot_id=bot_id,
+ chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"),
+ file=file,
+ wait_callback=False,
+ )
+
+ # - Assert -
+ assert "..." in loguru_caplog.text
+ assert endpoint.called
diff --git a/tests/test_middlewares.py b/tests/test_middlewares.py
new file mode 100644
index 00000000..a7d29143
--- /dev/null
+++ b/tests/test_middlewares.py
@@ -0,0 +1,211 @@
+from typing import Callable
+from unittest.mock import Mock
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ IncomingMessage,
+ IncomingMessageHandlerFunc,
+ Middleware,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__middlewares__correct_order(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ middlewares_called_order = []
+ user_command = incoming_message_factory(body="/command")
+
+ def middleware_factory(number: int) -> Middleware:
+ async def middleware(
+ message: IncomingMessage,
+ bot: Bot,
+ call_next: IncomingMessageHandlerFunc,
+ ) -> None:
+ nonlocal middlewares_called_order
+ middlewares_called_order.append(number)
+
+ await call_next(message, bot)
+
+ return middleware
+
+ collector = HandlerCollector(
+ middlewares=[middleware_factory(3), middleware_factory(4)],
+ )
+
+ @collector.command(
+ "/command",
+ description="My command",
+ middlewares=[middleware_factory(5), middleware_factory(6)],
+ )
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(
+ collectors=[collector],
+ bot_accounts=[bot_account],
+ middlewares=[middleware_factory(1), middleware_factory(2)],
+ )
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+ assert middlewares_called_order == [1, 2, 3, 4, 5, 6]
+
+
+async def test__middlewares__called_in_default_handler(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ middlewares_called_order = []
+ user_command = incoming_message_factory(body="/command")
+
+ def middleware_factory(number: int) -> Middleware:
+ async def middleware(
+ message: IncomingMessage,
+ bot: Bot,
+ call_next: IncomingMessageHandlerFunc,
+ ) -> None:
+ nonlocal middlewares_called_order
+ middlewares_called_order.append(number)
+
+ await call_next(message, bot)
+
+ return middleware
+
+ collector = HandlerCollector(
+ middlewares=[middleware_factory(3), middleware_factory(4)],
+ )
+
+ @collector.default_message_handler(
+ middlewares=[middleware_factory(5), middleware_factory(6)],
+ )
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ built_bot = Bot(
+ collectors=[collector],
+ bot_accounts=[bot_account],
+ middlewares=[middleware_factory(1), middleware_factory(2)],
+ )
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ assert middlewares_called_order == [1, 2, 3, 4, 5, 6]
+
+
+async def test__middlewares__correct_child_collector_middlewares(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ middlewares_called_order = []
+ user_command = incoming_message_factory(body="/command")
+
+ def middleware_factory(number: int) -> Middleware:
+ async def middleware(
+ message: IncomingMessage,
+ bot: Bot,
+ call_next: IncomingMessageHandlerFunc,
+ ) -> None:
+ nonlocal middlewares_called_order
+ middlewares_called_order.append(number)
+
+ await call_next(message, bot)
+
+ return middleware
+
+ collector_1 = HandlerCollector(
+ middlewares=[middleware_factory(1), middleware_factory(2)],
+ )
+
+ @collector_1.command("/other-command", description="My command")
+ async def handler_1(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ collector_2 = HandlerCollector(
+ middlewares=[middleware_factory(3), middleware_factory(4)],
+ )
+
+ @collector_2.command("/command", description="My command")
+ async def handler_2(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ collector_1.include(collector_2)
+ built_bot = Bot(collectors=[collector_1], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ assert middlewares_called_order == [1, 2, 3, 4]
+
+
+async def test__middlewares__correct_parent_collector_middlewares(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ middlewares_called_order = []
+ user_command = incoming_message_factory(body="/command")
+
+ def middleware_factory(number: int) -> Middleware:
+ async def middleware(
+ message: IncomingMessage,
+ bot: Bot,
+ call_next: IncomingMessageHandlerFunc,
+ ) -> None:
+ nonlocal middlewares_called_order
+ middlewares_called_order.append(number)
+
+ await call_next(message, bot)
+
+ return middleware
+
+ collector_1 = HandlerCollector(
+ middlewares=[middleware_factory(1), middleware_factory(2)],
+ )
+
+ @collector_1.command("/command", description="My command")
+ async def handler_1(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ collector_2 = HandlerCollector(
+ middlewares=[middleware_factory(3), middleware_factory(4)],
+ )
+
+ @collector_2.command("/other-command", description="My command")
+ async def handler_2(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ collector_1.include(collector_2)
+ built_bot = Bot(collectors=[collector_1], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ assert middlewares_called_order == [1, 2]
diff --git a/tests/test_middlewares/__init__.py b/tests/test_middlewares/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_middlewares/test_authorization_middleware.py b/tests/test_middlewares/test_authorization_middleware.py
deleted file mode 100644
index 32abdb96..00000000
--- a/tests/test_middlewares/test_authorization_middleware.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import pytest
-
-from botx.middlewares.authorization import AuthorizationMiddleware
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_obtaining_token_if_not_set(client, incoming_message):
- bot = client.bot
- bot.add_middleware(AuthorizationMiddleware)
- bot_account = bot.get_account_by_bot_id(incoming_message.bot_id)
- bot_account.token = None
-
- await client.send_command(incoming_message)
-
- assert bot.get_token_for_bot(incoming_message.bot_id)
-
-
-async def test_doing_nothing_if_token_present(client, incoming_message):
- bot = client.bot
- bot.add_middleware(AuthorizationMiddleware)
- token = bot.get_token_for_bot(incoming_message.bot_id)
-
- await client.send_command(incoming_message)
-
- assert bot.get_token_for_bot(incoming_message.bot_id) == token
diff --git a/tests/test_middlewares/test_concurrency/__init__.py b/tests/test_middlewares/test_concurrency/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_middlewares/test_concurrency/fixtures.py b/tests/test_middlewares/test_concurrency/fixtures.py
deleted file mode 100644
index 187d1cb1..00000000
--- a/tests/test_middlewares/test_concurrency/fixtures.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import threading
-
-import pytest
-
-from botx import Message
-from botx.middlewares.base import BaseMiddleware
-from botx.typing import AsyncExecutor, Executor, SyncExecutor
-
-
-class SyncMiddleware(BaseMiddleware):
- def __init__(self, executor: Executor) -> None:
- super().__init__(executor)
- self.event = threading.Event()
-
- def dispatch(self, message: Message, call_next: SyncExecutor) -> None:
- self.event.set()
- call_next(message)
-
-
-class AsyncMiddleware(BaseMiddleware):
- def __init__(self, executor: Executor) -> None:
- super().__init__(executor)
- self.event = threading.Event()
-
- async def dispatch(self, message: Message, call_next: AsyncExecutor) -> None:
- self.event.set()
- await call_next(message)
-
-
-@pytest.fixture()
-def sync_middleware_class():
- return SyncMiddleware
-
-
-@pytest.fixture()
-def async_middleware_class():
- return AsyncMiddleware
diff --git a/tests/test_middlewares/test_concurrency/test_async_processing.py b/tests/test_middlewares/test_concurrency/test_async_processing.py
deleted file mode 100644
index 760e70a0..00000000
--- a/tests/test_middlewares/test_concurrency/test_async_processing.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import threading
-
-import pytest
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_middlewares.test_concurrency.fixtures",)
-
-
-async def test_async_middleware_receives_async_executor(
- bot,
- client,
- incoming_message,
- build_handler,
- async_middleware_class,
-):
- bot.add_middleware(async_middleware_class)
-
- event = threading.Event()
- bot.default(build_handler(event))
-
- await client.send_command(incoming_message)
-
- assert event.is_set()
-
- executor = bot.exception_middleware.executor
-
- assert isinstance(executor, async_middleware_class)
- assert executor.event.is_set()
diff --git a/tests/test_middlewares/test_concurrency/test_sync_async_combinaton.py b/tests/test_middlewares/test_concurrency/test_sync_async_combinaton.py
deleted file mode 100644
index 254b871d..00000000
--- a/tests/test_middlewares/test_concurrency/test_sync_async_combinaton.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import threading
-
-import pytest
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_middlewares.test_concurrency.fixtures",)
-
-
-@pytest.mark.parametrize(
- "order",
- [("sync", "sync"), ("sync", "async"), ("async", "sync"), ("async", "async")],
-)
-async def test_that_both_async_and_sync_middlewares_will_work(
- bot,
- client,
- incoming_message,
- async_middleware_class,
- sync_middleware_class,
- build_handler,
- order,
-):
- event = threading.Event()
-
- if order[0] == "sync":
- bot.add_middleware(sync_middleware_class)
- else:
- bot.add_middleware(async_middleware_class)
-
- if order[1] == "sync":
- bot.add_middleware(sync_middleware_class)
- else:
- bot.add_middleware(async_middleware_class)
-
- bot.default(build_handler(event))
-
- await client.send_command(incoming_message)
-
- assert event.is_set()
-
- middleware1 = bot.exception_middleware.executor
- assert middleware1.event.is_set()
-
- middleware2 = middleware1.executor
- assert middleware2.event.is_set()
diff --git a/tests/test_middlewares/test_concurrency/test_sync_processing.py b/tests/test_middlewares/test_concurrency/test_sync_processing.py
deleted file mode 100644
index f2bd7fc2..00000000
--- a/tests/test_middlewares/test_concurrency/test_sync_processing.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import threading
-
-import pytest
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_middlewares.test_concurrency.fixtures",)
-
-
-async def test_sync_middleware_receives_sync_executor(
- bot,
- client,
- incoming_message,
- build_handler,
- sync_middleware_class,
-):
- bot.add_middleware(sync_middleware_class)
-
- event = threading.Event()
- bot.default(build_handler(event))
-
- await client.send_command(incoming_message)
-
- assert event.is_set()
-
- executor = bot.exception_middleware.executor
-
- assert isinstance(executor, sync_middleware_class)
- assert executor.event.is_set()
diff --git a/tests/test_middlewares/test_exception_middleware.py b/tests/test_middlewares/test_exception_middleware.py
deleted file mode 100644
index eeb92185..00000000
--- a/tests/test_middlewares/test_exception_middleware.py
+++ /dev/null
@@ -1,101 +0,0 @@
-import threading
-
-import pytest
-
-from botx import TestClient
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_handling_exception_with_custom_catcher(
- bot,
- incoming_message,
- client,
- build_exception_catcher,
- build_failed_handler,
- storage,
-):
- exc_for_raising = Exception("exception from handler")
-
- cather_event = threading.Event()
- handler_event = threading.Event()
-
- bot.add_exception_handler(Exception, build_exception_catcher(cather_event))
- bot.default(build_failed_handler(exc_for_raising, handler_event))
-
- await client.send_command(incoming_message)
-
- assert cather_event.is_set()
- assert handler_event.is_set()
- assert storage.exception == exc_for_raising
- assert storage.message.incoming_message == incoming_message
-
-
-async def test_handling_from_nearest_mro_handler(
- bot,
- incoming_message,
- client,
- build_exception_catcher,
- build_failed_handler,
- storage,
-):
- exc_for_raising = UnicodeError("exception from handler")
-
- exception_catcher_event = threading.Event()
- value_error_catcher_event = threading.Event()
- handler_event = threading.Event()
-
- bot.add_exception_handler(
- Exception,
- build_exception_catcher(exception_catcher_event),
- )
- bot.add_exception_handler(
- Exception,
- build_exception_catcher(value_error_catcher_event),
- )
- bot.default(handler=build_failed_handler(exc_for_raising, handler_event))
-
- await client.send_command(incoming_message)
-
- assert not exception_catcher_event.is_set()
- assert value_error_catcher_event.is_set()
- assert handler_event.is_set()
- assert storage.exception == exc_for_raising
- assert storage.message.incoming_message == incoming_message
-
-
-async def test_logging_exception_if_was_not_found(
- bot,
- incoming_message,
- loguru_caplog,
- build_failed_handler,
-) -> None:
- event = threading.Event()
- bot.default(build_failed_handler(ValueError, event))
-
- with TestClient(bot, suppress_errors=True) as client:
- await client.send_command(incoming_message)
-
- assert event.is_set()
- assert "uncaught ValueError exception" in loguru_caplog.text
-
-
-async def test_logging_from_failed_exception_handler(
- bot,
- incoming_message,
- client,
- loguru_caplog,
- build_exception_catcher,
- build_failed_handler,
-) -> None:
- exc_for_raising = ValueError("exception from handler")
-
- handler_event = threading.Event()
-
- bot.add_exception_handler(Exception, build_exception_catcher(None))
- bot.default(build_failed_handler(exc_for_raising, handler_event))
-
- await client.send_command(incoming_message)
-
- assert "ValueError('exception from handler')" in loguru_caplog.text
- assert "uncaught AttributeError exception in error handler:" in loguru_caplog.text
diff --git a/tests/test_middlewares/test_ns_middleware/__init__.py b/tests/test_middlewares/test_ns_middleware/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_middlewares/test_ns_middleware/fixtures.py b/tests/test_middlewares/test_ns_middleware/fixtures.py
deleted file mode 100644
index 796cf204..00000000
--- a/tests/test_middlewares/test_ns_middleware/fixtures.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import threading
-from typing import Optional
-
-import pytest
-
-from botx import Message
-from botx.middlewares.ns import register_next_step_handler
-
-
-@pytest.fixture()
-def build_handler_to_start_chain():
- def factory(next_handler_name: Optional[str], event: threading.Event):
- def decorator(message: Message):
- event.set()
- if next_handler_name is not None:
- register_next_step_handler(message, next_handler_name)
-
- return decorator
-
- return factory
diff --git a/tests/test_middlewares/test_ns_middleware/test_arguments.py b/tests/test_middlewares/test_ns_middleware/test_arguments.py
deleted file mode 100644
index 12a36b73..00000000
--- a/tests/test_middlewares/test_ns_middleware/test_arguments.py
+++ /dev/null
@@ -1,73 +0,0 @@
-import threading
-from typing import Any
-
-import pytest
-
-from botx import Message
-from botx.middlewares.ns import NextStepMiddleware, register_next_step_handler
-
-pytestmark = pytest.mark.asyncio
-
-
-@pytest.fixture()
-def build_handler_to_store_arguments():
- def factory(next_handler_name: str, event: threading.Event, **ns_args: Any):
- def decorator(message: Message):
- event.set()
- register_next_step_handler(message, next_handler_name, **ns_args)
-
- return decorator
-
- return factory
-
-
-@pytest.fixture()
-def build_handler_to_save_message_in_storage(storage):
- def factory(event: threading.Event):
- def decorator(message: Message):
- event.set()
- storage.state = message.state
-
- return decorator
-
- return factory
-
-
-async def test_setting_args_into_message_state(
- bot,
- incoming_message,
- client,
- build_handler_to_store_arguments,
- build_handler_to_save_message_in_storage,
- storage,
-):
- event1 = threading.Event()
- event2 = threading.Event()
-
- bot.default(
- handler=build_handler_to_store_arguments(
- "ns_handler",
- event1,
- arg1=1,
- arg2="2",
- arg3=True,
- ),
- )
-
- ns_handler = build_handler_to_save_message_in_storage(event2)
-
- bot.add_middleware(
- NextStepMiddleware,
- bot=bot,
- functions={"ns_handler": ns_handler},
- )
-
- await client.send_command(incoming_message)
- assert event1.is_set()
-
- await client.send_command(incoming_message)
- assert event2.is_set()
-
- assert storage.state.arg1 == 1
- assert storage.state.arg2 == "2"
- assert storage.state.arg3
diff --git a/tests/test_middlewares/test_ns_middleware/test_errors.py b/tests/test_middlewares/test_ns_middleware/test_errors.py
deleted file mode 100644
index f876ebfb..00000000
--- a/tests/test_middlewares/test_ns_middleware/test_errors.py
+++ /dev/null
@@ -1,49 +0,0 @@
-import threading
-
-import pytest
-
-from botx.middlewares.ns import NextStepMiddleware
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_middlewares.test_ns_middleware.fixtures",)
-
-
-async def test_error_for_ns_for_unregistered_handler(
- bot,
- client,
- incoming_message,
- build_handler_to_start_chain,
-):
- event = threading.Event()
-
- bot.default(handler=build_handler_to_start_chain("unknown_handler_name", event))
-
- bot.add_middleware(NextStepMiddleware, bot=bot, functions={})
-
- with pytest.raises(ValueError):
- await client.send_command(incoming_message)
-
- assert event.is_set()
-
-
-async def test_error_for_message_without_huid(
- bot,
- incoming_message,
- client,
- chat_created_message,
- build_handler_to_start_chain,
- build_handler_for_collector,
-):
- event = threading.Event()
- bot.chat_created(build_handler_to_start_chain("ns_handler", event))
-
- bot.add_middleware(
- NextStepMiddleware,
- bot=bot,
- functions={build_handler_for_collector("ns_handler")},
- )
-
- with pytest.raises(ValueError):
- await client.send_command(chat_created_message)
-
- assert event.is_set()
diff --git a/tests/test_middlewares/test_ns_middleware/test_execution.py b/tests/test_middlewares/test_ns_middleware/test_execution.py
deleted file mode 100644
index 2ef5193c..00000000
--- a/tests/test_middlewares/test_ns_middleware/test_execution.py
+++ /dev/null
@@ -1,80 +0,0 @@
-import threading
-
-import pytest
-
-from botx.middlewares.ns import NextStepMiddleware
-
-pytestmark = pytest.mark.asyncio
-pytest_plugins = ("tests.test_middlewares.test_ns_middleware.fixtures",)
-
-
-async def test_executing_ns_handlers(
- bot,
- incoming_message,
- client,
- build_handler_to_start_chain,
-) -> None:
- chain_start_event = threading.Event()
- ns_handler_event = threading.Event()
-
- bot.default(handler=build_handler_to_start_chain("ns_handler", chain_start_event))
- bot.add_middleware(
- NextStepMiddleware,
- bot=bot,
- functions={"ns_handler": build_handler_to_start_chain(None, ns_handler_event)},
- )
-
- incoming_message.command.body = "/start-ns"
-
- await client.send_command(incoming_message)
- await client.send_command(incoming_message)
-
- assert chain_start_event.is_set()
- assert ns_handler_event.is_set()
-
-
-async def test_breaking_chain(
- bot,
- incoming_message,
- client,
- build_handler_to_start_chain,
-):
- break_handler_event = threading.Event()
- chain_start_event = threading.Event()
- ns_handler_event = threading.Event()
-
- chain_start_handler = build_handler_to_start_chain("ns_handler", chain_start_event)
-
- bot.handler(
- handler=build_handler_to_start_chain(None, break_handler_event),
- command="/break",
- name="break_handler",
- )
- bot.handler(handler=chain_start_handler, command="/ns-start", name="chain_start")
-
- bot.add_middleware(
- NextStepMiddleware,
- bot=bot,
- functions={
- "ns_handler": build_handler_to_start_chain("chain_start", ns_handler_event),
- "chain_start": chain_start_handler,
- },
- break_handler="break_handler",
- )
-
- incoming_message.command.body = "/ns-start"
- await client.send_command(incoming_message)
- assert chain_start_event.is_set()
- chain_start_event.clear()
-
- await client.send_command(incoming_message)
- assert ns_handler_event.is_set()
- ns_handler_event.clear()
-
- await client.send_command(incoming_message)
- assert chain_start_event.is_set()
- chain_start_event.clear()
-
- incoming_message.command.body = "/break-handler"
- await client.send_command(incoming_message)
- assert break_handler_event.is_set()
diff --git a/tests/test_middlewares/test_ns_middleware/test_registration.py b/tests/test_middlewares/test_ns_middleware/test_registration.py
deleted file mode 100644
index 54c4dc5e..00000000
--- a/tests/test_middlewares/test_ns_middleware/test_registration.py
+++ /dev/null
@@ -1,72 +0,0 @@
-import pytest
-
-from botx.middlewares.ns import NextStepMiddleware, register_function_as_ns_handler
-
-
-def test_register_middleware_with_functions_dict(bot, build_handler_for_collector):
- functions = {"ns_handler": build_handler_for_collector("ns_handler")}
-
- bot.add_middleware(NextStepMiddleware, bot=bot, functions=functions)
-
- assert [bot.state.ns_collector.handler_for(name) for name in functions]
-
-
-def test_register_ns_middleware_using_functions_set(bot, build_handler_for_collector):
- functions = {build_handler_for_collector("ns_handler")}
-
- bot.add_middleware(NextStepMiddleware, bot=bot, functions=functions)
-
- assert [bot.state.ns_collector.handler_for(name) for name in ["ns_handler"]]
-
-
-def test_no_duplicate_handlers_registration(bot, build_handler_for_collector):
- bot.add_middleware(NextStepMiddleware, bot=bot, functions={})
-
- handler = build_handler_for_collector("ns_handler")
-
- register_function_as_ns_handler(bot, handler)
-
- with pytest.raises(ValueError):
- register_function_as_ns_handler(bot, handler)
-
-
-def test_register_break_handler_as_string(bot, build_handler_for_collector):
- bot.handler(handler=build_handler_for_collector("break_handler"), command="/break")
-
- bot.add_middleware(
- NextStepMiddleware,
- bot=bot,
- functions={},
- break_handler="break_handler",
- )
-
- assert bot.state.ns_collector.handler_for("break_handler") == bot.handler_for(
- "break_handler",
- )
-
-
-def test_register_break_handler_as_handler(bot, build_handler_for_collector):
- bot.handler(build_handler_for_collector("break_handler"), command="/break")
-
- bot.add_middleware(
- NextStepMiddleware,
- bot=bot,
- functions={},
- break_handler=bot.handler_for("break_handler"),
- )
-
- assert bot.state.ns_collector.handler_for("break_handler") == bot.handler_for(
- "break_handler",
- )
-
-
-def test_register_break_handler_as_function(bot, build_handler_for_collector):
- handler = build_handler_for_collector("break_handler")
- bot.add_middleware(
- NextStepMiddleware,
- bot=bot,
- functions={},
- break_handler=handler,
- )
-
- assert bot.state.ns_collector.handler_for("break_handler").handler == handler
diff --git a/tests/test_missing.py b/tests/test_missing.py
new file mode 100644
index 00000000..25ed6b4b
--- /dev/null
+++ b/tests/test_missing.py
@@ -0,0 +1,10 @@
+import pytest
+
+from botx.missing import Undefined, not_undefined
+
+
+def test__not_undefined__all_args_undefined() -> None:
+ with pytest.raises(ValueError) as exc:
+ not_undefined(Undefined, Undefined)
+
+ assert "All arguments" in str(exc.value)
diff --git a/tests/test_models/__init__.py b/tests/test_models/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_models/test_attachments.py b/tests/test_models/test_attachments.py
deleted file mode 100644
index 051d09a7..00000000
--- a/tests/test_models/test_attachments.py
+++ /dev/null
@@ -1,229 +0,0 @@
-import pytest
-
-from botx import MessageBuilder
-from botx.models.attachments import Video, Voice
-
-
-def test_is_link_in_attachment():
- builder = MessageBuilder()
- builder.link()
- assert builder.message.attachments.__root__[0].data.is_link()
-
-
-def test_is_mail_in_attachment():
- builder = MessageBuilder()
- mailto_url = "mailto:mail@mail.com"
- builder.link(url=mailto_url)
- assert builder.message.attachments.__root__[0].data.is_mail()
-
-
-def test_is_telephone_number_in_attachment():
- builder = MessageBuilder()
- tel_url = "tel://+77777777777"
- builder.link(url=tel_url)
- assert builder.message.attachments.__root__[0].data.is_telephone()
-
-
-def test_mailto_property_in_attachment():
- builder = MessageBuilder()
- mailto_url = "mailto:mail@mail.com"
- builder.link(mailto_url)
- assert builder.message.attachments.__root__[0].data.mailto == "mail@mail.com"
-
-
-def test_raising_missing_mailto():
- builder = MessageBuilder()
- builder.link()
- with pytest.raises(AttributeError):
- builder.message.attachments.__root__[0].data.mailto
-
-
-def test_tel_property_in_attachment():
- builder = MessageBuilder()
- tel_url = "tel://+77777777777"
- builder.link(url=tel_url)
- assert builder.message.attachments.__root__[0].data.tel == "+77777777777"
-
-
-def test_raising_missing_tel():
- builder = MessageBuilder()
- builder.link()
- with pytest.raises(AttributeError):
- builder.message.attachments.__root__[0].data.tel
-
-
-def test_image_in_attachments():
- builder = MessageBuilder()
- builder.document()
- builder.image()
- assert builder.message.attachments.image
-
-
-def test_missing_image_in_attachments():
- builder = MessageBuilder()
- with pytest.raises(AttributeError):
- builder.message.attachments.image
-
-
-def test_document_in_attachments():
- builder = MessageBuilder()
- builder.image()
- builder.document()
- assert builder.message.attachments.document
-
-
-def test_missing_document_in_attachments():
- builder = MessageBuilder()
- with pytest.raises(AttributeError):
- builder.message.attachments.document
-
-
-def test_location_in_attachments():
- builder = MessageBuilder()
- builder.image()
- builder.location()
- assert builder.message.attachments.location
-
-
-def test_missing_location_in_attachments():
- builder = MessageBuilder()
- with pytest.raises(AttributeError):
- builder.message.attachments.location
-
-
-def test_contact_in_attachments():
- builder = MessageBuilder()
- builder.image()
- builder.contact()
- assert builder.message.attachments.contact
-
-
-def test_missing_contact_in_attachments():
- builder = MessageBuilder()
- with pytest.raises(AttributeError):
- builder.message.attachments.contact
-
-
-def test_voice_in_attachments():
- builder = MessageBuilder()
- builder.image()
- builder.voice()
- assert builder.message.attachments.voice
-
-
-def test_missing_voice_in_attachments():
- builder = MessageBuilder()
- with pytest.raises(AttributeError):
- builder.message.attachments.voice
-
-
-def test_video_in_attachments():
- builder = MessageBuilder()
- builder.image()
- builder.video()
- assert builder.message.attachments.video
-
-
-def test_missing_video_in_attachments():
- builder = MessageBuilder()
- with pytest.raises(AttributeError):
- builder.message.attachments.video
-
-
-def test_link_in_attachments():
- builder = MessageBuilder()
- builder.image()
- builder.link()
- assert builder.message.attachments.link
-
-
-def test_missing_link_in_attachments():
- builder = MessageBuilder()
- builder.link(url="mailto:mail@mail.com")
- with pytest.raises(AttributeError):
- builder.message.attachments.link
-
-
-def test_email_in_attachments():
- builder = MessageBuilder()
- mailto_url = "mailto:mail@mail.com"
- builder.image()
- builder.link(url=mailto_url)
- assert builder.message.attachments.email == "mail@mail.com"
-
-
-def test_missing_email_in_attachments():
- builder = MessageBuilder()
- builder.link(url="https://any.com")
- with pytest.raises(AttributeError):
- builder.message.attachments.email
-
-
-def test_telephone_in_attachments():
- builder = MessageBuilder()
- tel_url = "tel://+77777777777"
- builder.image()
- builder.link(url=tel_url)
- assert builder.message.attachments.telephone == "+77777777777"
-
-
-def test_missing_telephone_in_attachments():
- builder = MessageBuilder()
- builder.link(url="mailto:mail@mail.com")
- with pytest.raises(AttributeError):
- builder.message.attachments.telephone
-
-
-@pytest.mark.parametrize(
- "attach",
- [lambda x: x.document, lambda x: x.image, lambda x: x.video],
-)
-def test_file_in_attachments(attach):
- builder = MessageBuilder()
- attach(builder)()
- assert builder.message.attachments.file
-
-
-def test_no_file_in_message():
- builder = MessageBuilder()
- builder.link()
- with pytest.raises(AttributeError):
- builder.message.attachments.file
-
-
-def test_file_with_unsupported_extension():
- builder = MessageBuilder()
- builder.document(file_name="test.py")
- assert builder.message.attachments.file
-
-
-@pytest.mark.parametrize("len_of_attachments", [1, 2, 3])
-def test_get_all_attachments(len_of_attachments):
- builder = MessageBuilder()
- for _ in range(len_of_attachments):
- builder.document()
- assert len(builder.message.attachments.all_attachments) == len_of_attachments
-
-
-def test_video_attach_has_video_type():
- builder = MessageBuilder()
- builder.video()
- assert isinstance(builder.message.attachments.video, Video)
-
-
-def test_voice_attach_has_voice_type():
- builder = MessageBuilder()
- builder.voice()
- assert isinstance(builder.message.attachments.voice, Voice)
-
-
-def test_no_attach_type():
- builder = MessageBuilder()
- with pytest.raises(AttributeError):
- builder.message.attachments.attach_type
-
-
-def test_attach_type():
- builder = MessageBuilder()
- builder.link()
- builder.message.attachments.attach_type == "link"
diff --git a/tests/test_models/test_buttons.py b/tests/test_models/test_buttons.py
deleted file mode 100644
index 0e8dc644..00000000
--- a/tests/test_models/test_buttons.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import pytest
-from pydantic import ValidationError
-
-from botx.models.buttons import Button, ButtonOptions
-
-
-class CustomButton(Button):
- """Button without custom behaviour."""
-
-
-def test_label_will_be_set_to_command_if_none():
- assert CustomButton(command="/cmd").label == "/cmd"
-
-
-def test_label_can_be_set_if_passed_explicitly():
- assert CustomButton(command="/cmd", label="temp").label == "temp"
-
-
-def test_empty_label():
- assert CustomButton(command="/cmd", label="").label == ""
-
-
-def test_create_button_options_with_invalid_hsize():
- with pytest.raises(ValidationError) as exc_info:
- ButtonOptions(h_size=0)
-
- assert "should be positive integer" in str(exc_info)
diff --git a/tests/test_models/test_credentials.py b/tests/test_models/test_credentials.py
deleted file mode 100644
index f9b02c41..00000000
--- a/tests/test_models/test_credentials.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from uuid import UUID
-
-import pytest
-
-from botx import BotXCredentials
-from botx.clients.methods.v2.bots.token import Token
-
-
-def test_calculating_signature_for_token(host) -> None:
- bot_id = UUID("8dada2c8-67a6-4434-9dec-570d244e78ee")
- account = BotXCredentials(
- host=host,
- secret_key="secret",
- bot_id=bot_id,
- )
- signature = "904E39D3BC549C71F4A4BDA66AFCDA6FC90D471A64889B45CC8D2288E56526AD"
- assert account.signature == signature
-
-
-@pytest.mark.asyncio()
-async def test_auth_to_each_known_account(bot, client) -> None:
- accounts_len = len(bot.bot_accounts)
- for account in bot.bot_accounts:
- account.token = None
-
- await bot.authorize()
- assert len(client.requests) == accounts_len
-
- for request in client.requests:
- assert isinstance(request, Token)
-
- for account in bot.bot_accounts:
- assert account.token is not None
-
-
-@pytest.mark.asyncio()
-async def test_auth_with_wrong_credentials(bot, host) -> None:
- bot_id = UUID("8dada2c8-67a6-4434-9dec-570d244e78ee")
- bot.bot_accounts = [
- BotXCredentials(
- host=host,
- secret_key="wrong_secret",
- bot_id=bot_id,
- ),
- ]
- await bot.authorize()
-
- assert bot.bot_accounts[0].token is None
diff --git a/tests/test_models/test_datastructures.py b/tests/test_models/test_datastructures.py
deleted file mode 100644
index aaa3807c..00000000
--- a/tests/test_models/test_datastructures.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import pytest
-
-from botx.models.datastructures import State
-
-
-def test_passed_state_applied():
- state = State({"arg": 1})
- assert state.arg == 1
-
-
-def test_state_can_be_set():
- state = State()
- state.arg = 1
- assert state.arg == 1
-
-
-def test_state_will_raise_error_on_empty_attribute():
- state = State()
- with pytest.raises(AttributeError):
- _ = state.arg # noqa: WPS122
diff --git a/tests/test_models/test_entities.py b/tests/test_models/test_entities.py
deleted file mode 100644
index 2867dd37..00000000
--- a/tests/test_models/test_entities.py
+++ /dev/null
@@ -1,150 +0,0 @@
-import uuid
-
-import pytest
-from pydantic import ValidationError
-
-from botx import (
- ChatMention,
- Mention,
- MentionTypes,
- Message,
- MessageBuilder,
- UserMention,
-)
-
-
-class TestMentions:
- @pytest.mark.parametrize("mention_id", [None, uuid.uuid4()])
- def test_mention_id_will_be_generated_if_missed(self, mention_id):
- mention = Mention(
- mention_id=mention_id,
- mention_data=UserMention(user_huid=uuid.uuid4()),
- )
- assert mention.mention_id is not None
-
- def test_error_when_no_mention_data(self):
- with pytest.raises(ValidationError):
- Mention(mention_type=MentionTypes.user)
-
- @pytest.mark.parametrize(
- ("mention_data", "mention_type"),
- [
- (UserMention(user_huid=uuid.uuid4()), MentionTypes.user),
- (UserMention(user_huid=uuid.uuid4()), MentionTypes.contact),
- (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.chat),
- (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.channel),
- ],
- )
- def test_mention_corresponds_data_by_type(self, mention_data, mention_type) -> None:
- mention = Mention(mention_data=mention_data, mention_type=mention_type)
- assert mention.mention_type == mention_type
-
- @pytest.mark.parametrize(
- ("mention_data", "mention_type"),
- [
- (UserMention(user_huid=uuid.uuid4()), MentionTypes.chat),
- (UserMention(user_huid=uuid.uuid4()), MentionTypes.channel),
- (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.user),
- (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.contact),
- ],
- )
- def test_error_when_data_not_corresponds_type(
- self,
- mention_data,
- mention_type,
- ) -> None:
- with pytest.raises(ValidationError):
- assert Mention(mention_data=mention_data, mention_type=mention_type)
-
- @pytest.mark.parametrize(
- ("mention_data", "mention_type"),
- [
- (UserMention(user_huid=uuid.uuid4()), MentionTypes.user),
- (UserMention(user_huid=uuid.uuid4()), MentionTypes.contact),
- (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.chat),
- (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.channel),
- ],
- )
- def test_mention_in_message(self, mention_data, mention_type) -> None:
- builder = MessageBuilder()
- builder.mention(
- mention=Mention(mention_data=mention_data, mention_type=mention_type),
- )
- assert builder.message.entities.mentions[0]
-
- def test_mention_not_in_message(self, bot) -> None:
- builder = MessageBuilder()
- message = Message.from_dict(builder.message.dict(), bot)
- assert message.entities.mentions == []
-
- def test_user_mention_botx_format(self) -> None:
- mention_id = uuid.uuid4()
- user_mention = Mention(
- mention_id=mention_id,
- mention_data=UserMention(user_huid=uuid.uuid4()),
- mention_type=MentionTypes.user,
- )
-
- formatted_mention = user_mention.to_botx_format()
-
- assert formatted_mention == f"@{{mention:{mention_id}}}"
-
- def test_contact_mention_botx_format(self) -> None:
- mention_id = uuid.uuid4()
- contact_mention = Mention(
- mention_id=mention_id,
- mention_data=UserMention(user_huid=uuid.uuid4()),
- mention_type=MentionTypes.contact,
- )
-
- formatted_mention = contact_mention.to_botx_format()
-
- assert formatted_mention == f"@@{{mention:{mention_id}}}"
-
- def test_chat_mention_botx_format(self) -> None:
- mention_id = uuid.uuid4()
- chat_mention = Mention(
- mention_id=mention_id,
- mention_data=ChatMention(group_chat_id=uuid.uuid4()),
- mention_type=MentionTypes.chat,
- )
-
- formatted_mention = chat_mention.to_botx_format()
-
- assert formatted_mention == f"##{{mention:{mention_id}}}"
-
- def test_channel_mention_botx_format(self) -> None:
- mention_id = uuid.uuid4()
- channel_mention = Mention(
- mention_id=mention_id,
- mention_data=ChatMention(group_chat_id=uuid.uuid4()),
- mention_type=MentionTypes.channel,
- )
-
- formatted_mention = channel_mention.to_botx_format()
-
- assert formatted_mention == f"##{{mention:{mention_id}}}"
-
-
-class TestReply:
- def test_reply_in_message(self, message) -> None:
- builder = MessageBuilder()
- builder.reply(message=message)
- assert builder.message.entities.reply.source_sync_id == message.sync_id
-
- def test_reply_not_in_message(self) -> None:
- builder = MessageBuilder()
- with pytest.raises(AttributeError):
- builder.message.entities.reply
-
-
-class TestForward:
- def test_forward_in_message(self, message) -> None:
- builder = MessageBuilder()
- builder.forward(message=message)
- assert builder.message.entities.forward.source_sync_id == message.sync_id
-
- def test_forward_not_in_message(self) -> None:
- builder = MessageBuilder()
- with pytest.raises(AttributeError):
- builder.message.entities.forward
diff --git a/tests/test_models/test_errors.py b/tests/test_models/test_errors.py
deleted file mode 100644
index cdadeaf7..00000000
--- a/tests/test_models/test_errors.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import pytest
-from pydantic import ValidationError
-
-from botx import BotDisabledErrorData, BotDisabledResponse
-
-
-def test_error_for_missing_status_message_field():
- with pytest.raises(ValidationError):
- BotDisabledResponse(error_data={})
-
-
-def test_doing_nothing_when_passed_error_data_model():
- response = BotDisabledResponse(
- error_data=BotDisabledErrorData(status_message="test"),
- )
- assert response.error_data.status_message == "test"
diff --git a/tests/test_models/test_files/__init__.py b/tests/test_models/test_files/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_models/test_files/test_attributes.py b/tests/test_models/test_files/test_attributes.py
deleted file mode 100644
index 43d8f984..00000000
--- a/tests/test_models/test_files/test_attributes.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from botx import File
-
-
-def test_retrieving_file_data_in_bytes():
- assert File.from_string(b"test", filename="test.txt").data_in_bytes == b"test"
-
-
-def test_retrieving_file_data_in_base64():
- assert File.from_string(b"test", filename="test.txt").data_in_base64 == "dGVzdA=="
-
-
-def test_retrieving_txt_media_type():
- assert File.from_string(b"test", filename="test.txt").media_type == "text/plain"
-
-
-def test_retrieving_png_media_type():
- assert File.from_string(b"test", filename="test.png").media_type == "image/png"
-
-
-def test_retrieving_file_size():
- assert File.from_string(b"file\ncontents", filename="test.txt").size_in_bytes == 13
-
-
-def test_get_ext_by_unsupported_mimetype():
- assert File.get_ext_by_mimetype("application/javascript") is None
diff --git a/tests/test_models/test_files/test_constructing.py b/tests/test_models/test_files/test_constructing.py
deleted file mode 100644
index 0b0a5908..00000000
--- a/tests/test_models/test_files/test_constructing.py
+++ /dev/null
@@ -1,85 +0,0 @@
-from io import BytesIO, StringIO
-
-import aiofiles
-import pytest
-
-from botx import File
-
-
-@pytest.mark.parametrize("extension", [".docx", ".txt", ".html", ".pdf"])
-def test_file_creation_with_right_extension(extension):
- File(file_name=f"tmp{extension}", data="")
-
-
-@pytest.mark.parametrize(
- ("io_cls", "file_data", "file_name"),
- [(StringIO, "test", "test.txt"), (BytesIO, b"test", "test.txt")],
-)
-@pytest.mark.parametrize("explicit_file_name", ["test2.txt", None])
-def test_creating_file_from_io_with_name(
- io_cls,
- file_data,
- file_name,
- explicit_file_name,
-):
- created_file = io_cls(file_data)
- if not explicit_file_name:
- created_file.name = file_name
-
- assert File.from_file(created_file, filename=explicit_file_name) == File(
- file_name=explicit_file_name or file_name,
- data="data:text/plain;base64,dGVzdA==",
- )
-
-
-@pytest.mark.parametrize("file_data", ["test", b"test"])
-def test_creating_file_from_string(file_data):
- assert File.from_string(file_data, filename="test.txt") == File(
- file_name="test.txt",
- data="data:text/plain;base64,dGVzdA==",
- )
-
-
-@pytest.fixture()
-def filename():
- return "test.txt"
-
-
-@pytest.fixture()
-def origin_data():
- return b"Hello,\nworld!"
-
-
-@pytest.fixture()
-def encoded_data():
- return "data:text/plain;base64,SGVsbG8sCndvcmxkIQ=="
-
-
-@pytest.fixture()
-def temp_file(tmp_path, filename, origin_data):
- file_path = tmp_path / filename
- file_path.write_bytes(origin_data)
-
- return file_path
-
-
-@pytest.mark.asyncio()
-async def test_async_from_file(temp_file, encoded_data):
- async with aiofiles.open(temp_file, "rb") as fo:
- file = await File.async_from_file(fo)
-
- assert file.file_name == temp_file.name
- assert file.data == encoded_data
-
-
-def test_file_chunks(filename, encoded_data, origin_data):
- file = File.construct(file_name=filename, data=encoded_data)
- temp_file = BytesIO()
-
- with file.file_chunks() as chunks:
- for chunk in chunks:
- temp_file.write(chunk)
-
- temp_file.seek(0)
-
- assert temp_file.read() == origin_data
diff --git a/tests/test_models/test_messages/__init__.py b/tests/test_models/test_messages/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_models/test_messages/test_messages.py b/tests/test_models/test_messages/test_messages.py
deleted file mode 100644
index 717e5928..00000000
--- a/tests/test_models/test_messages/test_messages.py
+++ /dev/null
@@ -1,663 +0,0 @@
-import uuid
-from io import BytesIO, StringIO
-
-import pytest
-
-from botx import (
- Bot,
- BubbleElement,
- ChatMention,
- File,
- IncomingMessage,
- KeyboardElement,
- Mention,
- MentionTypes,
- Message,
- MessageBuilder,
- MessageMarkup,
- MessageOptions,
- NotificationOptions,
- SendingCredentials,
- SendingMessage,
- UserMention,
-)
-
-
-@pytest.fixture()
-def incoming_message() -> IncomingMessage:
- return IncomingMessage.parse_obj(
- {
- "sync_id": "a465f0f3-1354-491c-8f11-f400164295cb",
- "command": {
- "body": "system:chat_created",
- "command_type": "system",
- "data": {
- "group_chat_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
- "chat_type": "group_chat",
- "name": "Meeting Room",
- "creator": "ab103983-6001-44e9-889e-d55feb295494",
- "members": [
- {
- "huid": "ab103983-6001-44e9-889e-d55feb295494",
- "name": "Bob",
- "user_kind": "user",
- "admin": True,
- },
- {
- "huid": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
- "name": "Funny Bot",
- "user_kind": "botx",
- "admin": False,
- },
- ],
- },
- "metadata": {"account_id": 94},
- },
- "file": None,
- "from": {
- "user_huid": None,
- "group_chat_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
- "ad_login": None,
- "ad_domain": None,
- "username": None,
- "chat_type": "group_chat",
- "host": "cts.ccteam.ru",
- "is_admin": False,
- "is_creator": False,
- },
- "bot_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
- "entities": [
- {
- "type": "mention",
- "data": {
- "mention_type": "contact",
- "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f",
- "mention_data": {
- "user_huid": "ab103983-6001-44e9-889e-d55feb295494",
- "name": "User",
- },
- },
- },
- ],
- },
- )
-
-
-@pytest.fixture()
-def embed_mention_with_name() -> str:
- user_huid = uuid.uuid4()
- return f""
-
-
-@pytest.fixture()
-def embed_mention_without_name() -> str:
- user_huid = uuid.uuid4()
- return f""
-
-
-@pytest.fixture()
-def embed_mention_with_wrong_type() -> str:
- user_huid = uuid.uuid4()
- return f""
-
-
-def test_setting_ui_flag_property_for_common_message() -> None:
- msg = Message.from_dict(
- {
- "sync_id": "a465f0f3-1354-491c-8f11-f400164295cb",
- "source_sync_id": "ff934be3-a03f-45d8-b315-738ba1ddec45",
- "command": {
- "body": "/cmd",
- "command_type": "user",
- "data": {"ui": True},
- "metadata": {"account_id": 94},
- },
- "file": None,
- "from": {
- "user_huid": None,
- "group_chat_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
- "ad_login": None,
- "ad_domain": None,
- "username": None,
- "chat_type": "group_chat",
- "host": "cts.ccteam.ru",
- "is_admin": False,
- "is_creator": False,
- },
- "bot_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
- },
- Bot(),
- )
-
- assert msg.source_sync_id == uuid.UUID("ff934be3-a03f-45d8-b315-738ba1ddec45")
-
-
-def test_setting_ui_flag_property_for_system_message(incoming_message) -> None:
- msg = Message.from_dict(incoming_message.dict(), Bot())
- assert not msg.source_sync_id
-
-
-@pytest.fixture()
-def sending_message() -> SendingMessage:
- return SendingMessage(
- text="text",
- file=File.from_string(b"data", filename="file.txt"),
- credentials=SendingCredentials(
- sync_id=uuid.uuid4(),
- bot_id=uuid.uuid4(),
- host="host",
- ),
- markup=MessageMarkup(
- bubbles=[[BubbleElement(command="")]],
- keyboard=[[KeyboardElement(command="")]],
- ),
- options=MessageOptions(
- recipients=[uuid.uuid4()],
- mentions=[
- Mention(mention_data=UserMention(user_huid=uuid.uuid4())),
- Mention(
- mention_data=UserMention(user_huid=uuid.uuid4()),
- mention_type=MentionTypes.contact,
- ),
- Mention(
- mention_data=ChatMention(group_chat_id=uuid.uuid4()),
- mention_type=MentionTypes.chat,
- ),
- ],
- notifications=NotificationOptions(send=False, force_dnd=True),
- ),
- )
-
-
-def test_message_is_proxy_to_incoming_message(incoming_message) -> None:
- msg = Message.from_dict(incoming_message.dict(), Bot())
- assert msg.sync_id == incoming_message.sync_id
- assert msg.command == incoming_message.command
- assert msg.file == incoming_message.file
- assert msg.user == incoming_message.user
- assert msg.bot_id == incoming_message.bot_id
- assert msg.body == incoming_message.command.body
- assert msg.data == {**msg.metadata, **incoming_message.command.data_dict}
- assert msg.metadata == incoming_message.command.metadata
- assert msg.user_huid == incoming_message.user.user_huid
- assert msg.ad_login == incoming_message.user.ad_login
- assert msg.group_chat_id == incoming_message.user.group_chat_id
- assert msg.chat_type == incoming_message.user.chat_type
- assert msg.host == incoming_message.user.host
- assert msg.credentials.sync_id == incoming_message.sync_id
- assert msg.credentials.bot_id == incoming_message.bot_id
- assert msg.credentials.host == incoming_message.user.host
- assert msg.entities == incoming_message.entities
- assert msg.incoming_message == incoming_message
-
-
-class TestBuildingSendingMessage:
- def test_building_from_message(self, sending_message: SendingMessage) -> None:
- builder = MessageBuilder()
- msg = Message(message=builder.message, bot=Bot())
- sending_msg = SendingMessage.from_message(
- text=sending_message.text,
- message=msg,
- )
- assert sending_msg.host == msg.host
- assert sending_msg.sync_id == msg.sync_id
- assert sending_msg.bot_id == msg.bot_id
-
- def test_building_with_embed_mentions(
- self,
- sending_message: SendingMessage,
- ) -> None:
- credentials = sending_message.credentials
- user_huid = uuid.uuid4()
-
- mention_id = uuid.uuid4()
- text = (
- f"Text with embed_mention: "
- )
-
- msg = SendingMessage(text=text, credentials=credentials, embed_mentions=True)
-
- assert msg.text == f"Text with embed_mention: @{{mention:{mention_id}}}"
- assert msg.options.mentions
-
- class TestCredentialsBuilding:
- def test_only_credentials_or_separate_credential_parts(
- self,
- sending_message: SendingMessage,
- ) -> None:
- with pytest.raises(AssertionError):
- _ = SendingMessage(
- sync_id=sending_message.sync_id,
- bot_id=sending_message.bot_id,
- host=sending_message.host,
- credentials=sending_message.credentials,
- )
-
- def test_credentials_will_be_built_from_credential_parts(
- self,
- sending_message: SendingMessage,
- ) -> None:
- msg = SendingMessage(
- text=sending_message.text,
- sync_id=sending_message.sync_id,
- bot_id=sending_message.bot_id,
- host=sending_message.host,
- )
- assert msg.credentials == sending_message.credentials
-
- def test_merging_message_id_into_credentials(
- self,
- sending_message: SendingMessage,
- ) -> None:
- message_id = uuid.uuid4()
- msg = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- message_id=message_id,
- )
- assert msg.credentials.message_id == message_id
-
- def test_leaving_credentials_message_id_into_credentials_if_was_set(
- self,
- sending_message: SendingMessage,
- ) -> None:
- message_id = uuid.uuid4()
- sending_message.credentials.message_id = message_id
- msg = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- message_id=uuid.uuid4(),
- )
- assert msg.credentials.message_id == sending_message.credentials.message_id
-
- class TestMarkupBuilding:
- def test_markup_creation_from_bubbles(
- self,
- sending_message: SendingMessage,
- ) -> None:
- msg = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- bubbles=sending_message.markup.bubbles,
- )
- assert msg.markup.keyboard == []
- assert msg.markup.bubbles == sending_message.markup.bubbles
-
- def test_markup_creation_from_keyboard(
- self,
- sending_message: SendingMessage,
- ) -> None:
- msg = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- keyboard=sending_message.markup.keyboard,
- )
- assert msg.markup.keyboard == sending_message.markup.keyboard
- assert msg.markup.bubbles == []
-
- def test_markup_creation_from_bubbles_and_keyboard(
- self,
- sending_message: SendingMessage,
- ) -> None:
- msg = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- bubbles=sending_message.markup.bubbles,
- keyboard=sending_message.markup.keyboard,
- )
- assert msg.markup == sending_message.markup
-
- def test_only_markup_or_separate_markup_parts(
- self,
- sending_message: SendingMessage,
- ) -> None:
- with pytest.raises(AssertionError):
- _ = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- bubbles=sending_message.markup.bubbles,
- keyboard=sending_message.markup.keyboard,
- markup=sending_message.markup,
- )
-
- class TestOptionsBuilding:
- def test_options_from_mentions(self, sending_message: SendingMessage) -> None:
- msg = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- mentions=sending_message.options.mentions,
- )
- assert msg.options.mentions == sending_message.options.mentions
-
- def test_options_from_recipients(self, sending_message: SendingMessage) -> None:
- msg = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- recipients=sending_message.options.recipients,
- )
- assert msg.options.recipients == sending_message.options.recipients
-
- def test_options_from_notification_options(
- self,
- sending_message: SendingMessage,
- ) -> None:
- msg = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- notification_options=sending_message.options.notifications,
- )
- assert msg.options.notifications == sending_message.options.notifications
-
- def test_option_from_message_options(
- self,
- sending_message: SendingMessage,
- ) -> None:
- msg = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- options=sending_message.options,
- )
- assert msg.options == sending_message.options
-
- def test_only_options_or_separate_options_parts(
- self,
- sending_message: SendingMessage,
- ) -> None:
- with pytest.raises(AssertionError):
- _ = SendingMessage(
- text=sending_message.text,
- credentials=sending_message.credentials,
- options=sending_message.options,
- mentions=sending_message.options.mentions,
- recipients=sending_message.options.recipients,
- notification_options=sending_message.options.notifications,
- )
-
-
-class TestSendingMessageProperties:
- def test_message_text(self, sending_message: SendingMessage) -> None:
- sending_message.text = "test"
- assert sending_message.text == "test"
-
- def test_metadata(self, sending_message: SendingMessage) -> None:
- value = {"account_id", 94}
-
- sending_message.metadata = value
- assert sending_message.metadata == value
-
- class TestMessageFile:
- def test_message_file(self, sending_message: SendingMessage) -> None:
- file = sending_message.file
- sending_message.file = file
- assert sending_message.file == file
-
- def test_message_file_from_file(self, sending_message: SendingMessage) -> None:
- original_file = sending_message.file
- sending_message.add_file(File.from_file(original_file.file))
- assert sending_message.file == original_file
-
- def test_message_file_from_string_file(
- self,
- sending_message: SendingMessage,
- ) -> None:
- original_file = sending_message.file
- sending_message.add_file(
- StringIO(original_file.file.read().decode()),
- filename=original_file.file_name,
- )
- assert sending_message.file == original_file
-
- def test_message_file_from_bytes_file(
- self,
- sending_message: SendingMessage,
- ) -> None:
- original_file = sending_message.file
- sending_message.add_file(
- BytesIO(original_file.file.read()),
- filename=original_file.file_name,
- )
- assert sending_message.file == original_file
-
- def test_message_markup(self, sending_message: SendingMessage) -> None:
- markup = MessageMarkup(bubbles=[[BubbleElement(command="/test")]])
- sending_message.markup = markup
- assert sending_message.markup == markup
-
- def test_message_options(self, sending_message: SendingMessage) -> None:
- options = MessageOptions()
- sending_message.options = options
- assert sending_message.options == options
-
- def test_message_sync_id(self, sending_message: SendingMessage) -> None:
- sync_id = uuid.uuid4()
- sending_message.sync_id = sync_id
- assert sending_message.sync_id == sync_id
-
- def test_message_chat_id(self, sending_message: SendingMessage) -> None:
- chat_id = uuid.uuid4()
- sending_message.chat_id = chat_id
- assert sending_message.chat_id == chat_id
-
- def test_message_bot_id(self, sending_message: SendingMessage) -> None:
- bot_id = uuid.uuid4()
- sending_message.bot_id = bot_id
- assert sending_message.bot_id == bot_id
-
- def test_message_host(self, sending_message: SendingMessage) -> None:
- host = "example.com"
- sending_message.host = host
- assert sending_message.host == host
-
- class TestMentioning:
- def test_mentioning_user(self, sending_message: SendingMessage) -> None:
- sending_message.payload.options.mentions = []
- user_huid = uuid.uuid4()
- user_name = "test"
- sending_message.mention_user(user_huid, user_name)
- mention = sending_message.payload.options.mentions[0]
- assert mention.mention_type == MentionTypes.user
-
- def test_mentioning_contact(self, sending_message: SendingMessage) -> None:
- sending_message.payload.options.mentions = []
- user_huid = uuid.uuid4()
- user_name = "test"
- sending_message.mention_contact(user_huid, user_name)
- mention = sending_message.payload.options.mentions[0]
- assert mention.mention_type == MentionTypes.contact
-
- def test_mentioning_chat(self, sending_message: SendingMessage) -> None:
- sending_message.payload.options.mentions = []
- chat_id = uuid.uuid4()
- chat_name = "test"
- sending_message.mention_chat(chat_id, chat_name)
- mention = sending_message.payload.options.mentions[0]
- assert mention.mention_type == MentionTypes.chat
-
- def test_wrong_mention_chat(self, sending_message: SendingMessage) -> None:
- wrong_mention_chat = {
- "mention_type": MentionTypes.chat,
- "mention_id": uuid.uuid4(),
- "mention_data": {"foo": "bar"},
- }
- with pytest.raises(ValueError):
- Mention.parse_obj(wrong_mention_chat)
-
- def test_mention_data_error(self):
- mention_all = {
- "mention_type": "all",
- "mention_id": uuid.uuid4(),
- "mention_data": {},
- }
- mention = Mention.parse_obj(mention_all)
- assert mention.mention_data is None
-
- class TestBuildingMentions:
- def test_build_embeddable_user_mention(self) -> None:
- user_huid = uuid.uuid4()
-
- embeddable_mention = SendingMessage.build_embeddable_user_mention(user_huid)
-
- assert embeddable_mention.startswith(f" None:
- user_huid = uuid.uuid4()
-
- embeddable_mention = SendingMessage.build_embeddable_contact_mention(
- user_huid,
- )
-
- assert embeddable_mention.startswith(f" None:
- chat_id = uuid.uuid4()
-
- embeddable_mention = SendingMessage.build_embeddable_chat_mention(chat_id)
-
- assert embeddable_mention.startswith(f" None:
- channel_id = uuid.uuid4()
-
- embeddable_mention = SendingMessage.build_embeddable_channel_mention(
- channel_id,
- )
-
- assert embeddable_mention.startswith(
- f" None:
- _, found_mentions = sending_message._find_and_replace_embed_mentions(
- embed_mention_with_name,
- )
-
- assert len(found_mentions) == 1
-
- def test_replace_embed_mention_without_name(
- self,
- sending_message: SendingMessage,
- embed_mention_without_name: str,
- ) -> None:
- _, found_mentions = sending_message._find_and_replace_embed_mentions(
- embed_mention_without_name,
- )
-
- assert len(found_mentions) == 1
-
- @pytest.mark.parametrize(
- ("embed_mention_with_name", "embed_mention_without_name"),
- [
- (embed_mention_with_name, embed_mention_without_name),
- (embed_mention_without_name, embed_mention_with_name),
- ],
- indirect=True,
- )
- def test_replace_group_embed_mentions(
- self,
- sending_message: SendingMessage,
- embed_mention_with_name: str,
- embed_mention_without_name: str,
- ) -> None:
- embed_mentions = ", ".join(
- [embed_mention_with_name, embed_mention_without_name],
- )
- _, found_mentions = sending_message._find_and_replace_embed_mentions(
- embed_mentions,
- )
-
- assert len(found_mentions) == 2
-
- def test_replace_embed_mention_with_wrong_type(
- self,
- sending_message: SendingMessage,
- embed_mention_with_wrong_type: str,
- ) -> None:
- with pytest.raises(ValueError):
- _, found_mentions = sending_message._find_and_replace_embed_mentions(
- embed_mention_with_wrong_type,
- )
-
- class TestAddingRecipients:
- def test_adding_recipients_separately(
- self,
- sending_message: SendingMessage,
- ) -> None:
- users = [uuid.uuid4(), uuid.uuid4()]
- sending_message.payload.options.recipients = "all"
-
- sending_message.add_recipient(users[0])
- assert sending_message.options.recipients == [users[0]]
-
- sending_message.add_recipient(users[1])
- assert sending_message.options.recipients == users
-
- def test_adding_multiple_recipients(
- self,
- sending_message: SendingMessage,
- ) -> None:
- users = [uuid.uuid4(), uuid.uuid4()]
- sending_message.payload.options.recipients = "all"
-
- sending_message.add_recipients(users[:1])
- assert sending_message.options.recipients == users[:1]
-
- sending_message.add_recipients(users[1:])
- assert sending_message.options.recipients == users
-
- class TestMarkupAdding:
- def test_adding_bubbles(self, sending_message: SendingMessage) -> None:
- bubble = BubbleElement(command="/test")
- sending_message.markup = MessageMarkup()
- sending_message.add_bubble("/test")
- sending_message.add_bubble("/test", new_row=False)
- sending_message.add_bubble("/test")
- sending_message.add_bubble("/test")
- sending_message.add_bubble("/test", new_row=False)
- assert sending_message.markup == MessageMarkup(
- bubbles=[[bubble, bubble], [bubble], [bubble, bubble]],
- )
-
- def test_adding_keyboard(self, sending_message: SendingMessage) -> None:
- keyboard_button = KeyboardElement(command="/test")
- sending_message.markup = MessageMarkup()
- sending_message.add_keyboard_button("/test")
- sending_message.add_keyboard_button("/test", new_row=False)
- sending_message.add_keyboard_button("/test")
- sending_message.add_keyboard_button("/test")
- sending_message.add_keyboard_button("/test", new_row=False)
- assert sending_message.markup == MessageMarkup(
- keyboard=[
- [keyboard_button, keyboard_button],
- [keyboard_button],
- [keyboard_button, keyboard_button],
- ],
- )
-
- def test_setting_notification_show(self, sending_message: SendingMessage) -> None:
- sending_message.show_notification(True)
- assert sending_message.options.notifications.send
-
- def test_setting_dnd(self, sending_message: SendingMessage) -> None:
- sending_message.force_notification(True)
- assert sending_message.options.notifications.force_dnd
-
-
-def test_credentials_or_parameters_required_for_message_creation():
- with pytest.raises(AssertionError):
- SendingMessage()
-
-
-class TestIsForward:
- def test_is_forward_message(self, message, bot) -> None:
- builder = MessageBuilder()
- builder.forward(message=message)
- new_message = Message.from_dict(message=builder.message.dict(), bot=bot)
- assert new_message.is_forward
-
- def test_is_forward_message_error(self, message, bot) -> None:
- assert not message.is_forward
diff --git a/tests/test_models/test_messages/test_receiving.py b/tests/test_models/test_messages/test_receiving.py
deleted file mode 100644
index 30f1103a..00000000
--- a/tests/test_models/test_messages/test_receiving.py
+++ /dev/null
@@ -1,136 +0,0 @@
-import uuid
-from datetime import datetime as dt, timezone as tz
-from typing import List
-
-import pytest
-
-from botx import ChatTypes, CommandTypes, EntityTypes
-from botx.models.messages.incoming_message import Command, IncomingMessage, Sender
-
-
-@pytest.mark.parametrize(
- ("body", "command", "arguments", "single_argument"),
- [
- ("/command", "/command", (), ""),
- ("/command ", "/command", (), ""),
- ("/command arg", "/command", ("arg",), "arg"),
- ("/command arg ", "/command", ("arg",), "arg"),
- ("/command \t\t arg ", "/command", ("arg",), "arg"),
- ("/command arg arg", "/command", ("arg", "arg"), "arg arg"),
- ("/command arg arg ", "/command", ("arg", "arg"), "arg arg"),
- ],
-)
-def test_command_splits_right(
- body: str,
- command: str,
- arguments: List[str],
- single_argument: str,
-) -> None:
- command = Command(body=body, command_type=CommandTypes.user)
- assert command.body == body
- assert command.command == command.command
- assert command.arguments == arguments
- assert command.single_argument == command.single_argument
-
-
-def test_command_data_as_dict() -> None:
- command = Command(
- body="/test",
- command_type=CommandTypes.user,
- data={"some": "data"},
- )
- assert command.data_dict == command.data == {"some": "data"}
-
-
-def test_user_email_when_credentials_passed() -> None:
- sender = Sender(
- user_huid=uuid.uuid4(),
- group_chat_id=uuid.uuid4(),
- chat_type=ChatTypes.chat,
- ad_login="user",
- ad_domain="example.com",
- username="test user",
- is_admin=False,
- is_creator=True,
- host="cts.example.com",
- )
- assert sender.upn == "user@example.com"
-
-
-def test_user_email_when_credentials_missed() -> None:
- assert (
- Sender(
- group_chat_id=uuid.uuid4(),
- chat_type=ChatTypes.chat,
- is_admin=False,
- is_creator=True,
- host="cts.example.com",
- ).upn
- is None
- )
-
-
-def test_skip_validation_for_file() -> None:
- file_data = {"file_name": "zen.py", "data": "data:text/plain;base64,"}
-
- IncomingMessage.parse_obj(
- {
- "sync_id": "a465f0f3-1354-491c-8f11-f400164295cb",
- "command": {"body": "/cmd", "command_type": "user", "data": {}},
- "file": file_data,
- "from": {
- "user_huid": None,
- "group_chat_id": "8dada2c8-67a6-4434-9dec-570d244e78ee",
- "ad_login": None,
- "ad_domain": None,
- "username": None,
- "chat_type": "group_chat",
- "host": "cts.ccteam.ru",
- "is_admin": False,
- "is_creator": False,
- },
- "bot_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4",
- },
- )
-
-
-def test_parse_message_forward() -> None:
- inserted_at = dt(2020, 7, 10, 10, 12, 58, 420000, tzinfo=tz.utc)
-
- IncomingMessage.parse_obj(
- {
- "bot_id": "f6615a30-9d3d-5770-b453-749ea562a974",
- "command": {
- "body": "Message body",
- "command_type": CommandTypes.user,
- "data": {},
- "metadata": {},
- },
- "entities": [
- {
- "data": {
- "forward_type": ChatTypes.chat,
- "group_chat_id": "b51df4c1-3834-0949-1066-614ec424d28a",
- "sender_huid": "4471289e-5b52-5c1b-8eab-a22c548fef9b",
- "source_chat_name": "MessageAuthor Name",
- "source_inserted_at": inserted_at,
- "source_sync_id": "80d2c3a9-0031-50a8-aeed-32bb5d285758",
- },
- "type": EntityTypes.forward,
- },
- ],
- "file": None,
- "sync_id": "eeb8eeca-3f31-5037-8b41-84de63909a31",
- "user": {
- "ad_domain": "ccsteam.ru",
- "ad_login": "message.forwarder",
- "chat_type": ChatTypes.chat,
- "group_chat_id": "070d866f-fe5b-0222-2a9e-b7fc35c99465",
- "host": "cts.ccsteam.ru",
- "is_admin": True,
- "is_creator": True,
- "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
- "username": "MessageForwarder Name",
- },
- },
- )
diff --git a/tests/test_models/test_messages/test_sending.py b/tests/test_models/test_messages/test_sending.py
deleted file mode 100644
index 9ef90307..00000000
--- a/tests/test_models/test_messages/test_sending.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from botx import BubbleElement, KeyboardElement, MessageMarkup, UpdatePayload
-
-
-def test_message_markup_will_add_row_if_there_is_no_existed_and_not_new_row() -> None:
- markup1 = MessageMarkup()
- markup1.add_bubble("/command", new_row=False)
-
- markup2 = MessageMarkup()
- markup2.add_bubble("/command")
-
- assert markup1 == markup2
-
-
-def test_update_markup_will_can_be_set_from_markup() -> None:
- markup = MessageMarkup()
- markup.add_bubble("/command")
- markup.add_keyboard_button("/command")
-
- update = UpdatePayload()
- update.set_markup(markup)
-
- assert update.markup == markup
- assert update.bubbles == markup.bubbles
- assert update.keyboard == markup.keyboard
-
-
-def test_adding_markup_by_elements() -> None:
- bubble = BubbleElement(command="/command")
- keyboard = KeyboardElement(command="/command")
-
- markup = MessageMarkup()
- markup.add_bubble_element(bubble)
- markup.add_keyboard_button_element(keyboard)
-
- assert markup == MessageMarkup(bubbles=[[bubble]], keyboard=[[keyboard]])
diff --git a/tests/test_models/test_smartapps.py b/tests/test_models/test_smartapps.py
deleted file mode 100644
index ff210fcc..00000000
--- a/tests/test_models/test_smartapps.py
+++ /dev/null
@@ -1,141 +0,0 @@
-from io import BytesIO
-from typing import Any, Dict
-from uuid import UUID
-
-from botx import File, Message
-from botx.models.smartapps import SendingSmartAppEvent, SendingSmartAppNotification
-
-pytest_plugins = ("tests.test_clients.fixtures", "tests.fixtures.smartapps")
-
-
-def test_sending_smartapp_event(
- ref: UUID,
- smartapp_id: UUID,
- smartapp_api_version: int,
- group_chat_id: UUID,
- smartapp_data: Dict[str, Any],
-):
- sending_smartapp = SendingSmartAppEvent(
- ref=ref,
- smartapp_id=smartapp_id,
- smartapp_api_version=smartapp_api_version,
- group_chat_id=group_chat_id,
- data=smartapp_data,
- )
-
- assert sending_smartapp.ref == ref
- assert sending_smartapp.smartapp_id == smartapp_id
- assert sending_smartapp.smartapp_api_version == smartapp_api_version
- assert sending_smartapp.group_chat_id == group_chat_id
- assert sending_smartapp.data == smartapp_data
-
-
-def test_sending_smartapp_notification(
- ref: UUID,
- smartapp_api_version: int,
- group_chat_id: UUID,
- smartapp_counter: int,
-):
- sending_smartapp = SendingSmartAppNotification(
- smartapp_api_version=smartapp_api_version,
- group_chat_id=group_chat_id,
- smartapp_counter=smartapp_counter,
- )
-
- assert sending_smartapp.smartapp_api_version == smartapp_api_version
- assert sending_smartapp.group_chat_id == group_chat_id
- assert sending_smartapp.smartapp_counter == smartapp_counter
-
-
-def test_sending_smartapp_notification_from_message(
- message: Message,
- ref: UUID,
- smartapp_id: UUID,
- smartapp_api_version: int,
- group_chat_id: UUID,
- smartapp_counter: int,
-):
- message.incoming_message.command.data_dict[
- "smartapp_api_version"
- ] = smartapp_api_version
- message.incoming_message.command.data_dict["opts"] = {}
- message.group_chat_id = group_chat_id
-
- sending_smartapp = SendingSmartAppNotification.from_message(
- smartapp_counter=smartapp_counter,
- message=message,
- )
-
- assert sending_smartapp.smartapp_api_version == smartapp_api_version
- assert sending_smartapp.group_chat_id == group_chat_id
- assert sending_smartapp.smartapp_counter == smartapp_counter
-
-
-def test_sending_smartapp_event_from_message(
- message: Message,
- ref: UUID,
- smartapp_id: UUID,
- smartapp_api_version: int,
- group_chat_id: UUID,
- smartapp_data: Dict[str, Any],
-):
- message.incoming_message.command.data_dict[
- "smartapp_api_version"
- ] = smartapp_api_version
- message.incoming_message.command.data_dict["opts"] = {}
- message.incoming_message.command.data_dict["smartapp_id"] = smartapp_id
- message.incoming_message.command.data_dict["ref"] = ref
-
- message.group_chat_id = group_chat_id
-
- sending_smartapp = SendingSmartAppEvent.from_message(
- data=smartapp_data,
- message=message,
- )
-
- assert sending_smartapp.smartapp_api_version == smartapp_api_version
- assert sending_smartapp.group_chat_id == group_chat_id
- assert sending_smartapp.data == smartapp_data
-
-
-def test_sending_smartapp_event_add_botx_file(
- ref: UUID,
- smartapp_id: UUID,
- smartapp_api_version: int,
- group_chat_id: UUID,
- smartapp_data: Dict[str, Any],
-):
- sending_smartapp = SendingSmartAppEvent(
- ref=ref,
- smartapp_id=smartapp_id,
- smartapp_api_version=smartapp_api_version,
- group_chat_id=group_chat_id,
- data=smartapp_data,
- )
-
- file = File.from_string(b"data", filename="file.txt")
- sending_smartapp.add_file(file)
-
- assert sending_smartapp.files == [file]
-
-
-def test_sending_smartapp_event_add_file(
- ref: UUID,
- smartapp_id: UUID,
- smartapp_api_version: int,
- group_chat_id: UUID,
- smartapp_data: Dict[str, Any],
-):
- sending_smartapp = SendingSmartAppEvent(
- ref=ref,
- smartapp_id=smartapp_id,
- smartapp_api_version=smartapp_api_version,
- group_chat_id=group_chat_id,
- data=smartapp_data,
- )
-
- file_data = b"data"
- file = File.from_string(file_data, filename="file.txt")
- sending_smartapp.add_file(BytesIO(file_data), file.file_name)
-
- assert sending_smartapp.files == [file]
diff --git a/tests/test_models/test_status.py b/tests/test_models/test_status.py
deleted file mode 100644
index 53df32c0..00000000
--- a/tests/test_models/test_status.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import uuid
-
-from botx import ChatTypes
-from botx.models.status import StatusRecipient
-
-
-def test_status_recipient():
- bot_id = uuid.uuid4()
- user_huid = uuid.uuid4()
- ad_login = "login"
- ad_domain = "domain"
- is_admin = True
- chat_type = ChatTypes.chat
- recipient = StatusRecipient(
- bot_id=bot_id,
- user_huid=user_huid,
- ad_login=ad_login,
- ad_domain=ad_domain,
- is_admin=is_admin,
- chat_type=chat_type,
- )
- assert recipient.bot_id == bot_id
- assert recipient.user_huid == user_huid
- assert recipient.ad_login == ad_login
- assert recipient.ad_domain == ad_domain
- assert recipient.is_admin == is_admin
- assert recipient.chat_type == chat_type
diff --git a/tests/test_state.py b/tests/test_state.py
new file mode 100644
index 00000000..35ed2b3d
--- /dev/null
+++ b/tests/test_state.py
@@ -0,0 +1,93 @@
+from typing import Callable, Optional
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ IncomingMessage,
+ IncomingMessageHandlerFunc,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__bot_state__save_changes_between_middleware_and_handler(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ user_command = incoming_message_factory(body="/command")
+
+ async def middleware(
+ message: IncomingMessage,
+ bot: Bot,
+ call_next: IncomingMessageHandlerFunc,
+ ) -> None:
+ bot.state.api_token = "token"
+
+ await call_next(message, bot)
+
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ built_bot = Bot(
+ collectors=[collector],
+ bot_accounts=[bot_account],
+ middlewares=[middleware],
+ )
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ assert built_bot.state.api_token == "token"
+
+
+async def test__message_state__save_changes_between_middleware_and_handler(
+ incoming_message_factory: Callable[..., IncomingMessage],
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ incoming_message: Optional[IncomingMessage] = None
+ user_command = incoming_message_factory(body="/command")
+
+ async def middleware(
+ message: IncomingMessage,
+ bot: Bot,
+ call_next: IncomingMessageHandlerFunc,
+ ) -> None:
+ message.state.username = "ivanov_ivan_1990"
+
+ await call_next(message, bot)
+
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ nonlocal incoming_message
+ incoming_message = message
+
+ built_bot = Bot(
+ collectors=[collector],
+ bot_accounts=[bot_account],
+ middlewares=[middleware],
+ )
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(user_command)
+
+ # - Assert -
+ assert incoming_message is not None
+ assert incoming_message.state.username == "ivanov_ivan_1990"
diff --git a/tests/test_status.py b/tests/test_status.py
new file mode 100644
index 00000000..760d8aea
--- /dev/null
+++ b/tests/test_status.py
@@ -0,0 +1,319 @@
+from unittest.mock import Mock
+from uuid import UUID, uuid4
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ BotMenu,
+ ChatTypes,
+ HandlerCollector,
+ IncomingMessage,
+ StatusRecipient,
+ UnknownBotAccountError,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+@pytest.fixture
+def status_recipient(bot_id: UUID) -> StatusRecipient:
+ return StatusRecipient(
+ bot_id=bot_id,
+ huid=uuid4(),
+ ad_login=None,
+ ad_domain=None,
+ is_admin=True,
+ chat_type=ChatTypes.PERSONAL_CHAT,
+ )
+
+
+async def test__get_status__hidden_command_not_in_menu(
+ bot_account: BotAccountWithSecret,
+ status_recipient: StatusRecipient,
+ incorrect_handler_trigger: Mock,
+) -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ @collector.command("/_command", visible=False)
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ incorrect_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ status = await bot.get_status(status_recipient)
+
+ # - Assert -
+ assert status == BotMenu({})
+
+ incorrect_handler_trigger.assert_not_called()
+
+
+async def test__get_status__visible_command_in_menu(
+ bot_account: BotAccountWithSecret,
+ status_recipient: StatusRecipient,
+ incorrect_handler_trigger: Mock,
+) -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ @collector.command("/command", description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ incorrect_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ status = await bot.get_status(status_recipient)
+
+ # - Assert -
+ assert status == BotMenu({"/command": "My command"})
+
+ incorrect_handler_trigger.assert_not_called()
+
+
+async def test__get_status__command_not_in_menu_if_visible_func_return_false(
+ bot_account: BotAccountWithSecret,
+ status_recipient: StatusRecipient,
+ incorrect_handler_trigger: Mock,
+) -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ async def visible_func(status_recipient: StatusRecipient, bot: Bot) -> bool:
+ return False
+
+ @collector.command("/command", visible=visible_func, description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ incorrect_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ status = await bot.get_status(status_recipient)
+
+ # - Assert -
+ assert status == BotMenu({})
+
+ incorrect_handler_trigger.assert_not_called()
+
+
+async def test__get_status__command_in_menu_if_visible_func_return_true(
+ bot_account: BotAccountWithSecret,
+ status_recipient: StatusRecipient,
+ incorrect_handler_trigger: Mock,
+) -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ async def visible_func(status_recipient: StatusRecipient, bot: Bot) -> bool:
+ return True
+
+ @collector.command("/command", visible=visible_func, description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ incorrect_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ status = await bot.get_status(status_recipient)
+
+ # - Assert -
+ assert status == BotMenu({"/command": "My command"})
+
+ incorrect_handler_trigger.assert_not_called()
+
+
+async def test__raw_get_status__invalid_query() -> None:
+ # - Arrange -
+ query = {"user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11"}
+
+ collector = HandlerCollector()
+
+ @collector.command("/_command", visible=False)
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[])
+
+ # - Act -
+ with pytest.raises(ValueError) as exc:
+ async with lifespan_wrapper(built_bot) as bot:
+ await bot.raw_get_status(query)
+
+ # - Assert -
+ assert "validation error" in str(exc.value)
+
+
+async def test__raw_get_status__unknown_bot_account_error_raised() -> None:
+ # - Arrange -
+ query = {
+ "bot_id": "123e4567-e89b-12d3-a456-426655440000",
+ "chat_type": "chat",
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ }
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ with pytest.raises(UnknownBotAccountError) as exc:
+ await bot.raw_get_status(query)
+
+ # - Assert -
+ assert "123e4567-e89b-12d3-a456-426655440000" in str(exc.value)
+
+
+async def test__raw_get_status__minimally_filled_succeed(
+ bot_account: BotAccountWithSecret,
+ bot_id: UUID,
+) -> None:
+ # - Arrange -
+ query = {
+ "bot_id": str(bot_id),
+ "chat_type": "chat",
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ }
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ status = await bot.raw_get_status(query)
+
+ # - Assert -
+ assert status
+
+
+async def test__raw_get_status__minimum_filled_succeed(
+ bot_account: BotAccountWithSecret,
+ bot_id: UUID,
+) -> None:
+ # - Arrange -
+ query = {
+ "ad_domain": "",
+ "ad_login": "",
+ "is_admin": "",
+ "bot_id": str(bot_id),
+ "chat_type": "group_chat",
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ }
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ status = await bot.raw_get_status(query)
+
+ # - Assert -
+ assert status
+
+
+async def test__raw_get_status__maximum_filled_succeed(
+ bot_account: BotAccountWithSecret,
+ bot_id: UUID,
+) -> None:
+ # - Arrange -
+ query = {
+ "ad_domain": "domain",
+ "ad_login": "login",
+ "bot_id": str(bot_id),
+ "chat_type": "chat",
+ "is_admin": "true",
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ }
+
+ built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ status = await bot.raw_get_status(query)
+
+ # - Assert -
+ assert status
+
+
+async def test__raw_get_status__hidden_command_not_in_status(
+ bot_account: BotAccountWithSecret,
+ bot_id: UUID,
+) -> None:
+ # - Arrange -
+ query = {
+ "bot_id": str(bot_id),
+ "chat_type": "chat",
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ }
+
+ collector = HandlerCollector()
+
+ @collector.command("/_command", visible=False)
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ status = await bot.raw_get_status(query)
+
+ # - Assert -
+ assert status == {
+ "result": {
+ "commands": [],
+ "enabled": True,
+ "status_message": "Bot is working",
+ },
+ "status": "ok",
+ }
+
+
+async def test__raw_get_status__visible_command_in_status(
+ bot_account: BotAccountWithSecret,
+ bot_id: UUID,
+) -> None:
+ # - Arrange -
+ query = {
+ "bot_id": str(bot_id),
+ "chat_type": "chat",
+ "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11",
+ }
+
+ collector = HandlerCollector()
+
+ @collector.command("/command", visible=True, description="My command")
+ async def handler(message: IncomingMessage, bot: Bot) -> None:
+ pass
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ status = await bot.raw_get_status(query)
+
+ # - Assert -
+ assert status == {
+ "result": {
+ "commands": [
+ {
+ "body": "/command",
+ "description": "My command",
+ "name": "/command",
+ },
+ ],
+ "enabled": True,
+ "status_message": "Bot is working",
+ },
+ "status": "ok",
+ }
diff --git a/tests/test_stickers.py b/tests/test_stickers.py
new file mode 100644
index 00000000..32de09e2
--- /dev/null
+++ b/tests/test_stickers.py
@@ -0,0 +1,77 @@
+from http import HTTPStatus
+from typing import Any, Callable, Dict
+from uuid import UUID
+
+import httpx
+import pytest
+from aiofiles.tempfile import NamedTemporaryFile
+from respx.router import MockRouter
+
+from botx import (
+ Bot,
+ BotAccountWithSecret,
+ HandlerCollector,
+ IncomingMessage,
+ Sticker,
+ lifespan_wrapper,
+)
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+PNG_IMAGE = (
+ b"\x89PNG\r\n\x1a\n"
+ b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x01\x03\x00"
+ b"\x00\x00%\xdbV\xca\x00\x00\x00\x03PLTE\x00\x00\x00\xa7z=\xda\x00"
+ b"\x00\x00\x01tRNS\x00@\xe6\xd8f\x00\x00\x00\nIDAT\x08\xd7c`\x00"
+ b"\x00\x00\x02\x00\x01\xe2!\xbc3\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
+
+async def test__sticker__download(
+ respx_mock: MockRouter,
+ host: str,
+ bot_account: BotAccountWithSecret,
+ async_buffer: NamedTemporaryFile,
+ api_incoming_message_factory: Callable[..., Dict[str, Any]],
+) -> None:
+ # - Arrange -
+ image_link = (
+ f"https://{host}/uploads/sticker_pack/"
+ "4ff8113b-8460-5977-86b2-c1798eb4fbce/"
+ "14a762edf2e04c579de98098e22b01da.png"
+ )
+
+ endpoint = respx_mock.get(image_link).mock(
+ return_value=httpx.Response(
+ HTTPStatus.OK,
+ content=PNG_IMAGE,
+ ),
+ )
+
+ sticker = Sticker(
+ id=UUID("4ff8113b-8460-5977-86b2-c1798eb4fbce"),
+ emoji="🤔",
+ image_link=image_link,
+ )
+
+ payload = api_incoming_message_factory()
+
+ collector = HandlerCollector()
+
+ @collector.default_message_handler
+ async def default_handler(message: IncomingMessage, bot: Bot) -> None:
+ await sticker.download(async_buffer)
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_raw_bot_command(payload)
+
+ # - Assert -
+ assert await async_buffer.read() == PNG_IMAGE
+ assert endpoint.called
diff --git a/tests/test_system_events_routing.py b/tests/test_system_events_routing.py
new file mode 100644
index 00000000..df41ce2a
--- /dev/null
+++ b/tests/test_system_events_routing.py
@@ -0,0 +1,138 @@
+from unittest.mock import Mock
+from uuid import UUID, uuid4
+
+import pytest
+
+from botx import (
+ Bot,
+ BotAccount,
+ BotAccountWithSecret,
+ Chat,
+ ChatCreatedEvent,
+ ChatCreatedMember,
+ ChatTypes,
+ HandlerCollector,
+ UserKinds,
+ lifespan_wrapper,
+)
+
+
+@pytest.fixture
+def chat_created(
+ bot_id: UUID,
+) -> ChatCreatedEvent:
+ return ChatCreatedEvent(
+ bot=BotAccount(
+ id=bot_id,
+ host="cts.example.com",
+ ),
+ sync_id=uuid4(),
+ chat_name="Test",
+ chat=Chat(
+ id=uuid4(),
+ type=ChatTypes.PERSONAL_CHAT,
+ ),
+ creator_id=uuid4(),
+ members=[
+ ChatCreatedMember(
+ is_admin=False,
+ huid=uuid4(),
+ username="Ivanov Ivan Ivanovich",
+ kind=UserKinds.CTS_USER,
+ ),
+ ],
+ raw_command=None,
+ )
+
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.mock_authorization,
+ pytest.mark.usefixtures("respx_mock"),
+]
+
+
+async def test__system_event_handler__called(
+ chat_created: ChatCreatedEvent,
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ @collector.chat_created
+ async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(chat_created)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+
+async def test__system_event_handler__no_handler_for_system_event(
+ chat_created: ChatCreatedEvent,
+ bot_account: BotAccountWithSecret,
+ loguru_caplog: pytest.LogCaptureFixture,
+) -> None:
+ # - Arrange -
+ collector = HandlerCollector()
+
+ built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(chat_created)
+
+ # - Assert -
+ assert "Handler for `ChatCreatedEvent` not found" in loguru_caplog.text
+
+
+async def test__system_event_handler__handler_in_first_collector(
+ chat_created: ChatCreatedEvent,
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ collector_1 = HandlerCollector()
+ collector_2 = HandlerCollector()
+
+ @collector_1.chat_created
+ async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector_1, collector_2], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(chat_created)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
+
+
+async def test__system_event_handler__handler_in_second_collector(
+ chat_created: ChatCreatedEvent,
+ correct_handler_trigger: Mock,
+ bot_account: BotAccountWithSecret,
+) -> None:
+ # - Arrange -
+ collector_1 = HandlerCollector()
+ collector_2 = HandlerCollector()
+
+ @collector_2.chat_created
+ async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None:
+ correct_handler_trigger()
+
+ built_bot = Bot(collectors=[collector_1, collector_2], bot_accounts=[bot_account])
+
+ # - Act -
+ async with lifespan_wrapper(built_bot) as bot:
+ bot.async_execute_bot_command(chat_created)
+
+ # - Assert -
+ correct_handler_trigger.assert_called_once()
diff --git a/tests/test_testing/__init__.py b/tests/test_testing/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_testing/test_builder.py b/tests/test_testing/test_builder.py
deleted file mode 100644
index f5383ea8..00000000
--- a/tests/test_testing/test_builder.py
+++ /dev/null
@@ -1,199 +0,0 @@
-import uuid
-from datetime import datetime
-from io import StringIO
-
-import pytest
-from pydantic import ValidationError
-
-from botx import (
- ChatTypes,
- Entity,
- EntityTypes,
- File,
- Forward,
- Mention,
- MentionTypes,
- MessageBuilder,
- UserMention,
-)
-from botx.models.entities import Reply
-
-
-def test_setting_new_file_from_file():
- file = File.from_string("some data", "name.txt")
- builder = MessageBuilder()
- builder.file = file
-
- assert builder.file == file
-
-
-def test_setting_new_file_from_io():
- file = File.from_string("some data", "name.txt")
- builder = MessageBuilder()
- builder.file = file.file
-
- assert builder.file == file
-
-
-def test_settings_new_user_for_message(incoming_message):
- builder = MessageBuilder()
- builder.user = incoming_message.user
-
- assert builder.user == incoming_message.user
-
-
-def test_file_transfer_event():
- builder = MessageBuilder()
- builder.file = File.from_string("some data", "name.txt")
-
- builder.body = "file_transfer"
- builder.system_command = True
-
-
-def test_setting_not_processable_file_for_incoming_message():
- file = StringIO("import this")
- file.name = "zen.py"
-
- builder = MessageBuilder()
- builder.file = file
-
- message = builder.message
-
- assert message.file.file_name == "zen.py"
-
-
-def test_mention_user_in_message():
- user_huid = uuid.uuid4()
- builder = MessageBuilder()
- builder.mention_user(user_huid)
-
- assert builder.message.entities.__root__[0].data.mention_type == MentionTypes.user
- assert builder.message.entities.__root__[0].data.mention_data.user_huid == user_huid
-
-
-def test_mention_contact_in_message():
- user_huid = uuid.uuid4()
- builder = MessageBuilder()
- builder.mention_contact(user_huid)
-
- assert (
- builder.message.entities.__root__[0].data.mention_type == MentionTypes.contact
- )
- assert builder.message.entities.__root__[0].data.mention_data.user_huid == user_huid
-
-
-def test_mention_chat_in_message():
- chat_id = uuid.uuid4()
- builder = MessageBuilder()
- builder.mention_chat(chat_id)
-
- assert builder.message.entities.__root__[0].data.mention_type == MentionTypes.chat
- assert (
- builder.message.entities.__root__[0].data.mention_data.group_chat_id == chat_id
- )
-
-
-def test_setting_raw_entities():
- builder = MessageBuilder()
- builder.entities = [
- Entity(
- type=EntityTypes.mention,
- data=Mention(mention_data=UserMention(user_huid=uuid.uuid4())),
- ),
- ]
-
- assert builder.message.entities.__root__[0].data.mention_type == MentionTypes.user
-
-
-@pytest.mark.parametrize(
- "include_param",
- ["user_huid", "ad_login", "ad_domain", "username"],
-)
-def test_error_when_chat_validation_not_passed(include_param):
- user_params = {"user_huid", "ad_login", "ad_domain", "username"}
- builder = MessageBuilder()
-
- builder.body = "system:chat_created"
- builder.user = builder.user.copy(
- update={param: None for param in user_params - {include_param}},
- )
- builder.command_data = {
- "group_chat_id": uuid.uuid4(),
- "chat_type": "group_chat",
- "name": "",
- "creator": uuid.uuid4(),
- "members": [],
- }
- with pytest.raises(ValidationError):
- builder.system_command = True
-
-
-def test_error_when_file_validation_not_passed():
- builder = MessageBuilder()
- builder.body = "file_transfer"
- with pytest.raises(ValidationError):
- builder.system_command = True
-
-
-class TestBuildForward:
- def test_building_forward_via_models(self):
- builder = MessageBuilder()
- builder.forward(
- forward=Forward(
- group_chat_id=uuid.uuid4(),
- sender_huid=uuid.uuid4(),
- forward_type=ChatTypes.group_chat, # ignore: type
- source_inserted_at=datetime.now(),
- source_sync_id=uuid.uuid4(),
- ),
- )
-
- def test_building_forward_via_message(self, message):
- builder = MessageBuilder()
- builder.forward(message=message)
-
- def test_building_forward_arguments_error(self, message):
- builder = MessageBuilder()
- with pytest.raises(ValueError):
- builder.forward(
- message=message,
- forward=Forward(
- group_chat_id=uuid.uuid4(),
- sender_huid=uuid.uuid4(),
- forward_type=ChatTypes.botx, # ignore: type
- source_inserted_at=datetime.now(),
- source_sync_id=uuid.uuid4(),
- ),
- )
-
-
-class TestBuildReply:
- def test_building_forward_via_models(self):
- builder = MessageBuilder()
- builder.reply(
- reply=Reply(
- body="foo",
- reply_type=ChatTypes.group_chat,
- sender=uuid.uuid4(),
- source_chat_name="bar",
- source_sync_id=uuid.uuid4(),
- ),
- )
-
- def test_building_forward_via_message(self, message):
- builder = MessageBuilder()
- builder.reply(message=message)
-
- def test_building_forward_arguments_error(self, message):
- builder = MessageBuilder()
- with pytest.raises(ValueError):
- builder.reply(
- message=message,
- reply=Reply(
- body="foo",
- reply_type=ChatTypes.group_chat,
- sender=uuid.uuid4(),
- source_chat_name="bar",
- source_sync_id=uuid.uuid4(),
- ),
- )
diff --git a/tests/test_testing/test_client.py b/tests/test_testing/test_client.py
deleted file mode 100644
index 9eafbf8b..00000000
--- a/tests/test_testing/test_client.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import threading
-
-import pytest
-
-from botx import TestClient
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_disabling_sync_send_for_client(bot, incoming_message, build_handler):
- bot.default(build_handler(threading.Event()))
-
- with TestClient(bot) as client:
- await client.send_command(incoming_message, False)
-
- assert bot.tasks
-
- await bot.shutdown()
-
- assert not bot.tasks