├── .gitattributes ├── .github └── workflows │ ├── publish_to_pypi.yml │ └── run_tests.yml ├── .gitignore ├── LICENSE ├── README-PyPI.md ├── README-en.md ├── README.md ├── aioalice ├── __init__.py ├── dispatcher │ ├── __init__.py │ ├── api.py │ ├── filters.py │ ├── handler.py │ ├── storage.py │ └── webhook.py ├── types │ ├── __init__.py │ ├── alice_request.py │ ├── alice_response.py │ ├── base.py │ ├── button.py │ ├── card.py │ ├── card_footer.py │ ├── card_header.py │ ├── entity.py │ ├── entity_tokens.py │ ├── entity_value.py │ ├── image.py │ ├── interfaces.py │ ├── markup.py │ ├── media_button.py │ ├── meta.py │ ├── natural_language_understanding.py │ ├── quota.py │ ├── request.py │ ├── response.py │ ├── session.py │ └── uploaded_image.py └── utils │ ├── __init__.py │ ├── exceptions.py │ ├── helper.py │ ├── json.py │ ├── payload.py │ └── safe_kwargs.py ├── deploy_to_pypi.sh ├── examples ├── FSM_games.py ├── README-en.md ├── README.md ├── buy-elephant.py ├── card_big_image.py ├── card_items_list.py ├── get_images.py ├── handle-errors.py ├── hello-alice.py ├── quota_status_and_delete_image.py ├── skip_handler_log_everything.py └── upload_image.py ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── tests ├── _dataset.py └── test_types.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build-n-publish: 9 | name: Build and publish Python 🐍 distributions 📦 to PyPI 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 3.6 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: '3.6' 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install setuptools wheel twine 21 | - name: Build and publish 22 | env: 23 | TWINE_USERNAME: __token__ 24 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 25 | run: | 26 | python setup.py sdist bdist_wheel 27 | twine upload dist/* 28 | -------------------------------------------------------------------------------- /.github/workflows/run_tests.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | unit-tests: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [3.7, 3.8, 3.9, '3.10', '3.11'] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v3 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r requirements.txt 23 | - name: Install the package 24 | run: python setup.py install 25 | - name: Lint with flake8 26 | run: | 27 | pip install flake8 28 | # stop the build if there are Python syntax errors or undefined names 29 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 30 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 31 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 32 | - name: Test with pytest 33 | run: | 34 | pip install pytest 35 | pytest -s -vv 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | Pipfile* 106 | 107 | .idea/ 108 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Suren Khorenyan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README-PyPI.md: -------------------------------------------------------------------------------- 1 | # aioAlice 2 | 3 | ## AsyncIO library for Yandex Alice (Yandex Dialogs) 4 | 5 | 6 | ## Why? 7 | - Work with classes, don't bother parsing JSON 8 | - Auto answer to webhook even if you were not fast enough to create answer - there won't be a server error, but you'll get a log 9 | > Auto response will not help if you are not using async IO. So consider not to use any long processing synchronous tasks inside handlers 10 | - Handy handlers to match incoming commands 11 | - Finite-State Machine 12 | - Easy images upload, easy answers generation 13 | 14 | 15 | ### Installation 16 | 17 | ```bash 18 | # make sure you use virtual env and python 3.6+: 19 | python3.6 -m venv aliceenv 20 | source ./aliceenv/bin/activate 21 | 22 | pip install pip -U 23 | pip install setuptools -U 24 | pip install uvloop # uvloop if you want 25 | 26 | pip install aioalice -U 27 | # Or install from GitHub: 28 | # pip install git+https://github.com/surik00/aioalice.git -U 29 | 30 | # or if you don't have git installed: 31 | # 1. download ZIP 32 | # 2. unarchive and go to dir 33 | # 3. run: 34 | python setup.py install 35 | ``` 36 | 37 | 38 | ### Quick start 39 | 40 | [Hello alice](https://github.com/surik00/aioalice/blob/master/examples/hello-alice.py) 41 | 42 | ```python 43 | dp = Dispatcher() 44 | 45 | @dp.request_handler() 46 | async def handle_all_requests(alice_request): 47 | return alice_request.response('Hello world!') 48 | ``` 49 | 50 | 51 | ### Cards 52 | 53 | - [All examples](https://github.com/surik00/aioalice/blob/master/examples/README-en.md) 54 | 55 | - [Upload image example](https://github.com/surik00/aioalice/blob/master/examples/upload_image.py) 56 | - [Big Image Card example](https://github.com/surik00/aioalice/blob/master/examples/card_big_image.py) 57 | - [Items List Card example](https://github.com/surik00/aioalice/blob/master/examples/card_items_list.py) 58 | 59 | 60 | ### JSON serializing 61 | 62 | If you want to use a faster json library, install [rapidjson](https://github.com/python-rapidjson/python-rapidjson) or [ujson](https://github.com/esnme/ultrajson), it will be detected and used automatically 63 | 64 | ___ 65 | 66 | ### Skills using aioAlice 67 | 68 | * [The Erundopel game](https://github.com/Goodsmileduck/erundopel) 69 | 70 | 71 | ___ 72 | 73 | ### Testing and deployment 74 | 75 | 76 | In all examples the next configuration is used: 77 | 78 | ```python 79 | WEBHOOK_URL_PATH = '/my-alice-webhook/' # webhook endpoint 80 | 81 | WEBAPP_HOST = 'localhost' # running on local machine 82 | WEBAPP_PORT = 3001 # we can use any port that is not use by other apps 83 | ``` 84 | 85 | For testing purposes you can use [ngrok](https://ngrok.com/), so set webhook to `https://1a2b3c4d5e.ngrok.io/my-alice-webhook/` (endpoint has to be `WEBHOOK_URL_PATH`, because WebApp expects to get updates only there), post has to be `WEBAPP_PORT` (in this example it is 3001) 86 | 87 | 88 | For production you can use Nginx, then edit Nginx configuration and add these lines inside the `server` block: 89 | 90 | ``` 91 | location /my-alice-webhook/ { # WEBHOOK_URL_PATH 92 | proxy_pass http://127.0.0.1:3001/; # addr to reach WebApp, in this case it is localhost and port is 3001 93 | proxy_redirect off; 94 | proxy_set_header Host $host; 95 | proxy_set_header X-Real-IP $remote_addr; 96 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 97 | proxy_set_header X-Forwarded-Host $server_name; 98 | } 99 | ``` 100 | -------------------------------------------------------------------------------- /README-en.md: -------------------------------------------------------------------------------- 1 |

2 | Русский | English 3 |

4 | 5 | 6 | # aioAlice 7 | 8 | ## AsyncIO library for Yandex Alice (Yandex Dialogs) 9 | 10 | 11 | ## Why? 12 | - Work with classes, don't bother parsing JSON 13 | - Auto answer to webhook even if you were not fast enough to create answer - there won't be a server error, but you'll get a log 14 | > Auto response will not help if you are not using async IO. So consider not to use any long processing synchronous tasks inside handlers 15 | - Handy handlers to match incoming commands 16 | - Finite-State Machine 17 | - Easy images upload, easy answers generation 18 | 19 | 20 | ### Installation 21 | 22 | ```bash 23 | # make sure you use virtual env and python 3.6+: 24 | python3.6 -m venv aliceenv 25 | source ./aliceenv/bin/activate 26 | 27 | pip install pip -U 28 | pip install setuptools -U 29 | pip install uvloop # uvloop if you want, but it can cause some problems 30 | 31 | pip install aioalice -U 32 | # Or install from GitHub: 33 | # pip install git+https://github.com/surik00/aioalice.git -U 34 | 35 | # or if you don't have git installed: 36 | # 1. download ZIP 37 | # 2. unarchive and go to dir 38 | # 3. run: 39 | python setup.py install 40 | ``` 41 | 42 | 43 | ### Quick start 44 | 45 | [Hello alice](examples/hello-alice.py) 46 | 47 | ```python 48 | dp = Dispatcher() 49 | 50 | @dp.request_handler() 51 | async def handle_all_requests(alice_request): 52 | return alice_request.response('Hello world!') 53 | ``` 54 | 55 | 56 | ### Cards 57 | 58 | - [Upload image example](examples/upload_image.py) 59 | - [Big Image Card example](examples/card_big_image.py) 60 | - [Items List Card example](examples/card_items_list.py) 61 | 62 | - [All examples](examples/README-en.md) 63 | 64 | 65 | ### JSON serializing 66 | 67 | If you want to use a faster json library, install [rapidjson](https://github.com/python-rapidjson/python-rapidjson) or [ujson](https://github.com/esnme/ultrajson), it will be detected and used automatically 68 | 69 | ___ 70 | 71 | ### Skills using aioAlice 72 | 73 | * [The Erundopel game](https://github.com/Goodsmileduck/erundopel) 74 | 75 | 76 | ___ 77 | 78 | ### Testing and deployment 79 | 80 | 81 | In all examples the next configuration is used: 82 | 83 | ```python 84 | WEBHOOK_URL_PATH = '/my-alice-webhook/' # webhook endpoint 85 | 86 | WEBAPP_HOST = 'localhost' # running on local machine 87 | WEBAPP_PORT = 3001 # we can use any port that is not use by other apps 88 | ``` 89 | 90 | For testing purposes you can use [ngrok](https://ngrok.com/), so set webhook to `https://1a2b3c4d5e.ngrok.io/my-alice-webhook/` (endpoint has to be `WEBHOOK_URL_PATH`, because WebApp expects to get updates only there), post has to be `WEBAPP_PORT` (in this example it is 3001) 91 | 92 | 93 | For production you can use Nginx, then edit Nginx configuration and add these lines inside the `server` block: 94 | 95 | ``` 96 | location /my-alice-webhook/ { # WEBHOOK_URL_PATH 97 | proxy_pass http://127.0.0.1:3001/; # addr to reach WebApp, in this case it is localhost and port is 3001 98 | proxy_redirect off; 99 | proxy_set_header Host $host; 100 | proxy_set_header X-Real-IP $remote_addr; 101 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 102 | proxy_set_header X-Forwarded-Host $server_name; 103 | } 104 | ``` 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Русский | English 3 |

4 | 5 | # Яндекс Алиса. Диалоги (навыки) 6 | 7 | 8 | **aioAlice** это асинхронная библиотека для взаимодействия с Алисой 9 | 10 | 11 | ## Зачем? 12 | - Работайте с привычными классами, а не зайнимайтесь парсингом JSON'а 13 | - Автоматический ответ в вебхук, даже если вы не успели подготовить ответ вовремя - навык не вернет ошибку сервера, но запишет вам лог 14 | > Автоматический ответ сработает только при использовании async IO. Если затянется обработка в каком-то цикле или др. синхронное вычисление, это не поможет 15 | - Удобные хэндлеры - будет вызван обработчик, соответствующий полученной команде 16 | - Работа с состояниями 17 | - Легко загрузить изображение, сформировать ответ 18 | 19 | 20 | ### Установка 21 | 22 | ```bash 23 | # рекомендуется использовать virtual env и python 3.6+: 24 | python3.6 -m venv aliceenv 25 | source ./aliceenv/bin/activate 26 | 27 | pip install pip -U 28 | pip install setuptools -U 29 | pip install uvloop # uvloop при желании 30 | 31 | pip install aioalice -U 32 | # Or install from GitHub: 33 | # pip install git+https://github.com/surik00/aioalice.git -U 34 | 35 | # Если git не установлен: 36 | # 1. скачайте ZIP 37 | # 2. разархивируйте и перейдите в папку 38 | # 3. выполните следующую команду: 39 | python setup.py install 40 | ``` 41 | 42 | 43 | ### Быстрый старт 44 | 45 | [Пример простейшего навыка](examples/hello-alice.py) 46 | 47 | ```python 48 | dp = Dispatcher() 49 | 50 | @dp.request_handler() 51 | async def handle_all_requests(alice_request): 52 | return alice_request.response('Привет этому миру!') 53 | ``` 54 | 55 | 56 | ### Карточки 57 | 58 | - [Пример загрузки изображения](examples/upload_image.py) 59 | - [Пример карточки с одним большим изображением](examples/card_big_image.py) 60 | - [Пример карточки с альбомом из нескольких изображений](examples/card_items_list.py) 61 | 62 | - [Все примеры](examples/README.md) 63 | 64 | 65 | ### JSON serializing 66 | 67 | Если вы хотите использовать более быструю библиотеку для работы с JSON, установите [rapidjson](https://github.com/python-rapidjson/python-rapidjson) или [ujson](https://github.com/esnme/ultrajson). Библиотека определится и будет использована автоматически. 68 | 69 | ___ 70 | 71 | ### Навыки с использованием aioAlice 72 | 73 | * [Игра в Ерундопель](https://github.com/Goodsmileduck/erundopel) 74 | 75 | 76 | ___ 77 | 78 | ### Тестирование и деплой 79 | 80 | 81 | В примерах используется следующая конфигурация: 82 | 83 | ```python 84 | WEBHOOK_URL_PATH = '/my-alice-webhook/' # webhook endpoint 85 | 86 | WEBAPP_HOST = 'localhost' # запускаем на локальной машине 87 | WEBAPP_PORT = 3001 # испльзуем любой не занятый порт 88 | ``` 89 | 90 | Для тестирования можно использовать [ngrok](https://ngrok.com/), тогда вебхук нужно будет установить на `https://1a2b3c4d5e.ngrok.io/my-alice-webhook/` (endpoint должен быть `WEBHOOK_URL_PATH`, так как WebApp ожидает получать обновления именно там), порт в настройках нужно указать `WEBAPP_PORT` (в данном случае 3001) 91 | 92 | 93 | Для продакшена можно использовать Nginx, тогда в конфигурации Nginx внутри блока `server` необходимо добавить: 94 | 95 | ``` 96 | location /my-alice-webhook/ { # WEBHOOK_URL_PATH 97 | proxy_pass http://127.0.0.1:3001/; # адрес до запущенного WebApp, в нашем случае это localhost и порт 3001 98 | proxy_redirect off; 99 | proxy_set_header Host $host; 100 | proxy_set_header X-Real-IP $remote_addr; 101 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 102 | proxy_set_header X-Forwarded-Host $server_name; 103 | } 104 | ``` 105 | -------------------------------------------------------------------------------- /aioalice/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aioalice.dispatcher import Dispatcher 4 | from aioalice.dispatcher.webhook import get_new_configured_app, configure_app 5 | 6 | try: 7 | import uvloop 8 | except ImportError: 9 | pass 10 | else: 11 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 12 | 13 | 14 | __version__ = '1.5.1' 15 | -------------------------------------------------------------------------------- /aioalice/dispatcher/__init__.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import asyncio 3 | 4 | from aioalice.dispatcher import api 5 | from aioalice.dispatcher.handler import Handler, SkipHandler 6 | from aioalice.dispatcher.storage import DisabledStorage, MemoryStorage, DEFAULT_STATE 7 | from aioalice.dispatcher.filters import generate_default_filters, ExceptionsFilter 8 | from aioalice.utils import json, exceptions 9 | from aioalice.types import UploadedImage, Quota 10 | 11 | 12 | class Dispatcher: 13 | 14 | def __init__(self, loop=None, storage=None, *, skill_id=None, oauth_token=None): 15 | # TODO: include default handler for 'test' commands 16 | # TODO: create default handler for exceptions handler 17 | self.loop = loop or asyncio.get_event_loop() 18 | self.storage = storage or DisabledStorage() 19 | self.requests_handlers = Handler() 20 | self.errors_handlers = Handler() 21 | 22 | self.skill_id = skill_id 23 | self.oauth_token = oauth_token 24 | 25 | self.__session = None # Lazy initialize session 26 | 27 | @property 28 | def session(self): 29 | if self.__session is None: 30 | self.__session = aiohttp.ClientSession( 31 | loop=self.loop, json_serialize=json.dumps 32 | ) 33 | return self.__session 34 | 35 | async def close(self): 36 | """ 37 | Close all client sessions 38 | 39 | If doing any requests outside of web app don't forget 40 | to close session manually by calling `await dp.close` 41 | """ 42 | if self.__session and not self.__session.closed: 43 | await self.__session.close() 44 | 45 | async def shutdown(self, webapp): 46 | await self.close() 47 | 48 | async def process_request(self, request): 49 | try: 50 | return await self.requests_handlers.notify(request) 51 | except Exception as e: 52 | result = await self.errors_handlers.notify(request, e) 53 | if result: 54 | return result 55 | raise 56 | 57 | def register_request_handler(self, callback, *, commands=None, contains=None, starts_with=None, request_type=None, 58 | func=None, state=DEFAULT_STATE, regexp=None, custom_filters=None, **kwargs): 59 | """ 60 | Register handler for AliceRequest 61 | 62 | .. code-block:: python3 63 | dp = Dispatcher() 64 | 65 | async def handle_all_requests(alice_request): 66 | return alice_request.response('Hello world!') 67 | 68 | dp.register_request_handler(handle_all_requests) 69 | 70 | 71 | :param callback: function to process request 72 | :param commands: list of commands 73 | :param contains: list of lines to search in commands `any([line in command for line in contains])` 74 | :param starts_with: list of lines to check if command starts with any of them 75 | :param request_type: Type of the request can be 'SimpleUtterance' or 'ButtonPressed' 76 | :param func: any callable object (for custom checks) 77 | :param state: For FSM 78 | :param regexp: REGEXP 79 | :param custom_filters: list of custom filters 80 | :param kwargs: 81 | """ 82 | if custom_filters is None: 83 | custom_filters = [] 84 | 85 | prepared_filers = generate_default_filters( 86 | self, *custom_filters, commands=commands, contains=contains, 87 | starts_with=starts_with, request_type=request_type, 88 | func=func, state=state, regexp=regexp, **kwargs 89 | ) 90 | self.requests_handlers.register(callback, prepared_filers) 91 | 92 | def request_handler(self, *custom_filters, commands=None, contains=None, starts_with=None, 93 | request_type=None, func=None, state=DEFAULT_STATE, regexp=None, **kwargs): 94 | """ 95 | Decorator AliceRequest handler 96 | 97 | .. code-block:: python3 98 | dp = Dispatcher() 99 | 100 | @dp.request_handler() 101 | async def handle_all_requests(alice_request): 102 | return alice_request.response('Hello world!') 103 | 104 | 105 | :param callback: function to process request 106 | :param commands: list of commands 107 | :param contains: list of lines to search in commands `any([line in command for line in contains])` 108 | :param starts_with: list of lines to check if command starts with any of them 109 | :param request_type: Type of the request can be 'SimpleUtterance' or 'ButtonPressed' 110 | :param func: any callable object (for custom checks) 111 | :param state: For FSM 112 | :param regexp: REGEXP 113 | :param custom_filters: list of custom filters 114 | :param kwargs: 115 | :return: decorated function 116 | """ 117 | def decorator(callback): 118 | self.register_request_handler( 119 | callback, commands=commands, contains=contains, starts_with=starts_with, request_type=request_type, 120 | func=func, state=state, regexp=regexp, custom_filters=custom_filters, **kwargs 121 | ) 122 | return callback 123 | 124 | return decorator 125 | 126 | def register_errors_handler(self, callback, *, exception=None, func=None): 127 | """ 128 | Register handler for errors 129 | 130 | :param callback: 131 | :param exception: you can make handler for specific errors type 132 | :param func: any callable object (for custom checks) 133 | """ 134 | filters_list = [] 135 | if func is not None: 136 | filters_list.append(func) 137 | if exception is not None: 138 | filters_list.append(ExceptionsFilter(exception)) 139 | self.errors_handlers.register(callback, filters_list) 140 | 141 | def errors_handler(self, exception=None, func=None): 142 | """ 143 | Decorator for errors handler 144 | 145 | :param func: 146 | :param exception: you can make handler for specific errors type 147 | :param run_task: run callback in task (no wait results) 148 | :return: decorated function 149 | """ 150 | 151 | def decorator(callback): 152 | self.register_errors_handler(callback, exception=exception, func=func) 153 | return callback 154 | 155 | return decorator 156 | 157 | def _check_auth(self, skill_id, oauth_token): 158 | skill_id = skill_id or self.skill_id 159 | oauth_token = oauth_token or self.oauth_token 160 | if not (skill_id and oauth_token): 161 | raise exceptions.AuthRequired('Please provide both skill_id and oauth_token') 162 | return skill_id, oauth_token 163 | 164 | async def get_images(self, skill_id=None, oauth_token=None): 165 | """ 166 | Get uploaded images 167 | 168 | :param skill_id: Provide if was not set at the Dispatcher init 169 | :type skill_id: :obj:`str` 170 | :param oauth_token: Provide if was not set at the Dispatcher init 171 | :type oauth_token: :obj:`str` 172 | 173 | :return: list of UploadedImage instances 174 | """ 175 | skill_id, oauth_token = self._check_auth(skill_id, oauth_token) 176 | result = await api.request( 177 | self.session, oauth_token, skill_id, 178 | api.Methods.IMAGES, request_method='GET' 179 | ) 180 | if 'images' not in result: 181 | raise exceptions.ApiChanged(f'Expected "images" in result, got {result}') 182 | return [UploadedImage(**dct) for dct in result['images']] 183 | 184 | async def upload_image(self, image_url_or_bytes, skill_id=None, oauth_token=None): 185 | """ 186 | Upload image by either url or bytes 187 | 188 | :param image_url_or_bytes: Image URL or bytes 189 | :type image_url_or_bytes: :obj:`str` or `io.BytesIO` 190 | :param skill_id: Provide if was not set at the Dispatcher init 191 | :type skill_id: :obj:`str` 192 | :param oauth_token: Provide if was not set at the Dispatcher init 193 | :type oauth_token: :obj:`str` 194 | 195 | :return: UploadedImage 196 | """ 197 | skill_id, oauth_token = self._check_auth(skill_id, oauth_token) 198 | json = None 199 | file = None 200 | if isinstance(image_url_or_bytes, str): 201 | json = {'url': image_url_or_bytes} 202 | else: 203 | file = image_url_or_bytes 204 | result = await api.request( 205 | self.session, oauth_token, skill_id, 206 | api.Methods.IMAGES, json, file 207 | ) 208 | if 'image' not in result: 209 | raise exceptions.ApiChanged(f'Expected "image" in result, got {result}') 210 | return UploadedImage(**result['image']) 211 | 212 | async def get_images_quota(self, oauth_token=None): 213 | """ 214 | Get images storage quota 215 | 216 | :param oauth_token: Provide if was not set at the Dispatcher init 217 | :type oauth_token: :obj:`str` 218 | 219 | :return: Quota 220 | """ 221 | oauth_token = oauth_token or self.oauth_token 222 | if oauth_token is None: 223 | raise exceptions.AuthRequired('Please provide oauth_token') 224 | 225 | result = await api.request( 226 | self.session, oauth_token, request_method='GET', 227 | custom_url=api.BASE_URL + api.Methods.STATUS 228 | ) 229 | if 'images' not in result or 'quota' not in result['images']: 230 | raise exceptions.ApiChanged(f'Expected "images" "quota" in result, got {result}') 231 | return Quota(**result['images']['quota']) 232 | 233 | async def delete_image(self, image_id, skill_id=None, oauth_token=None): 234 | """ 235 | Delete image by id 236 | 237 | :param image_id: Image id to be deleted 238 | :type image_id: :obj:`str` 239 | :param skill_id: Provide if was not set at the Dispatcher init 240 | :type skill_id: :obj:`str` 241 | :param oauth_token: Provide if was not set at the Dispatcher init 242 | :type oauth_token: :obj:`str` 243 | 244 | :return: True if result is ok 245 | """ 246 | skill_id, oauth_token = self._check_auth(skill_id, oauth_token) 247 | url = api.Methods.api_url(skill_id, api.Methods.IMAGES) + image_id 248 | result = await api.request( 249 | self.session, oauth_token, 250 | request_method='DELETE', custom_url=url 251 | ) 252 | return result['result'] == 'ok' if 'result' in result else False 253 | -------------------------------------------------------------------------------- /aioalice/dispatcher/api.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import logging 3 | from http import HTTPStatus 4 | 5 | from aioalice.utils import json, exceptions 6 | from aioalice.utils.helper import Helper, HelperMode, Item 7 | 8 | BASE_URL = 'https://dialogs.yandex.net/api/v1/' 9 | API_URL = BASE_URL + 'skills/{skill_id}/{method}/' 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | async def _check_result(response): 15 | body = await response.text() 16 | if response.content_type != 'application/json': 17 | log.error('Invalid response with content type %r: %r', 18 | response.content_type, body) 19 | exceptions.DialogsAPIError.detect(body) 20 | 21 | try: 22 | result_json = await response.json(loads=json.loads) 23 | except ValueError: 24 | result_json = {} 25 | 26 | log.debug('Request result is %r', result_json) 27 | 28 | if HTTPStatus.OK <= response.status <= HTTPStatus.IM_USED: 29 | return result_json 30 | if result_json and 'message' in result_json: 31 | description = result_json['message'] 32 | else: 33 | description = body 34 | 35 | log.warning('Response status %r with description %r', 36 | response.status, description) 37 | exceptions.DialogsAPIError.detect(description) 38 | 39 | 40 | async def request(session, oauth_token, skill_id=None, method=None, json=None, 41 | file=None, request_method='POST', custom_url=None, **kwargs): 42 | """ 43 | Make a request to API 44 | 45 | :param session: HTTP Client session 46 | :type session: :obj:`aiohttp.ClientSession` 47 | :param oauth_token: oauth_token 48 | :type oauth_token: :obj:`str` 49 | :param skill_id: skill_id. Optional. Not used if custom_url is provided 50 | :type skill_id: :obj:`str` 51 | :param method: API method. Optional. Not used if custom_url is provided 52 | :type method: :obj:`str` 53 | :param json: request payload 54 | :type json: :obj: `dict` 55 | :param file: file 56 | :type file: :obj: `io.BytesIO` 57 | :param request_method: API request method 58 | :type request_method: :obj:`str` 59 | :param custom_url: Yandex has very developer UNfriendly API, so some endpoints cannot be achieved by standatd template. 60 | :type custom_url: :obj:`str` 61 | :return: result 62 | :rtype: ::obj:`dict` 63 | """ 64 | log.debug("Making a `%s` request to %r with json `%r` or file `%r`", 65 | request_method, method, json, file) 66 | if custom_url is None: 67 | url = Methods.api_url(skill_id, method) 68 | else: 69 | url = custom_url 70 | headers = {'Authorization': oauth_token} 71 | data = None 72 | if file: 73 | data = aiohttp.FormData() 74 | data.add_field('file', file) 75 | try: 76 | async with session.request(request_method, url, json=json, data=data, headers=headers, **kwargs) as response: 77 | return await _check_result(response) 78 | except aiohttp.ClientError as e: 79 | raise exceptions.NetworkError(f"aiohttp client throws an error: {e.__class__.__name__}: {e}") 80 | 81 | 82 | class Methods(Helper): 83 | 84 | mode = HelperMode.lowerCamelCase 85 | 86 | IMAGES = Item() # images 87 | STATUS = Item() # status 88 | 89 | @staticmethod 90 | def api_url(skill_id, method): 91 | """ 92 | Generate API URL with skill_id and method 93 | 94 | :param skill_id: 95 | :param method: 96 | :return: 97 | """ 98 | return API_URL.format(skill_id=skill_id, method=method) 99 | -------------------------------------------------------------------------------- /aioalice/dispatcher/filters.py: -------------------------------------------------------------------------------- 1 | import re 2 | import inspect 3 | import logging 4 | 5 | from aioalice.utils.helper import Helper, HelperMode, Item 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | async def check_filter(filter_, args): 11 | """ 12 | Helper for executing filter 13 | 14 | :param filter_: 15 | :param args: 16 | :param kwargs: 17 | :return: 18 | """ 19 | if not callable(filter_): 20 | raise TypeError(f'Filter must be callable and/or awaitable! Error with {filter_}') 21 | 22 | if inspect.isawaitable(filter_) or inspect.iscoroutinefunction(filter_): 23 | return await filter_(*args) 24 | else: 25 | return filter_(*args) 26 | 27 | 28 | async def check_filters(filters, args): 29 | """ 30 | Check list of filters 31 | 32 | :param filters: 33 | :param args: 34 | :return: 35 | """ 36 | if filters is not None: 37 | for f in filters: 38 | filter_result = await check_filter(f, args) 39 | if not filter_result: 40 | return False 41 | return True 42 | 43 | 44 | class Filter: 45 | """ 46 | Base class for filters 47 | """ 48 | 49 | def __call__(self, *args, **kwargs): 50 | return self.check(*args, **kwargs) 51 | 52 | def check(self, *args, **kwargs): 53 | raise NotImplementedError 54 | 55 | 56 | class AsyncFilter(Filter): 57 | """ 58 | Base class for asynchronous filters 59 | """ 60 | 61 | def __aiter__(self): 62 | return None 63 | 64 | def __await__(self): 65 | return self.check 66 | 67 | async def check(self, *args, **kwargs): 68 | raise NotImplementedError 69 | 70 | 71 | class StringCompareFilter(AsyncFilter): 72 | """ 73 | Base for string comparison 74 | """ 75 | 76 | def __init__(self, lines): 77 | self.lines = [w.lower() for w in lines] 78 | 79 | 80 | class StartsWithFilter(StringCompareFilter): 81 | """ 82 | Check if command starts with one of these lines 83 | """ 84 | 85 | async def check(self, areq): 86 | command = areq.request.command.lower() 87 | return any([command.startswith(line) for line in self.lines]) 88 | 89 | 90 | class ContainsFilter(StringCompareFilter): 91 | """ 92 | Check if command contains one of these lines 93 | """ 94 | 95 | async def check(self, areq): 96 | command = areq.request.command.lower() 97 | return any([line in command for line in self.lines]) 98 | 99 | 100 | class CommandsFilter(AsyncFilter): 101 | """ 102 | Check if command is one of these phrases 103 | Pass commands in lower case 104 | """ 105 | 106 | def __init__(self, commands): 107 | self.commands = commands 108 | 109 | async def check(self, areq): 110 | command = areq.request.command.lower() 111 | return command in self.commands 112 | 113 | 114 | class StateFilter(AsyncFilter): 115 | """Check user's state""" 116 | 117 | def __init__(self, dispatcher, state): 118 | self.dispatcher = dispatcher 119 | self.state = state 120 | 121 | async def check(self, areq): 122 | if self.state == '*': 123 | return True 124 | user_state = await self.dispatcher.storage.get_state(areq.session.user_id) 125 | return user_state == self.state 126 | 127 | 128 | class StatesListFilter(StateFilter): 129 | """Check if user's state is in list of states""" 130 | 131 | async def check(self, areq): 132 | user_state = await self.dispatcher.storage.get_state(areq.session.user_id) 133 | return user_state in self.state 134 | 135 | 136 | class RegexpFilter(Filter): 137 | """ 138 | Regexp filter for original_utterance (full request text) 139 | If `AliceRequest.request.original_utterance` matches regular expression 140 | """ 141 | 142 | def __init__(self, regexp): 143 | self.regexp = re.compile(regexp, flags=re.IGNORECASE | re.MULTILINE) 144 | 145 | def check(self, areq): 146 | return bool(self.regexp.search(areq.request.original_utterance)) 147 | 148 | 149 | class RequestTypeFilter(Filter): 150 | """ 151 | Check AliceRequest.request type 152 | On API 1.0 it can be 'SimpleUtterance' or 'ButtonPressed' 153 | """ 154 | 155 | def __init__(self, content_types): 156 | if isinstance(content_types, str): 157 | content_types = [content_types] 158 | self.content_types = content_types 159 | 160 | def check(self, areq): 161 | return areq.request.type in self.content_types 162 | 163 | 164 | class ExceptionsFilter(Filter): 165 | """ 166 | Filter for exceptions 167 | """ 168 | 169 | def __init__(self, exception): 170 | self.exception = exception 171 | 172 | def check(self, dispatcher, update, exception): 173 | return isinstance(exception, self.exception) 174 | 175 | 176 | def generate_default_filters(dispatcher, *args, **kwargs): 177 | """ 178 | Prepare filters 179 | 180 | :param dispatcher: for states 181 | :param args: 182 | :param kwargs: 183 | :return: 184 | """ 185 | filters_list = [] 186 | 187 | for name, filter_data in kwargs.items(): 188 | if filter_data is None: 189 | # skip not setted filter names 190 | # Note that state by default is not None, 191 | # check dispatcher.storage for more information 192 | continue 193 | 194 | if name == DefaultFilters.REQUEST_TYPE: 195 | filters_list.append(RequestTypeFilter(filter_data)) 196 | elif name == DefaultFilters.COMMANDS: 197 | if isinstance(filter_data, str): 198 | filters_list.append(CommandsFilter([filter_data])) 199 | else: 200 | filters_list.append(CommandsFilter(filter_data)) 201 | elif name == DefaultFilters.STARTS_WITH: 202 | if isinstance(filter_data, str): 203 | filters_list.append(StartsWithFilter([filter_data])) 204 | else: 205 | filters_list.append(StartsWithFilter(filter_data)) 206 | elif name == DefaultFilters.CONTAINS: 207 | if isinstance(filter_data, str): 208 | filters_list.append(ContainsFilter([filter_data])) 209 | else: 210 | filters_list.append(ContainsFilter(filter_data)) 211 | elif name == DefaultFilters.STATE: 212 | if isinstance(filter_data, (list, set, tuple, frozenset)): 213 | filters_list.append(StatesListFilter(dispatcher, filter_data)) 214 | else: 215 | filters_list.append(StateFilter(dispatcher, filter_data)) 216 | elif name == DefaultFilters.FUNC: 217 | filters_list.append(filter_data) 218 | elif name == DefaultFilters.REGEXP: 219 | filters_list.append(RegexpFilter(filter_data)) 220 | elif isinstance(filter_data, Filter): 221 | filters_list.append(filter_data) 222 | else: 223 | log.warning('Unexpected filter with name %r of type `%r` (%s)', 224 | name, type(filter_data), filter_data) 225 | 226 | filters_list += list(args) # Some custom filters 227 | 228 | return filters_list 229 | 230 | 231 | class DefaultFilters(Helper): 232 | mode = HelperMode.snake_case 233 | 234 | REQUEST_TYPE = Item() # request_type 235 | STARTS_WITH = Item() # starts_with 236 | CONTAINS = Item() # contains 237 | COMMANDS = Item() # commands 238 | REGEXP = Item() # regexp 239 | STATE = Item() # state 240 | FUNC = Item() # func 241 | -------------------------------------------------------------------------------- /aioalice/dispatcher/handler.py: -------------------------------------------------------------------------------- 1 | from aioalice.dispatcher.filters import check_filters 2 | 3 | 4 | class SkipHandler(BaseException): 5 | """Raise this exception if the handler needs to be skipped""" 6 | 7 | 8 | class Handler: 9 | 10 | def __init__(self): 11 | self.handlers = [] 12 | 13 | def register(self, handler, filters=None, index=None): 14 | """ 15 | Register callback 16 | 17 | Filters can be awaitable or not. 18 | 19 | :param handler: coroutine 20 | :param filters: list of filters 21 | :param index: you can reorder handlers 22 | """ 23 | if filters and not isinstance(filters, (list, tuple, set)): 24 | filters = [filters] 25 | record = (filters, handler) 26 | if index is None: 27 | self.handlers.append(record) 28 | else: 29 | self.handlers.insert(index, record) 30 | 31 | def unregister(self, handler): 32 | """ 33 | Remove handler 34 | 35 | :param handler: callback 36 | :return: 37 | """ 38 | for handler_with_filters in self.handlers: 39 | _, registered = handler_with_filters 40 | if handler is registered: 41 | self.handlers.remove(handler_with_filters) 42 | return True 43 | raise ValueError('This handler is not registered!') 44 | 45 | async def notify(self, *args): 46 | """ 47 | Notify handlers 48 | 49 | :param args: 50 | :return: instance of AliceResponse 51 | You *have* to return something to answer to API 52 | Consider returning AliceResponse or prepared JSON 53 | """ 54 | 55 | for filters, handler in self.handlers: 56 | if await check_filters(filters, args): 57 | try: 58 | return await handler(*args) 59 | except SkipHandler: 60 | continue 61 | -------------------------------------------------------------------------------- /aioalice/dispatcher/storage.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # This state will be default instead of `None` to gain maximum speed 4 | # So if handler is registered with `state=None`, 5 | # no State filter will be applied by default 6 | # use `DEFAULT_STATE` for FSM, else no state check will be applied 7 | DEFAULT_STATE = 'DEFAULT_STATE' 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class BaseStorage: 13 | """ 14 | You are able to save current user's state 15 | and data for all steps in states storage 16 | """ 17 | 18 | async def close(self): 19 | """ 20 | You have to override this method and use when application shutdowns. 21 | Perhaps you would like to save data and etc. 22 | 23 | :return: 24 | """ 25 | raise NotImplementedError 26 | 27 | async def wait_closed(self): 28 | """ 29 | You have to override this method for all asynchronous storages (e.g., Redis). 30 | 31 | :return: 32 | """ 33 | raise NotImplementedError 34 | 35 | async def get_state(self, user_id): 36 | """ 37 | Get current state of user. 38 | Default is `DEFAULT_STATE` 39 | 40 | :param user_id: 41 | :param default: 42 | :return: 43 | """ 44 | raise NotImplementedError 45 | 46 | async def get_data(self, user_id): 47 | """ 48 | Get state data for user. 49 | Default is `{}` 50 | 51 | :param user_id: 52 | :param default: 53 | :return: 54 | """ 55 | raise NotImplementedError 56 | 57 | async def set_state(self, user_id, state): 58 | """ 59 | Set new state for user 60 | 61 | :param user_id: 62 | :param state: 63 | """ 64 | raise NotImplementedError 65 | 66 | async def set_data(self, user_id, data): 67 | """ 68 | Set data for user 69 | 70 | :param user_id: 71 | :param data: 72 | """ 73 | raise NotImplementedError 74 | 75 | async def update_data(self, user_id, data=None, **kwargs): 76 | """ 77 | Update data for user 78 | 79 | You can use data parameter or|and kwargs. 80 | 81 | :param data: 82 | :param user_id: 83 | :param kwargs: 84 | :return: 85 | """ 86 | raise NotImplementedError 87 | 88 | async def reset_data(self, user_id): 89 | """ 90 | Reset data for user. 91 | 92 | :param user_id: 93 | :return: 94 | """ 95 | await self.set_data(user_id, data={}) 96 | 97 | async def reset_state(self, user_id, with_data=False): 98 | """ 99 | Reset state for user. 100 | You may desire to use this method when finishing conversations. 101 | 102 | :param user_id: 103 | :param with_data: 104 | :return: 105 | """ 106 | await self.set_state(user_id, state=DEFAULT_STATE) 107 | if with_data: 108 | await self.reset_data(user_id) 109 | 110 | async def finish(self, user_id): 111 | """ 112 | Finish conversation with user. 113 | 114 | :param user_id: 115 | :return: 116 | """ 117 | await self.reset_state(user_id, with_data=True) 118 | 119 | 120 | class DisabledStorage(BaseStorage): 121 | """ 122 | Empty storage. Use it if you don't want to use Finite-State Machine 123 | """ 124 | 125 | async def close(self): 126 | pass 127 | 128 | async def wait_closed(self): 129 | pass 130 | 131 | async def get_state(self, user_id): 132 | return DEFAULT_STATE 133 | 134 | async def get_data(self, user_id): 135 | self._warn() 136 | return {} 137 | 138 | async def set_state(self, user_id, state): 139 | self._warn() 140 | 141 | async def set_data(self, user_id, data): 142 | self._warn() 143 | 144 | async def update_data(self, user_id, data=None, **kwargs): 145 | self._warn() 146 | 147 | @staticmethod 148 | def _warn(): 149 | log.warning("You haven’t set any storage yet so no states and no data will be saved.\n" 150 | "You can connect MemoryStorage for debug purposes or non-essential data.") 151 | 152 | 153 | class MemoryStorage(BaseStorage): 154 | """In-memory states storage""" 155 | 156 | def __init__(self): 157 | self.data = {} 158 | 159 | async def close(self): 160 | self.data.clear() 161 | 162 | async def wait_closed(self): 163 | pass 164 | 165 | def _get_user_data(self, user_id): 166 | if user_id not in self.data: 167 | self.data[user_id] = {'state': DEFAULT_STATE, 'data': {}} 168 | return self.data[user_id] 169 | 170 | async def get_state(self, user_id): 171 | user = self._get_user_data(user_id) 172 | return user['state'] 173 | 174 | async def get_data(self, user_id): 175 | user = self._get_user_data(user_id) 176 | return user['data'] 177 | 178 | async def set_state(self, user_id, state): 179 | user = self._get_user_data(user_id) 180 | user['state'] = state 181 | 182 | async def set_data(self, user_id, data): 183 | user = self._get_user_data(user_id) 184 | user['data'] = data 185 | 186 | async def update_data(self, user_id, data=None, **kwargs): 187 | user = self._get_user_data(user_id) 188 | if data is None: 189 | data = {} 190 | user['data'].update(data, **kwargs) 191 | -------------------------------------------------------------------------------- /aioalice/dispatcher/webhook.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import functools 4 | import sys 5 | from typing import TYPE_CHECKING 6 | 7 | from aiohttp import web 8 | 9 | from aioalice.utils import json, generate_json_payload 10 | from aioalice.types import AliceRequest, AliceResponse, Response 11 | 12 | if sys.version_info >= (3, 7): 13 | from asyncio import CancelledError 14 | else: 15 | from asyncio.futures import CancelledError 16 | 17 | 18 | if TYPE_CHECKING: 19 | from aioalice import Dispatcher 20 | 21 | 22 | log = logging.getLogger(__name__) 23 | 24 | 25 | DEFAULT_WEB_PATH = '/alicewh/' 26 | ALICE_DISPATCHER_KEY = 'ALICE_DISPATCHER' 27 | ERROR_RESPONSE_KEY = 'ALICE_ERROR_RESPONSE' 28 | 29 | DEFAULT_ERROR_RESPONSE_TEXT = 'Server error. Developer has to check logs.' 30 | # Max time to response to API is 3s: https://yandex.ru/blog/dialogs/bolshe-vremeni-na-otvet-time-out-3-sekundy 31 | # with server on Aruba (Italy) whole processing (with networking) takes about 0.3s 32 | # NOTE that this timeout can help only if using non-blocking IO 33 | # in e.g use asyncio.sleep instead of time.sleep, aiohttp instead of requests, etc 34 | # Whole processing usually takes from 0.0004 до 0.001 (depends on system IO), 35 | # but Yandex starts countdown the moment when user asks a question 36 | # networking takes some time 37 | RESPONSE_TIMEOUT = 2.7 38 | 39 | 40 | class WebhookRequestHandler(web.View): 41 | """ 42 | Simple Wehhook request handler for aiohttp web server. 43 | 44 | You need to register that in app: 45 | 46 | .. code-block:: python3 47 | 48 | app.router.add_route('*', '/your/webhook/path', WebhookRequestHadler, name='webhook_handler') 49 | 50 | But first you need to configure application for getting Dispatcher instance from request handler! 51 | It must always be with key 'ALICE_DISPATCHER' 52 | 53 | .. code-block:: python3 54 | 55 | dp = Dispatcher() 56 | app['ALICE_DISPATCHER'] = dp 57 | 58 | """ 59 | 60 | def get_dispatcher(self) -> "Dispatcher": 61 | """ 62 | Get Dispatcher instance from environment 63 | """ 64 | return self.request.app[ALICE_DISPATCHER_KEY] 65 | 66 | async def parse_request(self): 67 | """ 68 | Read request from stream and deserialize it. 69 | :return: :class:`aioalice.types.AliceRequest` 70 | """ 71 | data = await self.request.json() 72 | try: 73 | return AliceRequest(self.request, **data) 74 | except Exception: 75 | log.exception('Exception loading AliceRequest from\n%r', data) 76 | raise 77 | 78 | async def process_request(self, request): 79 | """ 80 | You have to respond in less than 3 seconds to webhook. 81 | 82 | So... If you process longer than 2.7 (RESPONSE_TIMEOUT) seconds 83 | webhook automatically respond with FALLBACK VALUE (ERROR_RESPONSE_KEY) 84 | 85 | :param request: 86 | :return: 87 | """ 88 | dispatcher: "Dispatcher" = self.get_dispatcher() 89 | loop = dispatcher.loop 90 | 91 | # Analog of `asyncio.wait_for` but without cancelling task 92 | waiter = loop.create_future() 93 | timeout_handle = loop.call_later(RESPONSE_TIMEOUT, asyncio.tasks._release_waiter, waiter) 94 | done_cb = functools.partial(asyncio.tasks._release_waiter, waiter) 95 | 96 | fut = asyncio.ensure_future(dispatcher.process_request(request), loop=loop) 97 | fut.add_done_callback(done_cb) 98 | 99 | try: 100 | try: 101 | await waiter 102 | except CancelledError: 103 | fut.remove_done_callback(done_cb) 104 | fut.cancel() 105 | raise 106 | 107 | if fut.done(): 108 | return fut.result() 109 | else: 110 | fut.remove_done_callback(done_cb) 111 | fut.add_done_callback(self.warn_slow_process(request)) 112 | finally: 113 | timeout_handle.cancel() 114 | 115 | def warn_slow_process(self, request): 116 | """ 117 | Wrapper for slow requests warning 118 | """ 119 | 120 | def slow_request_processor(task): 121 | """ 122 | Handle response after 2.7 sec (RESPONSE_TIMEOUT) 123 | 124 | :param task: 125 | :return: 126 | """ 127 | log.warning('Long request processing detected.\n' 128 | 'Request was %s\nYou have to process request in %ss\n' 129 | 'request was automatically responded with `ERROR_RESPONSE_KEY`', 130 | request, RESPONSE_TIMEOUT) 131 | 132 | dispatcher = self.get_dispatcher() 133 | loop = dispatcher.loop 134 | 135 | try: 136 | result = task.result() 137 | except Exception as e: 138 | log.info('Slow request processor raised an error, passing to errors_handlers') 139 | loop.create_task(dispatcher.errors_handlers.notify(dispatcher, request, e)) 140 | else: 141 | log.warning('Result is %s', result) 142 | 143 | return slow_request_processor 144 | 145 | def default_error_response(self, alice_request): 146 | """ 147 | Default error response will be called on timeout 148 | if processing of the request will take more than 2.7s (RESPONSE_TIMEOUT) 149 | 150 | :param result: dict or AliceRequest 151 | :return: AliceResponse 152 | """ 153 | default_response = self.request.app[ERROR_RESPONSE_KEY] 154 | response = alice_request.response(default_response) 155 | return generate_json_payload(**response.to_json()) 156 | 157 | def get_response(self, result, request): 158 | """ 159 | Make response object from result. 160 | 161 | :param result: dict or AliceResponse 162 | :return: 163 | """ 164 | if isinstance(result, AliceResponse): 165 | return generate_json_payload(**result.to_json()) 166 | if result is None: 167 | log.critical('Got `None` instead of a response!\nGenerating' 168 | ' default error response based on %r', request) 169 | return self.default_error_response(request) 170 | if not isinstance(result, dict): 171 | # If result is not a dict, it may cause an error. Warn developer 172 | log.warning('Result expected `AliceResponse` or dict, got %r (%r)', 173 | type(result), result) 174 | return result 175 | 176 | async def post(self): 177 | """ 178 | Process POST response 179 | 180 | :return: :class:`aiohttp.web.Response` 181 | """ 182 | request = await self.parse_request() 183 | result = await self.process_request(request) 184 | # request has to be passed to generate fallback value 185 | # if None is returned from process_request or on timeout 186 | response = self.get_response(result, request) 187 | return web.json_response(response, dumps=json.dumps) 188 | 189 | 190 | def configure_app(app, dispatcher, path=DEFAULT_WEB_PATH, 191 | default_response_or_text=DEFAULT_ERROR_RESPONSE_TEXT): 192 | """ 193 | You can prepare web.Application for working with webhook handler. 194 | 195 | :param app: :class:`aiohttp.web.Application` 196 | :param dispatcher: Dispatcher instance 197 | :param path: Path to your webhook. 198 | :default_response_or_text: `aioalice.types.Response` OR text to answer user on fail or timeout 199 | :return: 200 | """ 201 | app.on_shutdown.append(dispatcher.shutdown) 202 | app.router.add_route('*', path, WebhookRequestHandler, name='alice_webhook_handler') 203 | app[ALICE_DISPATCHER_KEY] = dispatcher 204 | # Prepare default Response 205 | if isinstance(default_response_or_text, Response): 206 | app[ERROR_RESPONSE_KEY] = default_response_or_text 207 | elif isinstance(default_response_or_text, str): 208 | app[ERROR_RESPONSE_KEY] = Response(default_response_or_text) 209 | else: 210 | response_text = str(default_response_or_text) 211 | app[ERROR_RESPONSE_KEY] = Response(response_text) 212 | log.warning('Automatically converted default_response_or_text' 213 | ' to str\nIt\'ll be %r\nConsider using string or' 214 | ' `aioalice.types.Response` next time', response_text) 215 | 216 | 217 | def get_new_configured_app(dispatcher, path=DEFAULT_WEB_PATH, 218 | default_response_or_text=DEFAULT_ERROR_RESPONSE_TEXT): 219 | """ 220 | Create new :class:`aiohttp.web.Application` and configure it. 221 | 222 | :param dispatcher: Dispatcher instance 223 | :param path: Path to your webhook. 224 | :default_response_or_text: `aioalice.types.Response` OR text to answer user on fail or timeout 225 | :return: 226 | """ 227 | app = web.Application() 228 | configure_app(app, dispatcher, path, default_response_or_text) 229 | return app 230 | -------------------------------------------------------------------------------- /aioalice/types/__init__.py: -------------------------------------------------------------------------------- 1 | from aioalice.types.base import AliceObject 2 | from aioalice.types.interfaces import Interfaces 3 | from aioalice.types.meta import Meta 4 | from aioalice.types.markup import Markup 5 | from aioalice.types.entity_tokens import EntityTokens 6 | from aioalice.types.entity_value import EntityValue 7 | from aioalice.types.entity import Entity, EntityType 8 | from aioalice.types.natural_language_understanding import NaturalLanguageUnderstanding 9 | from aioalice.types.request import Request, RequestType 10 | from aioalice.types.session import BaseSession, Session 11 | 12 | from aioalice.types.quota import Quota 13 | from aioalice.types.uploaded_image import UploadedImage 14 | from aioalice.types.media_button import MediaButton 15 | from aioalice.types.image import Image 16 | from aioalice.types.card_header import CardHeader 17 | from aioalice.types.card_footer import CardFooter 18 | from aioalice.types.card import Card, CardType 19 | 20 | from aioalice.types.button import Button 21 | from aioalice.types.response import Response 22 | 23 | from aioalice.types.alice_response import AliceResponse 24 | from aioalice.types.alice_request import AliceRequest 25 | -------------------------------------------------------------------------------- /aioalice/types/alice_request.py: -------------------------------------------------------------------------------- 1 | from aiohttp.web import Request as WebRequest 2 | from attr import attrs, attrib 3 | 4 | from aioalice.types import ( 5 | AliceObject, 6 | Meta, 7 | Session, 8 | Card, 9 | Request, 10 | Response, 11 | AliceResponse, 12 | ) 13 | from aioalice.utils import ensure_cls, safe_kwargs 14 | 15 | 16 | @safe_kwargs 17 | @attrs 18 | class AliceRequest(AliceObject): 19 | """AliceRequest is a request from Alice API""" 20 | original_request = attrib(type=WebRequest) 21 | meta = attrib(converter=ensure_cls(Meta)) 22 | request = attrib(converter=ensure_cls(Request)) 23 | session = attrib(converter=ensure_cls(Session)) 24 | version = attrib(type=str) 25 | 26 | def _response(self, response, session_state=None, user_state_update=None, application_state=None): 27 | return AliceResponse( 28 | response=response, 29 | session=self.session.base, 30 | session_state=session_state or {}, 31 | user_state_update=user_state_update or {}, 32 | application_state=application_state or {}, 33 | version=self.version, 34 | ) 35 | 36 | def response(self, response_or_text, session_state=None, user_state_update=None, application_state=None, **kwargs): 37 | """ 38 | Generate response 39 | 40 | :param response_or_text: Response or Response's text: 41 | if response_or_text is not an instance of Response, 42 | it is passed to the Response initialisator with kwargs. 43 | Otherwise it is used as a Response 44 | 45 | :param kwargs: tts, card, buttons, end_session for Response 46 | NOTE: if you want to pass card, consider using one of 47 | these methods: response_big_image, response_items_list 48 | 49 | :param session_state: Session's state 50 | :param user_state_update: User's state 51 | :param application_state: Application's state 52 | Allows to store data on Yandex's side 53 | Read more: https://yandex.ru/dev/dialogs/alice/doc/session-persistence.html 54 | 55 | :return: AliceResponse 56 | """ 57 | if not isinstance(response_or_text, Response): 58 | response_or_text = Response(response_or_text, **kwargs) 59 | return self._response(response_or_text, session_state, user_state_update, application_state) 60 | 61 | def response_big_image(self, text, image_id, title, description, button=None, 62 | session_state=None, user_state_update=None, application_state=None, **kwargs): 63 | """ 64 | Generate response with Big Image card 65 | 66 | :param text: Response's text 67 | :param image_id: Image's id for BigImage Card 68 | :param title: Image's title for BigImage Card 69 | :param description: Image's description for BigImage Card 70 | :param button: Image's button for BigImage Card 71 | :param session_state: Session's state 72 | :param user_state_update: User's state 73 | :param application_state: Application's state 74 | Allows to store data on Yandex's side 75 | Read more: https://yandex.ru/dev/dialogs/alice/doc/session-persistence.html 76 | :param kwargs: tts, buttons, end_session for Response 77 | :return: AliceResponse 78 | """ 79 | return self._response( 80 | Response( 81 | text, 82 | card=Card.big_image(image_id, title, description, button), 83 | **kwargs, 84 | ), 85 | session_state, user_state_update, application_state 86 | ) 87 | 88 | def response_items_list(self, text, header, items, footer=None, 89 | session_state=None, user_state_update=None, application_state=None, **kwargs): 90 | """ 91 | Generate response with Items List card 92 | 93 | :param text: Response's text 94 | :param header: Card's header 95 | :param items: Card's items - list of `Image` objects 96 | :param footer: Card's footer 97 | :param session_state: Session's state 98 | :param user_state_update: User's state 99 | :param application_state: Application's state 100 | Allows to store data on Yandex's side 101 | Read more: https://yandex.ru/dev/dialogs/alice/doc/session-persistence.html 102 | :param kwargs: tts, buttons, end_session for Response 103 | :return: AliceResponse 104 | """ 105 | return self._response( 106 | Response( 107 | text, 108 | card=Card.items_list(header, items, footer), 109 | **kwargs, 110 | ), 111 | session_state, user_state_update, application_state 112 | ) 113 | -------------------------------------------------------------------------------- /aioalice/types/alice_response.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject, BaseSession, Response 4 | from aioalice.utils import ensure_cls 5 | 6 | 7 | @attrs 8 | class AliceResponse(AliceObject): 9 | """AliceResponse is a response to Alice API""" 10 | 11 | response = attrib(converter=ensure_cls(Response)) 12 | session = attrib(converter=ensure_cls(BaseSession)) 13 | session_state = attrib(type=dict) 14 | user_state_update = attrib(type=dict) 15 | application_state = attrib(type=dict) 16 | version = attrib(type=str) 17 | -------------------------------------------------------------------------------- /aioalice/types/base.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib, asdict 2 | 3 | 4 | @attrs 5 | class AliceObject: 6 | """AliceObject is base class for all Alice requests related objects""" 7 | 8 | _raw_kwargs = attrib(factory=dict, init=False) 9 | """ 10 | here the raw JSON (dict) will be stored 11 | for using with compatible API 12 | """ 13 | 14 | def to_json(self): 15 | data = asdict(self, filter=filter_to_json) 16 | return data 17 | 18 | 19 | def filter_to_json(attr, value) -> bool: 20 | return attr.name != '_raw_kwargs' 21 | -------------------------------------------------------------------------------- /aioalice/types/button.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject 4 | 5 | 6 | @attrs 7 | class Button(AliceObject): 8 | """Button object""" 9 | title = attrib(type=str) 10 | url = attrib(default=None, type=str) 11 | payload = attrib(default=None) 12 | hide = attrib(default=True, type=bool) 13 | -------------------------------------------------------------------------------- /aioalice/types/card.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject, MediaButton, Image, CardHeader, CardFooter 4 | from aioalice.utils import ensure_cls 5 | from aioalice.utils.helper import Helper, HelperMode, Item 6 | 7 | 8 | @attrs 9 | class Card(AliceObject): 10 | """This object represents a Card either of type `BigImage` or `ItemsList`""" 11 | 12 | type = attrib(type=str) 13 | 14 | # for BigImage 15 | image_id = attrib(default=None, type=str) 16 | title = attrib(default=None, type=str) 17 | description = attrib(default=None, type=str) 18 | button = attrib(default=None, converter=ensure_cls(MediaButton)) 19 | 20 | # for ItemsList 21 | header = attrib(default=None, converter=ensure_cls(CardHeader)) 22 | items = attrib(default=None, converter=ensure_cls(Image)) # List of Image objects 23 | 24 | footer = attrib(default=None, converter=ensure_cls(CardFooter)) 25 | 26 | @type.validator 27 | def check(self, attribute, value): 28 | """ 29 | Type can be 'BigImage' or 'ItemsList' 30 | "BigImage" — с одним изображением 31 | "ItemsList" — с галереей из нескольких изображений 32 | """ 33 | if value not in CardType.all(): 34 | raise ValueError(f'Card type must be "BigImage" or "ItemsList", not {value!r}') 35 | 36 | @classmethod 37 | def big_image(cls, image_id, title, description, button=None): 38 | """ 39 | Generate Big Image card 40 | 41 | :param image_id: Image's id for BigImage Card 42 | :param title: Image's title for BigImage Card 43 | :param description: Image's description for BigImage Card 44 | :param button: Image's button for BigImage Card 45 | :return: Card 46 | """ 47 | return cls( 48 | CardType.BIG_IMAGE, 49 | image_id=image_id, 50 | title=title, 51 | description=description, 52 | button=button, 53 | ) 54 | 55 | @classmethod 56 | def items_list(cls, header, items, footer=None): 57 | """ 58 | Generate Items List card 59 | 60 | :param header: Card's header 61 | :param items: Card's items - list of `Image` objects 62 | :param footer: Card's footer 63 | :return: Card 64 | """ 65 | return cls( 66 | CardType.ITEMS_LIST, 67 | header=header, 68 | items=items, 69 | footer=footer, 70 | ) 71 | 72 | 73 | class CardType(Helper): 74 | mode = HelperMode.CamelCase 75 | 76 | BIG_IMAGE = Item() # BigImage 77 | ITEMS_LIST = Item() # ItemsList 78 | -------------------------------------------------------------------------------- /aioalice/types/card_footer.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject, MediaButton 4 | from aioalice.utils import ensure_cls 5 | 6 | 7 | @attrs 8 | class CardFooter(AliceObject): 9 | """This object represents a card's footer""" 10 | text = attrib(type=str) 11 | button = attrib(default=None, converter=ensure_cls(MediaButton)) 12 | -------------------------------------------------------------------------------- /aioalice/types/card_header.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject 4 | 5 | 6 | @attrs 7 | class CardHeader(AliceObject): 8 | """This object represents a card's header""" 9 | text = attrib(type=str) 10 | -------------------------------------------------------------------------------- /aioalice/types/entity.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from attr import attrs, attrib 3 | 4 | from aioalice.types import AliceObject, EntityTokens, EntityValue 5 | from aioalice.utils import ensure_cls 6 | from aioalice.utils.helper import Helper, HelperMode, Item 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | @attrs 12 | class Entity(AliceObject): 13 | """Entity object""" 14 | type = attrib(type=str) 15 | tokens = attrib(converter=ensure_cls(EntityTokens)) 16 | value = attrib(factory=dict) 17 | 18 | @type.validator 19 | def check(self, attribute, value): 20 | """Report unknown type""" 21 | if value not in EntityType.all(): 22 | log.error('Unknown Entity type! `%r`', value) 23 | 24 | def __attrs_post_init__(self): 25 | """If entity type not number, convert to EntityValue""" 26 | if self.value and self.type != EntityType.YANDEX_NUMBER: 27 | self.value = EntityValue(**self.value) 28 | 29 | 30 | class EntityType(Helper): 31 | mode = HelperMode.UPPER_DOT_SEPARATED 32 | 33 | YANDEX_GEO = Item() 34 | YANDEX_FIO = Item() 35 | YANDEX_NUMBER = Item() 36 | YANDEX_DATETIME = Item() 37 | -------------------------------------------------------------------------------- /aioalice/types/entity_tokens.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject 4 | 5 | 6 | @attrs 7 | class EntityTokens(AliceObject): 8 | """EntityTokens object""" 9 | start = attrib(type=int) 10 | end = attrib(type=int) 11 | -------------------------------------------------------------------------------- /aioalice/types/entity_value.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject 4 | 5 | 6 | @attrs 7 | class EntityValue(AliceObject): 8 | """EntityValue object""" 9 | 10 | # YANDEX.FIO 11 | first_name = attrib(default=None, type=str) 12 | patronymic_name = attrib(default=None, type=str) 13 | last_name = attrib(default=None, type=str) 14 | 15 | # YANDEX.GEO 16 | country = attrib(default=None, type=str) 17 | city = attrib(default=None, type=str) 18 | street = attrib(default=None, type=str) 19 | house_number = attrib(default=None, type=str) 20 | airport = attrib(default=None, type=str) 21 | 22 | # YANDEX.DATETIME 23 | year = attrib(default=None, type=str) 24 | year_is_relative = attrib(default=False, type=bool) 25 | month = attrib(default=None, type=str) 26 | month_is_relative = attrib(default=False, type=bool) 27 | day = attrib(default=None, type=str) 28 | day_is_relative = attrib(default=False, type=bool) 29 | hour = attrib(default=None, type=str) 30 | hour_is_relative = attrib(default=False, type=bool) 31 | minute = attrib(default=None, type=str) 32 | minute_is_relative = attrib(default=False, type=bool) 33 | -------------------------------------------------------------------------------- /aioalice/types/image.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject, MediaButton 4 | from aioalice.utils import ensure_cls 5 | 6 | 7 | @attrs 8 | class Image(AliceObject): 9 | """Image object""" 10 | image_id = attrib(type=str) 11 | title = attrib(type=str) 12 | description = attrib(type=str) 13 | button = attrib(default=None, converter=ensure_cls(MediaButton)) 14 | -------------------------------------------------------------------------------- /aioalice/types/interfaces.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject 4 | 5 | 6 | @attrs 7 | class Interfaces(AliceObject): 8 | """Interfaces object""" 9 | account_linking = attrib(factory=dict) 10 | payments = attrib(factory=dict) 11 | screen = attrib(factory=dict) 12 | -------------------------------------------------------------------------------- /aioalice/types/markup.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject 4 | from aioalice.utils import safe_kwargs 5 | 6 | 7 | @safe_kwargs 8 | @attrs 9 | class Markup(AliceObject): 10 | """Markup object""" 11 | dangerous_context = attrib(type=bool) 12 | -------------------------------------------------------------------------------- /aioalice/types/media_button.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject 4 | 5 | 6 | @attrs 7 | class MediaButton(AliceObject): 8 | """MediaButton object""" 9 | text = attrib(type=str) 10 | url = attrib(type=str) 11 | payload = attrib(default=None) 12 | -------------------------------------------------------------------------------- /aioalice/types/meta.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject, Interfaces 4 | from aioalice.utils import ensure_cls 5 | 6 | 7 | @attrs 8 | class Meta(AliceObject): 9 | """Meta object""" 10 | locale = attrib(type=str) 11 | timezone = attrib(type=str) 12 | client_id = attrib(type=str) 13 | interfaces = attrib(default=None, converter=ensure_cls(Interfaces)) 14 | flags = attrib(factory=list) 15 | -------------------------------------------------------------------------------- /aioalice/types/natural_language_understanding.py: -------------------------------------------------------------------------------- 1 | """ 2 | Natural Language Understanding: https://medium.com/@lola.com/nlp-vs-nlu-whats-the-difference-d91c06780992 3 | """ 4 | 5 | from attr import attrs, attrib 6 | 7 | from aioalice.types import AliceObject, Entity 8 | from aioalice.utils import ensure_cls 9 | 10 | 11 | @attrs 12 | class NaturalLanguageUnderstanding(AliceObject): 13 | """Natural Language Understanding object""" 14 | tokens = attrib(factory=list) 15 | entities = attrib(factory=list, converter=ensure_cls(Entity)) 16 | -------------------------------------------------------------------------------- /aioalice/types/quota.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject 4 | 5 | 6 | @attrs 7 | class Quota(AliceObject): 8 | """Quota object. Values in bytes""" 9 | total = attrib(type=int) 10 | used = attrib(type=int) 11 | available = attrib(init=False, default=0) 12 | 13 | def __attrs_post_init__(self): 14 | self.available = self.total - self.used 15 | -------------------------------------------------------------------------------- /aioalice/types/request.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject, Markup, NaturalLanguageUnderstanding 4 | from aioalice.utils import ensure_cls 5 | from aioalice.utils.helper import Helper, HelperMode, Item 6 | 7 | 8 | @attrs 9 | class Request(AliceObject): 10 | """Request object""" 11 | type = attrib(type=str) 12 | command = attrib(default='', type=str) # Can be none if payload passed 13 | original_utterance = attrib(default='', type=str) # Can be none if payload passed 14 | markup = attrib(default=None) 15 | payload = attrib(default=None) 16 | nlu = attrib(default=None, converter=ensure_cls(NaturalLanguageUnderstanding)) 17 | 18 | @type.validator 19 | def check(self, attribute, value): 20 | """ 21 | Type can be 'SimpleUtterance' or 'ButtonPressed' 22 | "SimpleUtterance" — голосовой ввод; 23 | "ButtonPressed" — нажатие кнопки. 24 | """ 25 | if value not in RequestType.all(): 26 | raise ValueError(f'Request type must be "SimpleUtterance" or "ButtonPressed", not "{value}"') 27 | 28 | def __attrs_post_init__(self): 29 | if self.markup is not None: 30 | self.markup = Markup(**self.markup) 31 | 32 | 33 | class RequestType(Helper): 34 | mode = HelperMode.CamelCase 35 | 36 | SIMPLE_UTTERANCE = Item() # SimpleUtterance 37 | BUTTON_PRESSED = Item() # ButtonPressed 38 | -------------------------------------------------------------------------------- /aioalice/types/response.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject, Card, Button 4 | from aioalice.utils import ensure_cls 5 | 6 | 7 | @attrs 8 | class Response(AliceObject): 9 | """Response object""" 10 | 11 | text = attrib(type=str) 12 | tts = attrib(default=None, type=str) 13 | card = attrib(default=None, converter=ensure_cls(Card)) 14 | buttons = attrib(default=None, converter=ensure_cls(Button)) 15 | end_session = attrib(default=False, type=bool) 16 | -------------------------------------------------------------------------------- /aioalice/types/session.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject 4 | 5 | 6 | @attrs 7 | class BaseSession(AliceObject): 8 | """Base Session object""" 9 | session_id = attrib(type=str) 10 | message_id = attrib(type=int) 11 | user_id = attrib(type=str) 12 | 13 | 14 | @attrs 15 | class Session(BaseSession): 16 | """Session object""" 17 | new = attrib(type=bool) 18 | skill_id = attrib(type=str) 19 | 20 | @property 21 | def base(self): 22 | return BaseSession( 23 | self.session_id, 24 | self.message_id, 25 | self.user_id, 26 | ) 27 | -------------------------------------------------------------------------------- /aioalice/types/uploaded_image.py: -------------------------------------------------------------------------------- 1 | from attr import attrs, attrib 2 | 3 | from aioalice.types import AliceObject 4 | 5 | 6 | @attrs 7 | class UploadedImage(AliceObject): 8 | """This object represents an uploaded image""" 9 | id = attrib(type=str) 10 | origUrl = attrib(default=None, type=str) 11 | # origUrl will be None if image was uploaded from bytes, not by url 12 | 13 | @property 14 | def orig_url(self): 15 | return self.origUrl 16 | -------------------------------------------------------------------------------- /aioalice/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from aioalice.utils import exceptions 2 | from aioalice.utils.json import json 3 | from aioalice.utils.payload import generate_json_payload 4 | from aioalice.utils.safe_kwargs import safe_kwargs 5 | 6 | 7 | def ensure_cls(klass): 8 | from aioalice.types.base import AliceObject 9 | safe_cls = safe_kwargs(klass) if issubclass(klass, AliceObject) else klass 10 | 11 | def converter(val): 12 | if val is None: 13 | return 14 | if isinstance(val, dict): 15 | return safe_cls(**val) 16 | if isinstance(val, list): 17 | return [converter(v) for v in val] 18 | if not isinstance(val, klass): 19 | return klass(val) 20 | return val 21 | return converter 22 | 23 | 24 | __all__ = [ 25 | "exceptions", 26 | "json", 27 | "generate_json_payload", 28 | "safe_kwargs", 29 | "ensure_cls", 30 | ] 31 | -------------------------------------------------------------------------------- /aioalice/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | class DialogsError(Exception): 2 | '''Base class for alice exceptions''' 3 | 4 | 5 | class DialogsAPIError(Exception): 6 | '''Base Exception for API related requests''' 7 | __subclasses = [] 8 | match = None 9 | 10 | def __init_subclass__(cls, match=None, **kwargs): 11 | super(DialogsAPIError, cls).__init_subclass__(**kwargs) 12 | if match is not None: 13 | cls.match = match.lower() 14 | cls.__subclasses.append(cls) 15 | 16 | @classmethod 17 | def detect(cls, description): 18 | """Detect API Error (match by response text)""" 19 | description = description.strip() 20 | match = description.lower() 21 | for err in cls.__subclasses: 22 | if err is cls: 23 | pass 24 | if err.match in match: 25 | raise err(description) 26 | raise cls(description) 27 | 28 | 29 | class AuthRequired(Exception): 30 | '''Passed is skill_id of oauth_token is not provided''' 31 | 32 | 33 | class ApiChanged(DialogsError): 34 | '''Is thrown if there are some unpredicted changes in API''' 35 | 36 | 37 | class NetworkError(DialogsAPIError): 38 | '''Is thrown when aiohttp client throws an error''' 39 | 40 | 41 | # class ClientError(DialogsAPIError): 42 | # '''Is thrown when response code is 4xx''' 43 | 44 | 45 | # class ServerError(DialogsAPIError): 46 | # '''Is thrown when response code is 5xx''' 47 | 48 | 49 | class Forbidden(DialogsAPIError, match='Forbidden'): 50 | '''Is thrown when Authorization failed (403)''' 51 | 52 | 53 | class ContentNotProvided(DialogsAPIError, match='URL or FILE is needed'): 54 | '''Is thrown when no image is provided within request''' 55 | 56 | 57 | class InvalidImageID(DialogsAPIError, match='Invalid image ID'): 58 | '''Is thrown if a wrong image_id is provided within request''' 59 | -------------------------------------------------------------------------------- /aioalice/utils/helper.py: -------------------------------------------------------------------------------- 1 | # taken from aiogram: https://github.com/aiogram/aiogram/blob/master/aiogram/utils/helper.py 2 | 3 | """ 4 | Example: 5 | >>> from aioalice.utils.helper import Helper, ListItem, HelperMode, Item 6 | >>> class MyHelper(Helper): 7 | ... mode = HelperMode.lowerCamelCase 8 | ... FOO_ITEM = ListItem() 9 | ... BAR_ITEM = ListItem() 10 | ... BAZ_ITEM = ListItem() 11 | ... LOREM = Item() 12 | ... 13 | >>> print(MyHelper.FOO_ITEM & MyHelper.BAR_ITEM) 14 | <<< ['fooItem', 'barItem'] 15 | >>> print(MyHelper.all()) 16 | <<< ['barItem', 'bazItem', 'fooItem', 'lorem'] 17 | """ 18 | 19 | 20 | class Helper: 21 | mode = '' 22 | 23 | @classmethod 24 | def all(cls): 25 | """ 26 | Get all consts 27 | :return: list 28 | """ 29 | result = [] 30 | for name in dir(cls): 31 | if not name.isupper(): 32 | continue 33 | value = getattr(cls, name) 34 | if isinstance(value, ItemsList): 35 | result.append(value[0]) 36 | else: 37 | result.append(value) 38 | return result 39 | 40 | 41 | class HelperMode(Helper): 42 | mode = 'original' 43 | 44 | SCREAMING_SNAKE_CASE = 'SCREAMING_SNAKE_CASE' 45 | lowerCamelCase = 'lowerCamelCase' 46 | CamelCase = 'CamelCase' 47 | snake_case = 'snake_case' 48 | lowercase = 'lowercase' 49 | UPPER_DOT_SEPARATED = 'UPPER.DOT.SEPARATED' 50 | 51 | @classmethod 52 | def all(cls): 53 | return [ 54 | cls.SCREAMING_SNAKE_CASE, 55 | cls.lowerCamelCase, 56 | cls.CamelCase, 57 | cls.snake_case, 58 | cls.lowercase, 59 | ] 60 | 61 | @classmethod 62 | def _screaming_snake_case(cls, text): 63 | """ 64 | Transform text to SCREAMING_SNAKE_CASE 65 | 66 | :param text: 67 | :return: 68 | """ 69 | if text.isupper(): 70 | return text 71 | result = '' 72 | for pos, symbol in enumerate(text): 73 | if symbol.isupper() and pos > 0: 74 | result += '_' + symbol 75 | else: 76 | result += symbol.upper() 77 | return result 78 | 79 | @classmethod 80 | def _snake_case(cls, text): 81 | """ 82 | Transform text to snake cale (Based on SCREAMING_SNAKE_CASE) 83 | 84 | :param text: 85 | :return: 86 | """ 87 | if text.islower(): 88 | return text 89 | return cls._screaming_snake_case(text).lower() 90 | 91 | @classmethod 92 | def _camel_case(cls, text, first_upper=False): 93 | """ 94 | Transform text to camelCase or CamelCase 95 | 96 | :param text: 97 | :param first_upper: first symbol must be upper? 98 | :return: 99 | """ 100 | result = '' 101 | need_upper = False 102 | for pos, symbol in enumerate(text): 103 | if symbol == '_' and pos > 0: 104 | need_upper = True 105 | else: 106 | if need_upper: 107 | result += symbol.upper() 108 | else: 109 | result += symbol.lower() 110 | need_upper = False 111 | if first_upper: 112 | result = result[0].upper() + result[1:] 113 | return result 114 | 115 | @classmethod 116 | def apply(cls, text, mode): 117 | """ 118 | Apply mode for text 119 | 120 | :param text: 121 | :param mode: 122 | :return: 123 | """ 124 | if mode == cls.UPPER_DOT_SEPARATED: 125 | return cls._screaming_snake_case(text).replace('_', '.') 126 | elif mode == cls.SCREAMING_SNAKE_CASE: 127 | return cls._screaming_snake_case(text) 128 | elif mode == cls.snake_case: 129 | return cls._snake_case(text) 130 | elif mode == cls.lowerCamelCase: 131 | return cls._camel_case(text) 132 | elif mode == cls.CamelCase: 133 | return cls._camel_case(text, True) 134 | elif mode == cls.lowercase: 135 | return cls._snake_case(text).replace('_', '') 136 | elif callable(mode): 137 | return mode(text) 138 | return text 139 | 140 | 141 | class Item: 142 | """ 143 | Helper item 144 | 145 | If a value is not provided, 146 | it will be automatically generated based on a variable's name 147 | """ 148 | 149 | def __init__(self, value=None): 150 | self._value = value 151 | 152 | def __get__(self, instance, owner): 153 | return self._value 154 | 155 | def __set_name__(self, owner, name): 156 | if not name.isupper(): 157 | raise NameError('Name for helper item must be in uppercase!') 158 | if not self._value: 159 | if hasattr(owner, 'mode'): 160 | self._value = HelperMode.apply(name, getattr(owner, 'mode')) 161 | 162 | 163 | class ListItem(Item): 164 | """ 165 | This item is always a list 166 | 167 | You can use &, | and + operators for that. 168 | """ 169 | 170 | def add(self, other): 171 | return self + other 172 | 173 | def __get__(self, instance, owner): 174 | return ItemsList(self._value) 175 | 176 | def __getitem__(self, item): 177 | # Only for IDE. This method is never be called. 178 | return self._value 179 | 180 | # Need only for IDE 181 | __iadd__ = __add__ = __rand__ = __and__ = __ror__ = __or__ = add 182 | 183 | 184 | class ItemsList(list): 185 | """ 186 | Patch for default list 187 | 188 | This class provides +, &, |, +=, &=, |= operators for extending the list 189 | """ 190 | 191 | def __init__(self, *seq): 192 | super(ItemsList, self).__init__(map(str, seq)) 193 | 194 | def add(self, other): 195 | self.extend(other) 196 | return self 197 | 198 | __iadd__ = __add__ = __rand__ = __and__ = __ror__ = __or__ = add 199 | -------------------------------------------------------------------------------- /aioalice/utils/json.py: -------------------------------------------------------------------------------- 1 | try: 2 | import simplejson as json 3 | except ImportError: 4 | try: 5 | import rapidjson as json 6 | except ImportError: 7 | try: 8 | import ujson as json 9 | except ImportError: 10 | import json 11 | -------------------------------------------------------------------------------- /aioalice/utils/payload.py: -------------------------------------------------------------------------------- 1 | DEFAULT_FILTER = ['self', 'cls'] 2 | 3 | 4 | def generate_json_payload(exclude=[], **kwargs): 5 | """ 6 | Generate payload 7 | 8 | Usage: payload = generate_json_payload(**locals(), exclude=['foo']) 9 | 10 | :param exclude: 11 | :param kwargs: 12 | :return: dict 13 | """ 14 | return {key: _normalize(value) for key, value in kwargs.items() if 15 | key not in exclude + DEFAULT_FILTER 16 | and value is not None 17 | and not key.startswith('_')} 18 | 19 | 20 | def _normalize(obj): 21 | """ 22 | Normalize dicts and lists 23 | 24 | :param obj: 25 | :return: normalized object 26 | """ 27 | if isinstance(obj, dict): 28 | return {k: _normalize(v) for k, v in obj.items() if v is not None} 29 | # elif hasattr(obj, 'to_json'): 30 | # return _normalize(obj.to_json()) 31 | elif isinstance(obj, list): 32 | return [_normalize(item) for item in obj] 33 | return obj 34 | -------------------------------------------------------------------------------- /aioalice/utils/safe_kwargs.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://gist.github.com/mahenzon/a6c2804a2d18a2ab75630bb5d93693c8 3 | """ 4 | 5 | import functools 6 | from inspect import isclass, getfullargspec 7 | 8 | 9 | def safe_kwargs(func_or_class): 10 | from aioalice.types.base import AliceObject 11 | 12 | spec = getfullargspec(func_or_class) 13 | all_args = spec.args 14 | 15 | save_raw_kwargs = isclass(func_or_class) and issubclass(func_or_class, AliceObject) 16 | 17 | @functools.wraps(func_or_class) 18 | def wrap(*args, **kwargs): 19 | accepted_kwargs = {k: v for k, v in kwargs.items() if k in all_args} 20 | res = func_or_class(*args, **accepted_kwargs) 21 | 22 | if save_raw_kwargs: 23 | # saving all kwargs for access to unexpected attrs 24 | res._raw_kwargs.update(kwargs) 25 | 26 | return res 27 | 28 | return wrap 29 | -------------------------------------------------------------------------------- /deploy_to_pypi.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # remove old builds 4 | rm ./dist/* 5 | 6 | # build 7 | python setup.py sdist 8 | python setup.py bdist_wheel 9 | 10 | # upload to PYPI 11 | twine upload dist/* 12 | -------------------------------------------------------------------------------- /examples/FSM_games.py: -------------------------------------------------------------------------------- 1 | import random 2 | import logging 3 | 4 | from aiohttp import web 5 | from aioalice import Dispatcher, get_new_configured_app, types 6 | from aioalice.dispatcher import MemoryStorage 7 | from aioalice.utils.helper import Helper, HelperMode, Item 8 | 9 | 10 | WEBHOOK_URL_PATH = '/my-alice-webhook/' # webhook endpoint 11 | 12 | WEBAPP_HOST = 'localhost' 13 | WEBAPP_PORT = 3001 14 | 15 | 16 | logging.basicConfig(format=u'%(filename)s [LINE:%(lineno)d] #%(levelname)-8s [%(asctime)s] %(message)s', 17 | level=logging.INFO) 18 | 19 | # Создаем экземпляр диспетчера и подключаем хранилище в памяти 20 | dp = Dispatcher(storage=MemoryStorage()) 21 | 22 | 23 | CANCEL_TEXTS = ['отмени', 'прекрати', 'выйти', 'выход'] 24 | GAMES_LIST = ['Угадай число', 'Наперстки'] 25 | THIMBLE = '⚫' 26 | 27 | 28 | # Можно использовать класс Helper для хранения списка состояний 29 | class GameStates(Helper): 30 | mode = HelperMode.snake_case 31 | 32 | SELECT_GAME = Item() # = select_game 33 | GUESS_NUM = Item() # = guess_num 34 | THIMBLES = Item() # = thimbles 35 | 36 | 37 | def gen_thimbles(): 38 | # Генерируем массив из 3 кнопок 39 | # Первый аргумент - юникод символ черного кружочка 40 | # Аргумент по ключевому слову payload может содержать произвольный JSON 41 | buttons = [types.Button(THIMBLE, payload={'win': False}) for _ in range(3)] 42 | # Делаем первую кнопку выигрышной 43 | buttons[0].payload['win'] = True 44 | # Смешиваем кнопки 45 | random.shuffle(buttons) 46 | logging.info(f'Thimbles are: {buttons}') 47 | return buttons 48 | 49 | 50 | async def get_number_from_data(user_id): 51 | data = await dp.storage.get_data(user_id) 52 | return data.get('num') 53 | 54 | 55 | ''' 56 | Дальше хэндлеры расположены по предпочтительности срабатывания 57 | Общение пользователя с Алисой будет ходить по хэндлерам в зависимости 58 | от состояния. Будет отрабатывать первый подходящий хэндлер 59 | ''' 60 | 61 | 62 | # Если текст команды содержит одно из слов отмены, 63 | # возвращаемся в "главное меню" 64 | @dp.request_handler(contains=CANCEL_TEXTS) 65 | async def cancel_operation(alice_request): 66 | user_id = alice_request.session.user_id 67 | await dp.storage.reset_state(user_id) 68 | return alice_request.response('Хорошо, прекращаем.') 69 | 70 | 71 | # Создаем функцию обработчик команды, которая сработает, 72 | # если во время игры в "угадай число" отправлено число 73 | async def user_guesses_number(alice_request): 74 | user_id = alice_request.session.user_id 75 | my_num = await get_number_from_data(user_id) 76 | num = int(alice_request.request.command) 77 | buttons = None 78 | if num == my_num: 79 | await dp.storage.reset_state(user_id) 80 | text = 'Верно! Молодец!' 81 | else: 82 | text = 'Нет, но ты близко! Пробуй ещё.\nЗагаданное число ' 83 | if my_num > num: 84 | text += 'больше' 85 | else: 86 | text += 'меньше' 87 | buttons = ['Сдаюсь', 'Прекратить'] 88 | return alice_request.response(text, buttons=buttons) 89 | 90 | 91 | # Регистрируем обработчик не через декоратор (результат не отличается) 92 | # Проверяем, что состояние - игра "Угадай число", а команда может быть преобразована в число 93 | dp.register_request_handler( 94 | user_guesses_number, 95 | state=GameStates.GUESS_NUM, 96 | func=lambda areq: areq.request.command.isdigit() 97 | ) 98 | 99 | 100 | # Отработает, если во время игры в угадай число 101 | # пользовател написал команду, содержащую слово "сдаюсь" 102 | @dp.request_handler(state=GameStates.GUESS_NUM, contains='сдаюсь') 103 | async def user_loses_tell_number(alice_request): 104 | user_id = alice_request.session.user_id 105 | await dp.storage.reset_state(user_id) 106 | my_num = await get_number_from_data(user_id) 107 | return alice_request.response(f'А ведь ты почти угадал!\nЧисло было {my_num}') 108 | 109 | 110 | # Регистрируем хэндлер, срабатывающий во время игры "Угадай число" 111 | # если не сработали хэндлеры выше - команда не является числом, 112 | # или текст не содержит слово "сдаюсь" 113 | @dp.request_handler(state=GameStates.GUESS_NUM) 114 | async def game_number_command_not_digit(alice_request): 115 | return alice_request.response( 116 | 'Это не число! Можешь продолжить угадывать' 117 | ' число или попроси меня прекратить', 118 | buttons=['Прекратить'] 119 | ) 120 | 121 | 122 | # Этот хэндлер сработает только если нажата кнопка во время игры в наперстки 123 | # Тип запроса "SimpleUtterance" это не только голосовой ввод, 124 | # но и нажатие кнопки без payload. "ButtonPressed" — нажатие кнопки с payload 125 | @dp.request_handler(state=GameStates.THIMBLES, request_type=types.RequestType.BUTTON_PRESSED) 126 | async def check_if_winning_button_pressed(alice_request): 127 | # Не смотря на то, что данный тип запроса может быть только с payload 128 | # проверяем, что он существует, вдруг изменится апи 129 | # по умолчанию payload = None 130 | if alice_request.request.payload and alice_request.request.payload['win']: 131 | text = 'Верно! Ты молодец!' 132 | else: 133 | text = 'Неа :( Давай ещё!' 134 | text += '\nКручу, верчу! Где?' 135 | return alice_request.response(text, buttons=gen_thimbles()) 136 | 137 | 138 | # Отрабатываем, если во время игры в наперстки отправлен текст, а не нажата кнопка 139 | # Можно было бы сделать request_type=types.RequestType.SIMPLE_UTTERANCE, 140 | # но так как варианта всего два, а первый был бы отработан выше, то это не нужно 141 | @dp.request_handler(state=GameStates.THIMBLES) 142 | async def thimbles_not_button(alice_request): 143 | return alice_request.response( 144 | 'В этой игре нужно нажимать кнопки. ' 145 | 'Если хочешь прекратить, так и скажи', 146 | buttons=['Прекратить'] 147 | ) 148 | 149 | 150 | # Если состояние "SELECT_GAME" (!) _И_ (!) при этом 151 | # текст содержит название одной из игр - хэндлер отработает 152 | @dp.request_handler(state=GameStates.SELECT_GAME, contains=GAMES_LIST) 153 | async def selecting_game(alice_request): 154 | user_id = alice_request.session.user_id 155 | text = 'Отлично! Играем в ' 156 | buttons = None 157 | if 'угадай число' in alice_request.request.command.lower(): 158 | new_state = GameStates.GUESS_NUM 159 | text += '"Угадай число"!\nЯ загадала число от 1 до 100, угадывай!' 160 | # Загадываем число от 1 до 100 и запоминаем 161 | new_num = random.randint(1, 100) 162 | await dp.storage.update_data(user_id, num=new_num) 163 | logging.info(f'Num for {user_id} is {new_num}') 164 | else: 165 | new_state = GameStates.THIMBLES 166 | text += 'наперстки!\nКручу, верчу, запутать хочу!\nГде?' 167 | buttons = gen_thimbles() 168 | # Устанавливаем новое состояние - выбранная игра 169 | await dp.storage.set_state(user_id, new_state) 170 | return alice_request.response(text, buttons=buttons) 171 | 172 | 173 | # Если состояние "SELECT_GAME" 174 | # Хэндлер отработает только если фильтры хэндлера выше не ок 175 | @dp.request_handler(state=GameStates.SELECT_GAME) 176 | async def select_game_not_in_list(alice_request): 177 | return alice_request.response( 178 | 'Я ещё не знаю такой игры :(\nВыбери одну из списка!', 179 | buttons=GAMES_LIST 180 | ) 181 | 182 | 183 | # Приветствуем пользователя и предлагаем сыграть в игру 184 | # В этот хэндлер будут попадать любые команды, 185 | # не отработанные хэндлерами выше. это - "главное меню" 186 | @dp.request_handler() 187 | async def handle_any_request(alice_request): 188 | user_id = alice_request.session.user_id 189 | # Устанавливаем состояние пользователя "выбор игры" 190 | await dp.storage.set_state(user_id, GameStates.SELECT_GAME) 191 | 192 | text = 'Давай играть! Выбери игру:' 193 | # Если сессия новая, приветствуем пользователя 194 | if alice_request.session.new: 195 | text = 'Привет! ' + text 196 | # Предлагаем пользователю список игр 197 | return alice_request.response(text, buttons=GAMES_LIST) 198 | 199 | 200 | if __name__ == '__main__': 201 | app = get_new_configured_app(dispatcher=dp, path=WEBHOOK_URL_PATH) 202 | web.run_app(app, host=WEBAPP_HOST, port=WEBAPP_PORT) 203 | -------------------------------------------------------------------------------- /examples/README-en.md: -------------------------------------------------------------------------------- 1 |

2 | Русский | English 3 |

4 | 5 | 6 | # aioAlice examples 7 | 8 | 9 | - [Quick start](hello-alice.py) 10 | - [Buy elephant](buy-elephant.py) 11 | - [FSM example \(Finite State Machine\)](FSM_games.py) 12 | - [Skip Handlers, log requests](skip_handler_log_everything.py) 13 | - [Handle errors](handle-errors.py) 14 | - [Get uploaded images](get_images.py) 15 | - [Upload an image](upload_image.py) 16 | - [Get quota status and delete an image](quota_status_and_delete_image.py) 17 | - [Send Big Image Card](card_big_image.py) 18 | - [Send Items List Card](card_items_list.py) 19 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 |

2 | Русский | English 3 |

4 | 5 | 6 | # Примеры работы с aioAlice 7 | 8 | 9 | - [Самый простой навык](hello-alice.py) 10 | - [Купи слона](buy-elephant.py) 11 | - [Работа с FSM на примере игры \(Finite State Machine - Машина состояний\)](FSM_games.py) 12 | - [Пропускаем хэндлеры, логгируем запросы](skip_handler_log_everything.py) 13 | - [Ловим ошибки](handle-errors.py) 14 | - [Проверить загруженные изображения](get_images.py) 15 | - [Загрузить изображение](upload_image.py) 16 | - [Проверить занятое место и удалить изображение](quota_status_and_delete_image.py) 17 | - [Отправить карточку с одним большим изображением](card_big_image.py) 18 | - [Отправить карточку с альбомом из нескольких изображений](card_items_list.py) 19 | -------------------------------------------------------------------------------- /examples/buy-elephant.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiohttp import web 4 | from aioalice import Dispatcher, get_new_configured_app, types 5 | from aioalice.dispatcher import MemoryStorage 6 | 7 | 8 | WEBHOOK_URL_PATH = '/my-alice-webhook/' # webhook endpoint 9 | 10 | WEBAPP_HOST = 'localhost' 11 | WEBAPP_PORT = 3001 12 | 13 | 14 | logging.basicConfig(format=u'%(filename)s [LINE:%(lineno)d] #%(levelname)-8s [%(asctime)s] %(message)s', 15 | level=logging.INFO) 16 | 17 | # Создаем экземпляр диспетчера и подключаем хранилище в памяти 18 | dp = Dispatcher(storage=MemoryStorage()) 19 | 20 | 21 | ele_link = 'https://market.yandex.ru/search?text=слон' 22 | # Заготавливаем кнопку на всякий случай 23 | OK_Button = types.Button('Ладно', url=ele_link) 24 | 25 | 26 | # Функция возвращает две подсказки для ответа. 27 | async def get_suggests(user_id): 28 | # Получаем хранимые в памяти данные 29 | data = await dp.storage.get_data(user_id) 30 | # Не исключаем, что данные могут оказаться пустыми, получаем список подсказок 31 | user_suggests = data.get('suggests', []) 32 | # Выбираем две первые подсказки из массива. 33 | suggests = [text for text in user_suggests[:2]] 34 | # Если осталась только одна подсказка, предлагаем 35 | # подсказку (кнопку) со ссылкой на Яндекс.Маркет. 36 | if len(suggests) < 2: 37 | suggests.append(OK_Button) 38 | # Обновляем данные в хранилище, убираем первую подсказку, чтобы подсказки менялись 39 | await dp.storage.update_data(user_id, suggests=user_suggests[1:]) 40 | return suggests 41 | 42 | 43 | # Новая сессия. Приветствуем пользователя и предлагаем купить слона 44 | # В этот хэндлер будут попадать только новые сессии 45 | @dp.request_handler(func=lambda areq: areq.session.new) 46 | async def handle_new_session(alice_request): 47 | user_id = alice_request.session.user_id 48 | await dp.storage.update_data( 49 | user_id, suggests=[ 50 | "Не хочу..", 51 | "Не буду...", 52 | "Отстань!!", 53 | ] 54 | ) 55 | logging.info(f'Initialized suggests for new session!\nuser_id is {user_id!r}') 56 | 57 | # В кнопки нужно передать список из строк или готовых кнопок 58 | # Строки будут преобразованы в кнопки автоматически 59 | suggests = await get_suggests(user_id) 60 | return alice_request.response('Привет! Купи слона!', buttons=suggests) 61 | 62 | 63 | # Пользователь соглашается - команда соответствует одному из слов: 64 | @dp.request_handler(commands=['ладно', 'куплю', 'покупаю', 'хорошо', 'окей']) 65 | async def handle_user_agrees(alice_request): 66 | # Отвечаем пользователю, что слона можно купить на Яндекс.Маркете 67 | # Ответ генерируется автоматически на основе запроса, 68 | # туда подставляются сессия и версия апи. 69 | # Текст ответа - единственный необходимый параметр 70 | # Кнопки передаем по ключевому слову. 71 | return alice_request.response(f'Слона можно найти на Яндекс.Маркете!\n{ele_link}') 72 | 73 | 74 | # Все остальные запросы попадают в этот хэндлер, так как у него не настроены фильтры 75 | @dp.request_handler() 76 | async def handle_all_other_requests(alice_request): 77 | # Всеми силами убеждаем пользователя купить слона, 78 | # предлагаем варианты ответа на основе текста запроса 79 | requst_text = alice_request.request.original_utterance 80 | suggests = await get_suggests(alice_request.session.user_id) 81 | return alice_request.response( 82 | f'Все говорят "{requst_text}", а ты купи слона!', 83 | buttons=suggests 84 | ) 85 | 86 | 87 | if __name__ == '__main__': 88 | app = get_new_configured_app(dispatcher=dp, path=WEBHOOK_URL_PATH) 89 | web.run_app(app, host=WEBAPP_HOST, port=WEBAPP_PORT) 90 | -------------------------------------------------------------------------------- /examples/card_big_image.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from aioalice import Dispatcher, get_new_configured_app 3 | from aioalice.types import MediaButton 4 | 5 | WEBHOOK_URL_PATH = '/my-alice-webhook/' # webhook endpoint 6 | 7 | WEBAPP_HOST = 'localhost' 8 | WEBAPP_PORT = 3001 9 | 10 | dp = Dispatcher() 11 | 12 | 13 | # если в commands передать строку, то она автоматически 14 | # будет превращена в список из одного элемента - строки 15 | @dp.request_handler(commands='без кнопки') 16 | async def handle_no_button_request(alice_request): 17 | return alice_request.response_big_image( 18 | 'Показываю тебе картинку!', # Текст 19 | '123456/efa943ab0c03767ce857', # id изображения, загруженного через upload_image 20 | 'Заголовок изображения', 21 | 'Это описание изображения' 22 | ) 23 | 24 | # Если кнопка не передана, клик по изображению не даст ничего 25 | # Если кнопка передана, в ней должен быть указан URL, который 26 | # открывается по нажатию на изображение 27 | 28 | 29 | @dp.request_handler() 30 | async def handle_all_requests(alice_request): 31 | return alice_request.response_big_image( 32 | 'Показываю тебе картинку с кнопкой!', # Текст 33 | '123456/efa943ab0c03767ce857', # id изображения, загруженного через upload_image 34 | 'Заголовок изображения', 35 | 'Это описание изображения', 36 | # вместо объекта класса MediaButton можно передать словарь 37 | # Поле payload может отсутствовать (третий агрумент) 38 | MediaButton('Текст кнопки', 'https://yandex.ru', {'some': 'payload'}) 39 | ) 40 | 41 | 42 | if __name__ == '__main__': 43 | app = get_new_configured_app(dispatcher=dp, path=WEBHOOK_URL_PATH) 44 | web.run_app(app, host=WEBAPP_HOST, port=WEBAPP_PORT) 45 | -------------------------------------------------------------------------------- /examples/card_items_list.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from aioalice import Dispatcher, get_new_configured_app 3 | from aioalice.types import Image, MediaButton, CardFooter 4 | 5 | WEBHOOK_URL_PATH = '/my-alice-webhook/' # webhook endpoint 6 | 7 | WEBAPP_HOST = 'localhost' 8 | WEBAPP_PORT = 3001 9 | 10 | dp = Dispatcher() 11 | 12 | 13 | # Если кнопка не передана, клик по изображению не даст ничего 14 | # Если кнопка передана, в ней должен быть указан URL, который 15 | # открывается по нажатию на изображение 16 | # (каждому изображению в списке может быть присвоена кнопка) 17 | 18 | 19 | @dp.request_handler() 20 | async def handle_all_requests(alice_request): 21 | return alice_request.response_items_list( 22 | 'Вот альбом изображений', 23 | 'Это текст заголовка, который будет над списком изображений', 24 | [ # Список картинок Image или словарей 25 | { 26 | # Пример полностью из словаря 27 | "image_id": '987654/efa943ab0c03767ce857', 28 | "title": None, # Не обязательно передавать текст в заголовок 29 | "description": "Описание картинки, которое тоже не обязательно", 30 | # Поле button можно вообще не передавать. 31 | # Но если его не передать, то нажатие на картинку не будет ничего делать 32 | "button": { 33 | 'text': 'Текст кнопки', 34 | 'url': 'https://github.com', 35 | # payload передавать не обязательно 36 | 'payload': {'some': 'payload'} 37 | } 38 | }, 39 | { 40 | # Пример, когда кнопка передается объектом 41 | "image_id": '908173/efa943ab0c03767ce857', 42 | "title": 'Заголовок картинки', 43 | "description": None, 44 | "button": MediaButton('Текст кнопки', 'https://google.ru', {'this_is': 'payload'}) 45 | }, 46 | # Image(image_id, title, description, button), где button можно опустить 47 | Image('123456/efa943ab0c03767ce857', 48 | 'Заголовок изображения', 49 | 'Описание изображения', 50 | # Опускаем передачу третьего агрумента - никакого payload 51 | # Можно вообще не передавать кнопку 52 | MediaButton('Текст кнопки', 'https://yandex.ru')) 53 | ], 54 | # Футер можно не передавать вообще 55 | # Можно передать словарем {'text': 'текст футера', 'button': None} (кнопка MediaButton по желанию) 56 | # А можно передать просто текстом - тогда он не будет нажиматься 57 | CardFooter( 58 | 'Текст футера (под списком изображений)', 59 | # Снова пропускаем payload 60 | MediaButton('Текст кнопки', 'https://example.com') 61 | ) 62 | ) 63 | 64 | 65 | if __name__ == '__main__': 66 | app = get_new_configured_app(dispatcher=dp, path=WEBHOOK_URL_PATH) 67 | web.run_app(app, host=WEBAPP_HOST, port=WEBAPP_PORT) 68 | -------------------------------------------------------------------------------- /examples/get_images.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from aioalice import Dispatcher 5 | 6 | logging.basicConfig(format=u'%(filename)s [LINE:%(lineno)d] #%(levelname)-8s [%(asctime)s] %(message)s', 7 | level=logging.INFO) 8 | 9 | 10 | # Provide your own skill_id and token! 11 | SKILL_ID = '12a34567-c42d-9876-0e3f-123g55h12345' 12 | OAUTH_TOKEN = 'OAuth AQAAAAABCDEFGHI_jklmnopqrstuvwxyz' 13 | 14 | # You can create dp instance without auth: `dp = Dispatcher()`, 15 | # but you'll have to provide it in request 16 | dp = Dispatcher(skill_id=SKILL_ID, oauth_token=OAUTH_TOKEN) 17 | 18 | 19 | async def check_uploaded_images(): 20 | try: 21 | # Use `await dp.get_images(SKILL_ID, OAUTH_TOKEN)` 22 | # If tokens were not provided on dp's initialisation 23 | imgs = await dp.get_images() 24 | except Exception: 25 | logging.exception('Oops!') 26 | else: 27 | print(imgs) 28 | # You have to close session manually 29 | # if you called any request outside web app 30 | # Session close is added to on_shutdown list 31 | # in webhhok.configure_app 32 | await dp.close() 33 | 34 | 35 | loop = asyncio.get_event_loop() 36 | tasks = [loop.create_task(check_uploaded_images())] 37 | wait_tasks = asyncio.wait(tasks) 38 | loop.run_until_complete(wait_tasks) 39 | loop.close() 40 | -------------------------------------------------------------------------------- /examples/handle-errors.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiohttp import web 4 | from aioalice import Dispatcher, get_new_configured_app 5 | 6 | 7 | logging.basicConfig(format=u'%(filename)s [LINE:%(lineno)d] #%(levelname)-8s [%(asctime)s] %(message)s', 8 | level=logging.INFO) 9 | 10 | 11 | WEBHOOK_URL_PATH = '/my-alice-webhook/' # webhook endpoint 12 | 13 | WEBAPP_HOST = 'localhost' 14 | WEBAPP_PORT = 3001 15 | 16 | dp = Dispatcher() 17 | 18 | 19 | @dp.request_handler() 20 | async def handle_all_requests(alice_request): 21 | 1 / 0 # some unexpected error 22 | return alice_request.response('Hello world!') 23 | 24 | 25 | # if any error inside handler occur 26 | @dp.errors_handler() 27 | async def the_only_errors_handler(alice_request, e): 28 | # Log the error 29 | logging.error('An error!', exc_info=e) 30 | # Return answer so API doesn't consider your skill non-working 31 | return alice_request.response('Oops! There was an error!') 32 | 33 | 34 | if __name__ == '__main__': 35 | app = get_new_configured_app(dispatcher=dp, path=WEBHOOK_URL_PATH) 36 | web.run_app(app, host=WEBAPP_HOST, port=WEBAPP_PORT) 37 | -------------------------------------------------------------------------------- /examples/hello-alice.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from aioalice import Dispatcher, get_new_configured_app 3 | 4 | 5 | WEBHOOK_URL_PATH = '/my-alice-webhook/' # webhook endpoint 6 | 7 | WEBAPP_HOST = 'localhost' 8 | WEBAPP_PORT = 3001 9 | 10 | dp = Dispatcher() 11 | 12 | 13 | @dp.request_handler() 14 | async def handle_all_requests(alice_request): 15 | return alice_request.response('Привет этому миру!') 16 | 17 | 18 | if __name__ == '__main__': 19 | app = get_new_configured_app(dispatcher=dp, path=WEBHOOK_URL_PATH) 20 | web.run_app(app, host=WEBAPP_HOST, port=WEBAPP_PORT) 21 | -------------------------------------------------------------------------------- /examples/quota_status_and_delete_image.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from aioalice import Dispatcher 5 | 6 | logging.basicConfig(format=u'%(filename)s [LINE:%(lineno)d] #%(levelname)-8s [%(asctime)s] %(message)s', 7 | level=logging.INFO) 8 | 9 | 10 | # Provide your own skill_id and token! 11 | SKILL_ID = '12a34567-c42d-9876-0e3f-123g55h12345' 12 | OAUTH_TOKEN = 'OAuth AQAAAAABCDEFGHI_jklmnopqrstuvwxyz' 13 | 14 | # You can create dp instance without auth: `dp = Dispatcher()`, 15 | # but you'll have to provide it in request 16 | dp = Dispatcher(skill_id=SKILL_ID, oauth_token=OAUTH_TOKEN) 17 | 18 | 19 | async def check_quota_status_and_delete_image(): 20 | try: 21 | # Use `await dp.get_images_quota(OAUTH_TOKEN)` 22 | # If token was not provided on dp's initialisation 23 | quota_before = await dp.get_images_quota() 24 | 25 | # Use `await dp.delete_image(image_id, SKILL_ID, OAUTH_TOKEN)` 26 | # If tokens were not provided on dp's initialisation 27 | success = await dp.delete_image('1234567/ff123f70e0e0cf70079a') 28 | 29 | # recheck to see the difference 30 | quota_after = await dp.get_images_quota() 31 | except Exception: 32 | logging.exception('Oops!') 33 | else: 34 | print(quota_before) 35 | print('Success?', success) 36 | print(quota_after) 37 | print('Freed up', quota_after.available - quota_before.available, 'bytes') 38 | 39 | ''' Output: 40 | Quota(total=104857600, used=10423667, available=94433933) 41 | Success? True 42 | Quota(total=104857600, used=10383100, available=94474500) 43 | Freed up 40567 bytes 44 | ''' 45 | 46 | # You have to close session manually 47 | # if you called any request outside web app 48 | # Session close is added to on_shutdown list 49 | # in webhhok.configure_app 50 | await dp.close() 51 | 52 | 53 | loop = asyncio.get_event_loop() 54 | tasks = [ 55 | loop.create_task(check_quota_status_and_delete_image()), 56 | ] 57 | wait_tasks = asyncio.wait(tasks) 58 | loop.run_until_complete(wait_tasks) 59 | loop.close() 60 | -------------------------------------------------------------------------------- /examples/skip_handler_log_everything.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from aiohttp import web 3 | from aioalice import Dispatcher, get_new_configured_app 4 | from aioalice.dispatcher import SkipHandler 5 | 6 | 7 | logging.basicConfig(format=u'%(filename)s [LINE:%(lineno)d] #%(levelname)-8s [%(asctime)s] %(message)s', 8 | level=logging.DEBUG) 9 | 10 | WEBHOOK_URL_PATH = '/my-alice-webhook/' # webhook endpoint 11 | 12 | WEBAPP_HOST = 'localhost' 13 | WEBAPP_PORT = 3001 14 | 15 | dp = Dispatcher() 16 | 17 | 18 | # Это обычный хэндлер. Через него мы будем пропускать 19 | # все входящие запросы, чтобы записать их в лог / БД. 20 | # Поэтому если вы используете FSM, не забудьте 21 | # указать "все состояния" (звёздочка), чтобы хэндлер 22 | # отрабатывал при любом состоянии (получать все запросы). 23 | # Вот так: `@dp.request_handler(state='*')` 24 | # SkipHandler можно использовать в любых хэндлерах запросов. 25 | 26 | # Этот хэндлер регистрируем первым, 27 | # чтобы он получал все входящие запросы 28 | @dp.request_handler() 29 | async def take_all_requests(alice_request): 30 | # Логгируем запрос. Можно записывать в БД и тд 31 | logging.debug('New request! %r', alice_request) 32 | # Поднимаем исключение, по которому обработка перейдёт 33 | # к следующему хэндлеру, у которого подойдут фильтры 34 | raise SkipHandler 35 | 36 | 37 | # Если передать не список, а строчку, она будет 38 | # автоматически преобразована в список из одного элемента 39 | @dp.request_handler(commands='привет') 40 | async def reply_hi(alice_request): 41 | logging.debug('Now processing Hi') 42 | return alice_request.response('И тебе не хворать!') 43 | 44 | 45 | # Отрабатываем все остальные запросы 46 | @dp.request_handler() 47 | async def handle_all_requests(alice_request): 48 | logging.debug('Now processing any request') 49 | return alice_request.response('Hello some request!') 50 | 51 | 52 | if __name__ == '__main__': 53 | app = get_new_configured_app(dispatcher=dp, path=WEBHOOK_URL_PATH) 54 | web.run_app(app, host=WEBAPP_HOST, port=WEBAPP_PORT) 55 | -------------------------------------------------------------------------------- /examples/upload_image.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from aioalice import Dispatcher 5 | 6 | logging.basicConfig(format=u'%(filename)s [LINE:%(lineno)d] #%(levelname)-8s [%(asctime)s] %(message)s', 7 | level=logging.INFO) 8 | 9 | 10 | # Provide your own skill_id and token! 11 | SKILL_ID = '12a34567-c42d-9876-0e3f-123g55h12345' 12 | OAUTH_TOKEN = 'OAuth AQAAAAABCDEFGHI_jklmnopqrstuvwxyz' 13 | 14 | # You can create dp instance without auth: `dp = Dispatcher()`, 15 | # but you'll have to provide it in request 16 | dp = Dispatcher(skill_id=SKILL_ID, oauth_token=OAUTH_TOKEN) 17 | 18 | 19 | async def upload_some_images(): 20 | # Use `await dp.upload_image(image_url_or_bytes, SKILL_ID, OAUTH_TOKEN)` 21 | # If tokens were not provided on dp's initialisation 22 | 23 | try: 24 | img_by_bytes = await dp.upload_image(open('/path/to/photo.png', 'rb')) 25 | except Exception: 26 | logging.exception('Oops! Error uploading image by bytes') 27 | else: 28 | # origUrl will be `None` 29 | print(img_by_bytes) 30 | 31 | try: 32 | img_by_url = await dp.upload_image('https://example.com/some_image.jpg') 33 | except Exception: 34 | logging.exception('Oops! Error uploading image by url') 35 | else: 36 | print(img_by_url) 37 | 38 | # You have to close session manually 39 | # if you called any request outside web app 40 | # Session close is added to on_shutdown list 41 | # in webhhok.configure_app 42 | await dp.close() 43 | 44 | 45 | loop = asyncio.get_event_loop() 46 | tasks = [loop.create_task(upload_some_images())] 47 | wait_tasks = asyncio.wait(tasks) 48 | loop.run_until_complete(wait_tasks) 49 | loop.close() 50 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | wheel>=0.33.4 4 | twine>=1.13.0 5 | pytest==5.3.5 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.3.2 2 | attrs==20.3.0 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import pathlib 3 | import sys 4 | 5 | from setuptools import find_packages, setup 6 | 7 | try: 8 | from pip.req import parse_requirements 9 | except ImportError: # pip >= 10.0.0 10 | from pip._internal.req import parse_requirements 11 | 12 | WORK_DIR = pathlib.Path(__file__).parent 13 | 14 | # Check python version 15 | MINIMAL_PY_VERSION = (3, 6) 16 | if sys.version_info < MINIMAL_PY_VERSION: 17 | raise RuntimeError('aioAlice works only with Python {}+'.format('.'.join(map(str, MINIMAL_PY_VERSION)))) 18 | 19 | __version__ = '1.5.1' 20 | 21 | 22 | def get_description(): 23 | """ 24 | Read full description from 'README-PyPI.md' 25 | 26 | :return: description 27 | :rtype: str 28 | """ 29 | with open('README-PyPI.md', 'r', encoding='utf-8') as f: 30 | return f.read() 31 | 32 | 33 | requirements_filepath = WORK_DIR / "requirements.txt" 34 | with open(requirements_filepath) as fp: 35 | install_requires = fp.read() 36 | 37 | 38 | setup( 39 | name='aioAlice', 40 | version=__version__, 41 | packages=find_packages(exclude=('tests', 'tests.*', 'examples',)), 42 | url='https://github.com/mahenzon/aioalice', 43 | license='MIT', 44 | author='Suren Khorenyan', 45 | requires_python='>=3.6', 46 | author_email='surenkhorenyan@gmail.com', 47 | description='Asynchronous library for Yandex Dialogs (Alice) API', 48 | long_description=get_description(), 49 | long_description_content_type='text/markdown', 50 | classifiers=[ 51 | 'Development Status :: 5 - Production/Stable', 52 | 'Environment :: Console', 53 | 'Framework :: AsyncIO', 54 | 'Intended Audience :: Developers', 55 | 'Intended Audience :: System Administrators', 56 | 'License :: OSI Approved :: MIT License', 57 | 'Programming Language :: Python :: 3.6', 58 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 59 | ], 60 | install_requires=install_requires, 61 | ) 62 | -------------------------------------------------------------------------------- /tests/_dataset.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | META = { 4 | "locale": "ru-RU", 5 | "timezone": "Europe/Moscow", 6 | "client_id": "ru.yandex.searchplugin/5.80 (Samsung Galaxy; Android 4.4)" 7 | } 8 | 9 | MARKUP = { 10 | "dangerous_context": True 11 | } 12 | 13 | REQUEST = { 14 | "command": "где поесть", 15 | "original_utterance": "Алиса где поесть", 16 | "type": "SimpleUtterance", 17 | "payload": {} 18 | } 19 | 20 | REQUEST_DANGEROUS = deepcopy(REQUEST) 21 | REQUEST_DANGEROUS["markup"] = { 22 | "dangerous_context": True 23 | } 24 | 25 | BASE_SESSION = { 26 | "message_id": 4, 27 | "session_id": "2eac4854-fce721f3-b845abba-20d60", 28 | "user_id": "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC" 29 | } 30 | 31 | SESSION = deepcopy(BASE_SESSION) 32 | SESSION.update({ 33 | "new": True, 34 | "skill_id": "3ad36498-f5rd-4079-a14b-788652932056", 35 | }) 36 | 37 | TTS = "Здравствуйте! Это мы, хоров+одо в+еды." 38 | 39 | RESPONSE_BUTTON = { 40 | "title": "Надпись на кнопке", 41 | "payload": {}, 42 | "url": "https://responseexample.com/", 43 | "hide": True 44 | } 45 | 46 | RESPONSE = { 47 | "text": "Здравствуйте! Это мы, хороводоведы.", 48 | "tts": TTS, 49 | "buttons": [RESPONSE_BUTTON], 50 | "end_session": False 51 | } 52 | 53 | RESPONSE2 = { 54 | 'text': 'Response Text', 55 | 'buttons': [ 56 | { 57 | 'title': 'Hi!', 58 | 'hide': True 59 | } 60 | ], 61 | 'end_session': False 62 | } 63 | 64 | ALICE_REQUEST = { 65 | "meta": { 66 | "locale": "ru-RU", 67 | "timezone": "Europe/Moscow", 68 | "client_id": "ru.yandex.searchplugin/5.80 (Samsung Galaxy; Android 4.4)" 69 | }, 70 | "request": { 71 | "command": "где ближайшее отделение", 72 | "original_utterance": "Алиса спроси у Сбербанка где ближайшее отделение", 73 | "type": "SimpleUtterance", 74 | "markup": { 75 | "dangerous_context": True 76 | }, 77 | "payload": {} 78 | }, 79 | "session": SESSION, 80 | "version": "1.0" 81 | } 82 | 83 | ALICE_RESPONSE = { 84 | "response": { 85 | "text": "Здравствуйте! Это мы, хороводоведы.", 86 | "tts": TTS, 87 | "end_session": False 88 | }, 89 | "session": BASE_SESSION, 90 | "session_state": {}, 91 | "user_state_update": {}, 92 | "application_state": {}, 93 | "version": "1.0" 94 | } 95 | 96 | ALICE_RESPONSE_WITH_BUTTONS = deepcopy(ALICE_RESPONSE) 97 | ALICE_RESPONSE_WITH_BUTTONS["response"]["buttons"] = [ 98 | { 99 | "title": "Надпись на кнопке", 100 | "payload": {}, 101 | "url": "https://example.com/", 102 | "hide": True 103 | }, 104 | { 105 | "title": "Надпись на кнопке1", 106 | "payload": {'key': 'value'}, 107 | "url": "https://ya.com/", 108 | "hide": False 109 | }, 110 | ] 111 | 112 | RESPONSE_TEXT = 'Здравствуйте! Это мы, хороводоведы.' 113 | EXPECTED_RESPONSE = { 114 | "response": { 115 | "text": RESPONSE_TEXT, 116 | "end_session": False 117 | }, 118 | "session": BASE_SESSION, 119 | "session_state": {}, 120 | "user_state_update": {}, 121 | "application_state": {}, 122 | "version": "1.0" 123 | } 124 | 125 | BUTTON_TEXT = "Надпись на кнопке 3" 126 | URL = 'https://example.com/' 127 | EXPECTED_RESPONSE_WITH_BUTTONS = deepcopy(EXPECTED_RESPONSE) 128 | EXPECTED_RESPONSE_WITH_BUTTONS['response'].update({ 129 | 'tts': TTS, 130 | 'buttons': [ 131 | { 132 | 'title': BUTTON_TEXT, 133 | 'url': URL, 134 | 'hide': True 135 | } 136 | ] 137 | }) 138 | 139 | UPLOADED_IMAGE = { 140 | "id": '1234567890/qwerty', 141 | "origUrl": 'http://example.com' 142 | } 143 | 144 | MB_PAYLOAD = {'mediabutton': True, 'key': 'smth'} 145 | MEDIA_BUTTON = { 146 | "text": BUTTON_TEXT, 147 | "url": URL, 148 | "payload": deepcopy(MB_PAYLOAD) 149 | } 150 | 151 | IMAGE_ID = '1027858/46r960da47f60207e924' 152 | 153 | IMAGE = { 154 | "image_id": IMAGE_ID, 155 | "title": "Заголовок", 156 | "description": "Описание", 157 | "button": deepcopy(MEDIA_BUTTON) 158 | } 159 | 160 | CARD_HEADER_TEXT = 'Click here to see more' 161 | FOOTER_TEXT = 'Click here to see more' 162 | FOOTER = { 163 | 'text': FOOTER_TEXT, 164 | 'button': deepcopy(MEDIA_BUTTON) 165 | } 166 | 167 | CARD_TITLE = 'Заголовок' 168 | CARD_DESCR = 'Описание' 169 | 170 | EXPECTED_CARD_BIG_IMAGE_JSON = { 171 | "type": "BigImage", 172 | "image_id": IMAGE_ID, 173 | "title": CARD_TITLE, 174 | "description": CARD_DESCR, 175 | "button": deepcopy(MEDIA_BUTTON), 176 | } 177 | 178 | EXPECTED_CARD_ITEMS_LIST_JSON = { 179 | "type": "ItemsList", 180 | "header": {"text": CARD_HEADER_TEXT}, 181 | "items": [deepcopy(IMAGE)], 182 | "footer": deepcopy(FOOTER), 183 | } 184 | 185 | EXPECTED_ALICE_RESPONSE_BIG_IMAGE_WITH_BUTTON = { 186 | "response": { 187 | "text": RESPONSE_TEXT, 188 | "card": deepcopy(EXPECTED_CARD_BIG_IMAGE_JSON), 189 | "buttons": [RESPONSE_BUTTON], 190 | "end_session": False 191 | }, 192 | "session": BASE_SESSION, 193 | "session_state": {}, 194 | "user_state_update": {}, 195 | "application_state": {}, 196 | "version": "1.0" 197 | } 198 | 199 | EXPECTED_ALICE_RESPONSE_ITEMS_LIST_WITH_BUTTON = { 200 | "response": { 201 | "text": RESPONSE_TEXT, 202 | "card": deepcopy(EXPECTED_CARD_ITEMS_LIST_JSON), 203 | "buttons": [RESPONSE_BUTTON], 204 | "end_session": False 205 | }, 206 | "session": BASE_SESSION, 207 | "session_state": {}, 208 | "user_state_update": {}, 209 | "application_state": {}, 210 | "version": "1.0" 211 | } 212 | 213 | DATA_FROM_STATION = { 214 | 'meta': { 215 | 'client_id': 'ru.yandex.quasar.services/1.0 (Yandex Station; android 6.0.1)', 216 | 'flags': [ 217 | 'no_cards_support' 218 | ], 219 | 'locale': 'ru-RU', 220 | 'timezone': 'Europe/Moscow' 221 | }, 222 | 'request': { 223 | 'command': '', 224 | 'original_utterance': 'запусти навык qwerty', 225 | 'type': 'SimpleUtterance' 226 | }, 227 | 'session': { 228 | 'message_id': 0, 229 | 'new': True, 230 | 'session_id': '618709-bb99dd92-82c4f626-442a4', 231 | 'skill_id': '94d16-a32f-4932-9f5e-354d31f71998', 232 | 'user_id': 'CFC516B0EC123B86C78532BCEC1C33CBF05D54EF15C8001B52628EF49F580' 233 | }, 234 | "session_state": {}, 235 | "user_state_update": {}, 236 | "application_state": {}, 237 | 'version': '1.0' 238 | } 239 | 240 | ENTITY_TOKEN = { 241 | "start": 2, 242 | "end": 6 243 | } 244 | 245 | ENTITY_VALUE = { 246 | "house_number": "16", 247 | "street": "льва толстого" 248 | } 249 | 250 | ENTITY = { 251 | "tokens": ENTITY_TOKEN, 252 | "type": "YANDEX.GEO", 253 | "value": ENTITY_VALUE 254 | } 255 | 256 | ENTITY_INTEGER = { 257 | "tokens": { 258 | "start": 5, 259 | "end": 6 260 | }, 261 | "type": "YANDEX.NUMBER", 262 | "value": 16 263 | } 264 | 265 | NLU = { 266 | "tokens": [ 267 | "закажи", 268 | "пиццу", 269 | "на", 270 | "льва", 271 | "толстого", 272 | "16", 273 | "на", 274 | "завтра" 275 | ], 276 | "entities": [ 277 | ENTITY, 278 | { 279 | "tokens": { 280 | "start": 3, 281 | "end": 5 282 | }, 283 | "type": "YANDEX.FIO", 284 | "value": { 285 | "first_name": "лев", 286 | "last_name": "толстой" 287 | } 288 | }, 289 | ENTITY_INTEGER, 290 | { 291 | "tokens": { 292 | "start": 6, 293 | "end": 8 294 | }, 295 | "type": "YANDEX.DATETIME", 296 | "value": { 297 | "day": 1, 298 | "day_is_relative": True 299 | } 300 | } 301 | ] 302 | } 303 | 304 | REQUEST_WITH_NLU = { 305 | "meta": { 306 | "locale": "ru-RU", 307 | "timezone": "Europe/Moscow", 308 | "client_id": "ru.yandex.searchplugin/5.80 (Samsung Galaxy; Android 4.4)" 309 | }, 310 | "request": { 311 | "command": "закажи пиццу на улицу льва толстого, 16 на завтра", 312 | "original_utterance": "закажи пиццу на улицу льва толстого, 16 на завтра", 313 | "type": "SimpleUtterance", 314 | "markup": { 315 | "dangerous_context": True 316 | }, 317 | "payload": {}, 318 | "nlu": NLU, 319 | }, 320 | "session": { 321 | "new": True, 322 | "message_id": 4, 323 | "session_id": "2eac4854-fce721f3-b845abba-20d60", 324 | "skill_id": "3ad36498-f5rd-4079-a14b-788652932056", 325 | "user_id": "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC" 326 | }, 327 | "version": "1.0" 328 | } 329 | 330 | PING_REQUEST_1 = { 331 | 'meta': { 332 | 'client_id': 'ru.yandex.searchplugin/7.16 (none none; android 4.4.2)', 333 | 'interfaces': { 334 | 'screen': {} 335 | }, 336 | 'locale': 'ru-RU', 337 | 'timezone': 'UTC' 338 | }, 339 | 'request': { 340 | 'command': 'ping', 341 | 'nlu': { 342 | 'entities': [], 343 | 'tokens': ['ping'] 344 | }, 345 | 'original_utterance': 'ping', 346 | 'type': 'SimpleUtterance' 347 | }, 348 | 'session': { 349 | 'message_id': 0, 350 | 'new': True, 351 | 'session_id': '33234b1a-b8254783-45161bd7-3475df', 352 | 'skill_id': '94d12322-a36f-4922-1f5e-364d31f77998', 353 | 'user_id': '30395B6231A36EADCF17D4AF2707BF2D3A8E6AA48E5CD34A30365C1E642A9F9B' 354 | }, 355 | 'version': '1.0' 356 | } 357 | 358 | REQUEST_NEW_INTERFACES = { 359 | 'meta': { 360 | 'client_id': 'ru.yandex.searchplugin/7.16 (none none; android 4.4.2)', 361 | 'interfaces': { 362 | 'account_linking': {}, 363 | 'payments': {}, 364 | 'screen': {} 365 | }, 366 | 'locale': 'ru-RU', 367 | 'timezone': 'UTC' 368 | }, 369 | 'request': { 370 | 'command': '', 371 | 'nlu': { 372 | 'entities': [], 373 | 'tokens': [], 374 | }, 375 | 'original_utterance': '', 376 | 'type': 'SimpleUtterance', 377 | }, 378 | 'session': { 379 | 'message_id': 0, 380 | 'new': True, 381 | 'session_id': 'aa6be578-34b9d8f7-e2f013b9-5c3b058d', 382 | 'skill_id': '94d16422-a32f-4932-9f5e-354d31f71998', 383 | 'user_id': '30397B6278A36EADCF17D4AF2707BF2C3A8E6BA48E5CD34A30365C1E628A9F9B' 384 | }, 385 | 'version': '1.0' 386 | } 387 | 388 | REQUEST_WITH_EXTRA_KWARGS = { 389 | 'meta': { 390 | 'client_id': 'JS/1.0', 391 | 'locale': 'ru_RU', 392 | 'timezone': 'Europe/Moscow', 393 | 'interfaces': { 394 | 'screen': {}, 395 | }, 396 | '_city_ru': 'Москва', 397 | }, 398 | 'request': { 399 | 'command': '', 400 | 'original_utterance': 'Запусти навык qwerty', 401 | 'type': 'SimpleUtterance', 402 | 'nlu': { 403 | 'tokens': ['запусти', 'навык', 'qwerty'], 404 | 'entities': [], 405 | }, 406 | }, 407 | 'session': { 408 | 'session_id': '4b124ca8-19c4-4ec5-75ca-24f96ef5718e', 409 | 'user_id': '8e4156d21488cac9b7a7175a9374e63a74bb6ddd46cfbe34cf9dfb60c30c7bfb', 410 | 'skill_id': 'f5f39790-2ee1-4744-8345-ee8229dadd58', 411 | 'new': True, 412 | 'message_id': 0, 413 | 'deploy_tokens': {}, 414 | }, 415 | 'version': '1.0', 416 | } 417 | 418 | REQUEST_W_EXTRA_KW_NEW = { 419 | 'meta': { 420 | 'client_id': 'MailRu-VC/1.0', 421 | 'locale': 'ru_RU', 422 | 'timezone': 'Europe/Moscow', 423 | 'interfaces': { 424 | 'screen': {}, 425 | }, 426 | '_city_ru': 'Москва', 427 | }, 428 | 'request': { 429 | 'command': '', 430 | 'original_utterance': 'Включи навык абракадабра', 431 | 'type': 'SimpleUtterance', 432 | 'nlu': { 433 | 'tokens': ['включи', 434 | 'навык', 435 | 'абракадабра'], 436 | 'entities': [], 437 | } 438 | }, 439 | 'session': { 440 | 'session_id': 'a6dcdc42-92b8-4076-9bae-fced146bb1b2', 441 | 'user_id': 'f67490185d2080870a55490310dcd14007ddad00eb330dd4e3356a8bac77d13f', 442 | 'skill_id': 'efe83b82-c63e-4035-afa4-80ebed0973c8', 443 | 'new': True, 444 | 'message_id': 0, 445 | 'user': { 446 | 'user_id': 'bfe750f47d3548c13d46fa35a461dcdb49c5ab340e6f62097a77b2e023c7a4af', 447 | }, 448 | 'application': { 449 | 'application_id': 'f67490185d2080870a55490310dcd14007ddad00eb330dd4e3356a8bac77d13f', 450 | 'application_type': 'mobile', 451 | }, 452 | }, 453 | 'state': { 454 | 'session': {}, 455 | 'user': {}, 456 | }, 457 | 'version': '1.0', 458 | } 459 | 460 | YANDEX_ALICE_REQUEST_EXAMPLE = { 461 | "meta": { 462 | "locale": "ru-RU", 463 | "timezone": "Europe/Moscow", 464 | "client_id": "ru.yandex.searchplugin/5.80 (Samsung Galaxy; Android 4.4)", 465 | "interfaces": { 466 | "screen": {}, 467 | "account_linking": {} 468 | } 469 | }, 470 | "request": { 471 | "command": "закажи пиццу на улицу льва толстого 16 на завтра", 472 | "original_utterance": "закажи пиццу на улицу льва толстого, 16 на завтра", 473 | "type": "SimpleUtterance", 474 | "markup": { 475 | "dangerous_context": True 476 | }, 477 | "payload": {}, 478 | "nlu": { 479 | "tokens": [ 480 | "закажи", 481 | "пиццу", 482 | "на", 483 | "льва", 484 | "толстого", 485 | "16", 486 | "на", 487 | "завтра" 488 | ], 489 | "entities": [ 490 | { 491 | "tokens": { 492 | "start": 2, 493 | "end": 6 494 | }, 495 | "type": "YANDEX.GEO", 496 | "value": { 497 | "house_number": "16", 498 | "street": "льва толстого" 499 | } 500 | }, 501 | { 502 | "tokens": { 503 | "start": 3, 504 | "end": 5 505 | }, 506 | "type": "YANDEX.FIO", 507 | "value": { 508 | "first_name": "лев", 509 | "last_name": "толстой" 510 | } 511 | }, 512 | { 513 | "tokens": { 514 | "start": 5, 515 | "end": 6 516 | }, 517 | "type": "YANDEX.NUMBER", 518 | "value": 16 519 | }, 520 | { 521 | "tokens": { 522 | "start": 6, 523 | "end": 8 524 | }, 525 | "type": "YANDEX.DATETIME", 526 | "value": { 527 | "day": 1, 528 | "day_is_relative": True 529 | } 530 | } 531 | ] 532 | } 533 | }, 534 | "session": { 535 | "message_id": 0, 536 | "session_id": "2eac4854-fce721f3-b845abba-20d60", 537 | "skill_id": "3ad36498-f5rd-4079-a14b-788652932056", 538 | "user_id": "47C73714B580ED2469056E71081159529FFC676A4E5B059D629A819E857DC2F8", 539 | "user": { 540 | "user_id": "6C91DA5198D1758C6A9F63A7C5CDDF09359F683B13A18A151FBF4C8B092BB0C2", 541 | "access_token": "AgAAAAAB4vpbAAApoR1oaCd5yR6eiXSHqOGT8dT" 542 | }, 543 | "application": { 544 | "application_id": "47C73714B580ED2469056E71081159529FFC676A4E5B059D629A819E857DC2F8" 545 | }, 546 | "new": True 547 | }, 548 | "version": "1.0" 549 | } -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from functools import partial 3 | 4 | from aioalice import types 5 | from aioalice.utils import generate_json_payload 6 | 7 | from _dataset import META, MARKUP, SESSION, \ 8 | REQUEST, REQUEST_DANGEROUS, BASE_SESSION, \ 9 | RESPONSE, RESPONSE2, ALICE_REQUEST, \ 10 | ALICE_RESPONSE, ALICE_RESPONSE_WITH_BUTTONS, \ 11 | EXPECTED_RESPONSE, TTS, BUTTON_TEXT, URL, \ 12 | EXPECTED_RESPONSE_WITH_BUTTONS, UPLOADED_IMAGE, \ 13 | MEDIA_BUTTON, IMAGE, FOOTER, IMAGE_ID, \ 14 | CARD_TITLE, CARD_DESCR, MB_PAYLOAD, FOOTER_TEXT, \ 15 | EXPECTED_CARD_BIG_IMAGE_JSON, CARD_HEADER_TEXT, \ 16 | EXPECTED_CARD_ITEMS_LIST_JSON, RESPONSE_TEXT, \ 17 | RESPONSE_BUTTON, \ 18 | EXPECTED_ALICE_RESPONSE_BIG_IMAGE_WITH_BUTTON, \ 19 | EXPECTED_ALICE_RESPONSE_ITEMS_LIST_WITH_BUTTON, \ 20 | DATA_FROM_STATION, REQUEST_WITH_NLU, ENTITY_TOKEN, \ 21 | ENTITY_VALUE, ENTITY, ENTITY_INTEGER, NLU, \ 22 | PING_REQUEST_1, REQUEST_NEW_INTERFACES, REQUEST_WITH_EXTRA_KWARGS, \ 23 | REQUEST_W_EXTRA_KW_NEW, YANDEX_ALICE_REQUEST_EXAMPLE 24 | 25 | 26 | TestAliceRequest = partial(types.AliceRequest, None) # original_request: https://github.com/surik00/aioalice/pull/2/ 27 | 28 | 29 | class TestAliceTypes(unittest.TestCase): 30 | 31 | def _assert_payload(self, alice_obj, expected_json): 32 | json_payload = generate_json_payload(**alice_obj.to_json()) 33 | self.assertEqual(json_payload, expected_json) 34 | 35 | def _test_interfaces(self, interfaces, dct): 36 | if 'account_linking' in dct: 37 | self.assertEqual(interfaces.account_linking, dct['account_linking']) 38 | if 'payments' in dct: 39 | self.assertEqual(interfaces.payments, dct['payments']) 40 | self.assertEqual(interfaces.screen, dct['screen']) 41 | 42 | def _test_meta(self, meta, dct): 43 | self.assertEqual(meta.locale, dct['locale']) 44 | self.assertEqual(meta.timezone, dct['timezone']) 45 | self.assertEqual(meta.client_id, dct['client_id']) 46 | if 'flags' in dct: 47 | self.assertEqual(meta.flags, dct['flags']) 48 | if 'interfaces' in dct: 49 | self._test_interfaces(meta.interfaces, dct['interfaces']) 50 | 51 | def test_meta(self): 52 | meta = types.Meta(**META) 53 | self._test_meta(meta, META) 54 | 55 | def _test_markup(self, markup, dct): 56 | self.assertEqual(markup.dangerous_context, dct['dangerous_context']) 57 | 58 | def test_markup(self): 59 | markup = types.Markup(**MARKUP) 60 | self._test_markup(markup, MARKUP) 61 | 62 | def _test_entity_tokens(self, et, dct): 63 | self.assertEqual(et.start, dct['start']) 64 | self.assertEqual(et.end, dct['end']) 65 | 66 | def test_entity_tokens(self): 67 | et = types.EntityTokens(**ENTITY_TOKEN) 68 | self._test_entity_tokens(et, ENTITY_TOKEN) 69 | 70 | def _test_entity_value(self, ev, dct): 71 | for key in ( 72 | 'first_name', 73 | 'patronymic_name', 74 | 'last_name', 75 | 'country', 76 | 'city', 77 | 'street', 78 | 'house_number', 79 | 'airport', 80 | 'year', 81 | 'year_is_relative', 82 | 'month', 83 | 'month_is_relative', 84 | 'day', 85 | 'day_is_relative', 86 | 'hour', 87 | 'hour_is_relative', 88 | 'minute', 89 | 'minute_is_relative', 90 | ): 91 | if key in dct: 92 | self.assertEqual(getattr(ev, key), dct[key]) 93 | 94 | def test_entity_value(self): 95 | ev = types.EntityValue(**ENTITY_VALUE) 96 | self._test_entity_value(ev, ENTITY_VALUE) 97 | 98 | def _test_entity(self, entity, dct): 99 | self._test_entity_tokens(entity.tokens, dct['tokens']) 100 | if entity.type == types.EntityType.YANDEX_NUMBER: 101 | entity.value == dct['value'] 102 | else: 103 | self._test_entity_value(entity.value, dct['value']) 104 | 105 | def test_entity(self): 106 | entity = types.Entity(**ENTITY) 107 | self._test_entity(entity, ENTITY) 108 | entity_int = types.Entity(**ENTITY_INTEGER) 109 | self._test_entity(entity_int, ENTITY_INTEGER) 110 | 111 | def _test_nlu(self, nlu, dct): 112 | self.assertEqual(nlu.tokens, dct['tokens']) 113 | for entity, _dct in zip(nlu.entities, dct['entities']): 114 | self._test_entity(entity, _dct) 115 | 116 | def test_nlu(self): 117 | nlu = types.NaturalLanguageUnderstanding(**NLU) 118 | self._test_nlu(nlu, NLU) 119 | 120 | def _test_request(self, req, dct): 121 | self.assertEqual(req.command, dct['command']) 122 | self.assertEqual(req.original_utterance, dct['original_utterance']) 123 | self.assertEqual(req.type, dct['type']) 124 | if 'payload' in dct: 125 | self.assertEqual(req.payload, dct['payload']) 126 | if 'markup' in dct: 127 | self._test_markup(req.markup, dct['markup']) 128 | if 'nlu' in dct: 129 | self._test_nlu(req.nlu, dct['nlu']) 130 | 131 | def test_request(self): 132 | request = types.Request(**REQUEST) 133 | self._test_request(request, REQUEST) 134 | request_dang = types.Request(**REQUEST_DANGEROUS) 135 | self._test_request(request_dang, REQUEST_DANGEROUS) 136 | 137 | def _test_base_session(self, bs, dct): 138 | self.assertEqual(bs.user_id, dct['user_id']) 139 | self.assertEqual(bs.message_id, dct['message_id']) 140 | self.assertEqual(bs.session_id, dct['session_id']) 141 | 142 | def test_base_session(self): 143 | base_session = types.BaseSession(**BASE_SESSION) 144 | self._test_base_session(base_session, BASE_SESSION) 145 | 146 | def _test_session(self, sess, dct): 147 | self.assertEqual(sess.new, dct['new']) 148 | self.assertEqual(sess.skill_id, dct['skill_id']) 149 | self._test_base_session(sess, dct) 150 | 151 | def test_session(self): 152 | session = types.Session(**SESSION) 153 | self._test_session(session, SESSION) 154 | 155 | def _test_button(self, btn, title, url=None, payload=None, hide=True): 156 | self.assertEqual(btn.title, title) 157 | self.assertEqual(btn.url, url) 158 | self.assertEqual(btn.payload, payload) 159 | self.assertEqual(btn.hide, hide) 160 | 161 | def _tst_buttons(self, btn, dct): 162 | self._test_button( 163 | btn, 164 | dct.get('title'), 165 | dct.get('url'), 166 | dct.get('payload'), 167 | dct.get('hide') 168 | ) 169 | 170 | def test_buttons(self): 171 | title = 'Title' 172 | 173 | btn1 = types.Button(title) 174 | self._test_button(btn1, title) 175 | btn2 = types.Button(title, url='yandex.ru') 176 | self._test_button(btn2, title, 'yandex.ru') 177 | btn3 = types.Button(title, payload={'key': 'value'}) 178 | self._test_button(btn3, title, payload={'key': 'value'}) 179 | btn4 = types.Button(title, payload={'json': {'key': 'value'}}, hide=False) 180 | self._test_button(btn4, title, payload={'json': {'key': 'value'}}, hide=False) 181 | btn5 = types.Button(title, url='github.com', payload={'json': {'key': 'value'}}, hide=False) 182 | self._test_button(btn5, title, url='github.com', payload={'json': {'key': 'value'}}, hide=False) 183 | 184 | def _test_response(self, resp, dct): 185 | self.assertEqual(resp.text, dct['text']) 186 | self.assertEqual(resp.tts, dct['tts']) 187 | self.assertEqual(resp.end_session, dct['end_session']) 188 | if resp.buttons is not None: 189 | for btn, btn_dct in zip(resp.buttons, dct['buttons']): 190 | self._tst_buttons(btn, btn_dct) 191 | 192 | def test_response1(self): 193 | response = types.Response(**RESPONSE) 194 | self._test_response(response, RESPONSE) 195 | 196 | def test_response2(self): 197 | response = types.Response(RESPONSE2['text'], buttons=['Hi!']) 198 | self._assert_payload(response, RESPONSE2) 199 | 200 | def _test_alice_request(self, arq: types.AliceRequest, dct: dict): 201 | self.assertEqual(arq.version, dct['version']) 202 | self._test_session(arq.session, dct['session']) 203 | self._test_request(arq.request, dct['request']) 204 | self._test_meta(arq.meta, dct['meta']) 205 | 206 | def _test_alice_request_from_dct(self, dct): 207 | alice_request = TestAliceRequest(**dct) 208 | self._test_alice_request(alice_request, dct) 209 | 210 | def test_alice_request(self): 211 | self._test_alice_request_from_dct(ALICE_REQUEST) 212 | 213 | def test_alice_request_from_station(self): 214 | self._test_alice_request_from_dct(DATA_FROM_STATION) 215 | 216 | def _test_alice_response(self, arp, dct): 217 | self.assertEqual(arp.version, dct['version']) 218 | 219 | def test_alice_response(self): 220 | alice_response = types.AliceResponse(**ALICE_RESPONSE) 221 | self._test_alice_response(alice_response, ALICE_RESPONSE) 222 | alice_response = types.AliceResponse(**ALICE_RESPONSE_WITH_BUTTONS) 223 | self._assert_payload(alice_response, ALICE_RESPONSE_WITH_BUTTONS) 224 | self._test_alice_response(alice_response, ALICE_RESPONSE_WITH_BUTTONS) 225 | 226 | def test_response_from_request(self): 227 | alice_request = TestAliceRequest(**ALICE_REQUEST) 228 | 229 | alice_response = alice_request.response( 230 | EXPECTED_RESPONSE['response']['text'] 231 | ) 232 | self._assert_payload(alice_response, EXPECTED_RESPONSE) 233 | 234 | def test_response_from_request2(self): 235 | alice_request = TestAliceRequest(**ALICE_REQUEST) 236 | alice_response = alice_request.response( 237 | RESPONSE_TEXT, tts=TTS, 238 | buttons=[types.Button(BUTTON_TEXT, url=URL)] 239 | ) 240 | self._assert_payload(alice_response, EXPECTED_RESPONSE_WITH_BUTTONS) 241 | 242 | def test_response_big_image_from_request(self): 243 | alice_request = TestAliceRequest(**ALICE_REQUEST) 244 | alice_response = alice_request.response_big_image( 245 | RESPONSE_TEXT, IMAGE_ID, CARD_TITLE, CARD_DESCR, 246 | types.MediaButton(BUTTON_TEXT, URL, MB_PAYLOAD), 247 | buttons=[RESPONSE_BUTTON] 248 | ) 249 | self._assert_payload(alice_response, EXPECTED_ALICE_RESPONSE_BIG_IMAGE_WITH_BUTTON) 250 | 251 | def test_response_items_list_from_request(self): 252 | alice_request = TestAliceRequest(**ALICE_REQUEST) 253 | alice_response = alice_request.response_items_list( 254 | RESPONSE_TEXT, CARD_HEADER_TEXT, 255 | [types.Image(**IMAGE)], 256 | types.CardFooter(**FOOTER), 257 | buttons=[RESPONSE_BUTTON] 258 | ) 259 | self._assert_payload(alice_response, EXPECTED_ALICE_RESPONSE_ITEMS_LIST_WITH_BUTTON) 260 | 261 | def _test_uploaded_image(self, uimg, dct): 262 | self.assertEqual(uimg.id, dct['id']) 263 | self.assertEqual(uimg.origUrl, dct['origUrl']) 264 | self.assertEqual(uimg.orig_url, dct['origUrl']) 265 | 266 | def test_uploaded_image(self): 267 | uimg = types.UploadedImage(**UPLOADED_IMAGE) 268 | self._test_uploaded_image(uimg, UPLOADED_IMAGE) 269 | 270 | def _test_media_button(self, mb, mb_dct): 271 | self.assertEqual(mb.text, mb_dct['text']) 272 | self.assertEqual(mb.url, mb_dct['url']) 273 | self.assertEqual(mb.payload, mb_dct['payload']) 274 | 275 | def test_media_button(self): 276 | mb = types.MediaButton(**MEDIA_BUTTON) 277 | self._test_media_button(mb, MEDIA_BUTTON) 278 | 279 | def _test_image_with_button(self, img, dct): 280 | self.assertEqual(img.image_id, dct['image_id']) 281 | self.assertEqual(img.title, dct['title']) 282 | self.assertEqual(img.description, dct['description']) 283 | self._test_media_button(img.button, dct['button']) 284 | 285 | def test_image_with_button(self): 286 | img = types.Image(**IMAGE) 287 | self._test_image_with_button(img, IMAGE) 288 | 289 | def _test_card_header(self, cheader, dct): 290 | self.assertEqual(cheader.text, dct['text']) 291 | 292 | def test_card_header(self): 293 | header = types.CardHeader(CARD_HEADER_TEXT) 294 | self._test_card_header(header, {'text': CARD_HEADER_TEXT}) 295 | 296 | def _test_card_footer(self, cfooter, dct): 297 | self.assertEqual(cfooter.text, dct['text']) 298 | self._test_media_button(cfooter.button, dct['button']) 299 | self.assertEqual(cfooter.to_json(), dct) 300 | 301 | def test_card_footer(self): 302 | card_footer = types.CardFooter(FOOTER_TEXT, FOOTER['button']) 303 | self._test_card_footer(card_footer, FOOTER) 304 | 305 | def test_card_big_image(self): 306 | card_big_image = types.Card( 307 | types.CardType.BIG_IMAGE, 308 | image_id=IMAGE_ID, 309 | title=CARD_TITLE, 310 | description=CARD_DESCR, 311 | button=types.MediaButton(BUTTON_TEXT, URL, MB_PAYLOAD), 312 | ) 313 | self._assert_payload(card_big_image, EXPECTED_CARD_BIG_IMAGE_JSON) 314 | 315 | def test_card_big_image_card_method(self): 316 | card_big_image = types.Card.big_image( 317 | IMAGE_ID, CARD_TITLE, CARD_DESCR, 318 | types.MediaButton(BUTTON_TEXT, URL, MB_PAYLOAD), 319 | ) 320 | self._assert_payload(card_big_image, EXPECTED_CARD_BIG_IMAGE_JSON) 321 | 322 | def test_card_items_list(self): 323 | card_items_list = types.Card( 324 | types.CardType.ITEMS_LIST, 325 | header=CARD_HEADER_TEXT, 326 | items=[types.Image(**IMAGE)], 327 | footer=dict(text=FOOTER_TEXT, button=MEDIA_BUTTON) 328 | ) 329 | self._assert_payload(card_items_list, EXPECTED_CARD_ITEMS_LIST_JSON) 330 | 331 | def test_card_items_list_card_method(self): 332 | card_items_list = types.Card.items_list( 333 | CARD_HEADER_TEXT, 334 | [types.Image(**IMAGE)], 335 | dict(text=FOOTER_TEXT, button=MEDIA_BUTTON) 336 | ) 337 | self._assert_payload(card_items_list, EXPECTED_CARD_ITEMS_LIST_JSON) 338 | 339 | def test_request_with_nlu(self): 340 | self._test_alice_request_from_dct(REQUEST_WITH_NLU) 341 | 342 | def test_request_with_interfaces(self): 343 | alice_request = TestAliceRequest(**PING_REQUEST_1) 344 | self._test_alice_request(alice_request, PING_REQUEST_1) 345 | 346 | def test_request_new_fields_in_interfaces(self): 347 | alice_request = TestAliceRequest(**REQUEST_NEW_INTERFACES) 348 | self._test_alice_request(alice_request, REQUEST_NEW_INTERFACES) 349 | 350 | def test_model_inits_ok_with_extra_kwargs(self): 351 | alice_request: types.AliceRequest = TestAliceRequest(**REQUEST_WITH_EXTRA_KWARGS) 352 | self._test_alice_request(alice_request, REQUEST_WITH_EXTRA_KWARGS) 353 | assert alice_request.session._raw_kwargs['deploy_tokens'] is REQUEST_WITH_EXTRA_KWARGS['session']['deploy_tokens'] 354 | assert alice_request.meta._raw_kwargs['_city_ru'] is REQUEST_WITH_EXTRA_KWARGS['meta']['_city_ru'] 355 | 356 | def test_model_inits_ok_with_extra_root_kwargs(self): 357 | alice_request: types.AliceRequest = TestAliceRequest(**REQUEST_W_EXTRA_KW_NEW) 358 | self._test_alice_request(alice_request, REQUEST_W_EXTRA_KW_NEW) 359 | assert alice_request._raw_kwargs['state'] is REQUEST_W_EXTRA_KW_NEW['state'] 360 | 361 | def test_request_demo_example(self): 362 | alice_request = TestAliceRequest(**YANDEX_ALICE_REQUEST_EXAMPLE) 363 | self._test_alice_request(alice_request, YANDEX_ALICE_REQUEST_EXAMPLE) 364 | --------------------------------------------------------------------------------