├── requirements ├── ci.txt ├── tests.txt ├── lint.txt ├── docs.txt └── dev.txt ├── docs ├── source │ ├── change-log │ │ └── index.rst │ ├── contributing-guide │ │ └── index.rst │ ├── getting-started │ │ ├── examples │ │ │ ├── index.rst │ │ │ ├── flask.rst │ │ │ └── django.rst │ │ └── installation.rst │ ├── api │ │ ├── contexts.rst │ │ ├── webhook-client.rst │ │ └── rich-responses.rst │ ├── conf.py │ ├── user-guide │ │ └── fulfillment-overview.rst │ └── index.rst └── Makefile ├── examples ├── django │ ├── requirements.txt │ ├── urls.py │ ├── settings.py │ ├── manage.py │ └── views.py ├── flask │ ├── requirements.txt │ └── app.py └── simple_example.py ├── MANIFEST.in ├── pytest.ini ├── .isort.cfg ├── environment.yaml ├── .coveragerc ├── .gitignore ├── scripts └── import_example_agent.py ├── .flake8 ├── source └── dialogflow_fulfillment │ ├── rich_responses │ ├── __init__.py │ ├── text.py │ ├── image.py │ ├── payload.py │ ├── base.py │ ├── quick_replies.py │ └── card.py │ ├── __init__.py │ ├── contexts.py │ └── webhook_client.py ├── .readthedocs.yaml ├── CONTRIBUTING.rst ├── tests ├── unit │ ├── test_webhook_client.py │ ├── test_contexts.py │ └── test_rich_responses.py ├── conftest.py └── integration │ └── test_webhook_client_integration.py ├── .github ├── dependabot.yaml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── ci.yaml │ └── publish.yaml ├── .pre-commit-config.yaml ├── tox.ini ├── setup.py ├── README.md ├── CHANGELOG.rst ├── CODE_OF_CONDUCT.md └── LICENSE /requirements/ci.txt: -------------------------------------------------------------------------------- 1 | tox==4.4.7 2 | -------------------------------------------------------------------------------- /docs/source/change-log/index.rst: -------------------------------------------------------------------------------- 1 | 2 | .. include:: ../../../CHANGELOG.rst 3 | -------------------------------------------------------------------------------- /docs/source/contributing-guide/index.rst: -------------------------------------------------------------------------------- 1 | 2 | .. include:: ../../../CONTRIBUTING.rst 3 | -------------------------------------------------------------------------------- /examples/django/requirements.txt: -------------------------------------------------------------------------------- 1 | dialogflow-fulfillment>=0.4,<1 2 | Django>=4.0,<5 3 | -------------------------------------------------------------------------------- /examples/flask/requirements.txt: -------------------------------------------------------------------------------- 1 | dialogflow-fulfillment>=0.4,<1 2 | Flask>=2.0,<3 3 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | pytest==7.2.1 2 | pytest-cov==4.0.0 3 | pytest-mock==3.10.0 4 | -------------------------------------------------------------------------------- /requirements/lint.txt: -------------------------------------------------------------------------------- 1 | flake8==5.0.4 2 | flake8-docstrings==1.6.0 3 | flake8-isort==6.0.0 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include CHANGELOG.rst 4 | include CONTRIBUTING.rst 5 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | --verbose 4 | --cov 5 | pythonpath = source 6 | testpaths = tests 7 | -------------------------------------------------------------------------------- /docs/source/getting-started/examples/index.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | .. toctree:: 5 | 6 | flask 7 | django 8 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | furo==2022.12.7 2 | Sphinx==5.3.0 3 | sphinx-autobuild==2021.3.14 4 | sphinxcontrib-mermaid==0.7.1 5 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | include_trailing_comma = true 3 | multi_line_output = 3 4 | src_paths = source 5 | use_parentheses = true 6 | -------------------------------------------------------------------------------- /examples/django/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from views import webhook 3 | 4 | urlpatterns = [ 5 | path('', webhook), 6 | ] 7 | -------------------------------------------------------------------------------- /environment.yaml: -------------------------------------------------------------------------------- 1 | name: dialogflow-fulfillment 2 | 3 | dependencies: 4 | - python=3.7 5 | - pip 6 | - pip: 7 | - -r requirements/dev.txt 8 | -------------------------------------------------------------------------------- /examples/django/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = '8yj!jmqst36kr^4d3=e=6u13^o_(+6#^3sium@_souha19=(bn' 2 | 3 | DEBUG = True 4 | 5 | ROOT_URLCONF = 'urls' 6 | -------------------------------------------------------------------------------- /docs/source/api/contexts.rst: -------------------------------------------------------------------------------- 1 | Contexts 2 | ======== 3 | 4 | .. automodule:: dialogflow_fulfillment.contexts 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r ci.txt 2 | -r docs.txt 3 | -r lint.txt 4 | -r tests.txt 5 | pre-commit==3.2.0 6 | setuptools==67.6.0 7 | twine==4.0.2 8 | twine==4.0.2 9 | wheel==0.38.4 10 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | omit = tests 4 | source = source 5 | 6 | [report] 7 | fail_under = 100 8 | show_missing = true 9 | skip_covered = true 10 | skip_empty = true 11 | -------------------------------------------------------------------------------- /docs/source/api/webhook-client.rst: -------------------------------------------------------------------------------- 1 | Webhook client 2 | ============== 3 | 4 | .. automodule:: dialogflow_fulfillment.webhook_client 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Hidden directories 2 | .vscode/ 3 | .tox/ 4 | .venv/ 5 | .pytest_cache/ 6 | 7 | # Artifact directories 8 | *.egg-info/ 9 | build/ 10 | dist/ 11 | docs/build/ 12 | __pycache__ 13 | 14 | # Artifact files 15 | .coverage 16 | 17 | # By file extension 18 | *.py[doc] 19 | -------------------------------------------------------------------------------- /docs/source/getting-started/examples/flask.rst: -------------------------------------------------------------------------------- 1 | Dialogflow fulfillment webhook server with **Flask** 2 | ==================================================== 3 | 4 | .. literalinclude:: ../../../../examples/flask/app.py 5 | :language: python 6 | :caption: app.py 7 | :emphasize-lines: 4, 29-30, 35 8 | -------------------------------------------------------------------------------- /docs/source/getting-started/examples/django.rst: -------------------------------------------------------------------------------- 1 | Dialogflow fulfillment webhook server with **Django** 2 | ===================================================== 3 | 4 | .. literalinclude:: ../../../../examples/django/views.py 5 | :language: python 6 | :caption: views.py 7 | :emphasize-lines: 4, 27-28, 33 8 | -------------------------------------------------------------------------------- /scripts/import_example_agent.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | from google.cloud import dialogflow 4 | 5 | PROJECT_ID = environ.get('PROJECT_ID') 6 | SESSION_ID = environ.get('SESSION_ID') 7 | 8 | session_client = dialogflow.SessionsClient() 9 | 10 | session = session_client.session_path(PROJECT_ID, SESSION_ID) 11 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .git/ 4 | .github/ 5 | .pytest_cache/ 6 | .tox/ 7 | .vscode/ 8 | *.egg-info/ 9 | build/ 10 | docs/build/ 11 | dist/ 12 | __pycache__ 13 | *.py[doc] 14 | ignore = 15 | D100 16 | D104 17 | D107 18 | per-file-ignores = 19 | tests/*: D101, D102, D103 20 | -------------------------------------------------------------------------------- /source/dialogflow_fulfillment/rich_responses/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import RichResponse 2 | from .card import Card 3 | from .image import Image 4 | from .payload import Payload 5 | from .quick_replies import QuickReplies 6 | from .text import Text 7 | 8 | __all__ = ( 9 | 'Card', 10 | 'Image', 11 | 'Payload', 12 | 'QuickReplies', 13 | 'RichResponse', 14 | 'Text', 15 | ) 16 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs settings 2 | # RTD version 3 | version: 2 4 | 5 | # Output formats for the documentation 6 | formats: all 7 | 8 | # Python environment settings 9 | python: 10 | version: "3.7" 11 | install: 12 | - requirements: requirements/docs.txt 13 | - method: pip 14 | path: . 15 | 16 | # Sphinx settings 17 | sphinx: 18 | builder: dirhtml 19 | configuration: docs/source/conf.py 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing guide 2 | ================== 3 | 4 | How can I contribute to *dialogflow-fulfillment*? 5 | ------------------------------------------------- 6 | 7 | If you want to **request an enhancement**, **report a bug**, or simply 8 | **have a question** that has not been answered by the documentation_, you are 9 | always welcome to create an issue on GitHub. 10 | 11 | .. _documentation: https://dialogflow-fulfillment.readthedocs.io 12 | -------------------------------------------------------------------------------- /source/dialogflow_fulfillment/__init__.py: -------------------------------------------------------------------------------- 1 | from .contexts import Context 2 | from .rich_responses import ( 3 | Card, 4 | Image, 5 | Payload, 6 | QuickReplies, 7 | RichResponse, 8 | Text, 9 | ) 10 | from .webhook_client import WebhookClient 11 | 12 | __all__ = ( 13 | 'Context', 14 | 'Card', 15 | 'Image', 16 | 'Payload', 17 | 'QuickReplies', 18 | 'RichResponse', 19 | 'Text', 20 | 'WebhookClient', 21 | ) 22 | -------------------------------------------------------------------------------- /tests/unit/test_webhook_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dialogflow_fulfillment.webhook_client import WebhookClient 4 | 5 | 6 | def test_non_dict(): 7 | with pytest.raises(TypeError): 8 | WebhookClient('this is not a dict') 9 | 10 | 11 | def test_non_callable_handler(webhook_request): 12 | agent = WebhookClient(webhook_request) 13 | 14 | handler = 'this is not a callable' 15 | 16 | with pytest.raises(TypeError): 17 | agent.handle_request(handler) 18 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "pip" 13 | directory: "/requirements" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.4.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: trailing-whitespace 7 | - id: mixed-line-ending 8 | - id: end-of-file-fixer 9 | - id: double-quote-string-fixer 10 | - id: requirements-txt-fixer 11 | - id: check-yaml 12 | - id: check-case-conflict 13 | - id: check-merge-conflict 14 | - id: check-docstring-first 15 | - id: debug-statements 16 | - id: name-tests-test 17 | args: ['--django'] 18 | - id: no-commit-to-branch 19 | args: ['--branch', 'main'] 20 | -------------------------------------------------------------------------------- /examples/django/manage.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from sys import argv 3 | 4 | 5 | def main(): 6 | """Run administrative tasks.""" 7 | environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError as exc: 11 | raise ImportError( 12 | "Couldn't import Django. Are you sure it's installed and " 13 | 'available on your PYTHONPATH environment variable? Did you ' 14 | 'forget to activate a virtual environment?' 15 | ) from exc 16 | execute_from_command_line(argv) 17 | 18 | 19 | if __name__ == '__main__': 20 | main() 21 | -------------------------------------------------------------------------------- /docs/source/api/rich-responses.rst: -------------------------------------------------------------------------------- 1 | Rich responses 2 | ============== 3 | 4 | Card 5 | ---- 6 | 7 | .. autoclass:: dialogflow_fulfillment.rich_responses.Card 8 | 9 | Image 10 | ----- 11 | 12 | .. autoclass:: dialogflow_fulfillment.rich_responses.Image 13 | 14 | Payload 15 | ------- 16 | 17 | .. autoclass:: dialogflow_fulfillment.rich_responses.Payload 18 | 19 | Quick Replies 20 | ------------- 21 | 22 | .. autoclass:: dialogflow_fulfillment.rich_responses.QuickReplies 23 | 24 | Rich Response 25 | ------------- 26 | 27 | .. autoclass:: dialogflow_fulfillment.rich_responses.base.RichResponse 28 | 29 | Text 30 | ---- 31 | 32 | .. autoclass:: dialogflow_fulfillment.rich_responses.Text 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /examples/simple_example.py: -------------------------------------------------------------------------------- 1 | from dialogflow_fulfillment import QuickReplies, WebhookClient 2 | 3 | 4 | # Define a custom handler function 5 | def handler(agent: WebhookClient) -> None: 6 | """ 7 | Handle the webhook request. 8 | 9 | This handler sends a text message along with a quick replies 10 | message back to Dialogflow, which uses the messages to build 11 | the final response to the user. 12 | """ 13 | agent.add('How are you feeling today?') 14 | agent.add(QuickReplies(quick_replies=['Happy :)', 'Sad :('])) 15 | 16 | 17 | # Create an instance of the WebhookClient 18 | agent = WebhookClient(request) # noqa: F821 19 | 20 | # Handle the request using the handler function 21 | agent.handle_request(handler) 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: 3 | push: 4 | branches: [development] 5 | pull_request: 6 | branches: [main, development] 7 | 8 | jobs: 9 | tests: 10 | name: Run CI commands 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, macos-latest, windows-latest] 15 | python-version: [3.7] 16 | steps: 17 | - name: Check out the repository 18 | uses: actions/checkout@v3 19 | - name: Set up Python environment 20 | uses: actions/setup-python@v4.5.0 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Upgrade pip 24 | run: python -m pip install --upgrade pip 25 | - name: Install continuous integration dependencies 26 | run: pip install -r requirements/ci.txt 27 | - name: Run tox 28 | run: tox 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish release to PyPI 2 | on: 3 | release: 4 | types: [created] 5 | 6 | jobs: 7 | publish: 8 | name: Build and publish 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out the repository 12 | uses: actions/checkout@v3 13 | - name: Set up Python environment 14 | uses: actions/setup-python@v4.5.0 15 | with: 16 | python-version: '3.x' 17 | - name: Display Python version 18 | run: python -c "import sys; print(sys.version)" 19 | - name: Upgrade pip 20 | run: python -m pip install --upgrade pip 21 | - name: Install development dependencies 22 | run: pip install -r requirements/dev.txt 23 | - name: Generate distribution package 24 | run: python setup.py sdist 25 | - name: Upload distribution package 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: twine upload dist/* 30 | -------------------------------------------------------------------------------- /examples/flask/app.py: -------------------------------------------------------------------------------- 1 | from logging import INFO 2 | from typing import Dict 3 | 4 | from flask import Flask, request 5 | from flask.logging import create_logger 6 | 7 | from dialogflow_fulfillment import WebhookClient 8 | 9 | # Create Flask app and enable info level logging 10 | app = Flask(__name__) 11 | logger = create_logger(app) 12 | logger.setLevel(INFO) 13 | 14 | 15 | def handler(agent: WebhookClient) -> None: 16 | """Handle the webhook request.""" 17 | 18 | 19 | @app.route('/', methods=['POST']) 20 | def webhook() -> Dict: 21 | """Handle webhook requests from Dialogflow.""" 22 | # Get WebhookRequest object 23 | request_ = request.get_json(force=True) 24 | 25 | # Log request headers and body 26 | logger.info(f'Request headers: {dict(request.headers)}') 27 | logger.info(f'Request body: {request_}') 28 | 29 | # Handle request 30 | agent = WebhookClient(request_) 31 | agent.handle_request(handler) 32 | 33 | # Log WebhookResponse object 34 | logger.info(f'Response body: {agent.response}') 35 | 36 | return agent.response 37 | 38 | 39 | if __name__ == '__main__': 40 | app.run(debug=True) 41 | -------------------------------------------------------------------------------- /examples/django/views.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | from logging import getLogger 3 | 4 | from django.http import HttpRequest, HttpResponse, JsonResponse 5 | from django.views.decorators.csrf import csrf_exempt 6 | 7 | from dialogflow_fulfillment import WebhookClient 8 | 9 | logger = getLogger('django.server.webhook') 10 | 11 | 12 | def handler(agent: WebhookClient) -> None: 13 | """Handle the webhook request.""" 14 | 15 | 16 | @csrf_exempt 17 | def webhook(request: HttpRequest) -> HttpResponse: 18 | """Handle webhook requests from Dialogflow.""" 19 | if request.method == 'POST': 20 | # Get WebhookRequest object 21 | request_ = loads(request.body) 22 | 23 | # Log request headers and body 24 | logger.info(f'Request headers: {dict(request.headers)}') 25 | logger.info(f'Request body: {request_}') 26 | 27 | # Handle request 28 | agent = WebhookClient(request_) 29 | agent.handle_request(handler) 30 | 31 | # Log WebhookResponse object 32 | logger.info(f'Response body: {agent.response}') 33 | 34 | return JsonResponse(agent.response) 35 | 36 | return HttpResponse() 37 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = 8 | lint 9 | tests 10 | docs 11 | 12 | [testenv:lint] 13 | skip_install = true 14 | deps = -r {toxinidir}/requirements/lint.txt 15 | commands = flake8 16 | 17 | [testenv:tests] 18 | skip_install = true 19 | passenv = 20 | TERM 21 | deps = -r {toxinidir}/requirements/tests.txt 22 | commands = pytest 23 | 24 | [testenv:docs] 25 | skip_install = true 26 | setenv = 27 | SPHINXOPTS = -b dirhtml 28 | SOURCEDIR = {toxinidir}/docs/source 29 | BUILDDIR = {envtmpdir}/build 30 | deps = -r {toxinidir}/requirements/docs.txt 31 | commands = sphinx-build -W -a -E {env:SPHINXOPTS} {env:SOURCEDIR} {env:BUILDDIR} 32 | 33 | [testenv:live-docs] 34 | skip_install = true 35 | setenv = 36 | {[testenv:docs]setenv} 37 | WATCHDIRS = {toxinidir}/source/dialogflow_fulfillment 38 | deps = {[testenv:docs]deps} 39 | commands = sphinx-autobuild --watch {env:WATCHDIRS} {env:SPHINXOPTS} {env:SOURCEDIR} {env:BUILDDIR} 40 | -------------------------------------------------------------------------------- /docs/source/getting-started/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | Version support 7 | --------------- 8 | 9 | .. note:: 10 | 11 | *dialogflow-fulfillment* requires Python 3 or later. 12 | 13 | Installing *dialogflow-fulfillment* 14 | ----------------------------------- 15 | 16 | The preferred way to install *dialogflow-fulfillment* is from `PyPI`_ with 17 | `pip`_, but it can be installed from source also. 18 | 19 | .. _PyPI: https://pypi.org/project/dialogflow-fulfillment/ 20 | .. _pip: https://pip.pypa.io/ 21 | 22 | From PyPI 23 | ~~~~~~~~~ 24 | 25 | To download *dialogflow-fulfillment* from `PyPI`_ with `pip`_, simply run 26 | 27 | .. code-block:: console 28 | 29 | $ pip install dialogflow-fulfillment 30 | 31 | From source 32 | ~~~~~~~~~~~ 33 | 34 | In order to install *dialogflow-fulfillment* from the source code, you must 35 | clone the repository from GitHub: 36 | 37 | .. code-block:: console 38 | 39 | $ git clone https://github.com/gcaccaos/dialogflow-fulfillment.git 40 | 41 | 42 | Then, install *dialogflow-fulfillment* in editable (:code:`-e`) mode with `pip`_: 43 | 44 | .. code-block:: console 45 | 46 | $ cd dialogflow-fulfillment 47 | $ pip install -e . 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='dialogflow-fulfillment', 5 | version='0.4.5', 6 | author='Gabriel Farias Caccáos', 7 | author_email='gabriel.caccaos@gmail.com', 8 | package_dir={'dialogflow_fulfillment': 'source'}, 9 | url='https://github.com/gcaccaos/dialogflow-fulfillment', 10 | project_urls={ 11 | 'Documentation': 'https://dialogflow-fulfillment.readthedocs.io', 12 | }, 13 | license='Apache License 2.0', 14 | description='Create webhook services for Dialogflow using Python', 15 | long_description_content_type='text/markdown', 16 | long_description=open('README.md').read(), 17 | include_package_data=True, 18 | python_requires='>=3', 19 | keywords=[ 20 | 'dialogflow', 21 | 'fulfillment', 22 | 'webhook', 23 | 'api', 24 | 'python', 25 | ], 26 | classifiers=[ 27 | 'Programming Language :: Python :: 3', 28 | 'Programming Language :: Python :: 3 :: Only', 29 | 'Programming Language :: Python :: 3.7', 30 | 'Programming Language :: Python :: 3.8', 31 | 'Programming Language :: Python :: 3.9', 32 | 'Programming Language :: Python :: 3.10', 33 | 'Intended Audience :: Developers', 34 | 'License :: OSI Approved :: Apache Software License', 35 | 'Operating System :: OS Independent', 36 | 'Topic :: Software Development :: Libraries :: Python Modules', 37 | ], 38 | ) 39 | -------------------------------------------------------------------------------- /source/dialogflow_fulfillment/rich_responses/text.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | from .base import RichResponse 4 | 5 | 6 | class Text(RichResponse): 7 | """ 8 | Send a basic (static) text response to the end-user. 9 | 10 | Examples: 11 | Constructing a :class:`Text` response: 12 | 13 | >>> text = Text('this is a text response') 14 | 15 | Parameters: 16 | text (str, optional): The content of the text response. 17 | 18 | See Also: 19 | For more information about the :class:`Text` response, see the 20 | `Text responses`_ section in Dialogflow's documentation. 21 | 22 | .. _Text responses: https://cloud.google.com/dialogflow/docs/intents-rich-messages#text 23 | """ # noqa: E501 24 | 25 | def __init__(self, text: Optional[str] = None) -> None: 26 | super().__init__() 27 | 28 | self.text = text 29 | 30 | @property 31 | def text(self) -> Optional[str]: 32 | """ 33 | str, optional: The content of the text response. 34 | 35 | Examples: 36 | Accessing the :attr:`text` attribute: 37 | 38 | >>> text.text 39 | 'this is a text response' 40 | 41 | Assigning a value to the :attr:`text` attribute: 42 | 43 | >>> text.text = 'this is a new text response' 44 | >>> text.text 45 | 'this is a new text response' 46 | 47 | Raises: 48 | TypeError: If the value to be assigned is not a string. 49 | """ 50 | return self._text 51 | 52 | @text.setter 53 | def text(self, text: Optional[str]) -> None: 54 | if text is not None and not isinstance(text, str): 55 | raise TypeError('text argument must be a string') 56 | 57 | self._text = text 58 | 59 | @classmethod 60 | def _from_dict(cls, message: Dict[str, Any]) -> 'Text': 61 | texts = message['text'].get('text', []) 62 | text = texts[0] if texts else None 63 | 64 | return cls(text=text) 65 | 66 | def _as_dict(self) -> Dict[str, Any]: 67 | text = self.text 68 | 69 | return {'text': {'text': [text if text is not None else '']}} 70 | -------------------------------------------------------------------------------- /tests/unit/test_contexts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dialogflow_fulfillment.contexts import Context 4 | 5 | 6 | def test_get(session): 7 | contexts = [ 8 | { 9 | 'name': f'projects/PROJECT_ID/agent/sessions/{session}/contexts/__system_counters__', # noqa: E501 10 | 'parameters': { 11 | 'no-input': 0, 12 | 'no-match': 0 13 | } 14 | } 15 | ] 16 | 17 | context_api = Context(contexts, session) 18 | 19 | assert context_api.get('a undefined context') is None 20 | 21 | 22 | def test_delete(session): 23 | contexts = [ 24 | { 25 | 'name': f'projects/PROJECT_ID/agent/sessions/{session}/contexts/__system_counters__', # noqa: E501 26 | 'parameters': { 27 | 'no-input': 0, 28 | 'no-match': 0 29 | } 30 | }, 31 | { 32 | 'name': f'projects/PROJECT_ID/agent/sessions/{session}/contexts/another_context', # noqa: E501 33 | 'lifespanCount': 1, 34 | 'parameters': {} 35 | } 36 | ] 37 | 38 | context_api = Context(contexts, session) 39 | 40 | context_api.delete('another_context') 41 | 42 | assert context_api.get('another_context')['lifespanCount'] == 0 43 | 44 | 45 | def test_set_non_string(session): 46 | context_api = Context([], session) 47 | 48 | with pytest.raises(TypeError): 49 | context_api.set({'this': 'is not a string'}) 50 | 51 | 52 | def test_set_new_context(session): 53 | context_api = Context([], session) 54 | 55 | context_api.set('new_context') 56 | 57 | assert 'new_context' in context_api.contexts 58 | 59 | 60 | def test_set_parameters(session): 61 | contexts = [ 62 | { 63 | 'name': f'projects/PROJECT_ID/agent/sessions/{session}/contexts/__system_counters__', # noqa: E501 64 | 'parameters': { 65 | 'no-input': 0, 66 | 'no-match': 0 67 | } 68 | }, 69 | ] 70 | 71 | context_api = Context(contexts, session) 72 | 73 | context_api.set('__system_counters__', parameters={}) 74 | 75 | assert context_api.get('__system_counters__')['parameters'] == {} 76 | -------------------------------------------------------------------------------- /source/dialogflow_fulfillment/rich_responses/image.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | from .base import RichResponse 4 | 5 | 6 | class Image(RichResponse): 7 | """ 8 | Send an image response to the end-user. 9 | 10 | Examples: 11 | Constructing an image response: 12 | 13 | >>> image = Image('https://picsum.photos/200/300.jpg') 14 | 15 | Parameters: 16 | image_url (str, optional): The URL of the image response. 17 | 18 | See Also: 19 | For more information about the :class:`Image` response, see the 20 | `Image responses`_ section in Dialogflow's documentation. 21 | 22 | .. _Image responses: https://cloud.google.com/dialogflow/docs/intents-rich-messages#image 23 | """ # noqa: E501 24 | 25 | def __init__(self, image_url: Optional[str] = None) -> None: 26 | super().__init__() 27 | 28 | self.image_url = image_url 29 | 30 | @property 31 | def image_url(self) -> Optional[str]: 32 | """ 33 | str, optional: The URL of the image response. 34 | 35 | Examples: 36 | Accessing the :attr:`image_url` attribute: 37 | 38 | >>> image.image_url 39 | 'https://picsum.photos/200/300.jpg' 40 | 41 | Assigning a value to the :attr:`image_url` attribute: 42 | 43 | >>> image.image_url = 'https://picsum.photos/200/300?blur.jpg' 44 | >>> image.image_url 45 | 'https://picsum.photos/200/300?blur.jpg' 46 | 47 | Raises: 48 | TypeError: If the value to be assigned is not a string. 49 | """ 50 | return self._image_url 51 | 52 | @image_url.setter 53 | def image_url(self, image_url: Optional[str]) -> None: 54 | if image_url is not None and not isinstance(image_url, str): 55 | raise TypeError('image_url argument must be a string') 56 | 57 | self._image_url = image_url 58 | 59 | @classmethod 60 | def _from_dict(cls, message: Dict[str, Any]) -> 'Image': 61 | image_url = message['image'].get('imageUri') 62 | 63 | return cls(image_url=image_url) 64 | 65 | def _as_dict(self) -> Dict[str, Any]: 66 | fields = {} 67 | 68 | if self.image_url is not None: 69 | fields['imageUri'] = self.image_url 70 | 71 | return {'image': fields} 72 | -------------------------------------------------------------------------------- /source/dialogflow_fulfillment/rich_responses/payload.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | from .base import RichResponse 4 | 5 | 6 | class Payload(RichResponse): 7 | """ 8 | Send a custom payload response to the end-user. 9 | 10 | This type of rich response allows to create advanced, custom, responses. 11 | 12 | Examples: 13 | Constructing a custom :class:`Payload` response for file attachments: 14 | 15 | >>> payload_data = { 16 | ... 'attachment': 'https://example.com/files/some_file.pdf', 17 | ... 'type': 'application/pdf' 18 | ... } 19 | >>> payload = Payload(payload_data) 20 | 21 | Parameters: 22 | payload (dict, optional): The content of the custom payload response. 23 | 24 | See Also: 25 | For more information about the :class:`Payload` response, see the 26 | `Custom payload responses`_ section in Dialogflow's documentation. 27 | 28 | .. _Custom payload responses: https://cloud.google.com/dialogflow/docs/intents-rich-messages#custom 29 | """ # noqa: E501 30 | 31 | def __init__(self, payload: Optional[Dict[Any, Any]] = None) -> None: 32 | super().__init__() 33 | 34 | self.payload = payload 35 | 36 | @property 37 | def payload(self) -> Optional[Dict[Any, Any]]: 38 | """ 39 | dict, optional: The content of the custom payload response. 40 | 41 | Examples: 42 | Accessing the :attr:`payload` attribute: 43 | 44 | >>> payload.payload 45 | {'attachment': 'https://example.com/files/some_file.pdf', 'type': 'application/pdf'} 46 | 47 | Assigning a value to the :attr:`payload` attribute: 48 | 49 | >>> payload.payload = { 50 | ... 'attachment': 'https://example.com/files/another_file.zip', 51 | ... 'type': 'application/zip' 52 | ... } 53 | >>> payload.payload 54 | {'attachment': 'https://example.com/files/another_file.zip', 'type': 'application/zip'} 55 | 56 | Raises: 57 | TypeError: If the value to be assigned is not a dictionary. 58 | """ # noqa: D401, E501 59 | return self._payload 60 | 61 | @payload.setter 62 | def payload(self, payload: Optional[Dict[Any, Any]]) -> None: 63 | if payload is not None and not isinstance(payload, dict): 64 | raise TypeError('payload argument must be a dictionary') 65 | 66 | self._payload = payload 67 | 68 | @classmethod 69 | def _from_dict(cls, message: Dict[str, Any]) -> 'Payload': 70 | payload = message['payload'] 71 | 72 | return cls(payload=payload) 73 | 74 | def _as_dict(self) -> Dict[str, Any]: 75 | fields = {} 76 | 77 | if self.payload is not None: 78 | fields.update(self.payload) 79 | 80 | return {'payload': fields} 81 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | from errno import ENOENT 16 | 17 | import sphinx.util.osutil 18 | 19 | sphinx.util.osutil.ENOENT = ENOENT 20 | 21 | sys.path.insert(0, os.path.abspath('../../source')) 22 | 23 | 24 | # -- Project information ----------------------------------------------------- 25 | 26 | project = 'dialogflow-fulfillment' 27 | copyright = '2020, Gabriel Farias Caccáos' 28 | author = 'Gabriel Farias Caccáos' 29 | 30 | 31 | # -- General configuration --------------------------------------------------- 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.autosummary', 39 | 'sphinx.ext.intersphinx', 40 | 'sphinx.ext.napoleon', 41 | 'sphinx.ext.viewcode', 42 | 'sphinxcontrib.mermaid', 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # List of patterns, relative to source directory, that match files and 49 | # directories to ignore when looking for source files. 50 | # This pattern also affects html_static_path and html_extra_path. 51 | exclude_patterns = [] 52 | 53 | 54 | # -- Options for HTML output ------------------------------------------------- 55 | 56 | # The theme to use for HTML and HTML Help pages. See the documentation for 57 | # a list of builtin themes. 58 | # 59 | html_theme = 'furo' 60 | 61 | # Add any paths that contain custom static files (such as style sheets) here, 62 | # relative to this directory. They are copied after the builtin static files, 63 | # so a file named "default.css" will overwrite the builtin "default.css". 64 | html_static_path = [] 65 | 66 | 67 | # -- Other settings ---------------------------------------------------------- 68 | 69 | add_module_names = False 70 | 71 | autodoc_typehints = 'description' 72 | autodoc_default_options = { 73 | 'show-inheritance': True, 74 | 'members': None, 75 | 'inherited-members': True, 76 | 'undoc-members': True, 77 | } 78 | 79 | master_doc = 'index' 80 | 81 | intersphinx_mapping = { 82 | 'python': ('https://docs.python.org/3', None), 83 | } 84 | 85 | nitpicky = True 86 | nitpick_ignore = [ 87 | ('py:class', 'any'), 88 | ('py:class', 'callable'), 89 | ('py:class', 'optional'), 90 | ] 91 | -------------------------------------------------------------------------------- /source/dialogflow_fulfillment/rich_responses/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from typing import Any, Dict 3 | 4 | 5 | class RichResponse(metaclass=ABCMeta): 6 | """ 7 | The base (abstract) class for the different types of rich responses. 8 | 9 | See Also: 10 | For more information about the :class:`RichResponse`, see the 11 | `Rich response messages`_ section in Dialogflow's documentation. 12 | 13 | .. _Rich response messages: https://cloud.google.com/dialogflow/docs/intents-rich-messages 14 | """ # noqa: E501 15 | 16 | @abstractmethod 17 | def _as_dict(self) -> Dict[str, Any]: 18 | """ 19 | Convert the rich response object to a dictionary. 20 | 21 | See Also: 22 | For more information about the fields for the different types of 23 | messages, see the Message_ section in Dialogflow's documentation. 24 | 25 | .. _Message: https://cloud.google.com/dialogflow/es/docs/reference/rest/v2/projects.agent.intents#message 26 | """ # noqa: E501 27 | 28 | @classmethod 29 | @abstractmethod 30 | def _from_dict(cls, message: Dict[str, Any]) -> 'RichResponse': 31 | """ 32 | Convert a response message object to a type of :class:`RichResponse`. 33 | 34 | Parameters: 35 | message (dict): The response message object from Dialogflow. 36 | 37 | Returns: 38 | :class:`RichResponse`: A subclass of :class:`RichResponse` that 39 | corresponds to the message field in the message object (e.g.: it 40 | creates an instance of a :class:`QuickReplies` if the response 41 | message object has a :obj:`quickReplies` field). 42 | 43 | Raises: 44 | TypeError: If the response message object doesn't have exactly one 45 | field for a supported type of message. 46 | 47 | See Also: 48 | For more information about the fields for the different types of 49 | messages, see the Message_ section in Dialogflow's documentation. 50 | 51 | .. _Message: https://cloud.google.com/dialogflow/es/docs/reference/rest/v2/projects.agent.intents#message 52 | """ # noqa: E501 53 | message_fields_to_classes = { 54 | cls._upper_camel_to_lower_camel(subclass.__name__): subclass 55 | for subclass in cls.__subclasses__() 56 | } 57 | 58 | fields_intersection = message.keys() & message_fields_to_classes.keys() 59 | 60 | if not len(fields_intersection) == 1: 61 | raise TypeError('unsupported type of message') 62 | 63 | message_field = fields_intersection.pop() 64 | 65 | return message_fields_to_classes[message_field]._from_dict(message) 66 | 67 | @classmethod 68 | def _upper_camel_to_lower_camel(cls, name: str) -> str: 69 | """ 70 | Convert a UpperCamelCase name to lowerCamelCase. 71 | 72 | Parameters: 73 | name (str): A UpperCamelCase string. 74 | 75 | Returns: 76 | str: The input string in lowerCamelCase. 77 | """ 78 | return name[0].lower() + name[1:] 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dialogflow-fulfillment 2 | 3 | ![PyPI version](https://img.shields.io/pypi/v/dialogflow-fulfillment) 4 | [![Downloads](https://static.pepy.tech/badge/dialogflow-fulfillment)](https://pepy.tech/project/dialogflow-fulfillment) 5 | [![Tests status](https://github.com/gcaccaos/dialogflow-fulfillment/workflows/Tests/badge.svg?&branch=master)](https://github.com/gcaccaos/dialogflow-fulfillment/actions) 6 | [![Documentation status](https://readthedocs.org/projects/dialogflow-fulfillment/badge/?version=latest)](https://dialogflow-fulfillment.readthedocs.io/en/latest/?badge=latest) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/c666df0add06a523e65b/maintainability)](https://codeclimate.com/github/gcaccaos/dialogflow-fulfillment/maintainability) 8 | [![GitHub license](https://img.shields.io/github/license/gcaccaos/dialogflow-fulfillment)](https://github.com/gcaccaos/dialogflow-fulfillment/blob/main/LICENSE) 9 | 10 | *dialogflow-fulfillment* is a package for Python that helps developers to 11 | create webhook services for Dialogflow. 12 | 13 | The package provides an API for creating and manipulating response messages, 14 | output contexts and follow-up events in conversations. 15 | 16 | ## A simple example 17 | 18 | ```python 19 | from dialogflow_fulfillment import QuickReplies, WebhookClient 20 | 21 | 22 | # Define a custom handler function 23 | def handler(agent: WebhookClient) -> None: 24 | """ 25 | This handler sends a text message along with a quick replies message 26 | back to Dialogflow, which uses the messages to build the final response 27 | to the user. 28 | """ 29 | agent.add('How are you feeling today?') 30 | agent.add(QuickReplies(quick_replies=['Happy :)', 'Sad :('])) 31 | 32 | 33 | # Create an instance of the WebhookClient 34 | agent = WebhookClient(request) 35 | 36 | # Handle the request using the handler function 37 | agent.handle_request(handler) 38 | 39 | # Get the response 40 | response = agent.response 41 | ``` 42 | 43 | ## Installation 44 | 45 | The preferred way to install *dialogflow-fulfillment* is from 46 | [PyPI](https://pypi.org/project/dialogflow-fulfillment/) with 47 | [**pip**](https://pip.pypa.io/): 48 | 49 | ```shell 50 | pip install dialogflow-fulfillment 51 | ``` 52 | 53 | ## Features 54 | 55 | *dialogflow-fulfillment*'s key features are: 56 | 57 | * **Webhook Client**: handle webhook requests using a custom handler function 58 | or a map of handlers for each intent 59 | * **Contexts**: process input contexts and add, set or delete output contexts 60 | * **Events**: trigger follow-up events with optional parameters 61 | * **Rich Responses**: create and send the following types of rich response 62 | messages: 63 | * Text 64 | * Image 65 | * Card 66 | * Quick Replies 67 | * Payload 68 | 69 | ## More examples 70 | 71 | * [Dialogflow fulfillment webhook server with **Flask**](https://dialogflow-fulfillment.readthedocs.io/en/latest/getting-started/examples/flask/) 72 | * [Dialogflow fulfillment webhook server with **Django**](https://dialogflow-fulfillment.readthedocs.io/en/latest/getting-started/examples/django/) 73 | 74 | ## Documentation 75 | 76 | For more information about the package, guides and examples of usage, see the 77 | [documentation](https://dialogflow-fulfillment.readthedocs.io). 78 | 79 | ## Contribute 80 | 81 | All kinds of contributions are welcome! 82 | 83 | For an overview about how to contribute to *dialogflow-fulfillment*, see the 84 | [contributing guide](CONTRIBUTING.rst). 85 | 86 | ## License 87 | 88 | This project is licensed under the Apache 2.0 license. 89 | 90 | For more details about the license, see the [LICENSE file](LICENSE). 91 | 92 | ## Acknowledgments 93 | 94 | Thanks to the Dialogflow development team! 95 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture() 7 | def session(): 8 | """Generate a random session ID (UUID4).""" 9 | return str(uuid4()) 10 | 11 | 12 | @pytest.fixture() 13 | def response_id(): 14 | """Generate a random response ID (UUID4).""" 15 | return str(uuid4()) 16 | 17 | 18 | @pytest.fixture() 19 | def intent_id(): 20 | """Generate a random intent ID (UUID4).""" 21 | return str(uuid4()) 22 | 23 | 24 | @pytest.fixture() 25 | def text(): 26 | """Return a sample text string.""" 27 | return 'this is a text' 28 | 29 | 30 | @pytest.fixture() 31 | def quick_replies(): 32 | """Return a sample list of quick replies.""" 33 | return ['reply 1', 'reply 2', 'reply 3'] 34 | 35 | 36 | @pytest.fixture() 37 | def payload(): 38 | """Return a sample payload dictionary.""" 39 | return {'test key 1': 'test value 1', 'test key 2': 'test value 2'} 40 | 41 | 42 | @pytest.fixture() 43 | def image_url(): 44 | """Return a sample image URL string.""" 45 | return 'https://test.url/image.jpg' 46 | 47 | 48 | @pytest.fixture() 49 | def title(): 50 | """Return a sample title string.""" 51 | return 'this is a title' 52 | 53 | 54 | @pytest.fixture() 55 | def subtitle(): 56 | """Return a sample subtitle string.""" 57 | return 'this is a subtitle' 58 | 59 | 60 | @pytest.fixture() 61 | def buttons(): 62 | """Return a sample list of card button dictionaries.""" 63 | return [ 64 | {'text': 'text 1', 'postback': 'postback 1'}, 65 | {'text': 'text 2', 'postback': 'postback 2'}, 66 | {'text': 'text 3', 'postback': 'postback 3'} 67 | ] 68 | 69 | 70 | @pytest.fixture() 71 | def webhook_request( 72 | response_id, 73 | session, 74 | intent_id, 75 | text, 76 | title, 77 | subtitle, 78 | image_url, 79 | buttons, 80 | payload, 81 | quick_replies 82 | ): 83 | """Return a sample WebhookRequest dictionary.""" 84 | project_id = 'PROJECT_ID' 85 | 86 | return { 87 | 'responseId': response_id, 88 | 'queryResult': { 89 | 'queryText': 'Hi', 90 | 'parameters': {}, 91 | 'allRequiredParamsPresent': True, 92 | 'fulfillmentText': 'Hello! How can I help you?', 93 | 'fulfillmentMessages': [ 94 | {'text': {'text': [text]}}, 95 | {'image': {'imageUri': image_url}}, 96 | { 97 | 'card': { 98 | 'title': title, 99 | 'subtitle': subtitle, 100 | 'imageUri': image_url, 101 | 'buttons': buttons 102 | } 103 | }, 104 | {'payload': payload}, 105 | {'quickReplies': {'quickReplies': quick_replies}} 106 | ], 107 | 'outputContexts': [ 108 | { 109 | 'name': f'projects/{project_id}/agent/sessions/{session}/contexts/__system_counters__', # noqa: E501 110 | 'parameters': { 111 | 'no-input': 0, 112 | 'no-match': 0 113 | } 114 | } 115 | ], 116 | 'intent': { 117 | 'name': f'projects/{project_id}/agent/intents/{intent_id}', 118 | 'displayName': 'Default Welcome Intent' 119 | }, 120 | 'intentDetectionConfidence': 1, 121 | 'languageCode': 'en' 122 | }, 123 | 'originalDetectIntentRequest': { 124 | 'payload': {}, 125 | }, 126 | 'session': f'projects/{project_id}/agent/sessions/{session}' 127 | } 128 | -------------------------------------------------------------------------------- /docs/source/user-guide/fulfillment-overview.rst: -------------------------------------------------------------------------------- 1 | .. _fulfillment-overview: 2 | 3 | Fulfillment overview 4 | ==================== 5 | 6 | What is fulfillment? 7 | -------------------- 8 | 9 | Dialogflow's console allows to create simple and static responses for user's 10 | intents in conversations. In order to create more dynamic and complex 11 | responses, such as retrieving information from other services, the intent's 12 | **fulfillment setting** must be enabled and a webhook service must be provided: 13 | 14 | When an intent with fulfillment enabled is matched, Dialogflow sends a 15 | request to your webhook service with information about the matched intent. 16 | Your system can perform any required actions and respond to Dialogflow with 17 | information for how to proceed. 18 | 19 | -- Source: Fulfillment_. 20 | 21 | .. _Fulfillment: https://cloud.google.com/dialogflow/docs/fulfillment-overview 22 | 23 | A detailed example 24 | ------------------ 25 | 26 | .. mermaid:: 27 | :caption: A representation of how data flows in a conversation between a 28 | user and a Dialogflow agent. 29 | :align: center 30 | 31 | sequenceDiagram 32 | autonumber 33 | User->>+Interface: User query 34 | Interface->>+Dialogflow: detectIntent request 35 | Dialogflow->>+Webhook service: WebhookRequest 36 | Webhook service->>+External API: API query 37 | External API->>-Webhook service: API response 38 | Webhook service->>-Dialogflow: WebhookResponse 39 | Dialogflow->>-Interface: DetectIntentResponse 40 | Interface->>-User: Display message(s) 41 | 42 | The above diagram is a simplified representation of how data flows in a 43 | conversation between a user and a Dialogflow agent through an user interface. 44 | In this example, the user's intent is fulfilled by the agent with the help of 45 | a webhook service, allowing to handle more dynamic responses, like calling an 46 | external API to fetch some information. 47 | 48 | The flow of data in a conversation with fulfillment enabled can be described as 49 | follows: 50 | 51 | 1. The user types a text into the application's front-end in order to send a 52 | query to the agent. 53 | 2. The input is captured by the application's back-end, which calls Dialogflow 54 | API's `detectIntent`` resource, either via the official client or via 55 | HTTPS request in the form of a JSON. The request's body contain a 56 | ``QueryInput`` object, which holds the user's query (along with other 57 | information). 58 | 3. Dialogflow detects the intent that corresponds to the user's query and, 59 | since the intent in this example has the fulfillment setting enabled, posts 60 | a ``WebhookRequest`` object to the external webhook service via HTTPS in 61 | the form of a JSON. This object has a ``QueryResult`` object, which also 62 | holds the user's query and information about the detected intent, such as 63 | the corresponding action, detected entities and input or output contexts. 64 | 4. The webhook service uses information from the ``QueryResult`` object 65 | (along with other data from the ``WebhookRequest`` object) in order to 66 | determine how the conversation must go. For example, it could trigger some 67 | event by setting an ``EventInput``, change the value of a parameter in a 68 | ``Context`` or generate ``Message`` objects using data from external 69 | services, such as APIs or databases. 70 | 5. In this example, the webhook service calls an external API in order to 71 | fulfill the user's query. 72 | 6. Then, a ``WebhookResponse`` object with the generated response data is 73 | returned to Dialogflow. 74 | 7. Dialogflow validates the response, checking for present keys and value 75 | types, and returns a ``DetectIntentResponse`` object to the interface 76 | application. 77 | 8. Finally, the application's front-end displays the resulting response 78 | message(s) to the user. 79 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. meta:: 2 | :description: Create webhook services for Dialogflow using Python. 3 | :keywords: Dialogflow, webhook, Python, fulfillment, package, library, 4 | WebhookClient, client, context, rich response, payload 5 | :author: Gabriel Farias Caccáos 6 | :google-site-verification: oCk6LPkvqQEgXyg4qnflHoFwvClVxcX2US1g9C4GlAg 7 | 8 | Overview 9 | ======== 10 | 11 | .. toctree:: 12 | :hidden: 13 | :caption: Overview 14 | 15 | .. toctree:: 16 | :hidden: 17 | :caption: Getting started 18 | 19 | getting-started/installation 20 | getting-started/examples/index 21 | 22 | .. toctree:: 23 | :hidden: 24 | :caption: User guide 25 | 26 | user-guide/fulfillment-overview 27 | 28 | .. toctree:: 29 | :hidden: 30 | :caption: API reference 31 | 32 | api/webhook-client 33 | api/contexts 34 | api/rich-responses 35 | 36 | .. toctree:: 37 | :hidden: 38 | :caption: Development 39 | 40 | contributing-guide/index 41 | 42 | .. toctree:: 43 | :hidden: 44 | :caption: Release notes 45 | 46 | change-log/index 47 | 48 | *dialogflow-fulfillment* is a package for Python that helps developers to 49 | create webhook services for Dialogflow. 50 | 51 | The package provides an API for creating and manipulating response messages, 52 | output contexts and follow-up events in conversations. 53 | 54 | .. seealso:: 55 | 56 | For more information about fulfillment and how it works, see 57 | :ref:`fulfillment-overview`. 58 | 59 | A simple example 60 | ---------------- 61 | 62 | Working with *dialogflow-fulfillment* is as simple as passing a webhook request 63 | object from Dialogflow (a.k.a. ``WebhookRequest``) to an instance of a 64 | :class:`~.WebhookClient` and using a handler function (or a mapping of 65 | functions for each intent) via the :meth:`~.WebhookClient.handle_request` 66 | method: 67 | 68 | .. literalinclude:: ../../examples/simple_example.py 69 | :language: python 70 | :caption: simple_example.py 71 | 72 | The above code produces the resulting response object (a.k.a. 73 | ``WebhookResponse``), which can be accessed via the 74 | :attr:`~.WebhookClient.response` attribute: 75 | 76 | .. code-block:: python 77 | 78 | { 79 | 'fulfillmentMessages': [ 80 | { 81 | 'text': { 82 | 'text': [ 83 | 'How are you feeling today?' 84 | ] 85 | } 86 | }, 87 | { 88 | 'quickReplies': { 89 | 'quickReplies': [ 90 | 'Happy :)', 91 | 'Sad :(' 92 | ] 93 | } 94 | } 95 | ] 96 | } 97 | 98 | Installation 99 | ------------ 100 | 101 | The preferred way to install *dialogflow-fulfillment* is from 102 | `PyPI`_ with `pip`_: 103 | 104 | .. code-block:: console 105 | 106 | $ pip install dialogflow-fulfillment 107 | 108 | .. _PyPI: https://pypi.org/project/dialogflow-fulfillment/ 109 | .. _pip: https://pip.pypa.io/ 110 | 111 | .. seealso:: 112 | 113 | For further details about the installation, see :ref:`installation`. 114 | 115 | Features 116 | -------- 117 | 118 | *dialogflow-fulfillment*'s key features are: 119 | 120 | * **Webhook Client**: handle webhook requests using a custom handler function 121 | or a map of handlers for each intent 122 | * **Contexts**: process input contexts and add, set or delete output contexts 123 | in conversations 124 | * **Events**: trigger follow-up events with optional parameters 125 | * **Rich Responses**: create and send the following types of rich response 126 | messages: 127 | 128 | * Text 129 | * Image 130 | * Card 131 | * Quick Replies 132 | * Payload 133 | 134 | Limitations 135 | ----------- 136 | 137 | Currently, *dialogflow-fulfillment* has some drawbacks, which will be addressed 138 | in the future: 139 | 140 | * No support for platform-specific responses 141 | -------------------------------------------------------------------------------- /tests/integration/test_webhook_client_integration.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dialogflow_fulfillment.webhook_client import WebhookClient 4 | 5 | 6 | def test_add_text(webhook_request): 7 | agent = WebhookClient(webhook_request) 8 | 9 | def handler(agent): 10 | agent.add('this is a text') 11 | 12 | agent.handle_request(handler) 13 | 14 | assert agent.response['fulfillmentMessages'] == [ 15 | {'text': {'text': ['this is a text']}} 16 | ] 17 | 18 | 19 | def test_add_list_of_texts(webhook_request): 20 | agent = WebhookClient(webhook_request) 21 | 22 | def handler(agent): 23 | agent.add(['this', 'is', 'a', 'list', 'of', 'texts']) 24 | 25 | agent.handle_request(handler) 26 | 27 | assert agent.response['fulfillmentMessages'] == [ 28 | {'text': {'text': ['this']}}, 29 | {'text': {'text': ['is']}}, 30 | {'text': {'text': ['a']}}, 31 | {'text': {'text': ['list']}}, 32 | {'text': {'text': ['of']}}, 33 | {'text': {'text': ['texts']}} 34 | ] 35 | 36 | 37 | def test_add_non_richresponse(webhook_request): 38 | agent = WebhookClient(webhook_request) 39 | 40 | def handler(agent): 41 | agent.add({'this': 'is not a RichResponse'}) 42 | 43 | with pytest.raises(TypeError): 44 | agent.handle_request(handler) 45 | 46 | 47 | def test_assign_followup_event(webhook_request): 48 | agent = WebhookClient(webhook_request) 49 | 50 | def handler(agent): 51 | agent.followup_event = 'test_event' 52 | 53 | agent.handle_request(handler) 54 | 55 | assert agent.response['followupEventInput'] == { 56 | 'name': 'test_event', 57 | 'languageCode': webhook_request['queryResult']['languageCode'] 58 | } 59 | 60 | 61 | def test_assign_followup_event_by_dict(webhook_request): 62 | agent = WebhookClient(webhook_request) 63 | 64 | def handler(agent): 65 | agent.followup_event = {'name': 'test_event'} 66 | 67 | agent.handle_request(handler) 68 | 69 | assert agent.response['followupEventInput'] == { 70 | 'name': 'test_event', 71 | 'languageCode': webhook_request['queryResult']['languageCode'] 72 | } 73 | 74 | 75 | def test_handler_intent_map(webhook_request): 76 | agent = WebhookClient(webhook_request) 77 | 78 | handler = { 79 | 'Default Welcome Intent': lambda agent: agent.add('Hello!'), 80 | 'Default Fallback Intent': lambda agent: agent.add('What was that?'), 81 | } 82 | 83 | agent.handle_request(handler) 84 | 85 | assert agent.response['fulfillmentMessages'] == [ 86 | {'text': {'text': ['Hello!']}} 87 | ] 88 | 89 | 90 | def test_no_contexts(webhook_request): 91 | modified_webhook_request = webhook_request 92 | modified_webhook_request['queryResult']['outputContexts'] = [] 93 | 94 | agent = WebhookClient(modified_webhook_request) 95 | 96 | def handler(agent): 97 | pass 98 | 99 | agent.handle_request(handler) 100 | 101 | assert 'outputContexts' not in agent.response 102 | 103 | 104 | def test_with_request_source(webhook_request): 105 | modified_webhook_request = webhook_request 106 | modified_webhook_request['originalDetectIntentRequest']['source'] = \ 107 | 'PLATFORM_UNSPECIFIED' 108 | 109 | agent = WebhookClient(modified_webhook_request) 110 | 111 | def handler(agent): 112 | pass 113 | 114 | agent.handle_request(handler) 115 | 116 | assert agent.response['source'] == 'PLATFORM_UNSPECIFIED' 117 | 118 | 119 | def test_with_unknown_message(webhook_request): 120 | modified_webhook_request = webhook_request 121 | modified_webhook_request['queryResult']['fulfillmentMessages'] = [ 122 | { 123 | 'foo': { 124 | 'foo': ['bar'] 125 | } 126 | } 127 | ] 128 | 129 | with pytest.raises(TypeError): 130 | WebhookClient(modified_webhook_request) 131 | 132 | 133 | def test_set_followup_event_non_string_or_dict(webhook_request): 134 | agent = WebhookClient(webhook_request) 135 | 136 | with pytest.raises(TypeError): 137 | agent.followup_event = ['this', 'is', 'not', 'an', 'event'] 138 | -------------------------------------------------------------------------------- /source/dialogflow_fulfillment/rich_responses/quick_replies.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Tuple, Union 2 | 3 | from .base import RichResponse 4 | 5 | 6 | class QuickReplies(RichResponse): 7 | """ 8 | Send a collection of quick replies to the end-user. 9 | 10 | When a quick reply button is clicked, the corresponding reply text is sent 11 | back to Dialogflow as if the user had typed it. 12 | 13 | Examples: 14 | Constructing a :class:`QuickReplies` response: 15 | 16 | >>> quick_replies = QuickReplies('Choose an answer', ['Yes', 'No']) 17 | 18 | Parameters: 19 | title (str, optional): The title of the quick reply buttons. 20 | quick_replies (list, tuple(str), optional): The texts for the quick 21 | reply buttons. 22 | 23 | See Also: 24 | For more information about the :class:`QuickReplies` response, see the 25 | `Quick reply responses`_ section in Dialogflow's documentation. 26 | 27 | .. _Quick reply responses: https://cloud.google.com/dialogflow/docs/intents-rich-messages#quick 28 | """ # noqa: E501 29 | 30 | def __init__( 31 | self, 32 | title: Optional[str] = None, 33 | quick_replies: Optional[Union[List[str], Tuple[str]]] = None 34 | ) -> None: 35 | super().__init__() 36 | 37 | self.title = title 38 | self.quick_replies = quick_replies 39 | 40 | @property 41 | def title(self) -> Optional[str]: 42 | """ 43 | str, optional: The title of the quick reply buttons. 44 | 45 | Examples: 46 | Accessing the :attr:`title` attribute: 47 | 48 | >>> quick_replies.title 49 | 'Choose an answer' 50 | 51 | Assigning a value to the :attr:`title` attribute: 52 | 53 | >>> quick_replies.title = 'Select yes or no' 54 | >>> quick_replies.title 55 | 'Select yes or no' 56 | 57 | Raises: 58 | TypeError: If the value to be assigned is not a string. 59 | """ 60 | return self._title 61 | 62 | @title.setter 63 | def title(self, title: Optional[str]) -> None: 64 | if title is not None and not isinstance(title, str): 65 | raise TypeError('title argument must be a string') 66 | 67 | self._title = title 68 | 69 | @property 70 | def quick_replies(self) -> Optional[Union[List[str], Tuple[str]]]: 71 | """ 72 | list, tuple(str), optional: The texts for the quick reply buttons. 73 | 74 | Examples: 75 | Accessing the :attr:`quick_replies` attribute: 76 | 77 | >>> quick_replies.quick_replies 78 | ['Yes', 'No'] 79 | 80 | Assigning a value to the :attr:`quick_replies` attribute: 81 | 82 | >>> quick_replies.quick_replies = ['Yes', 'No', 'Maybe'] 83 | >>> quick_replies.quick_replies 84 | ['Yes', 'No', 'Maybe'] 85 | 86 | Raises: 87 | TypeError: if the value to be assigned is not a list or tuple of 88 | strings. 89 | """ # noqa: D403 90 | return self._quick_replies 91 | 92 | @quick_replies.setter 93 | def quick_replies( 94 | self, 95 | quick_replies: Optional[Union[List[str], Tuple[str]]] 96 | ) -> None: 97 | if quick_replies is not None and not isinstance(quick_replies, 98 | (list, tuple)): 99 | raise TypeError('quick_replies argument must be a list or tuple') 100 | 101 | self._quick_replies = quick_replies 102 | 103 | @classmethod 104 | def _from_dict(cls, message: Dict[str, Any]) -> 'QuickReplies': 105 | title = message['quickReplies'].get('title') 106 | quick_replies = message['quickReplies'].get('quickReplies') 107 | 108 | return cls(title=title, quick_replies=quick_replies) 109 | 110 | def _as_dict(self) -> Dict[str, Any]: 111 | fields = {} 112 | 113 | if self.title is not None: 114 | fields['title'] = self.title 115 | 116 | if self.quick_replies is not None: 117 | fields['quickReplies'] = self.quick_replies 118 | 119 | return {'quickReplies': fields} 120 | -------------------------------------------------------------------------------- /source/dialogflow_fulfillment/contexts.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | 3 | 4 | class Context: 5 | """ 6 | A client class for accessing and manipulating input and output contexts. 7 | 8 | This class provides an API that allows to create, edit or delete contexts 9 | during conversations. 10 | 11 | Parameters: 12 | input_contexts (list(dict)): The contexts that were active in the 13 | conversation when the intent was triggered by Dialogflow. 14 | session (str): The session of the conversation. 15 | 16 | Attributes: 17 | input_contexts (list(dict)): The contexts that were active in the 18 | conversation when the intent was triggered by Dialogflow. 19 | session (str): The session of the conversation. 20 | contexts (dict(str, dict)): A mapping of context names to context 21 | objects (dictionaries). 22 | """ 23 | 24 | def __init__( 25 | self, 26 | input_contexts: List[Dict[str, Any]], 27 | session: str 28 | ) -> None: 29 | self.input_contexts = self._process_input_contexts(input_contexts) 30 | self.session = session 31 | self.contexts = {**self.input_contexts} 32 | 33 | @staticmethod 34 | def _process_input_contexts( 35 | input_contexts: List[Dict[str, Any]] 36 | ) -> Dict[str, Dict[str, Any]]: 37 | """Process a list of input contexts.""" 38 | contexts = {} 39 | 40 | for context in input_contexts: 41 | name = context.get('name', '').rsplit('/', 1).pop() 42 | contexts[name] = context 43 | 44 | return contexts 45 | 46 | def set( 47 | self, 48 | name: str, 49 | lifespan_count: Optional[int] = None, 50 | parameters: Optional[Dict[str, Any]] = None 51 | ) -> None: 52 | """ 53 | Set a new context or update an existing context. 54 | 55 | Sets the lifepan and parameters of a context (if the context exists) or 56 | creates a new output context (if the context doesn't exist). 57 | 58 | Parameters: 59 | name (str): The name of the context. 60 | lifespan_count (int, optional): The lifespan duration of the 61 | context (in minutes). 62 | parameters (dict, optional): The parameters of the context. 63 | 64 | Raises: 65 | TypeError: If the name is not a string. 66 | """ 67 | if not isinstance(name, str): 68 | raise TypeError('name argument must be a string') 69 | 70 | if name not in self.contexts: 71 | self.contexts[name] = {'name': name} 72 | 73 | if lifespan_count is not None: 74 | self.contexts[name]['lifespanCount'] = lifespan_count 75 | 76 | if parameters is not None: 77 | self.contexts[name]['parameters'] = parameters 78 | 79 | def get(self, name: str) -> Optional[Dict[str, Any]]: 80 | """ 81 | Get the context object (if exists). 82 | 83 | Parameters: 84 | name (str): The name of the context. 85 | 86 | Returns: 87 | dict, optional: The context object (dictionary) if exists. 88 | """ 89 | return self.contexts.get(name) 90 | 91 | def delete(self, name: str) -> None: 92 | """ 93 | Deactivate an output context by setting its lifespan to 0. 94 | 95 | Parameters: 96 | name (str): The name of the context. 97 | """ 98 | self.set(name, lifespan_count=0) 99 | 100 | def get_output_contexts_array(self) -> List[Dict[str, Any]]: 101 | """ 102 | Get the output contexts as an array. 103 | 104 | Returns: 105 | list(dict): The output contexts (dictionaries). 106 | """ 107 | return [*self] 108 | 109 | def __iter__(self) -> 'Context': 110 | """Implement iter(self).""" 111 | self._index = 0 112 | self._context_array = list(self.contexts.values()) 113 | 114 | return self 115 | 116 | def __next__(self) -> Dict[str, Any]: 117 | """Implement next(self).""" 118 | if self._index >= len(self._context_array): 119 | raise StopIteration 120 | 121 | context = self._context_array[self._index] 122 | 123 | self._index += 1 124 | 125 | return context 126 | -------------------------------------------------------------------------------- /tests/unit/test_rich_responses.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dialogflow_fulfillment.rich_responses import ( 4 | Card, 5 | Image, 6 | Payload, 7 | QuickReplies, 8 | RichResponse, 9 | Text, 10 | ) 11 | 12 | 13 | class TestText: 14 | def test_non_string(self, text): 15 | with pytest.raises(TypeError): 16 | Text({'this': ['is not a text']}) 17 | 18 | def test_as_dict(self, text): 19 | text_obj = Text(text) 20 | 21 | assert text_obj._as_dict() == {'text': {'text': [text]}} 22 | 23 | 24 | class TestQuickReplies: 25 | def test_empty_params(self): 26 | quick_replies_obj = QuickReplies() 27 | 28 | assert quick_replies_obj._as_dict() == {'quickReplies': {}} 29 | 30 | def test_non_string_title(self): 31 | with pytest.raises(TypeError): 32 | QuickReplies(title={'this': ['is not a string']}) 33 | 34 | def test_non_sequence_replies(self): 35 | with pytest.raises(TypeError): 36 | QuickReplies(quick_replies='this is not a sequence') 37 | 38 | def test_as_dict(self, title, quick_replies): 39 | quick_replies_obj = QuickReplies(title, quick_replies) 40 | 41 | assert quick_replies_obj._as_dict() == { 42 | 'quickReplies': { 43 | 'title': title, 44 | 'quickReplies': quick_replies 45 | } 46 | } 47 | 48 | 49 | class TestPayload: 50 | def test_empty_params(self): 51 | payload_obj = Payload() 52 | 53 | assert payload_obj._as_dict() == {'payload': {}} 54 | 55 | def test_non_dict(self): 56 | with pytest.raises(TypeError): 57 | Payload('this is not a dict') 58 | 59 | def test_as_dict(self, payload): 60 | payload_obj = Payload(payload) 61 | 62 | assert payload_obj._as_dict() == {'payload': payload} 63 | 64 | 65 | class TestImage: 66 | def test_empty_params(self): 67 | image_obj = Image() 68 | 69 | assert image_obj._as_dict() == {'image': {}} 70 | 71 | def test_non_string(self): 72 | with pytest.raises(TypeError): 73 | Image({'this': ['is not a string']}) 74 | 75 | def test_as_dict(self, image_url): 76 | image_obj = Image(image_url) 77 | 78 | assert image_obj._as_dict() == {'image': {'imageUri': image_url}} 79 | 80 | 81 | class TestCard: 82 | def test_empty_params(self): 83 | card_obj = Card() 84 | 85 | assert card_obj._as_dict() == {'card': {}} 86 | 87 | def test_title_non_string(self): 88 | with pytest.raises(TypeError): 89 | Card(title={'this': ['is not a string']}) 90 | 91 | def test_subtitle_non_string(self): 92 | with pytest.raises(TypeError): 93 | Card(subtitle={'this': ['is not a string']}) 94 | 95 | def test_image_url_non_string(self): 96 | with pytest.raises(TypeError): 97 | Card(image_url={'this': ['is not a string']}) 98 | 99 | def test_buttons_non_list(self): 100 | with pytest.raises(TypeError): 101 | Card(buttons='this is not a list of buttons') 102 | 103 | def test_buttons_non_dict(self): 104 | with pytest.raises(TypeError): 105 | Card(buttons=['this is not a button']) 106 | 107 | def test_button_text_non_string(self): 108 | with pytest.raises(TypeError): 109 | Card(buttons=[{'text': ['this is not a text']}]) 110 | 111 | def test_button_postback_non_string(self): 112 | with pytest.raises(TypeError): 113 | Card(buttons=[{'postback': ['this is not a text']}]) 114 | 115 | def test_button_empty_params(self): 116 | card_obj = Card(buttons=[{}]) 117 | 118 | assert card_obj._as_dict() == {'card': {'buttons': [{}]}} 119 | 120 | def test_as_dict(self, title, subtitle, image_url, buttons): 121 | card_obj = Card( 122 | title=title, 123 | subtitle=subtitle, 124 | image_url=image_url, 125 | buttons=buttons 126 | ) 127 | 128 | assert card_obj._as_dict() == { 129 | 'card': { 130 | 'title': title, 131 | 'subtitle': subtitle, 132 | 'imageUri': image_url, 133 | 'buttons': buttons 134 | } 135 | } 136 | 137 | 138 | class TestRichResponse: 139 | def test_instantiation(self): 140 | with pytest.raises(TypeError): 141 | RichResponse() 142 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Change log 2 | ========== 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on `Keep a Changelog`_ and this project adheres to 7 | `Semantic Versioning`_. 8 | 9 | .. _Keep a Changelog: https://keepachangelog.com/en/1.0.0 10 | .. _Semantic Versioning: https://semver.org/spec/v2.0.0.html 11 | 12 | Unreleased 13 | ---------- 14 | 15 | Removed 16 | ~~~~~~~ 17 | 18 | * RichResponse's set_* methods (use property attributes instead). 19 | * WebhookClient's set_followup_event method (use property attribute instead). 20 | 21 | Dependencies 22 | ~~~~~~~~~~~~ 23 | 24 | * Change sphinx's template to `furo`. 25 | * Upgrade many development dependencies. 26 | 27 | 0.4.5_ - 2023-01-19 28 | ------------------- 29 | 30 | Disaster recovery. 31 | 32 | 0.4.4_ - 2021-08-09 33 | ------------------- 34 | 35 | Fixed 36 | ~~~~~ 37 | 38 | * Bug when the webhook request is an empty JSON. 39 | 40 | Dependencies 41 | ~~~~~~~~~~~~ 42 | 43 | * Bump dependencies in requirements and setup files 44 | 45 | 0.4.3_ - 2021-06-29 46 | ------------------- 47 | 48 | Added 49 | ~~~~~ 50 | 51 | * Code of Conduct file. 52 | 53 | Dependencies 54 | ~~~~~~~~~~~~ 55 | 56 | * Django example dependencies versions. 57 | 58 | 0.4.2_ - 2020-11-29 59 | ------------------- 60 | 61 | Fixed 62 | ~~~~~ 63 | 64 | * Bug when parsing `WebhookRequest` object in Django example (#1). 65 | * Bug when calling `response` in `WebhookClient` multiple times (#2). 66 | 67 | 0.4.1_ - 2020-10-11 68 | ------------------- 69 | 70 | Added 71 | ~~~~~ 72 | 73 | * Continuous integration and continuous deployment with Github Actions. 74 | 75 | Improved 76 | ~~~~~~~~ 77 | 78 | * Health of the source code. 79 | * Documentation. 80 | 81 | 0.4.0_ - 2020-09-12 82 | ------------------- 83 | 84 | Added 85 | ~~~~~ 86 | 87 | * Getters and setters for RichResponse's attributes (and deprecation warnings 88 | to set_*() methods). 89 | * Getter and setter for WebhookClient's followup_event attribute (and 90 | deprecation warning to set_followup_event() method). 91 | * Docs: Examples to WebhookClient's methods docstrings. 92 | * Docs: Examples to RichResponse's attributes docstrings. 93 | * Docs: "See also" sections in RichResponse's docstrings. 94 | * Docs: Type hints to WebhookClient's handle_request() method's docstring. 95 | * Docs: "Detailed example" section in "Fulfillment overview" page. 96 | 97 | Improved 98 | ~~~~~~~~ 99 | 100 | * Typing annotations coverage. 101 | 102 | 0.3.0_ - 2020-07-29 103 | ------------------- 104 | 105 | Added 106 | ~~~~~ 107 | 108 | * Docs: Change log and contributing guide pages. 109 | * set_text() method for the Text response. 110 | * set_subtitle(), set_image() and set_buttons() methods for the Card response. 111 | * set_title() and set_quick_replies() to the QuickReplies response. 112 | 113 | Fixed 114 | ~~~~~ 115 | 116 | * Fix missing fields in Card and QuickReply responses. 117 | * Fix optional parameters for all rich responses. 118 | * Fix parsing of Image and Card responses from requests. 119 | * Fix RichResponse instantiation (shouldn't be able to instantiate an abstract 120 | base class). 121 | 122 | Improved 123 | ~~~~~~~~ 124 | * Docs: improve classes and methods docstrings. 125 | 126 | Changed 127 | ~~~~~~~ 128 | 129 | * Docs: Change theme to Read the Docs' theme. 130 | 131 | 0.2.0_ - 2020-07-17 132 | ------------------- 133 | 134 | Added 135 | ~~~~~ 136 | 137 | * Tests for Context and WebhookClient. 138 | 139 | Changed 140 | ~~~~~~~ 141 | 142 | * Rewrite tests using pytest. 143 | 144 | 0.1.5_ - 2020-07-17 145 | ------------------- 146 | 147 | Fixed 148 | ~~~~~ 149 | 150 | * Fix a key access error in WebhookClient's request processing. 151 | 152 | 0.1.4_ - 2020-07-17 153 | ------------------- 154 | 155 | Added 156 | ~~~~~ 157 | 158 | * Type hints for WebhookClient methods. 159 | * Type hints for Context methods. 160 | * Type hints for RichResponse methods. 161 | 162 | 0.1.3_ - 2020-07-17 163 | ------------------- 164 | 165 | Added 166 | ~~~~~ 167 | 168 | * Public API of the package. 169 | 170 | 0.1.2_ - 2020-03-27 171 | ------------------- 172 | 173 | * Initial release. 174 | 175 | .. _0.4.5: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.4.4...v0.4.5 176 | .. _0.4.4: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.4.3...v0.4.4 177 | .. _0.4.3: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.4.2...v0.4.3 178 | .. _0.4.2: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.4.1...v0.4.2 179 | .. _0.4.1: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.4.0...v0.4.1 180 | .. _0.4.0: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.3.0...v0.4.0 181 | .. _0.3.0: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.2.0...v0.3.0 182 | .. _0.2.0: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.1.5...v0.2.0 183 | .. _0.1.5: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.1.4...v0.1.5 184 | .. _0.1.4: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.1.3...v0.1.4 185 | .. _0.1.3: https://github.com/gcaccaos/dialogflow-fulfillment/compare/v0.1.2...v0.1.3 186 | .. _0.1.2: https://github.com/gcaccaos/dialogflow-fulfillment/releases/tag/v0.1.2 187 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | gabriel.caccaos@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /source/dialogflow_fulfillment/rich_responses/card.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | 3 | from .base import RichResponse 4 | 5 | 6 | class Card(RichResponse): 7 | """ 8 | Send a card response to the end-user. 9 | 10 | Examples: 11 | Constructing a :class:`Card` response: 12 | 13 | >>> card = Card( 14 | ... title='What is your favorite color?', 15 | ... subtitle='Choose a color', 16 | ... buttons=[{'text': 'Red'}, {'text': 'Green'}, {'text': 'Blue'}] 17 | ... ) 18 | 19 | Parameters: 20 | title (str, optional): The title of the card response. 21 | subtitle (str, optional): The subtitle of the card response. Defaults 22 | image_url (str, optional): The URL of the card response's image. 23 | buttons (list(dict(str, str)), optional): The buttons of the card 24 | response. 25 | 26 | See Also: 27 | For more information about the :class:`Card` response, see the 28 | `Card responses`_ section in Dialogflow's documentation. 29 | 30 | .. _Card responses: https://cloud.google.com/dialogflow/docs/intents-rich-messages#card 31 | """ # noqa: E501 32 | 33 | def __init__( 34 | self, 35 | title: Optional[str] = None, 36 | subtitle: Optional[str] = None, 37 | image_url: Optional[str] = None, 38 | buttons: Optional[List[Dict[str, str]]] = None 39 | ) -> None: 40 | super().__init__() 41 | 42 | self.title = title 43 | self.subtitle = subtitle 44 | self.image_url = image_url 45 | self.buttons = buttons 46 | 47 | @property 48 | def title(self) -> Optional[str]: 49 | """ 50 | str, optional: The title of the card response. 51 | 52 | Examples: 53 | Accessing the :attr:`title` attribute: 54 | 55 | >>> card.title 56 | 'What is your favorite color?' 57 | 58 | Assigning value to the :attr:`title` attribute: 59 | 60 | >>> card.title = 'Which color do you like?' 61 | >>> card.title 62 | 'Which color do you like?' 63 | 64 | Raises: 65 | TypeError: If the value to be assigned is not a string. 66 | """ 67 | return self._title 68 | 69 | @title.setter 70 | def title(self, title: Optional[str]) -> None: 71 | if title is not None and not isinstance(title, str): 72 | raise TypeError('title argument must be a string') 73 | 74 | self._title = title 75 | 76 | @property 77 | def subtitle(self) -> Optional[str]: 78 | """ 79 | str, optional: The subtitle of the card response. 80 | 81 | Examples: 82 | Accessing the :attr:`subtitle` attribute: 83 | 84 | >>> card.subtitle 85 | 'Choose a color' 86 | 87 | Assigning value to the :attr:`subtitle` attribute: 88 | 89 | >>> card.subtitle = 'Select a color below' 90 | >>> card.subtitle 91 | 'Select a color below' 92 | 93 | Raises: 94 | TypeError: If the value to be assigned is not a string. 95 | """ 96 | return self._subtitle 97 | 98 | @subtitle.setter 99 | def subtitle(self, subtitle: Optional[str]) -> None: 100 | if subtitle is not None and not isinstance(subtitle, str): 101 | raise TypeError('subtitle argument must be a string') 102 | 103 | self._subtitle = subtitle 104 | 105 | @property 106 | def image_url(self) -> Optional[str]: 107 | """ 108 | str, optional: The URL of the card response's image. 109 | 110 | Examples: 111 | Accessing the :attr:`image_url` attribute: 112 | 113 | >>> card.image_url 114 | None 115 | 116 | Assigning value to the :attr:`image_url` attribute: 117 | 118 | >>> card.image_url = 'https://picsum.photos/200/300.jpg' 119 | >>> card.image_url 120 | 'https://picsum.photos/200/300.jpg' 121 | 122 | Raises: 123 | TypeError: If the value to be assigned is not a string. 124 | """ 125 | return self._image_url 126 | 127 | @image_url.setter 128 | def image_url(self, image_url: Optional[str]) -> None: 129 | if image_url is not None and not isinstance(image_url, str): 130 | raise TypeError('image_url argument must be a string') 131 | 132 | self._image_url = image_url 133 | 134 | @property 135 | def buttons(self) -> Optional[List[Dict[str, str]]]: 136 | """ 137 | list(dict(str, str)), optional: The buttons of the card response. 138 | 139 | Examples: 140 | Accessing the :attr:`buttons` attribute: 141 | 142 | >>> card.buttons 143 | [{'text': 'Red'}, {'text': 'Green'}, {'text': 'Blue'}] 144 | 145 | Assigning value to the :attr:`buttons` attribute: 146 | 147 | >>> card.buttons = [{'text': 'Cyan'}, {'text': 'Magenta'}] 148 | >>> card.buttons 149 | [{'text': 'Cyan'}, {'text': 'Magenta'}] 150 | 151 | Raises: 152 | TypeError: If the value to be assigned is not a list of buttons. 153 | """ # noqa: D403 154 | return self._buttons 155 | 156 | @buttons.setter 157 | def buttons(self, buttons: Optional[List[Dict[str, str]]]) -> None: 158 | if buttons is not None and not isinstance(buttons, list): 159 | raise TypeError('buttons argument must be a list of buttons') 160 | 161 | self._buttons = self._validate_buttons(buttons) 162 | 163 | @classmethod 164 | def _validate_buttons( 165 | cls, 166 | buttons: Optional[List[Dict[str, str]]] 167 | ) -> Optional[List[Dict[str, str]]]: 168 | if buttons is None: 169 | return None 170 | 171 | return [cls._validate_button(button) for button in buttons] 172 | 173 | @classmethod 174 | def _validate_button(cls, button: Dict[str, str]) -> Dict[str, str]: 175 | if not isinstance(button, dict): 176 | raise TypeError('button must be a dictionary') 177 | 178 | text = button.get('text') 179 | postback = button.get('postback') 180 | 181 | if text is not None and not isinstance(text, str): 182 | raise TypeError('text argument must be a string') 183 | 184 | if postback is not None and not isinstance(text, str): 185 | raise TypeError('postback argument must be a string') 186 | 187 | validated_button = {} 188 | 189 | if text is not None: 190 | validated_button.update({'text': text}) 191 | 192 | if postback is not None: 193 | validated_button.update({'postback': postback}) 194 | 195 | return validated_button 196 | 197 | @classmethod 198 | def _from_dict(cls, message: Dict[str, Any]) -> 'Card': 199 | title = message['card'].get('title') 200 | subtitle = message['card'].get('subtitle') 201 | image_url = message['card'].get('imageUri') 202 | buttons = message['card'].get('buttons') 203 | 204 | return cls( 205 | title=title, 206 | subtitle=subtitle, 207 | image_url=image_url, 208 | buttons=buttons 209 | ) 210 | 211 | def _as_dict(self) -> Dict[str, Any]: 212 | fields = {} 213 | 214 | if self.title is not None: 215 | fields['title'] = self.title 216 | 217 | if self.subtitle is not None: 218 | fields['subtitle'] = self.subtitle 219 | 220 | if self.image_url is not None: 221 | fields['imageUri'] = self.image_url 222 | 223 | if self.buttons is not None: 224 | fields['buttons'] = self.buttons 225 | 226 | return {'card': fields} 227 | -------------------------------------------------------------------------------- /source/dialogflow_fulfillment/webhook_client.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, List, Optional, Union 2 | 3 | from .contexts import Context 4 | from .rich_responses import RichResponse, Text 5 | 6 | 7 | class WebhookClient: 8 | """ 9 | A client class for handling webhook requests from Dialogflow. 10 | 11 | This class allows to dinamically manipulate contexts and create responses 12 | to be sent back to Dialogflow (which will validate the response and send it 13 | back to the end-user). 14 | 15 | Parameters: 16 | request (dict): The webhook request object (``WebhookRequest``) from 17 | Dialogflow. 18 | 19 | Raises: 20 | TypeError: If the request is not a dictionary. 21 | 22 | See Also: 23 | For more information about the webhook request object, see the 24 | WebhookRequest_ section in Dialogflow's API reference. 25 | 26 | Attributes: 27 | query (str): The original query sent by the end-user. 28 | intent (str): The intent triggered by Dialogflow. 29 | action (str): The action defined for the intent. 30 | context (Context): An API class for handling input and output contexts. 31 | contexts (list(dict)): The array of input contexts. 32 | parameters (dict): The intent parameters extracted by Dialogflow. 33 | console_messages (list(RichResponse)): The response messages defined 34 | for the intent. 35 | original_request (str): The original request object from 36 | `detectIntent/query`. 37 | request_source (str): The source of the request. 38 | locale (str): The language code or locale of the original request. 39 | session (str): The session id of the conversation. 40 | 41 | .. _WebhookRequest: https://cloud.google.com/dialogflow/docs/reference/rpc/google.cloud.dialogflow.v2#webhookrequest 42 | """ # noqa: E501 43 | 44 | def __init__(self, request: Dict[str, Any]) -> None: 45 | if not isinstance(request, dict): 46 | raise TypeError('request argument must be a dictionary') 47 | 48 | self._response_messages: List[RichResponse] = [] 49 | self._followup_event: Optional[Dict[str, Any]] = None 50 | 51 | self._process_request(request) 52 | 53 | def _process_request(self, request: Dict[str, Any]) -> None: 54 | """ 55 | Set instance attributes from the webhook request. 56 | 57 | Parameters: 58 | request (dict): The webhook request object from Dialogflow. 59 | """ 60 | query_result = request.get('queryResult', {}) 61 | 62 | self.intent = query_result.get('intent', {}).get('displayName') 63 | self.action = query_result.get('action') 64 | self.parameters = query_result.get('parameters', {}) 65 | self.contexts = query_result.get('outputContexts', []) 66 | self.original_request = request.get('originalDetectIntentRequest', {}) 67 | self.request_source = self.original_request.get('source') 68 | self.query = query_result.get('queryText') 69 | self.locale = query_result.get('languageCode') 70 | self.session = request.get('session', '') 71 | self.context = Context(self.contexts, self.session) 72 | self.console_messages = self._process_console_messages(request) 73 | 74 | @property 75 | def followup_event(self) -> Optional[Dict[str, Any]]: 76 | """ 77 | dict, optional: The followup event to be triggered by the response. 78 | 79 | Examples: 80 | Accessing the :attr:`followup_event` attribute: 81 | 82 | >>> agent.followup_event 83 | None 84 | 85 | Assigning an event name to the :attr:`followup_event` attribute: 86 | 87 | >>> agent.followup_event = 'WELCOME' 88 | >>> agent.followup_event 89 | {'name': 'WELCOME', 'languageCode': 'en-US'} 90 | 91 | Assigning an event dictionary to the :attr:`followup_event` 92 | attribute: 93 | 94 | >>> agent.followup_event = {'name': 'GOODBYE', 'languageCode': 'en-US'} 95 | >>> agent.followup_event 96 | {'name': 'GOODBYE', 'languageCode': 'en-US'} 97 | 98 | Raises: 99 | TypeError: If the event is not a string or a dictionary. 100 | """ # noqa: D401, E501 101 | return self._followup_event 102 | 103 | @followup_event.setter 104 | def followup_event(self, event: Union[str, Dict[str, Any]]) -> None: 105 | if isinstance(event, str): 106 | event = {'name': event} 107 | 108 | if not isinstance(event, dict): 109 | raise TypeError('event argument must be a string or a dictionary') 110 | 111 | event['languageCode'] = event.get('languageCode', self.locale) 112 | 113 | self._followup_event = event 114 | 115 | @classmethod 116 | def _process_console_messages( 117 | cls, 118 | request: Dict[str, Any] 119 | ) -> List[RichResponse]: 120 | """Get messages defined in Dialogflow's console for matched intent.""" 121 | fulfillment_messages = request.get('queryResult', {}).get( 122 | 'fulfillmentMessages', 123 | [] 124 | ) 125 | 126 | return [RichResponse._from_dict(message) 127 | for message in fulfillment_messages] 128 | 129 | def add( 130 | self, 131 | responses: Union[str, RichResponse, List[Union[str, RichResponse]]] 132 | ) -> None: 133 | """ 134 | Add response messages to be sent back to Dialogflow. 135 | 136 | Examples: 137 | Adding a simple text response as a string: 138 | 139 | >>> agent.add('Hi! How can I help you?') 140 | 141 | Adding multiple rich responses one at a time: 142 | 143 | >>> agent.add(Text('How are you feeling today?')) 144 | >>> agent.add(QuickReplies(quick_replies=['Happy :)', 'Sad :('])) 145 | 146 | Adding multiple rich responses at once: 147 | 148 | >>> responses = [ 149 | ... Text('How are you feeling today?'), 150 | ... QuickReplies(quick_replies=['Happy :)', 'Sad :(']) 151 | ... ] 152 | >>> agent.add(responses) 153 | 154 | Parameters: 155 | responses (str, RichResponse, list(str, RichResponse)): 156 | A single response message or a list of response messages. 157 | """ # noqa: E501 158 | if not isinstance(responses, list): 159 | responses = [responses] 160 | 161 | for response in responses: 162 | self._add_response(response) 163 | 164 | def _add_response(self, response: Union[str, RichResponse]) -> None: 165 | """Add a single response to be sent back to Dialogflow.""" 166 | if isinstance(response, str): 167 | response = Text(response) 168 | 169 | if not isinstance(response, RichResponse): 170 | raise TypeError( 171 | 'response argument must be a string or a RichResponse' 172 | ) 173 | 174 | self._response_messages.append(response) 175 | 176 | def handle_request( 177 | self, 178 | handler: Union[ 179 | Callable[['WebhookClient'], Optional[Any]], 180 | Dict[str, Callable[['WebhookClient'], Optional[Any]]] 181 | ] 182 | ) -> Optional[Any]: 183 | """ 184 | Handle the webhook request using a handler or a mapping of handlers. 185 | 186 | In order to manipulate the conversation programatically, the handler 187 | function must receive an instance of :class:`WebhookClient` as a 188 | parameter. Then, inside the function, :class:`WebhookClient`'s 189 | attributes and methods can be used to access and manipulate the webhook 190 | request attributes and generate the webhook response. 191 | 192 | Alternatively, this method can receive a mapping of handler functions 193 | for each intent. 194 | 195 | Note: 196 | If a mapping of handler functions is provided, the name of the 197 | corresponding intent must be written exactly as it is in 198 | Dialogflow. 199 | 200 | Finally, once the request has been handled, the generated webhook 201 | response can be accessed via the :attr:`response` attribute. 202 | 203 | Examples: 204 | Creating a simple handler function that sends a text and a 205 | collection of quick reply buttons to the end-user (the response is 206 | independent of the triggered intent): 207 | 208 | >>> def handler(agent: WebhookClient) -> None: 209 | ... agent.add('How are you feeling today?') 210 | ... agent.add(QuickReplies(quick_replies=['Happy :)', 'Sad :('])) 211 | 212 | Creating a mapping of handler functions for different intents: 213 | 214 | >>> def welcome_handler(agent): 215 | ... agent.add('Hi!') 216 | ... agent.add('How can I help you?') 217 | ... 218 | >>> def fallback_handler(agent): 219 | ... agent.add('Sorry, I missed what you said.') 220 | ... agent.add('Can you say that again?') 221 | ... 222 | >>> handler = { 223 | ... 'Default Welcome Intent': welcome_handler, 224 | ... 'Default Fallback Intent': fallback_handler, 225 | ... } 226 | 227 | Parameters: 228 | handler (callable, dict(str, callable)): The handler function or 229 | a mapping of intents to handler functions. 230 | 231 | Raises: 232 | TypeError: If the handler is not a function or a map of functions. 233 | 234 | Returns: 235 | any, optional: The output from the handler function (if any). 236 | """ # noqa: E501 237 | if isinstance(handler, dict): 238 | handler_function = handler.get(self.intent) 239 | else: 240 | handler_function = handler 241 | 242 | if not callable(handler_function): 243 | raise TypeError( 244 | 'handler argument must be a function or a map of functions' 245 | ) 246 | 247 | return handler_function(self) 248 | 249 | @property 250 | def _response_messages_as_dicts(self) -> List[Dict[str, Any]]: 251 | """list of dict: The list of response messages.""" # noqa: D403 252 | return [response._as_dict() for response in self._response_messages] 253 | 254 | @property 255 | def response(self) -> Dict[str, Any]: 256 | """ 257 | dict: The generated webhook response object (``WebhookResponse``). 258 | 259 | See Also: 260 | For more information about the webhook response object, see the 261 | WebhookResponse_ section in Dialogflow's API reference. 262 | 263 | .. _WebhookResponse: https://cloud.google.com/dialogflow/docs/reference/rpc/google.cloud.dialogflow.v2#webhookresponse 264 | """ # noqa: D401, E501 265 | response = {} 266 | 267 | if self._response_messages: 268 | response['fulfillmentMessages'] = self._response_messages_as_dicts 269 | 270 | if self.followup_event is not None: 271 | response['followupEventInput'] = self.followup_event 272 | 273 | if self.context.contexts: 274 | response['outputContexts'] = self.context\ 275 | .get_output_contexts_array() 276 | 277 | if self.request_source is not None: 278 | response['source'] = self.request_source 279 | 280 | return response 281 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------