diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b601c41
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,102 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+.hypothesis/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# IPython Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# dotenv
+.env
+
+# virtualenv
+venv/
+ENV/
+
+# Spyder project settings
+.spyderproject
+
+# Rope project settings
+.ropeproject
+
+# Vagrant
+.vagrant
+
+# Misc
+.DS_Store
+temp
+.idea/
+
+# Project
+backups
+settings.cfg
+fabfile/settings.py
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..264ebb3
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2016 John Wheeler
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..557c423
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,6 @@
+include *.rst *.txt LICENSE tox.ini .travis.yml docs/Makefile .coveragerc conftest.py
+recursive-include tests *.py
+recursive-include docs *.rst
+recursive-include docs *.py
+prune docs/_build
+
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..63b4d34
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,142 @@
+
+.. image:: http://flask-ask.readthedocs.io/en/latest/_images/logo-full.png
+
+===================================
+Program the Amazon Echo with Python
+===================================
+
+Flask-Ask is a `Flask extension `_ that makes building Alexa skills for the Amazon Echo easier and much more fun.
+
+* `Flask-Ask quickstart on Amazon's Developer Blog `_.
+* `Level Up with our Alexa Skills Kit Video Tutorial `_
+* `Chat on Gitter.im `_
+
+The Basics
+===============
+
+A Flask-Ask application looks like this:
+
+.. code-block:: python
+
+ from flask import Flask
+ from flask_ask import Ask, statement
+
+ app = Flask(__name__)
+ ask = Ask(app, '/')
+
+ @ask.intent('HelloIntent')
+ def hello(firstname):
+ speech_text = "Hello %s" % firstname
+ return statement(speech_text).simple_card('Hello', speech_text)
+
+ if __name__ == '__main__':
+ app.run()
+
+In the code above:
+
+#. The ``Ask`` object is created by passing in the Flask application and a route to forward Alexa requests to.
+#. The ``intent`` decorator maps ``HelloIntent`` to a view function ``hello``.
+#. The intent's ``firstname`` slot is implicitly mapped to ``hello``'s ``firstname`` parameter.
+#. Lastly, a builder constructs a spoken response and displays a contextual card in the Alexa smartphone/tablet app.
+
+More code examples are in the `samples `_ directory.
+
+Jinja Templates
+---------------
+
+Since Alexa responses are usually short phrases, you might find it convenient to put them in the same file.
+Flask-Ask has a `Jinja template loader `_ that loads
+multiple templates from a single YAML file. For example, here's a template that supports the minimal voice interface
+above:
+
+.. code-block:: yaml
+
+ hello: Hello, {{ firstname }}
+
+Templates are stored in a file called `templates.yaml` located in the application root. Checkout the `Tidepooler example `_ to see why it makes sense to extract speech out of the code and into templates as the number of spoken phrases grow.
+
+Features
+===============
+
+Flask-Ask handles the boilerplate, so you can focus on writing clean code. Flask-Ask:
+
+* Has decorators to map Alexa requests and intent slots to view functions
+* Helps construct ask and tell responses, reprompts and cards
+* Makes session management easy
+* Allows for the separation of code and speech through Jinja templates
+* Verifies Alexa request signatures
+
+Installation
+===============
+
+To install Flask-Ask::
+
+ pip install flask-ask
+
+Documentation
+===============
+
+These resources will get you up and running quickly:
+
+* `5-minute quickstart `_
+* `Full online documentation `_
+
+Fantastic 3-part tutorial series by Harrison Kinsley
+
+* `Intro and Skill Logic - Alexa Skills w/ Python and Flask-Ask Part 1 `_
+* `Headlines Function - Alexa Skills w/ Python and Flask-Ask Part 2 `_
+* `Testing our Skill - Alexa Skills w/ Python and Flask-Ask Part 3 `_
+
+Deployment
+===============
+
+You can deploy using any WSGI compliant framework (uWSGI, Gunicorn). If you haven't deployed a Flask app to production, `checkout flask-live-starter `_.
+
+To deploy on AWS Lambda, you have two options. Use `Zappa `_ to automate the deployment of an AWS Lambda function and an AWS API Gateway to provide a public facing endpoint for your Lambda function. This `blog post `_ shows how to deploy Flask-Ask with Zappa from scratch. Note: When deploying to AWS Lambda with Zappa, make sure you point the Alexa skill to the HTTPS API gateway that Zappa creates, not the Lambda function's ARN.
+
+Alternatively, you can use AWS Lambda directly without the need for an AWS API Gateway endpoint. In this case you will need to `deploy `_ your Lambda function yourself and use `virtualenv `_ to create a deployment package that contains your Flask-Ask application along with its dependencies, which can be uploaded to Lambda. If your Lambda handler is configured as `lambda_function.lambda_handler`, then you would save the full application example above in a file called `lambda_function.py` and add the following two lines to it:
+
+.. code-block:: python
+
+ def lambda_handler(event, _context):
+ return ask.run_aws_lambda(event)
+
+
+Development
+===============
+
+If you'd like to work from the Flask-Ask source, clone the project and run::
+
+ pip install -r requirements-dev.txt
+
+This will install all base requirements from `requirements.txt` as well as requirements needed for running tests from the `tests` directory.
+
+Tests can be run with::
+
+ python setup.py test
+
+Or::
+
+ python -m unittest
+
+To install from your local clone or fork of the project, run::
+
+ python setup.py install
+
+Related projects
+===============
+
+`cookiecutter-flask-ask `_ is a Cookiecutter to easily bootstrap a Flask-Ask project, including documentation, speech assets and basic built-in intents.
+
+Have a Google Home? Checkout `Flask-Assistant `_ (early alpha)
+
+
+Thank You
+===============
+
+Thanks for checking this library out! I hope you find it useful.
+
+Of course, there's always room for improvement.
+Feel free to `open an issue `_ so we can make Flask-Ask better.
+
+Special thanks to `@kennethreitz `_ for his `sense `_ of `style `_, and of course, `@mitsuhiko `_ for `Flask `_
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..8061fad
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,230 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+PAPER =
+BUILDDIR = _build
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
+ $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/)
+endif
+
+# Internal variables.
+PAPEROPT_a4 = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help
+help:
+ @echo "Please use \`make ' where is one of"
+ @echo " html to make standalone HTML files"
+ @echo " dirhtml to make HTML files named index.html in directories"
+ @echo " singlehtml to make a single large HTML file"
+ @echo " pickle to make pickle files"
+ @echo " json to make JSON files"
+ @echo " htmlhelp to make HTML files and a HTML help project"
+ @echo " qthelp to make HTML files and a qthelp project"
+ @echo " applehelp to make an Apple Help Book"
+ @echo " devhelp to make HTML files and a Devhelp project"
+ @echo " epub to make an epub"
+ @echo " epub3 to make an epub3"
+ @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+ @echo " latexpdf to make LaTeX files and run them through pdflatex"
+ @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+ @echo " text to make text files"
+ @echo " man to make manual pages"
+ @echo " texinfo to make Texinfo files"
+ @echo " info to make Texinfo files and run them through makeinfo"
+ @echo " gettext to make PO message catalogs"
+ @echo " changes to make an overview of all changed/added/deprecated items"
+ @echo " xml to make Docutils-native XML files"
+ @echo " pseudoxml to make pseudoxml-XML files for display purposes"
+ @echo " linkcheck to check all external links for integrity"
+ @echo " doctest to run all doctests embedded in the documentation (if enabled)"
+ @echo " coverage to run coverage check of the documentation (if enabled)"
+ @echo " dummy to check syntax errors of document sources"
+
+.PHONY: clean
+clean:
+ rm -rf $(BUILDDIR)/*
+
+.PHONY: html
+html:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+.PHONY: dirhtml
+dirhtml:
+ $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+.PHONY: singlehtml
+singlehtml:
+ $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+ @echo
+ @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+.PHONY: pickle
+pickle:
+ $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+ @echo
+ @echo "Build finished; now you can process the pickle files."
+
+.PHONY: json
+json:
+ $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+ @echo
+ @echo "Build finished; now you can process the JSON files."
+
+.PHONY: htmlhelp
+htmlhelp:
+ $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+ @echo
+ @echo "Build finished; now you can run HTML Help Workshop with the" \
+ ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+.PHONY: qthelp
+qthelp:
+ $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+ @echo
+ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+ ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+ @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Flask-Ask.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Flask-Ask.qhc"
+
+.PHONY: applehelp
+applehelp:
+ $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
+ @echo
+ @echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
+ @echo "N.B. You won't be able to view it unless you put it in" \
+ "~/Library/Documentation/Help or install it in your application" \
+ "bundle."
+
+.PHONY: devhelp
+devhelp:
+ $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+ @echo
+ @echo "Build finished."
+ @echo "To view the help file:"
+ @echo "# mkdir -p $$HOME/.local/share/devhelp/Flask-Ask"
+ @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Flask-Ask"
+ @echo "# devhelp"
+
+.PHONY: epub
+epub:
+ $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+ @echo
+ @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+.PHONY: epub3
+epub3:
+ $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3
+ @echo
+ @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3."
+
+.PHONY: latex
+latex:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo
+ @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+ @echo "Run \`make' in that directory to run these through (pdf)latex" \
+ "(use \`make latexpdf' here to do that automatically)."
+
+.PHONY: latexpdf
+latexpdf:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through pdflatex..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+.PHONY: latexpdfja
+latexpdfja:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through platex and dvipdfmx..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+.PHONY: text
+text:
+ $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+ @echo
+ @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+.PHONY: man
+man:
+ $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+ @echo
+ @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+.PHONY: texinfo
+texinfo:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo
+ @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+ @echo "Run \`make' in that directory to run these through makeinfo" \
+ "(use \`make info' here to do that automatically)."
+
+.PHONY: info
+info:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo "Running Texinfo files through makeinfo..."
+ make -C $(BUILDDIR)/texinfo info
+ @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+.PHONY: gettext
+gettext:
+ $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+ @echo
+ @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+.PHONY: changes
+changes:
+ $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+ @echo
+ @echo "The overview file is in $(BUILDDIR)/changes."
+
+.PHONY: linkcheck
+linkcheck:
+ $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+ @echo
+ @echo "Link check complete; look for any errors in the above output " \
+ "or in $(BUILDDIR)/linkcheck/output.txt."
+
+.PHONY: doctest
+doctest:
+ $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+ @echo "Testing of doctests in the sources finished, look at the " \
+ "results in $(BUILDDIR)/doctest/output.txt."
+
+.PHONY: coverage
+coverage:
+ $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
+ @echo "Testing of coverage in the sources finished, look at the " \
+ "results in $(BUILDDIR)/coverage/python.txt."
+
+.PHONY: xml
+xml:
+ $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+ @echo
+ @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+.PHONY: pseudoxml
+pseudoxml:
+ $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+ @echo
+ @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
+
+.PHONY: dummy
+dummy:
+ $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy
+ @echo
+ @echo "Build finished. Dummy builder generates no files."
diff --git a/docs/_static/logo-full.png b/docs/_static/logo-full.png
new file mode 100644
index 0000000..9762abb
Binary files /dev/null and b/docs/_static/logo-full.png differ
diff --git a/docs/_static/logo-sm.png b/docs/_static/logo-sm.png
new file mode 100644
index 0000000..b4754fd
Binary files /dev/null and b/docs/_static/logo-sm.png differ
diff --git a/docs/_templates/links.html b/docs/_templates/links.html
new file mode 100644
index 0000000..b236224
--- /dev/null
+++ b/docs/_templates/links.html
@@ -0,0 +1,31 @@
+
+
+
+
+
diff --git a/docs/_themes/LICENSE b/docs/_themes/LICENSE
new file mode 100644
index 0000000..8daab7e
--- /dev/null
+++ b/docs/_themes/LICENSE
@@ -0,0 +1,37 @@
+Copyright (c) 2010 by Armin Ronacher.
+
+Some rights reserved.
+
+Redistribution and use in source and binary forms of the theme, with or
+without modification, are permitted provided that the following conditions
+are met:
+
+* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+
+* The names of the contributors may not be used to endorse or
+ promote products derived from this software without specific
+ prior written permission.
+
+We kindly ask you to only use these themes in an unmodified manner just
+for Flask and Flask-related products, not for unrelated projects. If you
+like the visual style and want to use it for your own projects, please
+consider making some larger changes to the themes (such as changing
+font faces, sizes, colors or margins).
+
+THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
diff --git a/docs/_themes/README b/docs/_themes/README
new file mode 100644
index 0000000..b3292bd
--- /dev/null
+++ b/docs/_themes/README
@@ -0,0 +1,31 @@
+Flask Sphinx Styles
+===================
+
+This repository contains sphinx styles for Flask and Flask related
+projects. To use this style in your Sphinx documentation, follow
+this guide:
+
+1. put this folder as _themes into your docs folder. Alternatively
+ you can also use git submodules to check out the contents there.
+2. add this to your conf.py:
+
+ sys.path.append(os.path.abspath('_themes'))
+ html_theme_path = ['_themes']
+ html_theme = 'flask'
+
+The following themes exist:
+
+- 'flask' - the standard flask documentation theme for large
+ projects
+- 'flask_small' - small one-page theme. Intended to be used by
+ very small addon libraries for flask.
+
+The following options exist for the flask_small theme:
+
+ [options]
+ index_logo = '' filename of a picture in _static
+ to be used as replacement for the
+ h1 in the index.rst file.
+ index_logo_height = 120px height of the index logo
+ github_fork = '' repository name on github for the
+ "fork me" badge
diff --git a/docs/_themes/flask/layout.html b/docs/_themes/flask/layout.html
new file mode 100644
index 0000000..a76d787
--- /dev/null
+++ b/docs/_themes/flask/layout.html
@@ -0,0 +1,36 @@
+{%- extends "basic/layout.html" %} {%- block extrahead %} {{ super() }}
+
+
+
+
+
+
+
+
+{% if theme_touch_icon %}
+ {% endif %} {% endblock %} {%- block relbar2 %} {% if theme_github_fork %}
+
+
+
+{% endif %} {% endblock %} {%- block footer %}
+
+{%- endblock %}
diff --git a/docs/_themes/flask/relations.html b/docs/_themes/flask/relations.html
new file mode 100644
index 0000000..3bbcde8
--- /dev/null
+++ b/docs/_themes/flask/relations.html
@@ -0,0 +1,19 @@
+
+
+
+Welcome to Flask-Ask
+====================
+
+Building high-quality Alexa skills for Amazon Echo Devices takes time. Flask-Ask makes it easier and much more fun.
+Use Flask-Ask with `ngrok `_ to eliminate the deploy-to-test step and get work done faster.
+
+Flask-Ask:
+
+* Has decorators to map Alexa requests and intent slots to view functions
+* Helps construct ask and tell responses, reprompts and cards
+* Makes session management easy
+* Allows for the separation of code and speech through Jinja templates
+* Verifies Alexa request signatures
+
+.. raw:: html
+
+
+
+
+Follow along with this quickstart on `Amazon
+`_.
+
+.. include:: contents.rst.inc
+
+.. raw:: html
+
+
+
+
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..8fae2bf
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,281 @@
+@ECHO OFF
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set BUILDDIR=_build
+set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
+set I18NSPHINXOPTS=%SPHINXOPTS% .
+if NOT "%PAPER%" == "" (
+ set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
+ set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
+)
+
+if "%1" == "" goto help
+
+if "%1" == "help" (
+ :help
+ echo.Please use `make ^` where ^ is one of
+ echo. html to make standalone HTML files
+ echo. dirhtml to make HTML files named index.html in directories
+ echo. singlehtml to make a single large HTML file
+ echo. pickle to make pickle files
+ echo. json to make JSON files
+ echo. htmlhelp to make HTML files and a HTML help project
+ echo. qthelp to make HTML files and a qthelp project
+ echo. devhelp to make HTML files and a Devhelp project
+ echo. epub to make an epub
+ echo. epub3 to make an epub3
+ echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
+ echo. text to make text files
+ echo. man to make manual pages
+ echo. texinfo to make Texinfo files
+ echo. gettext to make PO message catalogs
+ echo. changes to make an overview over all changed/added/deprecated items
+ echo. xml to make Docutils-native XML files
+ echo. pseudoxml to make pseudoxml-XML files for display purposes
+ echo. linkcheck to check all external links for integrity
+ echo. doctest to run all doctests embedded in the documentation if enabled
+ echo. coverage to run coverage check of the documentation if enabled
+ echo. dummy to check syntax errors of document sources
+ goto end
+)
+
+if "%1" == "clean" (
+ for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
+ del /q /s %BUILDDIR%\*
+ goto end
+)
+
+
+REM Check if sphinx-build is available and fallback to Python version if any
+%SPHINXBUILD% 1>NUL 2>NUL
+if errorlevel 9009 goto sphinx_python
+goto sphinx_ok
+
+:sphinx_python
+
+set SPHINXBUILD=python -m sphinx.__init__
+%SPHINXBUILD% 2> nul
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+:sphinx_ok
+
+
+if "%1" == "html" (
+ %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/html.
+ goto end
+)
+
+if "%1" == "dirhtml" (
+ %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
+ goto end
+)
+
+if "%1" == "singlehtml" (
+ %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
+ goto end
+)
+
+if "%1" == "pickle" (
+ %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can process the pickle files.
+ goto end
+)
+
+if "%1" == "json" (
+ %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can process the JSON files.
+ goto end
+)
+
+if "%1" == "htmlhelp" (
+ %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can run HTML Help Workshop with the ^
+.hhp project file in %BUILDDIR%/htmlhelp.
+ goto end
+)
+
+if "%1" == "qthelp" (
+ %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can run "qcollectiongenerator" with the ^
+.qhcp project file in %BUILDDIR%/qthelp, like this:
+ echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Flask-Ask.qhcp
+ echo.To view the help file:
+ echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Flask-Ask.ghc
+ goto end
+)
+
+if "%1" == "devhelp" (
+ %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished.
+ goto end
+)
+
+if "%1" == "epub" (
+ %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The epub file is in %BUILDDIR%/epub.
+ goto end
+)
+
+if "%1" == "epub3" (
+ %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The epub3 file is in %BUILDDIR%/epub3.
+ goto end
+)
+
+if "%1" == "latex" (
+ %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
+ goto end
+)
+
+if "%1" == "latexpdf" (
+ %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+ cd %BUILDDIR%/latex
+ make all-pdf
+ cd %~dp0
+ echo.
+ echo.Build finished; the PDF files are in %BUILDDIR%/latex.
+ goto end
+)
+
+if "%1" == "latexpdfja" (
+ %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+ cd %BUILDDIR%/latex
+ make all-pdf-ja
+ cd %~dp0
+ echo.
+ echo.Build finished; the PDF files are in %BUILDDIR%/latex.
+ goto end
+)
+
+if "%1" == "text" (
+ %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The text files are in %BUILDDIR%/text.
+ goto end
+)
+
+if "%1" == "man" (
+ %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The manual pages are in %BUILDDIR%/man.
+ goto end
+)
+
+if "%1" == "texinfo" (
+ %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
+ goto end
+)
+
+if "%1" == "gettext" (
+ %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
+ goto end
+)
+
+if "%1" == "changes" (
+ %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.The overview file is in %BUILDDIR%/changes.
+ goto end
+)
+
+if "%1" == "linkcheck" (
+ %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Link check complete; look for any errors in the above output ^
+or in %BUILDDIR%/linkcheck/output.txt.
+ goto end
+)
+
+if "%1" == "doctest" (
+ %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Testing of doctests in the sources finished, look at the ^
+results in %BUILDDIR%/doctest/output.txt.
+ goto end
+)
+
+if "%1" == "coverage" (
+ %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Testing of coverage in the sources finished, look at the ^
+results in %BUILDDIR%/coverage/python.txt.
+ goto end
+)
+
+if "%1" == "xml" (
+ %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The XML files are in %BUILDDIR%/xml.
+ goto end
+)
+
+if "%1" == "pseudoxml" (
+ %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
+ goto end
+)
+
+if "%1" == "dummy" (
+ %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. Dummy builder generates no files.
+ goto end
+)
+
+:end
diff --git a/docs/requests.rst b/docs/requests.rst
new file mode 100644
index 0000000..56ba348
--- /dev/null
+++ b/docs/requests.rst
@@ -0,0 +1,265 @@
+Handling Requests
+=================
+
+With the Alexa Skills Kit, spoken phrases are mapped to actions executed on a server. Alexa converts
+speech into JSON and delivers the JSON to your application.
+For example, the phrase:
+
+ "Alexa, Tell HelloApp to say hi to John"
+
+produces JSON like the following:
+
+.. code-block:: javascript
+
+ "request": {
+ "intent": {
+ "name": "HelloIntent",
+ "slots": {
+ "firstname": {
+ "name": "firstname",
+ "value": "John"
+ }
+ }
+ }
+ ...
+ }
+
+Parameters called 'slots' are defined and parsed out of speech at runtime.
+For example, the spoken word 'John' above is parsed into the slot named ``firstname`` with the ``AMAZON.US_FIRST_NAME``
+data type.
+
+For detailed information, see
+`Handling Requests Sent by Alexa `_
+on the Amazon developer website.
+
+This section shows how to process Alexa requests with Flask-Ask. It contains the following subsections:
+
+.. contents::
+ :local:
+ :backlinks: none
+
+Mapping Alexa Requests to View Functions
+----------------------------------------
+
+📼 Here is a video demo on `Handling Requests with Flask-Ask video `_.
+
+Flask-Ask has decorators to map Alexa requests to view functions.
+
+The ``launch`` decorator handles launch requests::
+
+ @ask.launch
+ def launched():
+ return question('Welcome to Foo')
+
+The ``intent`` decorator handles intent requests::
+
+ @ask.intent('HelloWorldIntent')
+ def hello():
+ return statement('Hello, world')
+
+The ``session_ended`` decorator is for the session ended request::
+
+ @ask.session_ended
+ def session_ended():
+ return "{}", 200
+
+Launch and intent requests can both start sessions. Avoid duplicate code with the ``on_session_started`` callback::
+
+ @ask.on_session_started
+ def new_session():
+ log.info('new session started')
+
+
+Mapping Intent Slots to View Function Parameters
+------------------------------------------------
+
+📼 Here is a video demo on `Intent Slots with Flask-Ask video `_.
+
+
+When Parameter and Slot Names Differ
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Tell Flask-Ask when slot and view function parameter names differ with ``mapping``::
+
+ @ask.intent('WeatherIntent', mapping={'city': 'City'})
+ def weather(city):
+ return statement('I predict great weather for {}'.format(city))
+
+Above, the parameter ``city`` is mapped to the slot ``City``.
+
+
+Assigning Default Values when Slots are Empty
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Parameters are assigned a value of ``None`` if the Alexa service:
+
+* Does not return a corresponding slot in the request
+* Includes a corresponding slot without its ``value`` attribute
+* Includes a corresponding slot with an empty ``value`` attribute (e.g. ``""``)
+
+Use the ``default`` parameter for default values instead of ``None``. The default itself should be a
+literal or a callable that resolves to a value. The next example shows the literal ``'World'``::
+
+ @ask.intent('HelloIntent', default={'name': 'World'})
+ def hello(name):
+ return statement('Hello, {}'.format(name))
+
+
+Converting Slots Values to Python Data Types
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+📼 Here is a video demo on `Slot Conversions with Flask-Ask video `_.
+
+When slot values are available, they're always assigned to parameters as strings. Convert to other Python
+data types with ``convert``. ``convert`` is a ``dict`` that maps parameter names to callables::
+
+ @ask.intent('AddIntent', convert={'x': int, 'y': int})
+ def add(x, y):
+ z = x + y
+ return statement('{} plus {} equals {}'.format(x, y, z))
+
+
+Above, ``x`` and ``y`` will both be passed to ``int()`` and thus converted to ``int`` instances.
+
+Flask-Ask provides convenient API constants for Amazon ``AMAZON.DATE``, ``AMAZON.TIME``, and ``AMAZON.DURATION``
+types exist since those are harder to build callables against. Instead of trying to define functions that work with
+inputs like those in Amazon's
+`documentation `_,
+just pass the strings in the second column below:
+
+📼 Here is a video demo on `Slot Conversion Helpers with Flask-Ask video `_.
+
+=================== =============== ======================
+Amazon Data Type String Python Data Type
+=================== =============== ======================
+``AMAZON.DATE`` ``'date'`` ``datetime.date``
+``AMAZON.TIME`` ``'time'`` ``datetime.time``
+``AMAZON.DURATION`` ``'timedelta'`` ``datetime.timedelta``
+=================== =============== ======================
+
+**Examples**
+
+.. code-block:: python
+
+ convert={'the_date': 'date'}
+
+converts ``'2015-11-24'``, ``'2015-W48-WE'``, or ``'201X'`` into a ``datetime.date``
+
+.. code-block:: python
+
+ convert={'appointment_time': 'time'}
+
+converts ``'06:00'``, ``'14:15'``, or ``'23:59'`` into a ``datetime.time``.
+
+.. code-block:: python
+
+ convert={'ago': 'timedelta'}
+
+converts ``'PT10M'``, ``'PT45S'``, or ``'P2YT3H10M'`` into a ``datetime.timedelta``.
+
+
+Handling Conversion Errors
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Sometimes Alexa doesn't understand what's said, and slots come in with question marks:
+
+.. code-block:: javascript
+
+ "slots": {
+ "age": {
+ "name": "age",
+ "value": "?"
+ }
+ }
+
+Recover gracefully with the ``convert_errors`` context local. Import it to use it:
+
+.. code-block:: python
+
+ ...
+ from flask_ask import statement, question, convert_errors
+
+
+ @ask.intent('AgeIntent', convert={'age': int})
+ def say_age(age):
+ if 'age' in convert_errors:
+ # since age failed to convert, it keeps its string
+ # value (e.g. "?") for later interrogation.
+ return question("Can you please repeat your age?")
+
+ # conversion guaranteed to have succeeded
+ # age is an int
+ return statement("Your age is {}".format(age))
+
+
+``convert_errors`` is a ``dict`` that maps parameter names to the ``Exceptions`` raised during
+conversion. When writing your own converters, raise ``Exceptions`` on failure, so
+they work with ``convert_errors``::
+
+ def to_direction_const(s):
+ if s.lower() not in ['left', 'right']
+ raise Exception("must be left or right")
+ return LEFT if s == 'left' else RIGHT
+
+ @ask.intent('TurnIntent', convert={'direction': to_direction_const})
+ def turn(direction):
+ # do something with direction
+ ...
+
+
+That ``convert_errors`` is a ``dict`` allows for granular error recovery::
+
+ if 'something' in convert_errors:
+ # Did something fail?
+
+or::
+
+ if convert_errors:
+ # Did anything fail?
+
+
+
+``session``, ``context``, ``request`` and ``version`` Context Locals
+---------------------------------------------------------------------
+An Alexa
+`request payload `_
+has four top-level elements: ``session``, ``context``, ``request`` and ``version``. Like Flask, Flask-Ask provides `context
+locals `_ that spare you from having to add these as extra parameters to
+your functions. However, the ``request`` and ``session`` objects are distinct from Flask's ``request`` and ``session``.
+Flask-Ask's ``request``, ``context`` and ``session`` correspond to the Alexa request payload components while Flask's correspond
+to lower-level HTTP constructs.
+
+To use Flask-Ask's context locals, just import them::
+
+ from flask import App
+ from flask_ask import Ask, request, context, session, version
+
+ app = Flask(__name__)
+ ask = Ask(app)
+ log = logging.getLogger()
+
+ @ask.intent('ExampleIntent')
+ def example():
+ log.info("Request ID: {}".format(request.requestId))
+ log.info("Request Type: {}".format(request.type))
+ log.info("Request Timestamp: {}".format(request.timestamp))
+ log.info("Session New?: {}".format(session.new))
+ log.info("User ID: {}".format(session.user.userId))
+ log.info("Alexa Version: {}".format(version))
+ log.info("Device ID: {}".format(context.System.device.deviceId))
+ log.info("Consent Token: {}".format(context.System.user.permissions.consentToken))
+ ...
+
+If you want to use both Flask and Flask-Ask context locals in the same module, use ``import as``::
+
+ from flask import App, request, session
+ from flask_ask import (
+ Ask,
+ request as ask_request,
+ session as ask_session,
+ version
+ )
+
+For a complete reference on ``request``, ``context`` and ``session`` fields, see the
+`JSON Interface Reference for Custom Skills `_
+in the Alexa Skills Kit documentation.
diff --git a/docs/responses.rst b/docs/responses.rst
new file mode 100644
index 0000000..b262929
--- /dev/null
+++ b/docs/responses.rst
@@ -0,0 +1,152 @@
+Building Responses
+==================
+
+📼 Here is a video demo on `Building Responses with Flask-Ask video `_ .
+
+The two primary constructs in Flask-Ask for creating responses are ``statement`` and ``question``.
+
+Statements terminate Echo sessions. The user is free to start another session, but Alexa will have no memory of it
+(unless persistence is programmed separately on the server with a database or the like).
+
+A ``question``, on the other hand, prompts the user for additional speech and keeps a session open.
+This session is similar to an HTTP session but the implementation is different. Since your application is
+communicating with the Alexa service instead of a browser, there are no cookies or local storage. Instead, the
+session is maintained in both the request and response JSON structures. In addition to the session component of
+questions, questions also allow a ``reprompt``, which is typically a rephrasing of the question if user did not answer
+the first time.
+
+This sections shows how to build responses with Flask-Ask. It contains the following subsections:
+
+.. contents::
+ :local:
+ :backlinks: none
+
+Telling with ``statement``
+--------------------------
+``statement`` closes the session::
+
+ @ask.intent('AllYourBaseIntent')
+ def all_your_base():
+ return statement('All your base are belong to us')
+
+
+Asking with ``question``
+------------------------
+Asking with ``question`` prompts the user for a response while keeping the session open::
+
+ @ask.intent('AppointmentIntent')
+ def make_appointment():
+ return question("What day would you like to make an appointment for?")
+
+If the user doesn't respond, encourage them by rephrasing the question with ``reprompt``::
+
+ @ask.intent('AppointmentIntent')
+ def make_appointment():
+ return question("What day would you like to make an appointment for?") \
+ .reprompt("I didn't get that. When would you like to be seen?")
+
+
+Session Management
+------------------
+
+The ``session`` context local has an ``attributes`` dictionary for persisting information across requests::
+
+ session.attributes['city'] = "San Francisco"
+
+When the response is rendered, the session attributes are automatically copied over into
+the response's ``sessionAttributes`` structure.
+
+The renderer looks for an ``attribute_encoder`` on the session. If the renderer finds one, it will pass it to
+``json.dumps`` as either that function's ``cls`` or ``default`` keyword parameters depending on whether
+a ``json.JSONEncoder`` or a function is used, respectively.
+
+Here's an example that uses a function::
+
+ def _json_date_handler(obj):
+ if isinstance(obj, datetime.date):
+ return obj.isoformat()
+
+ session.attributes['date'] = date
+ session.attributes_encoder = _json_date_handler
+
+See the `json.dump documentation `_ for for details about
+that method's ``cls`` and ``default`` parameters.
+
+
+Automatic Handling of Plaintext and SSML
+----------------------------------------
+The Alexa Skills Kit supports plain text or
+`SSML `_ outputs. Flask-Ask automatically
+detects if your speech text contains SSML by attempting to parse it into XML, and checking
+if the root element is ``speak``::
+
+ try:
+ xmldoc = ElementTree.fromstring(text)
+ if xmldoc.tag == 'speak':
+ # output type is 'SSML'
+ except ElementTree.ParseError:
+ pass
+ # output type is 'PlainText'
+
+
+Displaying Cards in the Alexa Smartphone/Tablet App
+---------------------------------------------------
+In addition to speaking back, Flask-Ask can display contextual cards in the Alexa smartphone/tablet app. All four
+of the Alexa Skills Kit card types are supported.
+
+Simple cards display a title and message::
+
+ @ask.intent('AllYourBaseIntent')
+ def all_your_base():
+ return statement('All your base are belong to us') \
+ .simple_card(title='CATS says...', content='Make your time')
+
+Standard cards are like simple cards but they also support small and large image URLs::
+
+ @ask.intent('AllYourBaseIntent')
+ def all_your_base():
+ return statement('All your base are belong to us') \
+ .standard_card(title='CATS says...',
+ text='Make your time',
+ small_image_url='https://example.com/small.png',
+ large_image_url='https://example.com/large.png')
+
+Link account cards display a link to authorize the Alexa user with a user account in your system. The link displayed is the auhorization URL you configure in the amazon skill developer portal::
+
+ @ask.intent('AllYourBaseIntent')
+ def all_your_base():
+ return statement('Please link your account in the Alexa app') \
+ .link_account_card()
+
+Consent cards ask for the permission to access the device's address. You can either ask for the country and postal code (`read::alexa:device:all:address:country_and_postal_code`) or for the full address (`read::alexa:device:all:address`). The permission you ask for has to match what you've specified in the amazon skill developer portal::
+
+ @ask.intent('AllYourBaseIntent')
+ def all_your_base():
+ return statement('Please allow access to your location') \
+ .consent_card("read::alexa:device:all:address")
+
+
+Jinja Templates
+---------------
+You can also use Jinja templates. Define them in a YAML file named `templates.yaml` inside your application root::
+
+ @ask.intent('RBelongToUsIntent')
+ def all_your_base():
+ notice = render_template('all_your_base_msg', who='us')
+ return statement(notice)
+
+.. code-block:: yaml
+
+ all_your_base_msg: All your base are belong to {{ who }}
+
+ multiple_line_example: |
+
+ I am a multi-line SSML template. My content spans more than one line,
+ so there's a pipe and a newline that separates my name and value.
+ Enjoy the sounds of the ocean.
+
+
+
+You can also use a custom templates file passed into the Ask object::
+
+ ask = Ask(app, '/', None, 'custom-templates.yml')
diff --git a/docs/user_contributions.rst b/docs/user_contributions.rst
new file mode 100644
index 0000000..7b8f5d4
--- /dev/null
+++ b/docs/user_contributions.rst
@@ -0,0 +1,28 @@
+User Contributions
+==================
+
+Have an article or video to submit? Please send it to john@johnwheeler.org
+
+`Flask-Ask: A New Python Framework for Rapid Alexa Skills Kit Development `_
+
+ by John Wheeler
+
+`Running with Alexa Part I. `_
+
+ by Tim Kjær Lange
+
+`Intro and Skill Logic - Alexa Skills w/ Python and Flask-Ask Part 1 `_
+
+ by Harrison Kinsley
+
+`Headlines Function - Alexa Skills w/ Python and Flask-Ask Part 2 `_
+
+ by Harrison Kinsley
+
+`Testing our Skill - Alexa Skills w/ Python and Flask-Ask Part 3 `_
+
+ by Harrison Kinsley
+
+`Flask-Ask — A tutorial on a simple and easy way to build complex Alexa Skills `_
+
+ by Bjorn Vuylsteker
diff --git a/flask_ask/__init__.py b/flask_ask/__init__.py
new file mode 100644
index 0000000..d878d12
--- /dev/null
+++ b/flask_ask/__init__.py
@@ -0,0 +1,30 @@
+import logging
+
+logger = logging.getLogger('flask_ask')
+logger.addHandler(logging.StreamHandler())
+if logger.level == logging.NOTSET:
+ logger.setLevel(logging.WARN)
+
+
+from .core import (
+ Ask,
+ request,
+ session,
+ version,
+ context,
+ current_stream,
+ convert_errors
+)
+
+from .models import (
+ question,
+ statement,
+ audio,
+ delegate,
+ elicit_slot,
+ confirm_slot,
+ confirm_intent,
+ buy,
+ upsell,
+ refund
+)
diff --git a/flask_ask/cache.py b/flask_ask/cache.py
new file mode 100644
index 0000000..b810af0
--- /dev/null
+++ b/flask_ask/cache.py
@@ -0,0 +1,80 @@
+"""
+Stream cache functions
+"""
+
+
+def push_stream(cache, user_id, stream):
+ """
+ Push a stream onto the stream stack in cache.
+
+ :param cache: werkzeug BasicCache-like object
+ :param user_id: id of user, used as key in cache
+ :param stream: stream object to push onto stack
+
+ :return: True on successful update,
+ False if failed to update,
+ None if invalid input was given
+ """
+ stack = cache.get(user_id)
+ if stack is None:
+ stack = []
+ if stream:
+ stack.append(stream)
+ return cache.set(user_id, stack)
+ return None
+
+
+def pop_stream(cache, user_id):
+ """
+ Pop an item off the stack in the cache. If stack
+ is empty after pop, it deletes the stack.
+
+ :param cache: werkzeug BasicCache-like object
+ :param user_id: id of user, used as key in cache
+
+ :return: top item from stack, otherwise None
+ """
+ stack = cache.get(user_id)
+ if stack is None:
+ return None
+
+ result = stack.pop()
+
+ if len(stack) == 0:
+ cache.delete(user_id)
+ else:
+ cache.set(user_id, stack)
+
+ return result
+
+
+def set_stream(cache, user_id, stream):
+ """
+ Overwrite stack in the cache.
+
+ :param cache: werkzeug BasicCache-liek object
+ :param user_id: id of user, used as key in cache
+ :param stream: value to initialize new stack with
+
+ :return: None
+ """
+ if stream:
+ return cache.set(user_id, [stream])
+
+
+def top_stream(cache, user_id):
+ """
+ Peek at the top of the stack in the cache.
+
+ :param cache: werkzeug BasicCache-like object
+ :param user_id: id of user, used as key in cache
+
+ :return: top item in user's cached stack, otherwise None
+ """
+ if not user_id:
+ return None
+
+ stack = cache.get(user_id)
+ if stack is None:
+ return None
+ return stack.pop()
diff --git a/flask_ask/convert.py b/flask_ask/convert.py
new file mode 100644
index 0000000..fcc6a8a
--- /dev/null
+++ b/flask_ask/convert.py
@@ -0,0 +1,57 @@
+import re
+from datetime import datetime, time
+
+import aniso8601
+
+from . import logger
+
+
+_DATE_PATTERNS = {
+ # "today", "tomorrow", "november twenty-fifth": 2015-11-25
+ '^\d{4}-\d{2}-\d{2}$': '%Y-%m-%d',
+ # "this week", "next week": 2015-W48
+ '^\d{4}-W\d{2}$': '%Y-W%U-%w',
+ # "this weekend": 2015-W48-WE
+ '^\d{4}-W\d{2}-WE$': '%Y-W%U-WE-%w',
+ # "this month": 2015-11
+ '^\d{4}-\d{2}$': '%Y-%m',
+ # "next year": 2016
+ '^\d{4}$': '%Y',
+}
+
+
+def to_date(amazon_date):
+ # make so 'next decade' matches work against 'next year' regex
+ amazon_date = re.sub('X$', '0', amazon_date)
+ for re_pattern, format_pattern in list(_DATE_PATTERNS.items()):
+ if re.match(re_pattern, amazon_date):
+ if '%U' in format_pattern:
+ # http://stackoverflow.com/a/17087427/1163855
+ amazon_date += '-0'
+ return datetime.strptime(amazon_date, format_pattern).date()
+ return None
+
+
+def to_time(amazon_time):
+ if amazon_time == "AM":
+ return time(hour=0)
+ if amazon_time == "PM":
+ return time(hour=12)
+ if amazon_time == "MO":
+ return time(hour=5)
+ if amazon_time == "AF":
+ return time(hour=12)
+ if amazon_time == "EV":
+ return time(hour=17)
+ if amazon_time == "NI":
+ return time(hour=21)
+ try:
+ return aniso8601.parse_time(amazon_time)
+ except ValueError as e:
+ logger.warn("ValueError for amazon_time '{}'.".format(amazon_time))
+ logger.warn("ValueError message: {}".format(e.message))
+ return None
+
+
+def to_timedelta(amazon_duration):
+ return aniso8601.parse_duration(amazon_duration)
diff --git a/flask_ask/core.py b/flask_ask/core.py
new file mode 100644
index 0000000..bf006c1
--- /dev/null
+++ b/flask_ask/core.py
@@ -0,0 +1,951 @@
+import os
+import sys
+import yaml
+import inspect
+import io
+from datetime import datetime
+from functools import wraps, partial
+
+import aniso8601
+from werkzeug.contrib.cache import SimpleCache
+from werkzeug.local import LocalProxy, LocalStack
+from jinja2 import BaseLoader, ChoiceLoader, TemplateNotFound
+from flask import current_app, json, request as flask_request, _app_ctx_stack
+
+from . import verifier, logger
+from .convert import to_date, to_time, to_timedelta
+from .cache import top_stream, set_stream
+import collections
+
+
+def find_ask():
+ """
+ Find our instance of Ask, navigating Local's and possible blueprints.
+
+ Note: This only supports returning a reference to the first instance
+ of Ask found.
+ """
+ if hasattr(current_app, 'ask'):
+ return getattr(current_app, 'ask')
+ else:
+ if hasattr(current_app, 'blueprints'):
+ blueprints = getattr(current_app, 'blueprints')
+ for blueprint_name in blueprints:
+ if hasattr(blueprints[blueprint_name], 'ask'):
+ return getattr(blueprints[blueprint_name], 'ask')
+
+
+def dbgdump(obj, default=None, cls=None):
+ if current_app.config.get('ASK_PRETTY_DEBUG_LOGS', False):
+ indent = 2
+ else:
+ indent = None
+ msg = json.dumps(obj, indent=indent, default=default, cls=cls)
+ logger.debug(msg)
+
+
+request = LocalProxy(lambda: find_ask().request)
+session = LocalProxy(lambda: find_ask().session)
+version = LocalProxy(lambda: find_ask().version)
+context = LocalProxy(lambda: find_ask().context)
+convert_errors = LocalProxy(lambda: find_ask().convert_errors)
+current_stream = LocalProxy(lambda: find_ask().current_stream)
+stream_cache = LocalProxy(lambda: find_ask().stream_cache)
+
+from . import models
+
+
+_converters = {'date': to_date, 'time': to_time, 'timedelta': to_timedelta}
+
+
+class Ask(object):
+ """The Ask object provides the central interface for interacting with the Alexa service.
+
+ Ask object maps Alexa Requests to flask view functions and handles Alexa sessions.
+ The constructor is passed a Flask App instance, and URL endpoint.
+ The Flask instance allows the convienient API of endpoints and their view functions,
+ so that Alexa requests may be mapped with syntax similar to a typical Flask server.
+ Route provides the entry point for the skill, and must be provided if an app is given.
+
+ Keyword Arguments:
+ app {Flask object} -- App instance - created with Flask(__name__) (default: {None})
+ route {str} -- entry point to which initial Alexa Requests are forwarded (default: {None})
+ blueprint {Flask blueprint} -- Flask Blueprint instance to use instead of Flask App (default: {None})
+ stream_cache {Werkzeug BasicCache} -- BasicCache-like object for storing Audio stream data (default: {SimpleCache})
+ path {str} -- path to templates yaml file for VUI dialog (default: {'templates.yaml'})
+ """
+
+ def __init__(self, app=None, route=None, blueprint=None, stream_cache=None, path='templates.yaml'):
+ self.app = app
+ self._route = route
+ self._intent_view_funcs = {}
+ self._intent_converts = {}
+ self._intent_defaults = {}
+ self._intent_mappings = {}
+ self._launch_view_func = None
+ self._session_ended_view_func = None
+ self._on_session_started_callback = None
+ self._default_intent_view_func = None
+ self._player_request_view_funcs = {}
+ self._player_mappings = {}
+ self._player_converts = {}
+ if app is not None:
+ self.init_app(app, path)
+ elif blueprint is not None:
+ self.init_blueprint(blueprint, path)
+ if stream_cache is None:
+ self.stream_cache = SimpleCache()
+ else:
+ self.stream_cache = stream_cache
+
+ def init_app(self, app, path='templates.yaml'):
+ """Initializes Ask app by setting configuration variables, loading templates, and maps Ask route to a flask view.
+
+ The Ask instance is given the following configuration variables by calling on Flask's configuration:
+
+ `ASK_APPLICATION_ID`:
+
+ Turn on application ID verification by setting this variable to an application ID or a
+ list of allowed application IDs. By default, application ID verification is disabled and a
+ warning is logged. This variable should be set in production to ensure
+ requests are being sent by the applications you specify.
+ Default: None
+
+ `ASK_VERIFY_REQUESTS`:
+
+ Enables or disables Alexa request verification, which ensures requests sent to your skill
+ are from Amazon's Alexa service. This setting should not be disabled in production.
+ It is useful for mocking JSON requests in automated tests.
+ Default: True
+
+ `ASK_VERIFY_TIMESTAMP_DEBUG`:
+
+ Turn on request timestamp verification while debugging by setting this to True.
+ Timestamp verification helps mitigate against replay attacks. It relies on the system clock
+ being synchronized with an NTP server. This setting should not be enabled in production.
+ Default: False
+
+ `ASK_PRETTY_DEBUG_LOGS`:
+
+ Add tabs and linebreaks to the Alexa request and response printed to the debug log.
+ This improves readability when printing to the console, but breaks formatting when logging to CloudWatch.
+ Default: False
+ """
+ if self._route is None:
+ raise TypeError("route is a required argument when app is not None")
+
+ self.app = app
+
+ app.ask = self
+
+ app.add_url_rule(self._route, view_func=self._flask_view_func, methods=['POST'])
+ app.jinja_loader = ChoiceLoader([app.jinja_loader, YamlLoader(app, path)])
+
+ def init_blueprint(self, blueprint, path='templates.yaml'):
+ """Initialize a Flask Blueprint, similar to init_app, but without the access
+ to the application config.
+
+ Keyword Arguments:
+ blueprint {Flask Blueprint} -- Flask Blueprint instance to initialize (Default: {None})
+ path {str} -- path to templates yaml file, relative to Blueprint (Default: {'templates.yaml'})
+ """
+ if self._route is not None:
+ raise TypeError("route cannot be set when using blueprints!")
+
+ # we need to tuck our reference to this Ask instance into the blueprint object and find it later!
+ blueprint.ask = self
+
+ # BlueprintSetupState.add_url_rule gets called underneath the covers and
+ # concats the rule string, so we should set to an empty string to allow
+ # Blueprint('blueprint_api', __name__, url_prefix="/ask") to result in
+ # exposing the rule at "/ask" and not "/ask/".
+ blueprint.add_url_rule("", view_func=self._flask_view_func, methods=['POST'])
+ blueprint.jinja_loader = ChoiceLoader([YamlLoader(blueprint, path)])
+
+ @property
+ def ask_verify_requests(self):
+ return current_app.config.get('ASK_VERIFY_REQUESTS', True)
+
+ @property
+ def ask_verify_timestamp_debug(self):
+ return current_app.config.get('ASK_VERIFY_TIMESTAMP_DEBUG', False)
+
+ @property
+ def ask_application_id(self):
+ return current_app.config.get('ASK_APPLICATION_ID', None)
+
+ def on_session_started(self, f):
+ """Decorator to call wrapped function upon starting a session.
+
+ @ask.on_session_started
+ def new_session():
+ log.info('new session started')
+
+ Because both launch and intent requests may begin a session, this decorator is used call
+ a function regardless of how the session began.
+
+ Arguments:
+ f {function} -- function to be called when session is started.
+ """
+ self._on_session_started_callback = f
+
+ def launch(self, f):
+ """Decorator maps a view function as the endpoint for an Alexa LaunchRequest and starts the skill.
+
+ @ask.launch
+ def launched():
+ return question('Welcome to Foo')
+
+ The wrapped function is registered as the launch view function and renders the response
+ for requests to the Launch URL.
+ A request to the launch URL is verified with the Alexa server before the payload is
+ passed to the view function.
+
+ Arguments:
+ f {function} -- Launch view function
+ """
+ self._launch_view_func = f
+
+ @wraps(f)
+ def wrapper(*args, **kw):
+ self._flask_view_func(*args, **kw)
+ return f
+
+ def session_ended(self, f):
+ """Decorator routes Alexa SessionEndedRequest to the wrapped view function to end the skill.
+
+ @ask.session_ended
+ def session_ended():
+ return "{}", 200
+
+ The wrapped function is registered as the session_ended view function
+ and renders the response for requests to the end of the session.
+
+ Arguments:
+ f {function} -- session_ended view function
+ """
+ self._session_ended_view_func = f
+
+ @wraps(f)
+ def wrapper(*args, **kw):
+ self._flask_view_func(*args, **kw)
+ return f
+
+ def intent(self, intent_name, mapping={}, convert={}, default={}):
+ """Decorator routes an Alexa IntentRequest and provides the slot parameters to the wrapped function.
+
+ Functions decorated as an intent are registered as the view function for the Intent's URL,
+ and provide the backend responses to give your Skill its functionality.
+
+ @ask.intent('WeatherIntent', mapping={'city': 'City'})
+ def weather(city):
+ return statement('I predict great weather for {}'.format(city))
+
+ Arguments:
+ intent_name {str} -- Name of the intent request to be mapped to the decorated function
+
+ Keyword Arguments:
+ mapping {dict} -- Maps parameters to intent slots of a different name
+ default: {}
+
+ convert {dict} -- Converts slot values to data types before assignment to parameters
+ default: {}
+
+ default {dict} -- Provides default values for Intent slots if Alexa reuqest
+ returns no corresponding slot, or a slot with an empty value
+ default: {}
+ """
+ def decorator(f):
+ self._intent_view_funcs[intent_name] = f
+ self._intent_mappings[intent_name] = mapping
+ self._intent_converts[intent_name] = convert
+ self._intent_defaults[intent_name] = default
+
+ @wraps(f)
+ def wrapper(*args, **kw):
+ self._flask_view_func(*args, **kw)
+ return f
+ return decorator
+
+ def default_intent(self, f):
+ """Decorator routes any Alexa IntentRequest that is not matched by any existing @ask.intent routing."""
+ self._default_intent_view_func = f
+
+ @wraps(f)
+ def wrapper(*args, **kw):
+ self._flask_view_func(*args, **kw)
+ return f
+
+ def display_element_selected(self, f):
+ """Decorator routes Alexa Display.ElementSelected request to the wrapped view function.
+
+ @ask.display_element_selected
+ def eval_element():
+ return "", 200
+
+ The wrapped function is registered as the display_element_selected view function
+ and renders the response for requests.
+
+ Arguments:
+ f {function} -- display_element_selected view function
+ """
+ self._display_element_selected_func = f
+
+ @wraps(f)
+ def wrapper(*args, **kw):
+ self._flask_view_func(*args, **kw)
+ return f
+
+
+ def on_purchase_completed(self, mapping={'payload': 'payload','name':'name','status':'status','token':'token'}, convert={}, default={}):
+ """Decorator routes an Connections.Response to the wrapped function.
+
+ Request is sent when Alexa completes the purchase flow.
+ See https://developer.amazon.com/docs/in-skill-purchase/add-isps-to-a-skill.html#handle-results
+
+
+ The wrapped view function may accept parameters from the Request.
+ In addition to locale, requestId, timestamp, and type
+
+
+ @ask.on_purchase_completed( mapping={'payload': 'payload','name':'name','status':'status','token':'token'})
+ def completed(payload, name, status, token):
+ logger.info(payload)
+ logger.info(name)
+ logger.info(status)
+ logger.info(token)
+
+ """
+ def decorator(f):
+ self._intent_view_funcs['Connections.Response'] = f
+ self._intent_mappings['Connections.Response'] = mapping
+ self._intent_converts['Connections.Response'] = convert
+ self._intent_defaults['Connections.Response'] = default
+ @wraps(f)
+ def wrapper(*args, **kwargs):
+ self._flask_view_func(*args, **kwargs)
+ return f
+ return decorator
+
+
+ def on_playback_started(self, mapping={'offset': 'offsetInMilliseconds'}, convert={}, default={}):
+ """Decorator routes an AudioPlayer.PlaybackStarted Request to the wrapped function.
+
+ Request sent when Alexa begins playing the audio stream previously sent in a Play directive.
+ This lets your skill verify that playback began successfully.
+ This request is also sent when Alexa resumes playback after pausing it for a voice request.
+
+ The wrapped view function may accept parameters from the AudioPlayer Request.
+ In addition to locale, requestId, timestamp, and type
+ AudioPlayer Requests include:
+ offsetInMilliseconds - Position in stream when request was sent.
+ Not end of stream, often few ms after Play Directive offset.
+ This parameter is automatically mapped to 'offset' by default
+
+ token - token of the stream that is nearly finished.
+
+ @ask.on_playback_started()
+ def on_playback_start(token, offset):
+ logger.info('stream has token {}'.format(token))
+ logger.info('Current position within the stream is {} ms'.format(offset))
+ """
+ def decorator(f):
+ self._intent_view_funcs['AudioPlayer.PlaybackStarted'] = f
+ self._intent_mappings['AudioPlayer.PlaybackStarted'] = mapping
+ self._intent_converts['AudioPlayer.PlaybackStarted'] = convert
+ self._intent_defaults['AudioPlayer.PlaybackStarted'] = default
+
+ @wraps(f)
+ def wrapper(*args, **kwargs):
+ self._flask_view_func(*args, **kwargs)
+ return f
+ return decorator
+
+ def on_playback_finished(self, mapping={'offset': 'offsetInMilliseconds'}, convert={}, default={}):
+ """Decorator routes an AudioPlayer.PlaybackFinished Request to the wrapped function.
+
+ This type of request is sent when the stream Alexa is playing comes to an end on its own.
+
+ Note: If your skill explicitly stops the playback with the Stop directive,
+ Alexa sends PlaybackStopped instead of PlaybackFinished.
+
+ The wrapped view function may accept parameters from the AudioPlayer Request.
+ In addition to locale, requestId, timestamp, and type
+ AudioPlayer Requests include:
+ offsetInMilliseconds - Position in stream when request was sent.
+ Not end of stream, often few ms after Play Directive offset.
+ This parameter is automatically mapped to 'offset' by default.
+
+ token - token of the stream that is nearly finished.
+
+ Audioplayer Requests do not include the stream URL, it must be accessed from current_stream.url
+ """
+ def decorator(f):
+ self._intent_view_funcs['AudioPlayer.PlaybackFinished'] = f
+ self._intent_mappings['AudioPlayer.PlaybackFinished'] = mapping
+ self._intent_converts['AudioPlayer.PlaybackFinished'] = convert
+ self._intent_defaults['AudioPlayer.PlaybackFinished'] = default
+
+ @wraps(f)
+ def wrapper(*args, **kwargs):
+ self._flask_view_func(*args, **kwargs)
+ return f
+ return decorator
+
+ def on_playback_stopped(self, mapping={'offset': 'offsetInMilliseconds'}, convert={}, default={}):
+ """Decorator routes an AudioPlayer.PlaybackStopped Request to the wrapped function.
+
+ Sent when Alexa stops playing an audio stream in response to one of the following:
+ -AudioPlayer.Stop
+ -AudioPlayer.Play with a playBehavior of REPLACE_ALL.
+ -AudioPlayer.ClearQueue with a clearBehavior of CLEAR_ALL.
+
+ This request is also sent if the user makes a voice request to Alexa,
+ since this temporarily pauses the playback.
+ In this case, the playback begins automatically once the voice interaction is complete.
+
+ Note: If playback stops because the audio stream comes to an end on its own,
+ Alexa sends PlaybackFinished instead of PlaybackStopped.
+
+ The wrapped view function may accept parameters from the AudioPlayer Request.
+ In addition to locale, requestId, timestamp, and type
+ AudioPlayer Requests include:
+ offsetInMilliseconds - Position in stream when request was sent.
+ Not end of stream, often few ms after Play Directive offset.
+ This parameter is automatically mapped to 'offset' by default.
+
+ token - token of the stream that is nearly finished.
+
+ Audioplayer Requests do not include the stream URL, it must be accessed from current_stream.url
+ """
+ def decorator(f):
+ self._intent_view_funcs['AudioPlayer.PlaybackStopped'] = f
+ self._intent_mappings['AudioPlayer.PlaybackStopped'] = mapping
+ self._intent_converts['AudioPlayer.PlaybackStopped'] = convert
+ self._intent_defaults['AudioPlayer.PlaybackStopped'] = default
+
+ @wraps(f)
+ def wrapper(*args, **kwargs):
+ self._flask_view_func(*args, **kwargs)
+ return f
+ return decorator
+
+ def on_playback_nearly_finished(self, mapping={'offset': 'offsetInMilliseconds'}, convert={}, default={}):
+ """Decorator routes an AudioPlayer.PlaybackNearlyFinished Request to the wrapped function.
+
+ This AudioPlayer Request sent when the device is ready to receive a new stream.
+ To progress through a playlist, respond to this request with an enqueue or play_next audio response.
+
+ **Note** that this request is sent when Alexa is ready to receive a new stream to enqueue, and not
+ necessarily when the stream's offset is near the end.
+ The request may be sent by Alexa immediately after your skill sends a Play Directive.
+
+ The wrapped view function may accept parameters from the AudioPlayer Request.
+ In addition to locale, requestId, timestamp, and type
+ This AudioPlayer Request includes:
+ AudioPlayer Requests include:
+ offsetInMilliseconds - Position in stream when request was sent.
+ Not end of stream, often few ms after Play Directive offset.
+ This parameter is automatically mapped to 'offset' by default.
+
+ token - token of the stream that is nearly finished.
+
+ Audioplayer Requests do not include the stream URL, and must be accessed from current_stream
+
+ Example usage:
+
+ @ask.on_playback_nearly_finished()
+ def play_next_stream():
+ audio().enqueue(my_next_song)
+
+ # offsetInMilliseconds is mapped to offset by default for convenience
+ @ask.on_playback_nearly_finished()
+ def show_request_feedback(offset, token):
+ logging.info('Nearly Finished')
+ logging.info('Stream at {} ms when Playback Request sent'.format(offset))
+ logging.info('Stream holds the token {}'.format(token))
+ logging.info('Streaming from {}'.format(current_stream.url))
+
+ # example of changing the default parameter mapping
+ @ask.on_playback_nearly_finished(mapping={'pos': 'offsetInMilliseconds', 'stream_token': 'token'})
+ def show_request_feedback(pos, stream_token):
+ _infodump('Nearly Finished')
+ _infodump('Stream at {} ms when Playback Request sent'.format(pos))
+ _infodump('Stream holds the token {}'.format(stream_token))
+ """
+ def decorator(f):
+ self._intent_view_funcs['AudioPlayer.PlaybackNearlyFinished'] = f
+ self._intent_mappings['AudioPlayer.PlaybackNearlyFinished'] = mapping
+ self._intent_converts['AudioPlayer.PlaybackNearlyFinished'] = convert
+ self._intent_defaults['AudioPlayer.PlaybackNearlyFinished'] = default
+
+ @wraps(f)
+ def wrapper(*args, **kwargs):
+ self._flask_view_func(*args, **kwargs)
+ return f
+ return decorator
+
+ def on_playback_failed(self, mapping={}, convert={}, default={}):
+ """Decorator routes an AudioPlayer.PlaybackFailed Request to the wrapped function.
+
+ This AudioPlayer Request sent when Alexa encounters an error when attempting to play a stream.
+
+ The wrapped view function may accept parameters from the AudioPlayer Request.
+ In addition to locale, requestId, timestamp, and type
+
+ PlayBackFailed Requests include:
+ error - Contains error info under parameters type and message
+
+ token - represents the stream that failed to play.
+
+ currentPlaybackState - Details about the playback activity occurring at the time of the error
+ Contains the following parameters:
+
+ token - represents the audio stream currently playing when the error occurred.
+ Note that this may be different from the value of the request.token property.
+
+ offsetInMilliseconds - Position in stream when request was sent.
+ Not end of stream, often few ms after Play Directive offset.
+ This parameter is automatically mapped to 'offset' by default.
+
+ playerActivity - player state when the error occurred
+ """
+ def decorator(f):
+ self._intent_view_funcs['AudioPlayer.PlaybackFailed'] = f
+ self._intent_mappings['AudioPlayer.PlaybackFailed'] = mapping
+ self._intent_converts['AudioPlayer.PlaybackFailed'] = convert
+ self._intent_defaults['AudioPlayer.PlaybackFailed'] = default
+
+ @wraps(f)
+ def wrapper(*args, **kwargs):
+ self._flask_view_func(*args, **kwargs)
+ return f
+ return decorator
+
+ @property
+ def request(self):
+ return getattr(_app_ctx_stack.top, '_ask_request', None)
+
+ @request.setter
+ def request(self, value):
+ _app_ctx_stack.top._ask_request = value
+
+ @property
+ def session(self):
+ return getattr(_app_ctx_stack.top, '_ask_session', models._Field())
+
+ @session.setter
+ def session(self, value):
+ _app_ctx_stack.top._ask_session = value
+
+ @property
+ def version(self):
+ return getattr(_app_ctx_stack.top, '_ask_version', None)
+
+ @version.setter
+ def version(self, value):
+ _app_ctx_stack.top._ask_version = value
+
+ @property
+ def context(self):
+ return getattr(_app_ctx_stack.top, '_ask_context', None)
+
+ @context.setter
+ def context(self, value):
+ _app_ctx_stack.top._ask_context = value
+
+ @property
+ def convert_errors(self):
+ return getattr(_app_ctx_stack.top, '_ask_convert_errors', None)
+
+ @convert_errors.setter
+ def convert_errors(self, value):
+ _app_ctx_stack.top._ask_convert_errors = value
+
+ @property
+ def current_stream(self):
+ #return getattr(_app_ctx_stack.top, '_ask_current_stream', models._Field())
+ user = self._get_user()
+ if user:
+ stream = top_stream(self.stream_cache, user)
+ if stream:
+ current = models._Field()
+ current.__dict__.update(stream)
+ return current
+ return models._Field()
+
+ @current_stream.setter
+ def current_stream(self, value):
+ # assumption 1 is we get a models._Field as value
+ # assumption 2 is if someone sets a value, it's resetting the stack
+ user = self._get_user()
+ if user:
+ set_stream(self.stream_cache, user, value.__dict__)
+
+ def run_aws_lambda(self, event):
+ """Invoke the Flask Ask application from an AWS Lambda function handler.
+
+ Use this method to service AWS Lambda requests from a custom Alexa
+ skill. This method will invoke your Flask application providing a
+ WSGI-compatible environment that wraps the original Alexa event
+ provided to the AWS Lambda handler. Returns the output generated by
+ a Flask Ask application, which should be used as the return value
+ to the AWS Lambda handler function.
+
+ Example usage:
+
+ from flask import Flask
+ from flask_ask import Ask, statement
+
+ app = Flask(__name__)
+ ask = Ask(app, '/')
+
+ # This function name is what you defined when you create an
+ # AWS Lambda function. By default, AWS calls this function
+ # lambda_handler.
+ def lambda_handler(event, _context):
+ return ask.run_aws_lambda(event)
+
+ @ask.intent('HelloIntent')
+ def hello(firstname):
+ speech_text = "Hello %s" % firstname
+ return statement(speech_text).simple_card('Hello', speech_text)
+ """
+
+ # We are guaranteed to be called by AWS as a Lambda function does not
+ # expose a public facing interface.
+ self.app.config['ASK_VERIFY_REQUESTS'] = False
+
+ # Convert an environment variable to a WSGI "bytes-as-unicode" string
+ enc, esc = sys.getfilesystemencoding(), 'surrogateescape'
+ def unicode_to_wsgi(u):
+ return u.encode(enc, esc).decode('iso-8859-1')
+
+ # Create a WSGI-compatible environ that can be passed to the
+ # application. It is loaded with the OS environment variables,
+ # mandatory CGI-like variables, as well as the mandatory WSGI
+ # variables.
+ environ = {k: unicode_to_wsgi(v) for k, v in os.environ.items()}
+ environ['REQUEST_METHOD'] = 'POST'
+ environ['PATH_INFO'] = '/'
+ environ['SERVER_NAME'] = 'AWS-Lambda'
+ environ['SERVER_PORT'] = '80'
+ environ['SERVER_PROTOCOL'] = 'HTTP/1.0'
+ environ['wsgi.version'] = (1, 0)
+ environ['wsgi.url_scheme'] = 'http'
+ environ['wsgi.errors'] = sys.stderr
+ environ['wsgi.multithread'] = False
+ environ['wsgi.multiprocess'] = False
+ environ['wsgi.run_once'] = True
+
+ # Convert the event provided by the AWS Lambda handler to a JSON
+ # string that can be read as the body of a HTTP POST request.
+ body = json.dumps(event)
+ environ['CONTENT_TYPE'] = 'application/json'
+ environ['CONTENT_LENGTH'] = len(body)
+
+ PY3 = sys.version_info[0] == 3
+
+ if PY3:
+ environ['wsgi.input'] = io.StringIO(body)
+ else:
+ environ['wsgi.input'] = io.BytesIO(body)
+
+ # Start response is a required callback that must be passed when
+ # the application is invoked. It is used to set HTTP status and
+ # headers. Read the WSGI spec for details (PEP3333).
+ headers = []
+ def start_response(status, response_headers, _exc_info=None):
+ headers[:] = [status, response_headers]
+
+ # Invoke the actual Flask application providing our environment,
+ # with our Alexa event as the body of the HTTP request, as well
+ # as the callback function above. The result will be an iterator
+ # that provides a serialized JSON string for our Alexa response.
+ result = self.app(environ, start_response)
+ try:
+ if not headers:
+ raise AssertionError("start_response() not called by WSGI app")
+
+ output = b"".join(result)
+ if not headers[0].startswith("2"):
+ raise AssertionError("Non-2xx from app: hdrs={}, body={}".format(headers, output))
+
+ # The Lambda handler expects a Python object that can be
+ # serialized as JSON, so we need to take the already serialized
+ # JSON and deserialize it.
+ return json.loads(output)
+
+ finally:
+ # Per the WSGI spec, we need to invoke the close method if it
+ # is implemented on the result object.
+ if hasattr(result, 'close'):
+ result.close()
+
+
+ def _get_user(self):
+ if self.context:
+ return self.context.get('System', {}).get('user', {}).get('userId')
+ return None
+
+
+ def _alexa_request(self, verify=True):
+ raw_body = flask_request.data
+ alexa_request_payload = json.loads(raw_body)
+
+ if verify:
+ cert_url = flask_request.headers['Signaturecertchainurl']
+ signature = flask_request.headers['Signature']
+
+ # load certificate - this verifies a the certificate url and format under the hood
+ cert = verifier.load_certificate(cert_url)
+ # verify signature
+ verifier.verify_signature(cert, signature, raw_body)
+
+ # verify timestamp
+ raw_timestamp = alexa_request_payload.get('request', {}).get('timestamp')
+ timestamp = self._parse_timestamp(raw_timestamp)
+
+ if not current_app.debug or self.ask_verify_timestamp_debug:
+ verifier.verify_timestamp(timestamp)
+
+ # verify application id
+ try:
+ application_id = alexa_request_payload['session']['application']['applicationId']
+ except KeyError:
+ application_id = alexa_request_payload['context'][
+ 'System']['application']['applicationId']
+ if self.ask_application_id is not None:
+ verifier.verify_application_id(application_id, self.ask_application_id)
+
+ return alexa_request_payload
+
+ @staticmethod
+ def _parse_timestamp(timestamp):
+ """
+ Parse a given timestamp value, raising ValueError if None or Flasey
+ """
+ if timestamp:
+ try:
+ return aniso8601.parse_datetime(timestamp)
+ except AttributeError:
+ # raised by aniso8601 if raw_timestamp is not valid string
+ # in ISO8601 format
+ try:
+ return datetime.utcfromtimestamp(timestamp)
+ except:
+ # relax the timestamp a bit in case it was sent in millis
+ return datetime.utcfromtimestamp(timestamp/1000)
+
+ raise ValueError('Invalid timestamp value! Cannot parse from either ISO8601 string or UTC timestamp.')
+
+
+ def _update_stream(self):
+ fresh_stream = models._Field()
+ fresh_stream.__dict__.update(self.current_stream.__dict__) # keeps url attribute after stopping stream
+ fresh_stream.__dict__.update(self._from_directive())
+
+ context_info = self._from_context()
+ if context_info != None:
+ fresh_stream.__dict__.update(context_info)
+
+ self.current_stream = fresh_stream
+ dbgdump(current_stream.__dict__)
+
+ def _from_context(self):
+ return getattr(self.context, 'AudioPlayer', {})
+
+ def _from_directive(self):
+ from_buffer = top_stream(self.stream_cache, self._get_user())
+ if from_buffer:
+ if self.request.intent and 'PauseIntent' in self.request.intent.name:
+ return {}
+ return from_buffer
+ return {}
+
+ def _flask_view_func(self, *args, **kwargs):
+ ask_payload = self._alexa_request(verify=self.ask_verify_requests)
+ dbgdump(ask_payload)
+ request_body = models._Field(ask_payload)
+
+ self.request = request_body.request
+ self.version = request_body.version
+ self.context = getattr(request_body, 'context', models._Field())
+ self.session = getattr(request_body, 'session', self.session) # to keep old session.attributes through AudioRequests
+
+ if not self.session:
+ self.session = models._Field()
+ if not self.session.attributes:
+ self.session.attributes = models._Field()
+
+ self._update_stream()
+
+ # add current dialog state in session
+ try:
+ self.session["dialogState"] = request.dialogState
+ except KeyError:
+ self.session["dialogState"] = "unknown"
+
+ try:
+ if self.session.new and self._on_session_started_callback is not None:
+ self._on_session_started_callback()
+ except AttributeError:
+ pass
+
+ result = None
+ request_type = self.request.type
+
+ if request_type == 'LaunchRequest' and self._launch_view_func:
+ result = self._launch_view_func()
+ elif request_type == 'SessionEndedRequest':
+ if self._session_ended_view_func:
+ result = self._session_ended_view_func()
+ else:
+ result = "{}", 200
+ elif request_type == 'IntentRequest' and self._intent_view_funcs:
+ result = self._map_intent_to_view_func(self.request.intent)()
+ elif request_type == 'Display.ElementSelected' and self._display_element_selected_func:
+ result = self._display_element_selected_func()
+ elif 'AudioPlayer' in request_type:
+ result = self._map_player_request_to_func(self.request.type)()
+ # routes to on_playback funcs
+ # user can also access state of content.AudioPlayer with current_stream
+ elif 'Connections.Response' in request_type:
+ result = self._map_purchase_request_to_func(self.request.type)()
+
+ if result is not None:
+ if isinstance(result, models._Response):
+ return result.render_response()
+ return result
+ return "", 400
+
+ def _map_intent_to_view_func(self, intent):
+ """Provides appropiate parameters to the intent functions."""
+ if intent.name in self._intent_view_funcs:
+ view_func = self._intent_view_funcs[intent.name]
+ elif self._default_intent_view_func is not None:
+ view_func = self._default_intent_view_func
+ else:
+ raise NotImplementedError('Intent "{}" not found and no default intent specified.'.format(intent.name))
+
+ PY3 = sys.version_info[0] == 3
+ if PY3:
+ argspec = inspect.getfullargspec(view_func)
+ else:
+ argspec = inspect.getargspec(view_func)
+
+ arg_names = argspec.args
+ arg_values = self._map_params_to_view_args(intent.name, arg_names)
+
+ return partial(view_func, *arg_values)
+
+ def _map_player_request_to_func(self, player_request_type):
+ """Provides appropriate parameters to the on_playback functions."""
+ # calbacks for on_playback requests are optional
+ view_func = self._intent_view_funcs.get(player_request_type, lambda: None)
+
+ argspec = inspect.getargspec(view_func)
+ arg_names = argspec.args
+ arg_values = self._map_params_to_view_args(player_request_type, arg_names)
+
+ return partial(view_func, *arg_values)
+
+ def _map_purchase_request_to_func(self, purchase_request_type):
+ """Provides appropriate parameters to the on_purchase functions."""
+
+ if purchase_request_type in self._intent_view_funcs:
+ view_func = self._intent_view_funcs[purchase_request_type]
+ else:
+ raise NotImplementedError('Request type "{}" not found and no default view specified.'.format(purchase_request_type))
+
+ argspec = inspect.getargspec(view_func)
+ arg_names = argspec.args
+ arg_values = self._map_params_to_view_args(purchase_request_type, arg_names)
+
+ print('_map_purchase_request_to_func', arg_names, arg_values, view_func, purchase_request_type)
+ return partial(view_func, *arg_values)
+
+ def _get_slot_value(self, slot_object):
+ slot_name = slot_object.name
+ slot_value = getattr(slot_object, 'value', None)
+ resolutions = getattr(slot_object, 'resolutions', None)
+
+ if resolutions is not None:
+ resolutions_per_authority = getattr(resolutions, 'resolutionsPerAuthority', None)
+ if resolutions_per_authority is not None and len(resolutions_per_authority) > 0:
+ values = resolutions_per_authority[0].get('values', None)
+ if values is not None and len(values) > 0:
+ value = values[0].get('value', None)
+ if value is not None:
+ slot_value = value.get('name', slot_value)
+
+ return slot_value
+
+ def _map_params_to_view_args(self, view_name, arg_names):
+
+ arg_values = []
+ convert = self._intent_converts.get(view_name)
+ default = self._intent_defaults.get(view_name)
+ mapping = self._intent_mappings.get(view_name)
+
+ convert_errors = {}
+
+ request_data = {}
+ intent = getattr(self.request, 'intent', None)
+ if intent is not None:
+ if intent.slots is not None:
+ for slot_key in intent.slots.keys():
+ slot_object = getattr(intent.slots, slot_key)
+ request_data[slot_object.name] = self._get_slot_value(slot_object=slot_object)
+
+ else:
+ for param_name in self.request:
+ request_data[param_name] = getattr(self.request, param_name, None)
+
+ for arg_name in arg_names:
+ param_or_slot = mapping.get(arg_name, arg_name)
+ arg_value = request_data.get(param_or_slot)
+ if arg_value is None or arg_value == "":
+ if arg_name in default:
+ default_value = default[arg_name]
+ if isinstance(default_value, collections.Callable):
+ default_value = default_value()
+ arg_value = default_value
+ elif arg_name in convert:
+ shorthand_or_function = convert[arg_name]
+ if shorthand_or_function in _converters:
+ shorthand = shorthand_or_function
+ convert_func = _converters[shorthand]
+ else:
+ convert_func = shorthand_or_function
+ try:
+ arg_value = convert_func(arg_value)
+ except Exception as e:
+ convert_errors[arg_name] = e
+ arg_values.append(arg_value)
+ self.convert_errors = convert_errors
+ return arg_values
+
+
+class YamlLoader(BaseLoader):
+
+ def __init__(self, app, path):
+ self.path = app.root_path + os.path.sep + path
+ self.mapping = {}
+ self._reload_mapping()
+
+ def _reload_mapping(self):
+ if os.path.isfile(self.path):
+ self.last_mtime = os.path.getmtime(self.path)
+ with open(self.path) as f:
+ self.mapping = yaml.safe_load(f.read())
+
+ def get_source(self, environment, template):
+ if not os.path.isfile(self.path):
+ return None, None, None
+ if self.last_mtime != os.path.getmtime(self.path):
+ self._reload_mapping()
+ if template in self.mapping:
+ source = self.mapping[template]
+ return source, None, lambda: source == self.mapping.get(template)
+ raise TemplateNotFound(template)
diff --git a/flask_ask/models.py b/flask_ask/models.py
new file mode 100644
index 0000000..d159f7b
--- /dev/null
+++ b/flask_ask/models.py
@@ -0,0 +1,460 @@
+import inspect
+from flask import json
+from xml.etree import ElementTree
+import aniso8601
+from .core import session, context, current_stream, stream_cache, dbgdump
+from .cache import push_stream
+import uuid
+
+
+class _Field(dict):
+ """Container to represent Alexa Request Data.
+
+ Initialized with request_json and creates a dict object with attributes
+ to be accessed via dot notation or as a dict key-value.
+
+ Parameters within the request_json that contain their data as a json object
+ are also represented as a _Field object.
+
+ Example:
+
+ payload_object = _Field(alexa_json_payload)
+
+ request_type_from_keys = payload_object['request']['type']
+ request_type_from_attrs = payload_object.request.type
+
+ assert request_type_from_keys == request_type_from_attrs
+ """
+
+ def __init__(self, request_json={}):
+ super(_Field, self).__init__(request_json)
+ for key, value in request_json.items():
+ if isinstance(value, dict):
+ value = _Field(value)
+ self[key] = value
+
+ def __getattr__(self, attr):
+ # converts timestamp str to datetime.datetime object
+ if 'timestamp' in attr:
+ return aniso8601.parse_datetime(self.get(attr))
+ return self.get(attr)
+
+ def __setattr__(self, key, value):
+ self.__setitem__(key, value)
+
+
+class _Response(object):
+
+ def __init__(self, speech):
+ self._json_default = None
+ self._response = {
+ 'outputSpeech': _output_speech(speech)
+ }
+
+ def simple_card(self, title=None, content=None):
+ card = {
+ 'type': 'Simple',
+ 'title': title,
+ 'content': content
+ }
+ self._response['card'] = card
+ return self
+
+ def standard_card(self, title=None, text=None, small_image_url=None, large_image_url=None):
+ card = {
+ 'type': 'Standard',
+ 'title': title,
+ 'text': text
+ }
+
+ if any((small_image_url, large_image_url)):
+ card['image'] = {}
+ if small_image_url is not None:
+ card['image']['smallImageUrl'] = small_image_url
+ if large_image_url is not None:
+ card['image']['largeImageUrl'] = large_image_url
+
+ self._response['card'] = card
+ return self
+
+ def list_display_render(self, template=None, title=None, backButton='HIDDEN', token=None, background_image_url=None, image=None, listItems=None, hintText=None):
+ directive = [
+ {
+ 'type': 'Display.RenderTemplate',
+ 'template': {
+ 'type': template,
+ 'backButton': backButton,
+ 'title': title,
+ 'listItems': listItems
+ }
+ }
+ ]
+
+ if background_image_url is not None:
+ directive[0]['template']['backgroundImage'] = {
+ 'sources': [
+ {'url': background_image_url}
+ ]
+ }
+
+ if hintText is not None:
+ hint = {
+ 'type':'Hint',
+ 'hint': {
+ 'type':"PlainText",
+ 'text': hintText
+ }
+ }
+ directive.append(hint)
+ self._response['directives'] = directive
+ return self
+
+ def display_render(self, template=None, title=None, backButton='HIDDEN', token=None, background_image_url=None, image=None, text=None, hintText=None):
+ directive = [
+ {
+ 'type': 'Display.RenderTemplate',
+ 'template': {
+ 'type': template,
+ 'backButton': backButton,
+ 'title': title,
+ 'textContent': text
+ }
+ }
+ ]
+
+ if background_image_url is not None:
+ directive[0]['template']['backgroundImage'] = {
+ 'sources': [
+ {'url': background_image_url}
+ ]
+ }
+
+ if image is not None:
+ directive[0]['template']['image'] = {
+ 'sources': [
+ {'url': image}
+ ]
+ }
+
+ if token is not None:
+ directive[0]['template']['token'] = token
+
+ if hintText is not None:
+ hint = {
+ 'type':'Hint',
+ 'hint': {
+ 'type':"PlainText",
+ 'text': hintText
+ }
+ }
+ directive.append(hint)
+
+ self._response['directives'] = directive
+ return self
+
+ def link_account_card(self):
+ card = {'type': 'LinkAccount'}
+ self._response['card'] = card
+ return self
+
+ def consent_card(self, permissions):
+ card = {
+ 'type': 'AskForPermissionsConsent',
+ 'permissions': [permissions]
+ }
+ self._response['card'] = card
+ return self
+
+ def render_response(self):
+ response_wrapper = {
+ 'version': '1.0',
+ 'response': self._response,
+ 'sessionAttributes': session.attributes
+ }
+
+ kw = {}
+ if hasattr(session, 'attributes_encoder'):
+ json_encoder = session.attributes_encoder
+ kwargname = 'cls' if inspect.isclass(json_encoder) else 'default'
+ kw[kwargname] = json_encoder
+ dbgdump(response_wrapper, **kw)
+
+ return json.dumps(response_wrapper, **kw)
+
+
+class statement(_Response):
+
+ def __init__(self, speech):
+ super(statement, self).__init__(speech)
+ self._response['shouldEndSession'] = True
+
+
+class question(_Response):
+
+ def __init__(self, speech):
+ super(question, self).__init__(speech)
+ self._response['shouldEndSession'] = False
+
+ def reprompt(self, reprompt):
+ reprompt = {'outputSpeech': _output_speech(reprompt)}
+ self._response['reprompt'] = reprompt
+ return self
+
+
+class buy(_Response):
+
+ def __init__(self, productId=None):
+ self._response = {
+ 'shouldEndSession': True,
+ 'directives': [{
+ 'type': 'Connections.SendRequest',
+ 'name': 'Buy',
+ 'payload': {
+ 'InSkillProduct': {
+ 'productId': productId
+ }
+ },
+ 'token': 'correlationToken'
+ }]
+ }
+
+
+class refund(_Response):
+
+ def __init__(self, productId=None):
+ self._response = {
+ 'shouldEndSession': True,
+ 'directives': [{
+ 'type': 'Connections.SendRequest',
+ 'name': 'Cancel',
+ 'payload': {
+ 'InSkillProduct': {
+ 'productId': productId
+ }
+ },
+ 'token': 'correlationToken'
+ }]
+ }
+
+class upsell(_Response):
+
+ def __init__(self, productId=None, msg=None):
+ self._response = {
+ 'shouldEndSession': True,
+ 'directives': [{
+ 'type': 'Connections.SendRequest',
+ 'name': 'Upsell',
+ 'payload': {
+ 'InSkillProduct': {
+ 'productId': productId
+ },
+ 'upsellMessage': msg
+ },
+ 'token': 'correlationToken'
+ }]
+ }
+
+class delegate(_Response):
+
+ def __init__(self, updated_intent=None):
+ self._response = {
+ 'shouldEndSession': False,
+ 'directives': [{'type': 'Dialog.Delegate'}]
+ }
+
+ if updated_intent:
+ self._response['directives'][0]['updatedIntent'] = updated_intent
+
+
+class elicit_slot(_Response):
+ """
+ Sends an ElicitSlot directive.
+ slot - The slot name to elicit
+ speech - The output speech
+ updated_intent - Optional updated intent
+ """
+
+ def __init__(self, slot, speech, updated_intent=None):
+ self._response = {
+ 'shouldEndSession': False,
+ 'directives': [{
+ 'type': 'Dialog.ElicitSlot',
+ 'slotToElicit': slot,
+ }],
+ 'outputSpeech': _output_speech(speech),
+ }
+
+ if updated_intent:
+ self._response['directives'][0]['updatedIntent'] = updated_intent
+
+class confirm_slot(_Response):
+ """
+ Sends a ConfirmSlot directive.
+ slot - The slot name to confirm
+ speech - The output speech
+ updated_intent - Optional updated intent
+ """
+
+ def __init__(self, slot, speech, updated_intent=None):
+ self._response = {
+ 'shouldEndSession': False,
+ 'directives': [{
+ 'type': 'Dialog.ConfirmSlot',
+ 'slotToConfirm': slot,
+ }],
+ 'outputSpeech': _output_speech(speech),
+ }
+
+ if updated_intent:
+ self._response['directives'][0]['updatedIntent'] = updated_intent
+
+class confirm_intent(_Response):
+ """
+ Sends a ConfirmIntent directive.
+
+ """
+ def __init__(self, speech, updated_intent=None):
+ self._response = {
+ 'shouldEndSession': False,
+ 'directives': [{
+ 'type': 'Dialog.ConfirmIntent',
+ }],
+ 'outputSpeech': _output_speech(speech),
+ }
+
+ if updated_intent:
+ self._response['directives'][0]['updatedIntent'] = updated_intent
+
+
+class audio(_Response):
+ """Returns a response object with an Amazon AudioPlayer Directive.
+
+ Responses for LaunchRequests and IntentRequests may include outputSpeech in addition to an audio directive
+
+ Note that responses to AudioPlayer requests do not allow outputSpeech.
+ These must only include AudioPlayer Directives.
+
+ @ask.intent('PlayFooAudioIntent')
+ def play_foo_audio():
+ speech = 'playing from foo'
+ stream_url = www.foo.com
+ return audio(speech).play(stream_url)
+
+
+ @ask.intent('AMAZON.PauseIntent')
+ def stop_audio():
+ return audio('Ok, stopping the audio').stop()
+ """
+
+ def __init__(self, speech=''):
+ super(audio, self).__init__(speech)
+ if not speech:
+ self._response = {}
+ self._response['directives'] = []
+
+ def play(self, stream_url, offset=0, opaque_token=None):
+ """Sends a Play Directive to begin playback and replace current and enqueued streams."""
+
+ self._response['shouldEndSession'] = True
+ directive = self._play_directive('REPLACE_ALL')
+ directive['audioItem'] = self._audio_item(stream_url=stream_url, offset=offset, opaque_token=opaque_token)
+ self._response['directives'].append(directive)
+ return self
+
+ def enqueue(self, stream_url, offset=0, opaque_token=None):
+ """Adds stream to the queue. Does not impact the currently playing stream."""
+ directive = self._play_directive('ENQUEUE')
+ audio_item = self._audio_item(stream_url=stream_url,
+ offset=offset,
+ push_buffer=False,
+ opaque_token=opaque_token)
+ audio_item['stream']['expectedPreviousToken'] = current_stream.token
+
+ directive['audioItem'] = audio_item
+ self._response['directives'].append(directive)
+ return self
+
+ def play_next(self, stream_url=None, offset=0, opaque_token=None):
+ """Replace all streams in the queue but does not impact the currently playing stream."""
+
+ directive = self._play_directive('REPLACE_ENQUEUED')
+ directive['audioItem'] = self._audio_item(stream_url=stream_url, offset=offset, opaque_token=opaque_token)
+ self._response['directives'].append(directive)
+ return self
+
+ def resume(self):
+ """Sends Play Directive to resume playback at the paused offset"""
+ directive = self._play_directive('REPLACE_ALL')
+ directive['audioItem'] = self._audio_item()
+ self._response['directives'].append(directive)
+ return self
+
+ def _play_directive(self, behavior):
+ directive = {}
+ directive['type'] = 'AudioPlayer.Play'
+ directive['playBehavior'] = behavior
+ return directive
+
+ def _audio_item(self, stream_url=None, offset=0, push_buffer=True, opaque_token=None):
+ """Builds an AudioPlayer Directive's audioItem and updates current_stream"""
+ audio_item = {'stream': {}}
+ stream = audio_item['stream']
+
+ # existing stream
+ if not stream_url:
+ # stream.update(current_stream.__dict__)
+ stream['url'] = current_stream.url
+ stream['token'] = current_stream.token
+ stream['offsetInMilliseconds'] = current_stream.offsetInMilliseconds
+
+ # new stream
+ else:
+ stream['url'] = stream_url
+ stream['token'] = opaque_token or str(uuid.uuid4())
+ stream['offsetInMilliseconds'] = offset
+
+ if push_buffer: # prevents enqueued streams from becoming current_stream
+ push_stream(stream_cache, context['System']['user']['userId'], stream)
+ return audio_item
+
+ def stop(self):
+ """Sends AudioPlayer.Stop Directive to stop the current stream playback"""
+ self._response['directives'].append({'type': 'AudioPlayer.Stop'})
+ return self
+
+ def clear_queue(self, stop=False):
+ """Clears queued streams and optionally stops current stream.
+
+ Keyword Arguments:
+ stop {bool} set True to stop current current stream and clear queued streams.
+ set False to clear queued streams and allow current stream to finish
+ default: {False}
+ """
+
+ directive = {}
+ directive['type'] = 'AudioPlayer.ClearQueue'
+ if stop:
+ directive['clearBehavior'] = 'CLEAR_ALL'
+ else:
+ directive['clearBehavior'] = 'CLEAR_ENQUEUED'
+
+ self._response['directives'].append(directive)
+ return self
+
+
+def _copyattr(src, dest, attr, convert=None):
+ if attr in src:
+ value = src[attr]
+ if convert is not None:
+ value = convert(value)
+ setattr(dest, attr, value)
+
+
+def _output_speech(speech):
+ try:
+ xmldoc = ElementTree.fromstring(speech)
+ if xmldoc.tag == 'speak':
+ return {'type': 'SSML', 'ssml': speech}
+ except (UnicodeEncodeError, ElementTree.ParseError) as e:
+ pass
+ return {'type': 'PlainText', 'text': speech}
diff --git a/flask_ask/verifier.py b/flask_ask/verifier.py
new file mode 100644
index 0000000..29b256c
--- /dev/null
+++ b/flask_ask/verifier.py
@@ -0,0 +1,69 @@
+import os
+import base64
+import posixpath
+from datetime import datetime
+from six.moves.urllib.parse import urlparse
+from six.moves.urllib.request import urlopen
+
+from OpenSSL import crypto
+
+from . import logger
+
+
+class VerificationError(Exception): pass
+
+
+def load_certificate(cert_url):
+ if not _valid_certificate_url(cert_url):
+ raise VerificationError("Certificate URL verification failed")
+ cert_data = urlopen(cert_url).read()
+ cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_data)
+ if not _valid_certificate(cert):
+ raise VerificationError("Certificate verification failed")
+ return cert
+
+
+def verify_signature(cert, signature, signed_data):
+ try:
+ signature = base64.b64decode(signature)
+ crypto.verify(cert, signature, signed_data, 'sha1')
+ except crypto.Error as e:
+ raise VerificationError(e)
+
+
+def verify_timestamp(timestamp):
+ dt = datetime.utcnow() - timestamp.replace(tzinfo=None)
+ if abs(dt.total_seconds()) > 150:
+ raise VerificationError("Timestamp verification failed")
+
+
+def verify_application_id(candidate, records):
+ if candidate not in records:
+ raise VerificationError("Application ID verification failed")
+
+
+def _valid_certificate_url(cert_url):
+ parsed_url = urlparse(cert_url)
+ if parsed_url.scheme == 'https':
+ if parsed_url.hostname == "s3.amazonaws.com":
+ if posixpath.normpath(parsed_url.path).startswith("/echo.api/"):
+ return True
+ return False
+
+
+def _valid_certificate(cert):
+ not_after = cert.get_notAfter().decode('utf-8')
+ not_after = datetime.strptime(not_after, '%Y%m%d%H%M%SZ')
+ if datetime.utcnow() >= not_after:
+ return False
+ found = False
+ for i in range(0, cert.get_extension_count()):
+ extension = cert.get_extension(i)
+ short_name = extension.get_short_name().decode('utf-8')
+ value = str(extension)
+ if 'subjectAltName' == short_name and 'DNS:echo-api.amazon.com' == value:
+ found = True
+ break
+ if not found:
+ return False
+ return True
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..f8d2793
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,5 @@
+-r requirements.txt
+mock==2.0.0
+requests==2.13.0
+tox==2.7.0
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..50a7927
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+aniso8601==1.2.0
+Flask==0.12.1
+cryptography==2.1.4
+pyOpenSSL==17.0.0
+PyYAML==3.12
+six==1.11.0
+
diff --git a/samples/audio/playlist_demo/playlist.py b/samples/audio/playlist_demo/playlist.py
new file mode 100644
index 0000000..fd493e1
--- /dev/null
+++ b/samples/audio/playlist_demo/playlist.py
@@ -0,0 +1,256 @@
+import collections
+import logging
+import os
+from copy import copy
+
+from flask import Flask, json
+from flask_ask import Ask, question, statement, audio, current_stream, logger
+
+app = Flask(__name__)
+ask = Ask(app, "/")
+logging.getLogger('flask_ask').setLevel(logging.INFO)
+
+
+playlist = [
+ # 'https://www.freesound.org/data/previews/367/367142_2188-lq.mp3',
+ 'https://archive.org/download/mailboxbadgerdrumsamplesvolume2/Ringing.mp3',
+ 'https://archive.org/download/petescott20160927/20160927%20RC300-53-127.0bpm.mp3',
+ 'https://archive.org/download/plpl011/plpl011_05-johnny_ripper-rain.mp3',
+ 'https://archive.org/download/piano_by_maxmsp/beats107.mp3',
+ 'https://archive.org/download/petescott20160927/20160927%20RC300-58-115.1bpm.mp3',
+ 'https://archive.org/download/PianoScale/PianoScale.mp3',
+ # 'https://archive.org/download/FemaleVoiceSample/Female_VoiceTalent_demo.mp4',
+ 'https://archive.org/download/mailboxbadgerdrumsamplesvolume2/Risset%20Drum%201.mp3',
+ 'https://archive.org/download/mailboxbadgerdrumsamplesvolume2/Submarine.mp3',
+ # 'https://ia800203.us.archive.org/27/items/CarelessWhisper_435/CarelessWhisper.ogg'
+]
+
+
+class QueueManager(object):
+ """Manages queue data in a seperate context from current_stream.
+
+ The flask-ask Local current_stream refers only to the current data from Alexa requests and Skill Responses.
+ Alexa Skills Kit does not provide enqueued or stream-histroy data and does not provide a session attribute
+ when delivering AudioPlayer Requests.
+
+ This class is used to maintain accurate control of multiple streams,
+ so that the user may send Intents to move throughout a queue.
+ """
+
+ def __init__(self, urls):
+ self._urls = urls
+ self._queued = collections.deque(urls)
+ self._history = collections.deque()
+ self._current = None
+
+ @property
+ def status(self):
+ status = {
+ 'Current Position': self.current_position,
+ 'Current URL': self.current,
+ 'Next URL': self.up_next,
+ 'Previous': self.previous,
+ 'History': list(self.history)
+ }
+ return status
+
+ @property
+ def up_next(self):
+ """Returns the url at the front of the queue"""
+ qcopy = copy(self._queued)
+ try:
+ return qcopy.popleft()
+ except IndexError:
+ return None
+
+ @property
+ def current(self):
+ return self._current
+
+ @current.setter
+ def current(self, url):
+ self._save_to_history()
+ self._current = url
+
+ @property
+ def history(self):
+ return self._history
+
+ @property
+ def previous(self):
+ history = copy(self.history)
+ try:
+ return history.pop()
+ except IndexError:
+ return None
+
+ def add(self, url):
+ self._urls.append(url)
+ self._queued.append(url)
+
+ def extend(self, urls):
+ self._urls.extend(urls)
+ self._queued.extend(urls)
+
+ def _save_to_history(self):
+ if self._current:
+ self._history.append(self._current)
+
+ def end_current(self):
+ self._save_to_history()
+ self._current = None
+
+ def step(self):
+ self.end_current()
+ self._current = self._queued.popleft()
+ return self._current
+
+ def step_back(self):
+ self._queued.appendleft(self._current)
+ self._current = self._history.pop()
+ return self._current
+
+ def reset(self):
+ self._queued = collections.deque(self._urls)
+ self._history = []
+
+ def start(self):
+ self.__init__(self._urls)
+ return self.step()
+
+ @property
+ def current_position(self):
+ return len(self._history) + 1
+
+
+queue = QueueManager(playlist)
+
+
+@ask.launch
+def launch():
+ card_title = 'Playlist Example'
+ text = 'Welcome to an example for playing a playlist. You can ask me to start the playlist.'
+ prompt = 'You can ask start playlist.'
+ return question(text).reprompt(prompt).simple_card(card_title, text)
+
+
+@ask.intent('PlaylistDemoIntent')
+def start_playlist():
+ speech = 'Heres a playlist of some sounds. You can ask me Next, Previous, or Start Over'
+ stream_url = queue.start()
+ return audio(speech).play(stream_url)
+
+
+# QueueManager object is not stepped forward here.
+# This allows for Next Intents and on_playback_finished requests to trigger the step
+@ask.on_playback_nearly_finished()
+def nearly_finished():
+ if queue.up_next:
+ _infodump('Alexa is now ready for a Next or Previous Intent')
+ # dump_stream_info()
+ next_stream = queue.up_next
+ _infodump('Enqueueing {}'.format(next_stream))
+ return audio().enqueue(next_stream)
+ else:
+ _infodump('Nearly finished with last song in playlist')
+
+
+@ask.on_playback_finished()
+def play_back_finished():
+ _infodump('Finished Audio stream for track {}'.format(queue.current_position))
+ if queue.up_next:
+ queue.step()
+ _infodump('stepped queue forward')
+ dump_stream_info()
+ else:
+ return statement('You have reached the end of the playlist!')
+
+
+# NextIntent steps queue forward and clears enqueued streams that were already sent to Alexa
+# next_stream will match queue.up_next and enqueue Alexa with the correct subsequent stream.
+@ask.intent('AMAZON.NextIntent')
+def next_song():
+ if queue.up_next:
+ speech = 'playing next queued song'
+ next_stream = queue.step()
+ _infodump('Stepped queue forward to {}'.format(next_stream))
+ dump_stream_info()
+ return audio(speech).play(next_stream)
+ else:
+ return audio('There are no more songs in the queue')
+
+
+@ask.intent('AMAZON.PreviousIntent')
+def previous_song():
+ if queue.previous:
+ speech = 'playing previously played song'
+ prev_stream = queue.step_back()
+ dump_stream_info()
+ return audio(speech).play(prev_stream)
+
+ else:
+ return audio('There are no songs in your playlist history.')
+
+
+@ask.intent('AMAZON.StartOverIntent')
+def restart_track():
+ if queue.current:
+ speech = 'Restarting current track'
+ dump_stream_info()
+ return audio(speech).play(queue.current, offset=0)
+ else:
+ return statement('There is no current song')
+
+
+@ask.on_playback_started()
+def started(offset, token, url):
+ _infodump('Started audio stream for track {}'.format(queue.current_position))
+ dump_stream_info()
+
+
+@ask.on_playback_stopped()
+def stopped(offset, token):
+ _infodump('Stopped audio stream for track {}'.format(queue.current_position))
+
+@ask.intent('AMAZON.PauseIntent')
+def pause():
+ seconds = current_stream.offsetInMilliseconds / 1000
+ msg = 'Paused the Playlist on track {}, offset at {} seconds'.format(
+ queue.current_position, seconds)
+ _infodump(msg)
+ dump_stream_info()
+ return audio(msg).stop().simple_card(msg)
+
+
+@ask.intent('AMAZON.ResumeIntent')
+def resume():
+ seconds = current_stream.offsetInMilliseconds / 1000
+ msg = 'Resuming the Playlist on track {}, offset at {} seconds'.format(queue.current_position, seconds)
+ _infodump(msg)
+ dump_stream_info()
+ return audio(msg).resume().simple_card(msg)
+
+
+@ask.session_ended
+def session_ended():
+ return "{}", 200
+
+def dump_stream_info():
+ status = {
+ 'Current Stream Status': current_stream.__dict__,
+ 'Queue status': queue.status
+ }
+ _infodump(status)
+
+
+def _infodump(obj, indent=2):
+ msg = json.dumps(obj, indent=indent)
+ logger.info(msg)
+
+
+if __name__ == '__main__':
+ if 'ASK_VERIFY_REQUESTS' in os.environ:
+ verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower()
+ if verify == 'false':
+ app.config['ASK_VERIFY_REQUESTS'] = False
+ app.run(debug=True)
diff --git a/samples/audio/playlist_demo/speech_assets/IntentSchema.json b/samples/audio/playlist_demo/speech_assets/IntentSchema.json
new file mode 100644
index 0000000..0c699c4
--- /dev/null
+++ b/samples/audio/playlist_demo/speech_assets/IntentSchema.json
@@ -0,0 +1,16 @@
+{
+ "intents": [
+ {
+ "intent": "AMAZON.PauseIntent"
+ },
+ {
+ "intent": "PlaylistDemoIntent"
+ },
+ {
+ "intent": "AMAZON.StopIntent"
+ },
+ {
+ "intent": "AMAZON.ResumeIntent"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/samples/audio/playlist_demo/speech_assets/SampleUtterances.txt b/samples/audio/playlist_demo/speech_assets/SampleUtterances.txt
new file mode 100644
index 0000000..167d52f
--- /dev/null
+++ b/samples/audio/playlist_demo/speech_assets/SampleUtterances.txt
@@ -0,0 +1 @@
+PlaylistDemoIntent start the playlist
\ No newline at end of file
diff --git a/samples/audio/simple_demo/ask_audio.py b/samples/audio/simple_demo/ask_audio.py
new file mode 100644
index 0000000..edaec93
--- /dev/null
+++ b/samples/audio/simple_demo/ask_audio.py
@@ -0,0 +1,89 @@
+import logging
+import os
+
+from flask import Flask, json, render_template
+from flask_ask import Ask, request, session, question, statement, context, audio, current_stream
+
+app = Flask(__name__)
+ask = Ask(app, "/")
+logger = logging.getLogger()
+logging.getLogger('flask_ask').setLevel(logging.INFO)
+
+
+@ask.launch
+def launch():
+ card_title = 'Audio Example'
+ text = 'Welcome to an audio example. You can ask to begin demo, or try asking me to play the sax.'
+ prompt = 'You can ask to begin demo, or try asking me to play the sax.'
+ return question(text).reprompt(prompt).simple_card(card_title, text)
+
+
+@ask.intent('DemoIntent')
+def demo():
+ speech = "Here's one of my favorites"
+ stream_url = 'https://www.vintagecomputermusic.com/mp3/s2t9_Computer_Speech_Demonstration.mp3'
+ return audio(speech).play(stream_url, offset=93000)
+
+
+# 'ask audio_skil Play the sax
+@ask.intent('SaxIntent')
+def george_michael():
+ speech = 'yeah you got it!'
+ stream_url = 'https://ia800203.us.archive.org/27/items/CarelessWhisper_435/CarelessWhisper.ogg'
+ return audio(speech).play(stream_url)
+
+
+@ask.intent('AMAZON.PauseIntent')
+def pause():
+ return audio('Paused the stream.').stop()
+
+
+@ask.intent('AMAZON.ResumeIntent')
+def resume():
+ return audio('Resuming.').resume()
+
+@ask.intent('AMAZON.StopIntent')
+def stop():
+ return audio('stopping').clear_queue(stop=True)
+
+
+
+# optional callbacks
+@ask.on_playback_started()
+def started(offset, token):
+ _infodump('STARTED Audio Stream at {} ms'.format(offset))
+ _infodump('Stream holds the token {}'.format(token))
+ _infodump('STARTED Audio stream from {}'.format(current_stream.url))
+
+
+@ask.on_playback_stopped()
+def stopped(offset, token):
+ _infodump('STOPPED Audio Stream at {} ms'.format(offset))
+ _infodump('Stream holds the token {}'.format(token))
+ _infodump('Stream stopped playing from {}'.format(current_stream.url))
+
+
+@ask.on_playback_nearly_finished()
+def nearly_finished():
+ _infodump('Stream nearly finished from {}'.format(current_stream.url))
+
+@ask.on_playback_finished()
+def stream_finished(token):
+ _infodump('Playback has finished for stream with token {}'.format(token))
+
+@ask.session_ended
+def session_ended():
+ return "{}", 200
+
+def _infodump(obj, indent=2):
+ msg = json.dumps(obj, indent=indent)
+ logger.info(msg)
+
+
+if __name__ == '__main__':
+ if 'ASK_VERIFY_REQUESTS' in os.environ:
+ verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower()
+ if verify == 'false':
+ app.config['ASK_VERIFY_REQUESTS'] = False
+ app.run(debug=True)
+
diff --git a/samples/audio/simple_demo/speech_assets/IntentSchema.json b/samples/audio/simple_demo/speech_assets/IntentSchema.json
new file mode 100644
index 0000000..553597f
--- /dev/null
+++ b/samples/audio/simple_demo/speech_assets/IntentSchema.json
@@ -0,0 +1,19 @@
+{
+ "intents": [
+ {
+ "intent": "AMAZON.PauseIntent"
+ },
+ {
+ "intent": "DemoIntent"
+ },
+ {
+ "intent": "SaxIntent"
+ },
+ {
+ "intent": "AMAZON.StopIntent"
+ },
+ {
+ "intent": "AMAZON.ResumeIntent"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/samples/audio/simple_demo/speech_assets/SampleUtterances.txt b/samples/audio/simple_demo/speech_assets/SampleUtterances.txt
new file mode 100644
index 0000000..98fdc7d
--- /dev/null
+++ b/samples/audio/simple_demo/speech_assets/SampleUtterances.txt
@@ -0,0 +1,3 @@
+DemoIntent begin demo
+SaxIntent play the sax
+SaxIntent play sax
\ No newline at end of file
diff --git a/samples/blueprint_demo/demo.py b/samples/blueprint_demo/demo.py
new file mode 100644
index 0000000..746c14c
--- /dev/null
+++ b/samples/blueprint_demo/demo.py
@@ -0,0 +1,18 @@
+import logging
+import os
+
+from flask import Flask
+from helloworld import blueprint
+
+app = Flask(__name__)
+app.register_blueprint(blueprint)
+
+logging.getLogger('flask_app').setLevel(logging.DEBUG)
+
+
+if __name__ == '__main__':
+ if 'ASK_VERIFY_REQUESTS' in os.environ:
+ verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower()
+ if verify == 'false':
+ app.config['ASK_VERIFY_REQUESTS'] = False
+ app.run(debug=True)
diff --git a/samples/blueprint_demo/helloworld.py b/samples/blueprint_demo/helloworld.py
new file mode 100644
index 0000000..55e6405
--- /dev/null
+++ b/samples/blueprint_demo/helloworld.py
@@ -0,0 +1,34 @@
+import logging
+
+from flask import Blueprint, render_template
+from flask_ask import Ask, question, statement
+
+
+blueprint = Blueprint('blueprint_api', __name__, url_prefix="/ask")
+ask = Ask(blueprint=blueprint)
+
+logging.getLogger('flask_ask').setLevel(logging.DEBUG)
+
+
+@ask.launch
+def launch():
+ speech_text = render_template('welcome')
+ return question(speech_text).reprompt(speech_text).simple_card('HelloWorld', speech_text)
+
+
+@ask.intent('HelloWorldIntent')
+def hello_world():
+ speech_text = render_template('hello')
+ return statement(speech_text).simple_card('HelloWorld', speech_text)
+
+
+@ask.intent('AMAZON.HelpIntent')
+def help():
+ speech_text = render_template('help')
+ return question(speech_text).reprompt(speech_text).simple_card('HelloWorld', speech_text)
+
+
+@ask.session_ended
+def session_ended():
+ return "{}", 200
+
diff --git a/samples/blueprint_demo/speech_assets/IntentSchema.json b/samples/blueprint_demo/speech_assets/IntentSchema.json
new file mode 100644
index 0000000..37c2405
--- /dev/null
+++ b/samples/blueprint_demo/speech_assets/IntentSchema.json
@@ -0,0 +1,10 @@
+{
+ "intents": [
+ {
+ "intent": "HelloWorldIntent"
+ },
+ {
+ "intent": "AMAZON.HelpIntent"
+ }
+ ]
+}
diff --git a/samples/blueprint_demo/speech_assets/SampleUtterances.txt b/samples/blueprint_demo/speech_assets/SampleUtterances.txt
new file mode 100644
index 0000000..d9f178e
--- /dev/null
+++ b/samples/blueprint_demo/speech_assets/SampleUtterances.txt
@@ -0,0 +1,7 @@
+HelloWorldIntent say hello
+HelloWorldIntent say hello world
+HelloWorldIntent hello
+HelloWorldIntent say hi
+HelloWorldIntent say hi world
+HelloWorldIntent hi
+HelloWorldIntent how are you
diff --git a/samples/blueprint_demo/templates.yaml b/samples/blueprint_demo/templates.yaml
new file mode 100644
index 0000000..81996fb
--- /dev/null
+++ b/samples/blueprint_demo/templates.yaml
@@ -0,0 +1,3 @@
+welcome: "Welcome to the Alexa Skills Kit, you can say hello"
+hello: "Hello world!"
+help: "You can say hello to me!"
\ No newline at end of file
diff --git a/samples/helloworld/helloworld.py b/samples/helloworld/helloworld.py
new file mode 100644
index 0000000..0daba43
--- /dev/null
+++ b/samples/helloworld/helloworld.py
@@ -0,0 +1,41 @@
+import logging
+import os
+
+from flask import Flask
+from flask_ask import Ask, request, session, question, statement
+
+
+app = Flask(__name__)
+ask = Ask(app, "/")
+logging.getLogger('flask_ask').setLevel(logging.DEBUG)
+
+
+@ask.launch
+def launch():
+ speech_text = 'Welcome to the Alexa Skills Kit, you can say hello'
+ return question(speech_text).reprompt(speech_text).simple_card('HelloWorld', speech_text)
+
+
+@ask.intent('HelloWorldIntent')
+def hello_world():
+ speech_text = 'Hello world'
+ return statement(speech_text).simple_card('HelloWorld', speech_text)
+
+
+@ask.intent('AMAZON.HelpIntent')
+def help():
+ speech_text = 'You can say hello to me!'
+ return question(speech_text).reprompt(speech_text).simple_card('HelloWorld', speech_text)
+
+
+@ask.session_ended
+def session_ended():
+ return "{}", 200
+
+
+if __name__ == '__main__':
+ if 'ASK_VERIFY_REQUESTS' in os.environ:
+ verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower()
+ if verify == 'false':
+ app.config['ASK_VERIFY_REQUESTS'] = False
+ app.run(debug=True)
diff --git a/samples/helloworld/speech_assets/IntentSchema.json b/samples/helloworld/speech_assets/IntentSchema.json
new file mode 100644
index 0000000..37c2405
--- /dev/null
+++ b/samples/helloworld/speech_assets/IntentSchema.json
@@ -0,0 +1,10 @@
+{
+ "intents": [
+ {
+ "intent": "HelloWorldIntent"
+ },
+ {
+ "intent": "AMAZON.HelpIntent"
+ }
+ ]
+}
diff --git a/samples/helloworld/speech_assets/SampleUtterances.txt b/samples/helloworld/speech_assets/SampleUtterances.txt
new file mode 100644
index 0000000..d9f178e
--- /dev/null
+++ b/samples/helloworld/speech_assets/SampleUtterances.txt
@@ -0,0 +1,7 @@
+HelloWorldIntent say hello
+HelloWorldIntent say hello world
+HelloWorldIntent hello
+HelloWorldIntent say hi
+HelloWorldIntent say hi world
+HelloWorldIntent hi
+HelloWorldIntent how are you
diff --git a/samples/historybuff/historybuff.py b/samples/historybuff/historybuff.py
new file mode 100644
index 0000000..fbe94f2
--- /dev/null
+++ b/samples/historybuff/historybuff.py
@@ -0,0 +1,145 @@
+import logging
+import os
+import re
+from six.moves.urllib.request import urlopen
+
+
+from flask import Flask
+from flask_ask import Ask, request, session, question, statement
+
+
+app = Flask(__name__)
+ask = Ask(app, "/")
+logging.getLogger('flask_ask').setLevel(logging.DEBUG)
+
+
+# URL prefix to download history content from Wikipedia.
+URL_PREFIX = 'https://en.wikipedia.org/w/api.php?action=query&prop=extracts' + \
+ '&format=json&explaintext=&exsectionformat=plain&redirects=&titles='
+
+# Constant defining number of events to be read at one time.
+PAGINATION_SIZE = 3
+
+# Length of the delimiter between individual events.
+DELIMITER_SIZE = 2
+
+# Size of events from Wikipedia response.
+SIZE_OF_EVENTS = 10
+
+# Constant defining session attribute key for the event index
+SESSION_INDEX = 'index'
+
+# Constant defining session attribute key for the event text key for date of events.
+SESSION_TEXT = 'text'
+
+
+@ask.launch
+def launch():
+ speech_output = 'History buff. What day do you want events for?'
+ reprompt_text = "With History Buff, you can get historical events for any day of the year. " + \
+ "For example, you could say today, or August thirtieth. " + \
+ "Now, which day do you want?"
+ return question(speech_output).reprompt(reprompt_text)
+
+
+@ask.intent('GetFirstEventIntent', convert={ 'day': 'date' })
+def get_first_event(day):
+ month_name = day.strftime('%B')
+ day_number = day.day
+ events = _get_json_events_from_wikipedia(month_name, day_number)
+ if not events:
+ speech_output = "There is a problem connecting to Wikipedia at this time. Please try again later."
+ return statement('{}'.format(speech_output))
+ else:
+ card_title = "Events on {} {}".format(month_name, day_number)
+ speech_output = "
For {} {}
".format(month_name, day_number)
+ card_output = ""
+ for i in range(PAGINATION_SIZE):
+ speech_output += "
{}
".format(events[i])
+ card_output += "{}\n".format(events[i])
+ speech_output += " Wanna go deeper into history?"
+ card_output += " Wanna go deeper into history?"
+ reprompt_text = "With History Buff, you can get historical events for any day of the year. " + \
+ "For example, you could say today, or August thirtieth. " + \
+ "Now, which day do you want?"
+ session.attributes[SESSION_INDEX] = PAGINATION_SIZE
+ session.attributes[SESSION_TEXT] = events
+ speech_output = '{}'.format(speech_output)
+ return question(speech_output).reprompt(reprompt_text).simple_card(card_title, card_output)
+
+
+@ask.intent('GetNextEventIntent')
+def get_next_event():
+ events = session.attributes[SESSION_TEXT]
+ index = session.attributes[SESSION_INDEX]
+ card_title = "More events on this day in history"
+ speech_output = ""
+ card_output = ""
+ i = 0
+ while i < PAGINATION_SIZE and index < len(events):
+ speech_output += "
{}
".format(events[index])
+ card_output += "{}\n".format(events[index])
+ i += 1
+ index += 1
+ speech_output += " Wanna go deeper into history?"
+ reprompt_text = "Do you want to know more about what happened on this date?"
+ session.attributes[SESSION_INDEX] = index
+ speech_output = '{}'.format(speech_output)
+ return question(speech_output).reprompt(reprompt_text).simple_card(card_title, card_output)
+
+
+@ask.intent('AMAZON.StopIntent')
+def stop():
+ return statement("Goodbye")
+
+
+@ask.intent('AMAZON.CancelIntent')
+def cancel():
+ return statement("Goodbye")
+
+
+@ask.session_ended
+def session_ended():
+ return "{}", 200
+
+
+def _get_json_events_from_wikipedia(month, date):
+ url = "{}{}_{}".format(URL_PREFIX, month, date)
+ data = urlopen(url).read().decode('utf-8')
+ return _parse_json(data)
+
+
+def _parse_json(text):
+ events = []
+ try:
+ slice_start = text.index("\\nEvents\\n") + SIZE_OF_EVENTS
+ slice_end = text.index("\\n\\n\\nBirths")
+ text = text[slice_start:slice_end];
+ except ValueError:
+ return events
+ start_index = end_index = 0
+ done = False
+ while not done:
+ try:
+ end_index = text.index('\\n', start_index + DELIMITER_SIZE)
+ event_text = text[start_index:end_index]
+ start_index = end_index + 2
+ except ValueError:
+ event_text = text[start_index:]
+ done = True
+ # replace dashes returned in text from Wikipedia's API
+ event_text = event_text.replace('\\u2013', '')
+ # add comma after year so Alexa pauses before continuing with the sentence
+ event_text = re.sub('^\d+', r'\g<0>,', event_text)
+ events.append(event_text)
+ events.reverse()
+ return events
+
+
+if __name__ == '__main__':
+ if 'ASK_VERIFY_REQUESTS' in os.environ:
+ verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower()
+ if verify == 'false':
+ app.config['ASK_VERIFY_REQUESTS'] = False
+ app.run(debug=True)
+
diff --git a/samples/historybuff/speech_assets/IntentSchema.json b/samples/historybuff/speech_assets/IntentSchema.json
new file mode 100644
index 0000000..8979d54
--- /dev/null
+++ b/samples/historybuff/speech_assets/IntentSchema.json
@@ -0,0 +1,25 @@
+{
+ "intents": [
+ {
+ "intent": "GetFirstEventIntent",
+ "slots": [
+ {
+ "name": "day",
+ "type": "AMAZON.DATE"
+ }
+ ]
+ },
+ {
+ "intent": "GetNextEventIntent"
+ },
+ {
+ "intent": "AMAZON.HelpIntent"
+ },
+ {
+ "intent": "AMAZON.StopIntent"
+ },
+ {
+ "intent": "AMAZON.CancelIntent"
+ }
+ ]
+}
diff --git a/samples/historybuff/speech_assets/SampleUtterances.txt b/samples/historybuff/speech_assets/SampleUtterances.txt
new file mode 100644
index 0000000..56ef054
--- /dev/null
+++ b/samples/historybuff/speech_assets/SampleUtterances.txt
@@ -0,0 +1,15 @@
+GetFirstEventIntent get events for {day}
+GetFirstEventIntent give me events for {day}
+GetFirstEventIntent what happened on {day}
+GetFirstEventIntent what happened
+GetFirstEventIntent {day}
+
+GetNextEventIntent yes
+GetNextEventIntent yup
+GetNextEventIntent sure
+GetNextEventIntent yes please
+
+AMAZON.StopIntent no
+AMAZON.StopIntent nope
+AMAZON.StopIntent no thanks
+AMAZON.StopIntent no thank you
diff --git a/samples/purchase/IntentSchema.json b/samples/purchase/IntentSchema.json
new file mode 100644
index 0000000..80b813f
--- /dev/null
+++ b/samples/purchase/IntentSchema.json
@@ -0,0 +1,81 @@
+{
+ "interactionModel": {
+ "languageModel": {
+ "invocationName": "demo",
+ "intents": [
+ {
+ "name": "AMAZON.FallbackIntent",
+ "samples": []
+ },
+ {
+ "name": "AMAZON.CancelIntent",
+ "samples": []
+ },
+ {
+ "name": "AMAZON.HelpIntent",
+ "samples": []
+ },
+ {
+ "name": "AMAZON.StopIntent",
+ "samples": []
+ },
+ {
+ "name": "BuySkillItemIntent",
+ "slots": [
+ {
+ "name": "ProductName",
+ "type": "LIST_OF_PRODUCT_NAMES"
+ }
+ ],
+ "samples": [
+ "{ProductName}",
+ "buy",
+ "shop",
+ "buy {ProductName}",
+ "purchase {ProductName}",
+ "want {ProductName}",
+ "would like {ProductName}"
+ ]
+ },
+ {
+ "name": "RefundSkillItemIntent",
+ "slots": [
+ {
+ "name": "ProductName",
+ "type": "LIST_OF_PRODUCT_NAMES"
+ }
+ ],
+ "samples": [
+ "cancel {ProductName}",
+ "return {ProductName}",
+ "refund {ProductName}",
+ "want a refund for {ProductName}",
+ "would like to return {ProductName}"
+ ]
+ }
+ ],
+ "types": [
+ {
+ "name": "LIST_OF_PRODUCT_NAMES",
+ "values": [
+ {
+ "name": {
+ "value": "monthly subscription"
+ }
+ },
+ {
+ "name": {
+ "value": "start smoking"
+ }
+ },
+ {
+ "name": {
+ "value": "stop smoking"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/samples/purchase/model.py b/samples/purchase/model.py
new file mode 100644
index 0000000..957033c
--- /dev/null
+++ b/samples/purchase/model.py
@@ -0,0 +1,79 @@
+import requests
+from flask import json
+from flask_ask import logger
+
+class Product():
+ '''
+ Object model for inSkillProducts and methods to access products.
+
+ {"inSkillProducts":[
+ {"productId":"amzn1.adg.product.your_product_id",
+ "referenceName":"product_name",
+ "type":"ENTITLEMENT",
+ "name":"product name",
+ "summary":"This product has helped many people.",
+ "entitled":"NOT_ENTITLED",
+ "purchasable":"NOT_PURCHASABLE"}],
+ "nextToken":null,
+ "truncated":false}
+
+ '''
+
+ def __init__(self, apiAccessToken):
+ self.token = apiAccessToken
+ self.product_list = self.query()
+
+
+ def query(self):
+ # Information required to invoke the API is available in the session
+ apiEndpoint = "https://api.amazonalexa.com"
+ apiPath = "/v1/users/~current/skills/~current/inSkillProducts"
+ token = "bearer " + self.token
+ language = "en-US" #self.event.request.locale
+
+ url = apiEndpoint + apiPath
+ headers = {
+ "Content-Type" : 'application/json',
+ "Accept-Language" : language,
+ "Authorization" : token
+ }
+ #Call the API
+ res = requests.get(url, headers=headers)
+ logger.info('PRODUCTS:' + '*' * 80)
+ logger.info(res.status_code)
+ logger.info(res.text)
+ if res.status_code == 200:
+ data = json.loads(res.text)
+ return data['inSkillProducts']
+ else:
+ return None
+
+ def list(self):
+ """ return list of purchasable and not entitled products"""
+ mylist = []
+ for prod in self.product_list:
+ if self.purchasable(prod) and not self.entitled(prod):
+ mylist.append(prod)
+ return mylist
+
+ def purchasable(self, product):
+ """ return True if purchasable product"""
+ return 'PURCHASABLE' == product['purchasable']
+
+ def entitled(self, product):
+ """ return True if entitled product"""
+ return 'ENTITLED' == product['entitled']
+
+
+ def productId(self, name):
+ print(self.product_list)
+ for prod in self.product_list:
+ if name == prod['name'].lower():
+ return prod['productId']
+ return None
+
+ def productName(self, id):
+ for prod in self.product_list:
+ if id == prod['productId']:
+ return prod['name']
+ return None
diff --git a/samples/purchase/purchase.py b/samples/purchase/purchase.py
new file mode 100644
index 0000000..ba03a27
--- /dev/null
+++ b/samples/purchase/purchase.py
@@ -0,0 +1,88 @@
+import logging
+import os
+import requests
+
+from flask import Flask, json, render_template
+from flask_ask import Ask, request, session, question, statement, context, buy, upsell, refund, logger
+from model import Product
+
+app = Flask(__name__)
+ask = Ask(app, "/")
+logging.getLogger('flask_ask').setLevel(logging.DEBUG)
+
+
+PRODUCT_KEY = "PRODUCT"
+
+
+
+@ask.on_purchase_completed( mapping={'payload': 'payload','name':'name','status':'status','token':'token'})
+def completed(payload, name, status, token):
+ products = Product(context.System.apiAccessToken)
+ logger.info('on-purchase-completed {}'.format( request))
+ logger.info('payload: {} {}'.format(payload.purchaseResult, payload.productId))
+ logger.info('name: {}'.format(name))
+ logger.info('token: {}'.format(token))
+ logger.info('status: {}'.format( status.code == 200))
+ product_name = products.productName(payload.productId)
+ logger.info('Product name'.format(product_name))
+ if status.code == '200' and ('ACCEPTED' in payload.purchaseResult):
+ return question('To listen it just say - play {} '.format(product_name))
+ else:
+ return question('Do you want to buy another product?')
+
+@ask.launch
+def launch():
+ products = Product(context.System.apiAccessToken)
+ question_text = render_template('welcome', products=products.list())
+ reprompt_text = render_template('welcome_reprompt')
+ return question(question_text).reprompt(reprompt_text).simple_card('Welcome', question_text)
+
+
+@ask.intent('BuySkillItemIntent', mapping={'product_name': 'ProductName'})
+def buy_intent(product_name):
+ products = Product(context.System.apiAccessToken)
+ logger.info("PRODUCT: {}".format(product_name))
+ buy_card = render_template('buy_card', product=product_name)
+ productId = products.productId(product_name)
+ if productId is not None:
+ session.attributes[PRODUCT_KEY] = productId
+ else:
+ return statement("I didn't find a product {}".format(product_name))
+ raise NotImplementedError()
+ return buy(productId).simple_card('Welcome', question_text)
+
+ #return upsell(product,'get this great product')
+
+
+@ask.intent('RefundSkillItemIntent', mapping={'product_name': 'ProductName'})
+def refund_intent(product_name):
+ refund_card = render_template('refund_card')
+ logger.info("PRODUCT: {}".format(product_name))
+
+ products = Product(context.System.apiAccessToken)
+ productId = products.productId(product_name)
+
+ if productId is not None:
+ session.attributes[PRODUCT_KEY] = productId
+ else:
+ raise NotImplementedError()
+ return refund(productId)
+
+
+@ask.intent('AMAZON.FallbackIntent')
+def fallback_intent():
+ return statement("FallbackIntent")
+
+
+@ask.session_ended
+def session_ended():
+ return "{}", 200
+
+
+if __name__ == '__main__':
+ if 'ASK_VERIFY_REQUESTS' in os.environ:
+ verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower()
+ if verify == 'false':
+ app.config['ASK_VERIFY_REQUESTS'] = False
+ app.run(debug=True)
+
diff --git a/samples/purchase/templates.yaml b/samples/purchase/templates.yaml
new file mode 100644
index 0000000..7b3d637
--- /dev/null
+++ b/samples/purchase/templates.yaml
@@ -0,0 +1,21 @@
+welcome: |
+ Welcome to the Flask-ask purchase demo.
+ {% if products %}
+ Here is a list of products available:
+ {%for product in products%}
+ {{ product.name}},
+ {%endfor %}
+ Please tell me the product name you want to buy.
+ {%else%}
+ You have no products configured. Please configure products using ASK CLI.
+ {%endif%}
+
+
+welcome_reprompt: Please tell me the product name you want to buy.
+
+refund_card: |
+ Refund Intent for {{product}}
+
+
+buy_card: |
+ Buy Intent for {{product}}
diff --git a/samples/session/session.py b/samples/session/session.py
new file mode 100644
index 0000000..0cd3927
--- /dev/null
+++ b/samples/session/session.py
@@ -0,0 +1,60 @@
+import logging
+import os
+
+from flask import Flask, json, render_template
+from flask_ask import Ask, request, session, question, statement
+
+
+app = Flask(__name__)
+ask = Ask(app, "/")
+logging.getLogger('flask_ask').setLevel(logging.DEBUG)
+
+
+COLOR_KEY = "COLOR"
+
+
+@ask.launch
+def launch():
+ card_title = render_template('card_title')
+ question_text = render_template('welcome')
+ reprompt_text = render_template('welcome_reprompt')
+ return question(question_text).reprompt(reprompt_text).simple_card(card_title, question_text)
+
+
+@ask.intent('MyColorIsIntent', mapping={'color': 'Color'})
+def my_color_is(color):
+ card_title = render_template('card_title')
+ if color is not None:
+ session.attributes[COLOR_KEY] = color
+ question_text = render_template('known_color', color=color)
+ reprompt_text = render_template('known_color_reprompt')
+ else:
+ question_text = render_template('unknown_color')
+ reprompt_text = render_template('unknown_color_reprompt')
+ return question(question_text).reprompt(reprompt_text).simple_card(card_title, question_text)
+
+
+@ask.intent('WhatsMyColorIntent')
+def whats_my_color():
+ card_title = render_template('card_title')
+ color = session.attributes.get(COLOR_KEY)
+ if color is not None:
+ statement_text = render_template('known_color_bye', color=color)
+ return statement(statement_text).simple_card(card_title, statement_text)
+ else:
+ question_text = render_template('unknown_color_reprompt')
+ return question(question_text).reprompt(question_text).simple_card(card_title, question_text)
+
+
+@ask.session_ended
+def session_ended():
+ return "{}", 200
+
+
+if __name__ == '__main__':
+ if 'ASK_VERIFY_REQUESTS' in os.environ:
+ verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower()
+ if verify == 'false':
+ app.config['ASK_VERIFY_REQUESTS'] = False
+ app.run(debug=True)
+
diff --git a/samples/session/speech_assets/IntentSchema.json b/samples/session/speech_assets/IntentSchema.json
new file mode 100644
index 0000000..a8e06f2
--- /dev/null
+++ b/samples/session/speech_assets/IntentSchema.json
@@ -0,0 +1,16 @@
+{
+ "intents": [
+ {
+ "intent": "MyColorIsIntent",
+ "slots": [
+ {
+ "name": "Color",
+ "type": "LIST_OF_COLORS"
+ }
+ ]
+ },
+ {
+ "intent": "WhatsMyColorIntent"
+ }
+ ]
+}
diff --git a/samples/session/speech_assets/SampleUtterances.txt b/samples/session/speech_assets/SampleUtterances.txt
new file mode 100644
index 0000000..6d4ae18
--- /dev/null
+++ b/samples/session/speech_assets/SampleUtterances.txt
@@ -0,0 +1,11 @@
+MyColorIsIntent my color is {Color}
+MyColorIsIntent my favorite color is {Color}
+WhatsMyColorIntent whats my color
+WhatsMyColorIntent what is my color
+WhatsMyColorIntent say my color
+WhatsMyColorIntent tell me my color
+WhatsMyColorIntent whats my favorite color
+WhatsMyColorIntent what is my favorite color
+WhatsMyColorIntent say my favorite color
+WhatsMyColorIntent tell me my favorite color
+WhatsMyColorIntent tell me what my favorite color is
diff --git a/samples/session/speech_assets/customSlotTypes/LIST_OF_COLORS b/samples/session/speech_assets/customSlotTypes/LIST_OF_COLORS
new file mode 100644
index 0000000..f9e6bcf
--- /dev/null
+++ b/samples/session/speech_assets/customSlotTypes/LIST_OF_COLORS
@@ -0,0 +1,6 @@
+green
+blue
+purple
+red
+orange
+yellow
diff --git a/samples/session/templates.yaml b/samples/session/templates.yaml
new file mode 100644
index 0000000..454528f
--- /dev/null
+++ b/samples/session/templates.yaml
@@ -0,0 +1,21 @@
+welcome: |
+ Welcome to the Alexa Skills Kit sample. Please tell me your favorite color by
+ saying, my favorite color is red
+
+welcome_reprompt: Please tell me your favorite color by saying, my favorite color is red
+
+known_color: |
+ I now know that your favorite color is {{ color }}. You can ask me your favorite color
+ by saying, what's my favorite color?
+
+known_color_reprompt: You can ask me your favorite color by saying, what's my favorite color?
+
+known_color_bye: Your favorite color is {{ color }}. Goodbye
+
+unknown_color: I'm not sure what your favorite color is, please try again
+
+unknown_color_reprompt: |
+ I'm not sure what your favorite color is. You can tell me your favorite color by saying,
+ my favorite color is red
+
+card_title: Session
diff --git a/samples/spacegeek/spacegeek.py b/samples/spacegeek/spacegeek.py
new file mode 100644
index 0000000..4f2153f
--- /dev/null
+++ b/samples/spacegeek/spacegeek.py
@@ -0,0 +1,56 @@
+import logging
+import os
+from random import randint
+
+from flask import Flask, render_template
+from flask_ask import Ask, request, session, question, statement
+
+
+app = Flask(__name__)
+ask = Ask(app, "/")
+logging.getLogger('flask_ask').setLevel(logging.DEBUG)
+
+
+@ask.launch
+def launch():
+ return get_new_fact()
+
+
+@ask.intent('GetNewFactIntent')
+def get_new_fact():
+ num_facts = 13 # increment this when adding a new fact template
+ fact_index = randint(0, num_facts-1)
+ fact_text = render_template('space_fact_{}'.format(fact_index))
+ card_title = render_template('card_title')
+ return statement(fact_text).simple_card(card_title, fact_text)
+
+
+@ask.intent('AMAZON.HelpIntent')
+def help():
+ help_text = render_template('help')
+ return question(help_text).reprompt(help_text)
+
+
+@ask.intent('AMAZON.StopIntent')
+def stop():
+ bye_text = render_template('bye')
+ return statement(bye_text)
+
+
+@ask.intent('AMAZON.CancelIntent')
+def cancel():
+ bye_text = render_template('bye')
+ return statement(bye_text)
+
+
+@ask.session_ended
+def session_ended():
+ return "{}", 200
+
+
+if __name__ == '__main__':
+ if 'ASK_VERIFY_REQUESTS' in os.environ:
+ verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower()
+ if verify == 'false':
+ app.config['ASK_VERIFY_REQUESTS'] = False
+ app.run(debug=True)
diff --git a/samples/spacegeek/speech_assets/IntentSchema.json b/samples/spacegeek/speech_assets/IntentSchema.json
new file mode 100644
index 0000000..dfdf9ba
--- /dev/null
+++ b/samples/spacegeek/speech_assets/IntentSchema.json
@@ -0,0 +1,16 @@
+{
+ "intents": [
+ {
+ "intent": "GetNewFactIntent"
+ },
+ {
+ "intent": "AMAZON.HelpIntent"
+ },
+ {
+ "intent": "AMAZON.StopIntent"
+ },
+ {
+ "intent": "AMAZON.CancelIntent"
+ }
+ ]
+}
diff --git a/samples/spacegeek/speech_assets/SampleUtterances.txt b/samples/spacegeek/speech_assets/SampleUtterances.txt
new file mode 100644
index 0000000..9d3e6c6
--- /dev/null
+++ b/samples/spacegeek/speech_assets/SampleUtterances.txt
@@ -0,0 +1,14 @@
+GetNewFactIntent a fact
+GetNewFactIntent a space fact
+GetNewFactIntent tell me a fact
+GetNewFactIntent tell me a space fact
+GetNewFactIntent give me a fact
+GetNewFactIntent give me a space fact
+GetNewFactIntent tell me trivia
+GetNewFactIntent tell me a space trivia
+GetNewFactIntent give me trivia
+GetNewFactIntent give me a space trivia
+GetNewFactIntent give me some information
+GetNewFactIntent give me some space information
+GetNewFactIntent tell me something
+GetNewFactIntent give me something
diff --git a/samples/spacegeek/templates.yaml b/samples/spacegeek/templates.yaml
new file mode 100644
index 0000000..930ea0b
--- /dev/null
+++ b/samples/spacegeek/templates.yaml
@@ -0,0 +1,16 @@
+space_fact_0: A year on Mercury is just 88 days long.
+space_fact_1: Despite being farther from the Sun, Venus experiences higher temperatures than Mercury.
+space_fact_2: Venus rotates counter-clockwise, possibly because of a collision in the past with an asteroid.
+space_fact_3: On Mars, the Sun appears about half the size as it does on Earth.
+space_fact_4: Earth is the only planet not named after a god.
+space_fact_5: Jupiter has the shortest day of all the planets.
+space_fact_6: The Milky Way galaxy will collide with the Andromeda Galaxy in about 5 billion years.
+space_fact_7: The Sun contains 99.86% of the mass in the Solar System.
+space_fact_8: The Sun is an almost perfect sphere.
+space_fact_9: A total solar eclipse can happen once every 1 to 2 years. This makes them a rare event.
+space_fact_10: Saturn radiates two and a half times more energy into space than it receives from the sun.
+space_fact_11: The temperature inside the Sun can reach 15 million degrees Celsius.
+space_fact_12: The Moon is moving approximately 3.8 cm away from our planet every year.
+card_title: SpaceGeek
+help: You can ask Space Geek tell me a space fact, or, you can say exit. What can I help you with?
+bye: Goodbye
diff --git a/samples/tidepooler/speech_assets/IntentSchema.json b/samples/tidepooler/speech_assets/IntentSchema.json
new file mode 100644
index 0000000..a428bdd
--- /dev/null
+++ b/samples/tidepooler/speech_assets/IntentSchema.json
@@ -0,0 +1,50 @@
+{
+ "intents": [
+ {
+ "intent": "OneshotTideIntent",
+ "slots": [
+ {
+ "name": "City",
+ "type": "LIST_OF_CITIES"
+ },
+ {
+ "name": "State",
+ "type": "LIST_OF_STATES"
+ },
+ {
+ "name": "Date",
+ "type": "AMAZON.DATE"
+ }
+ ]
+ },
+ {
+ "intent": "DialogTideIntent",
+ "slots": [
+ {
+ "name": "City",
+ "type": "LIST_OF_CITIES"
+ },
+ {
+ "name": "State",
+ "type": "LIST_OF_STATES"
+ },
+ {
+ "name": "Date",
+ "type": "AMAZON.DATE"
+ }
+ ]
+ },
+ {
+ "intent": "SupportedCitiesIntent"
+ },
+ {
+ "intent": "AMAZON.HelpIntent"
+ },
+ {
+ "intent": "AMAZON.StopIntent"
+ },
+ {
+ "intent": "AMAZON.CancelIntent"
+ }
+ ]
+}
diff --git a/samples/tidepooler/speech_assets/SampleUtterances.txt b/samples/tidepooler/speech_assets/SampleUtterances.txt
new file mode 100644
index 0000000..afee81d
--- /dev/null
+++ b/samples/tidepooler/speech_assets/SampleUtterances.txt
@@ -0,0 +1,92 @@
+DialogTideIntent {City}
+DialogTideIntent {City} {State}
+DialogTideIntent {Date}
+
+OneshotTideIntent get high tide
+OneshotTideIntent get high tide for {City} {State}
+OneshotTideIntent get high tide for {City} {State} {Date}
+OneshotTideIntent get high tide for {City} {Date}
+OneshotTideIntent get high tide for {Date}
+OneshotTideIntent get high tide {Date}
+OneshotTideIntent get the high tide for {City} {Date}
+OneshotTideIntent get the next tide for {City} for {Date}
+OneshotTideIntent get the tides for {Date}
+OneshotTideIntent get tide information for {City}
+OneshotTideIntent get tide information for {City} {State}
+OneshotTideIntent get tide information for {City} {State} on {Date}
+OneshotTideIntent get tide information for {City} city
+OneshotTideIntent get tide information for {City} for {Date}
+OneshotTideIntent get tide information for {City} on {Date}
+OneshotTideIntent get tide information for {City} {Date}
+OneshotTideIntent get tides for {City}
+OneshotTideIntent get tides for {City} {State}
+OneshotTideIntent get tides for {City} {State} {Date}
+OneshotTideIntent get tides for {City} {Date}
+OneshotTideIntent tide information
+OneshotTideIntent tide information for {City}
+OneshotTideIntent tide information for {City} {State}
+OneshotTideIntent tide information for {City} on {Date}
+OneshotTideIntent tide information for {Date}
+OneshotTideIntent when high tide is
+OneshotTideIntent when is high tide
+OneshotTideIntent when is high tide for {City} {Date}
+OneshotTideIntent when is high tide in {City}
+OneshotTideIntent when is high tide in {City} {State}
+OneshotTideIntent when is high tide in {City} city
+OneshotTideIntent when is high tide in {City} on {Date}
+OneshotTideIntent when is high tide on {Date}
+OneshotTideIntent when is high tide {Date}
+OneshotTideIntent when is next tide
+OneshotTideIntent when is next tide in {City} {State}
+OneshotTideIntent when is next tide in {City} {State} on {Date}
+OneshotTideIntent when is next tide on {Date}
+OneshotTideIntent when is the highest tide in {City}
+OneshotTideIntent when is the highest tide in {City} {Date}
+OneshotTideIntent when is the highest tide {Date}
+OneshotTideIntent when is the next high tide {Date}
+OneshotTideIntent when is the next highest water
+OneshotTideIntent when is the next highest water for {City}
+OneshotTideIntent when is the next highest water for {City} {State} for {Date}
+OneshotTideIntent when is the next highest water for {City} {State}
+OneshotTideIntent when is the next highest water for {Date}
+OneshotTideIntent when is the next tide for {City}
+OneshotTideIntent when is the next tide for {City} {State}
+OneshotTideIntent when is the next tide for {City} city
+OneshotTideIntent when is the next tide for {City} for {Date}
+OneshotTideIntent when is the next tide for {Date}
+OneshotTideIntent when is today's high tide
+OneshotTideIntent when is today's highest tide {Date}
+OneshotTideIntent when will the water be highest for {City}
+OneshotTideIntent when will the water be highest for {City} for {Date}
+OneshotTideIntent when will the water be highest for {Date}
+OneshotTideIntent when will the water be highest {Date}
+OneshotTideIntent when is high tide on {Date}
+OneshotTideIntent when is the next tide for {Date}
+OneshotTideIntent when is next tide on {Date}
+OneshotTideIntent get the tides for {Date}
+OneshotTideIntent when is the highest tide {Date}
+OneshotTideIntent when is the next highest water for {Date}
+OneshotTideIntent when is high tide on {Date}
+OneshotTideIntent get high tide for {Date}
+OneshotTideIntent get high tide {Date}
+OneshotTideIntent when is high tide {Date}
+OneshotTideIntent tide information for {Date}
+OneshotTideIntent when is high tide in {City} on {Date}
+OneshotTideIntent get the next tide for {City} for {Date}
+OneshotTideIntent get high tide for {City} California {Date}
+OneshotTideIntent when is high tide for {City} {Date}
+OneshotTideIntent tide information for {City} on {Date}
+OneshotTideIntent when is high tide in {City} on {Date}
+OneshotTideIntent when is the next tide for {City} for {Date}
+OneshotTideIntent when is next tide in {City} {State} on {Date}
+OneshotTideIntent get high tide for {City} {Date}
+OneshotTideIntent get the high tide for {City} {Date}
+OneshotTideIntent tide information for {City} on {Date}
+OneshotTideIntent get tide information for {City} on {Date}
+OneshotTideIntent get tides for {City} {Date}
+
+SupportedCitiesIntent what cities
+SupportedCitiesIntent what cities are supported
+SupportedCitiesIntent which cities are supported
+SupportedCitiesIntent which cities
+SupportedCitiesIntent which cities do you know
\ No newline at end of file
diff --git a/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_CITIES b/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_CITIES
new file mode 100644
index 0000000..63eb710
--- /dev/null
+++ b/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_CITIES
@@ -0,0 +1,17 @@
+seattle
+los angeles
+monterey
+san diego
+san francisco
+boston
+new york
+miami
+wilmington
+tampa
+galveston
+morehead
+new orleans
+beaufort
+myrtle beach
+virginia beach
+charleston
\ No newline at end of file
diff --git a/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_STATES b/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_STATES
new file mode 100644
index 0000000..3a8b3bb
--- /dev/null
+++ b/samples/tidepooler/speech_assets/customSlotTypes/LIST_OF_STATES
@@ -0,0 +1,10 @@
+california
+florida
+louisiana
+massachusetts
+new york
+north carolina
+south carolina
+texas
+virginia
+washington
\ No newline at end of file
diff --git a/samples/tidepooler/templates.yaml b/samples/tidepooler/templates.yaml
new file mode 100644
index 0000000..3ea2456
--- /dev/null
+++ b/samples/tidepooler/templates.yaml
@@ -0,0 +1,41 @@
+welcome: |
+
+ Welcome to Tide Pooler.
+
+ Which city would you like tide information for?
+
+
+tide_info: |
+ {{ date | humanize_date }} in {{ city }}, the first high tide will be around
+ {{ tideinfo.first_high_tide_time | humanize_time }}, and will peak at about
+ {{ tideinfo.first_high_tide_height | humanize_height }}, followed by a low tide around
+ {{ tideinfo.low_tide_time | humanize_time }}, that will be about
+ {{ tideinfo.low_tide_height | humanize_height }}.
+
+ The second high tide will be around {{ tideinfo.second_high_tide_time | humanize_time }},
+ and will peak at about {{ tideinfo.second_high_tide_height | humanize_height }}
+
+help: |
+ I can lead you through providing a city and day of the week to get tide information, or you can simply open
+ Tide Pooler and ask a question like, get tide information for Seattle on Saturday. For a list of supported
+ cities, ask what cities are supported. Which city would you like tide information for?
+
+list_cities: |
+ Currently, I know tide information for these coastal cities: {{ cities }}
+ Which city would you like tide information for?
+
+list_cities_reprompt: Which city would you like tide information for?
+
+city_dialog: For which city would you like tide information for {{ date | humanize_date }}
+
+city_dialog_reprompt: For which city?
+
+date_dialog: For which date would you like tide information for {{ city }}?
+
+date_dialog_reprompt: For which date?
+
+date_dialog2: Please try again saying a day of the week, for example, Saturday
+
+noaa_problem: Sorry, the National Oceanic tide service is experiencing a problem. Please try again later.
+
+bye: Goodbye
diff --git a/samples/tidepooler/tidepooler.py b/samples/tidepooler/tidepooler.py
new file mode 100644
index 0000000..e051b58
--- /dev/null
+++ b/samples/tidepooler/tidepooler.py
@@ -0,0 +1,298 @@
+import os
+import logging
+import datetime
+import math
+import re
+from six.moves.urllib.request import urlopen
+from six.moves.urllib.parse import urlencode
+
+import aniso8601
+from flask import Flask, json, render_template
+from flask_ask import Ask, request, session, question, statement
+
+
+ENDPOINT = "http://tidesandcurrents.noaa.gov/api/datagetter"
+SESSION_CITY = "city"
+SESSION_DATE = "date"
+
+# NOAA station codes
+STATION_CODE_SEATTLE = "9447130"
+STATION_CODE_SAN_FRANCISCO = "9414290"
+STATION_CODE_MONTEREY = "9413450"
+STATION_CODE_LOS_ANGELES = "9410660"
+STATION_CODE_SAN_DIEGO = "9410170"
+STATION_CODE_BOSTON = "8443970"
+STATION_CODE_NEW_YORK = "8518750"
+STATION_CODE_VIRGINIA_BEACH = "8638863"
+STATION_CODE_WILMINGTON = "8658163"
+STATION_CODE_CHARLESTON = "8665530"
+STATION_CODE_BEAUFORT = "8656483"
+STATION_CODE_MYRTLE_BEACH = "8661070"
+STATION_CODE_MIAMI = "8723214"
+STATION_CODE_TAMPA = "8726667"
+STATION_CODE_NEW_ORLEANS = "8761927"
+STATION_CODE_GALVESTON = "8771341"
+
+STATIONS = {}
+STATIONS["seattle"] = STATION_CODE_SEATTLE
+STATIONS["san francisco"] = STATION_CODE_SAN_FRANCISCO
+STATIONS["monterey"] = STATION_CODE_MONTEREY
+STATIONS["los angeles"] = STATION_CODE_LOS_ANGELES
+STATIONS["san diego"] = STATION_CODE_SAN_DIEGO
+STATIONS["boston"] = STATION_CODE_BOSTON
+STATIONS["new york"] = STATION_CODE_NEW_YORK
+STATIONS["virginia beach"] = STATION_CODE_VIRGINIA_BEACH
+STATIONS["wilmington"] = STATION_CODE_WILMINGTON
+STATIONS["charleston"] = STATION_CODE_CHARLESTON
+STATIONS["beaufort"] = STATION_CODE_BEAUFORT
+STATIONS["myrtle beach"] = STATION_CODE_MYRTLE_BEACH
+STATIONS["miami"] = STATION_CODE_MIAMI
+STATIONS["tampa"] = STATION_CODE_TAMPA
+STATIONS["new orleans"] = STATION_CODE_NEW_ORLEANS
+STATIONS["galveston"] = STATION_CODE_GALVESTON
+
+
+app = Flask(__name__)
+ask = Ask(app, "/")
+logging.getLogger('flask_ask').setLevel(logging.DEBUG)
+
+
+class TideInfo(object):
+
+ def __init__(self):
+ self.first_high_tide_time = None
+ self.first_high_tide_height = None
+ self.low_tide_time = None
+ self.low_tide_height = None
+ self.second_high_tide_time = None
+ self.second_high_tide_height = None
+
+
+@ask.launch
+def launch():
+ welcome_text = render_template('welcome')
+ help_text = render_template('help')
+ return question(welcome_text).reprompt(help_text)
+
+
+@ask.intent('OneshotTideIntent',
+ mapping={'city': 'City', 'date': 'Date'},
+ convert={'date': 'date'},
+ default={'city': 'seattle', 'date': datetime.date.today })
+def one_shot_tide(city, date):
+ if city.lower() not in STATIONS:
+ return supported_cities()
+ return _make_tide_request(city, date)
+
+
+@ask.intent('DialogTideIntent',
+ mapping={'city': 'City', 'date': 'Date'},
+ convert={'date': 'date'})
+def dialog_tide(city, date):
+ if city is not None:
+ if city.lower() not in STATIONS:
+ return supported_cities()
+ if SESSION_DATE not in session.attributes:
+ session.attributes[SESSION_CITY] = city
+ return _dialog_date(city)
+ date = aniso8601.parse_date(session.attributes[SESSION_DATE])
+ return _make_tide_request(city, date)
+ elif date is not None:
+ if SESSION_CITY not in session.attributes:
+ session.attributes[SESSION_DATE] = date.isoformat()
+ return _dialog_city(date)
+ city = session.attributes[SESSION_CITY]
+ return _make_tide_request(city, date)
+ else:
+ return _dialog_no_slot()
+
+
+@ask.intent('SupportedCitiesIntent')
+def supported_cities():
+ cities = ", ".join(sorted(STATIONS.keys()))
+ list_cities_text = render_template('list_cities', cities=cities)
+ list_cities_reprompt_text = render_template('list_cities_reprompt')
+ return question(list_cities_text).reprompt(list_cities_reprompt_text)
+
+
+@ask.intent('AMAZON.HelpIntent')
+def help():
+ help_text = render_template('help')
+ list_cities_reprompt_text = render_template('list_cities_reprompt')
+ return question(help_text).reprompt(list_cities_reprompt_text)
+
+
+@ask.intent('AMAZON.StopIntent')
+def stop():
+ bye_text = render_template('bye')
+ return statement(bye_text)
+
+
+@ask.intent('AMAZON.CancelIntent')
+def cancel():
+ bye_text = render_template('bye')
+ return statement(bye_text)
+
+
+@ask.session_ended
+def session_ended():
+ return "{}", 200
+
+
+@app.template_filter()
+def humanize_date(dt):
+ # http://stackoverflow.com/a/20007730/1163855
+ ordinal = lambda n: "%d%s" % (n,"tsnrhtdd"[(n/10%10!=1)*(n%10<4)*n%10::4])
+ month_and_day_of_week = dt.strftime('%A %B')
+ day_of_month = ordinal(dt.day)
+ year = dt.year if dt.year != datetime.datetime.now().year else ""
+ formatted_date = "{} {} {}".format(month_and_day_of_week, day_of_month, year)
+ formatted_date = re.sub('\s+', ' ', formatted_date)
+ return formatted_date
+
+
+@app.template_filter()
+def humanize_time(dt):
+ morning_threshold = 12
+ afternoon_threshold = 17
+ evening_threshold = 20
+ hour_24 = dt.hour
+ if hour_24 < morning_threshold:
+ period_of_day = "in the morning"
+ elif hour_24 < afternoon_threshold:
+ period_of_day = "in the afternoon"
+ elif hour_24 < evening_threshold:
+ period_of_day = "in the evening"
+ else:
+ period_of_day = " at night"
+ the_time = dt.strftime('%I:%M')
+ formatted_time = "{} {}".format(the_time, period_of_day)
+ return formatted_time
+
+
+@app.template_filter()
+def humanize_height(height):
+ round_down_threshold = 0.25
+ round_to_half_threshold = 0.75
+ is_negative = False
+ if height < 0:
+ height = abs(height)
+ is_negative = True
+ remainder = height % 1
+ if remainder < round_down_threshold:
+ remainder_text = ""
+ feet = int(math.floor(height))
+ elif remainder < round_to_half_threshold:
+ remainder_text = "and a half"
+ feet = int(math.floor(height))
+ else:
+ remainder_text = ""
+ feet = int(math.floor(height))
+ if is_negative:
+ feet *= -1
+ formatted_height = "{} {} feet".format(feet, remainder_text)
+ formatted_height = re.sub('\s+', ' ', formatted_height)
+ return formatted_height
+
+
+def _dialog_no_slot():
+ if SESSION_CITY in session.attributes:
+ date_dialog2_text = render_template('date_dialog2')
+ return question(date_dialog2_text).reprompt(date_dialog2_text)
+ else:
+ return supported_cities()
+
+
+def _dialog_date(city):
+ date_dialog_text = render_template('date_dialog', city=city)
+ date_dialog_reprompt_text = render_template('date_dialog_reprompt')
+ return question(date_dialog_text).reprompt(date_dialog_reprompt_text)
+
+
+def _dialog_city(date):
+ session.attributes[SESSION_DATE] = date
+ session.attributes_encoder = _json_date_handler
+ city_dialog_text = render_template('city_dialog', date=date)
+ city_dialog_reprompt_text = render_template('city_dialog_reprompt')
+ return question(city_dialog_text).reprompt(city_dialog_reprompt_text)
+
+
+def _json_date_handler(obj):
+ if isinstance(obj, datetime.date):
+ return obj.isoformat()
+
+
+def _make_tide_request(city, date):
+ station = STATIONS.get(city.lower())
+ noaa_api_params = {
+ 'station': station,
+ 'product': 'predictions',
+ 'datum': 'MLLW',
+ 'units': 'english',
+ 'time_zone': 'lst_ldt',
+ 'format': 'json'
+ }
+ if date == datetime.date.today():
+ noaa_api_params['date'] = 'today'
+ else:
+ noaa_api_params['begin_date'] = date.strftime('%Y%m%d')
+ noaa_api_params['range'] = 24
+ url = ENDPOINT + "?" + urlencode(noaa_api_params)
+ resp_body = urlopen(url).read()
+ if len(resp_body) == 0:
+ statement_text = render_template('noaa_problem')
+ else:
+ noaa_response_obj = json.loads(resp_body)
+ predictions = noaa_response_obj['predictions']
+ tideinfo = _find_tide_info(predictions)
+ statement_text = render_template('tide_info', date=date, city=city, tideinfo=tideinfo)
+ return statement(statement_text).simple_card("Tide Pooler", statement_text)
+
+
+def _find_tide_info(predictions):
+ """
+ Algorithm to find the 2 high tides for the day, the first of which is smaller and occurs
+ mid-day, the second of which is larger and typically in the evening.
+ """
+
+ last_prediction = None
+ first_high_tide = None
+ second_high_tide = None
+ low_tide = None
+ first_tide_done = False
+ for prediction in predictions:
+ if last_prediction is None:
+ last_prediction = prediction
+ continue
+ if last_prediction['v'] < prediction['v']:
+ if not first_tide_done:
+ first_high_tide = prediction
+ else:
+ second_high_tide = prediction
+ else: # we're decreasing
+ if not first_tide_done and first_high_tide is not None:
+ first_tide_done = True
+ elif second_high_tide is not None:
+ break # we're decreasing after having found the 2nd tide. We're done.
+ if first_tide_done:
+ low_tide = prediction
+ last_prediction = prediction
+
+ fmt = '%Y-%m-%d %H:%M'
+ parse = datetime.datetime.strptime
+ tideinfo = TideInfo()
+ tideinfo.first_high_tide_time = parse(first_high_tide['t'], fmt)
+ tideinfo.first_high_tide_height = float(first_high_tide['v'])
+ tideinfo.second_high_tide_time = parse(second_high_tide['t'], fmt)
+ tideinfo.second_high_tide_height = float(second_high_tide['v'])
+ tideinfo.low_tide_time = parse(low_tide['t'], fmt)
+ tideinfo.low_tide_height = float(low_tide['v'])
+ return tideinfo
+
+
+if __name__ == '__main__':
+ if 'ASK_VERIFY_REQUESTS' in os.environ:
+ verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower()
+ if verify == 'false':
+ app.config['ASK_VERIFY_REQUESTS'] = False
+ app.run(debug=True)
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..1eee7db
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,5 @@
+[bdist_wheel]
+universal = 1
+
+[metadata]
+description-file = README.md
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..17031ea
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,43 @@
+"""
+Flask-Ask
+-------------
+
+Easy Alexa Skills Kit integration for Flask
+"""
+from setuptools import setup
+
+def parse_requirements(filename):
+ """ load requirements from a pip requirements file """
+ lineiter = (line.strip() for line in open(filename))
+ return [line for line in lineiter if line and not line.startswith("#")]
+
+setup(
+ name='Flask-Ask',
+ version='0.9.7',
+ url='https://github.com/johnwheeler/flask-ask',
+ license='Apache 2.0',
+ author='John Wheeler',
+ author_email='john@johnwheeler.org',
+ description='Rapid Alexa Skills Kit Development for Amazon Echo Devices in Python',
+ long_description=__doc__,
+ packages=['flask_ask'],
+ zip_safe=False,
+ include_package_data=True,
+ platforms='any',
+ install_requires=parse_requirements('requirements.txt'),
+ test_requires=[
+ 'mock',
+ 'requests'
+ ],
+ test_suite='tests',
+ classifiers=[
+ 'License :: OSI Approved :: Apache Software License',
+ 'Framework :: Flask',
+ 'Programming Language :: Python',
+ 'Environment :: Web Environment',
+ 'Intended Audience :: Developers',
+ 'Operating System :: OS Independent',
+ 'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
+ 'Topic :: Software Development :: Libraries :: Python Modules'
+ ]
+)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_audio.py b/tests/test_audio.py
new file mode 100644
index 0000000..310d082
--- /dev/null
+++ b/tests/test_audio.py
@@ -0,0 +1,67 @@
+import unittest
+from mock import patch, MagicMock
+from flask import Flask
+from flask_ask import Ask, audio
+from flask_ask.models import _Field
+
+
+class AudioUnitTests(unittest.TestCase):
+
+ def setUp(self):
+ self.ask_patcher = patch('flask_ask.core.find_ask', return_value=Ask())
+ self.ask_patcher.start()
+ self.context_patcher = patch('flask_ask.models.context', return_value=MagicMock())
+ self.context_patcher.start()
+
+ def tearDown(self):
+ self.ask_patcher.stop()
+ self.context_patcher.stop()
+
+ def test_token_generation(self):
+ """ Confirm we get a new token when setting a stream url """
+ audio_item = audio()._audio_item(stream_url='https://fakestream', offset=123)
+ self.assertEqual(36, len(audio_item['stream']['token']))
+ self.assertEqual(123, audio_item['stream']['offsetInMilliseconds'])
+
+ def test_custom_token(self):
+ """ Check to see that the provided opaque token remains constant"""
+ token = "hello_world"
+ audio_item = audio()._audio_item(stream_url='https://fakestream', offset=10, opaque_token=token)
+ self.assertEqual(token, audio_item['stream']['token'])
+ self.assertEqual(10, audio_item['stream']['offsetInMilliseconds'])
+
+
+class AskStreamHandlingTests(unittest.TestCase):
+
+ def setUp(self):
+ fake_context = {'System': {'user': {'userId': 'dave'}}}
+ self.context_patcher = patch.object(Ask, 'context', return_value=fake_context)
+ self.context_patcher.start()
+ self.request_patcher = patch.object(Ask, 'request', return_value=MagicMock())
+ self.request_patcher.start()
+
+ def tearDown(self):
+ self.context_patcher.stop()
+ self.request_patcher.stop()
+
+ def test_setting_and_getting_current_stream(self):
+ ask = Ask()
+ with patch('flask_ask.core.find_ask', return_value=ask):
+ self.assertEqual(_Field(), ask.current_stream)
+
+ stream = _Field()
+ stream.__dict__.update({'token': 'asdf', 'offsetInMilliseconds': 123, 'url': 'junk'})
+ with patch('flask_ask.core.top_stream', return_value=stream):
+ self.assertEqual(stream, ask.current_stream)
+
+ def test_from_directive_call(self):
+ ask = Ask()
+ fake_stream = _Field()
+ fake_stream.__dict__.update({'token':'fake'})
+ with patch('flask_ask.core.top_stream', return_value=fake_stream):
+ from_buffer = ask._from_directive()
+ self.assertEqual(fake_stream, from_buffer)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..418dde8
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,60 @@
+import unittest
+from mock import patch, Mock
+from werkzeug.contrib.cache import SimpleCache
+from flask_ask.core import Ask
+from flask_ask.cache import push_stream, pop_stream, top_stream, set_stream
+
+
+class CacheTests(unittest.TestCase):
+
+ def setUp(self):
+ self.patcher = patch('flask_ask.core.find_ask', return_value=Ask())
+ self.ask = self.patcher.start()
+ self.user_id = 'dave'
+ self.token = '123-abc'
+ self.cache = SimpleCache()
+
+ def tearDown(self):
+ self.patcher.stop()
+
+ def test_adding_removing_stream(self):
+ self.assertTrue(push_stream(self.cache, self.user_id, self.token))
+
+ # peak at the top
+ self.assertEqual(self.token, top_stream(self.cache, self.user_id))
+ self.assertIsNone(top_stream(self.cache, 'not dave'))
+
+ # pop it off
+ self.assertEqual(self.token, pop_stream(self.cache, self.user_id))
+ self.assertIsNone(top_stream(self.cache, self.user_id))
+
+ def test_pushing_works_like_a_stack(self):
+ push_stream(self.cache, self.user_id, 'junk')
+ push_stream(self.cache, self.user_id, self.token)
+
+ self.assertEqual(self.token, pop_stream(self.cache, self.user_id))
+ self.assertEqual('junk', pop_stream(self.cache, self.user_id))
+ self.assertIsNone(pop_stream(self.cache, self.user_id))
+
+ def test_cannot_push_nones_into_stack(self):
+ self.assertIsNone(push_stream(self.cache, self.user_id, None))
+
+ def test_set_overrides_stack(self):
+ push_stream(self.cache, self.user_id, '1')
+ push_stream(self.cache, self.user_id, '2')
+ self.assertEqual('2', top_stream(self.cache, self.user_id))
+
+ set_stream(self.cache, self.user_id, '3')
+ self.assertEqual('3', pop_stream(self.cache, self.user_id))
+ self.assertIsNone(pop_stream(self.cache, self.user_id))
+
+ def test_calls_to_top_with_no_user_return_none(self):
+ """ RedisCache implementation doesn't like None key values. """
+ mock = Mock()
+ result = top_stream(mock, None)
+ self.assertFalse(mock.get.called)
+ self.assertIsNone(result)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 0000000..6b424c4
--- /dev/null
+++ b/tests/test_core.py
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+import unittest
+from aniso8601.timezone import UTCOffset, build_utcoffset
+from flask_ask.core import Ask
+
+from datetime import datetime, timedelta
+from mock import patch, MagicMock
+import json
+
+
+class FakeRequest(object):
+ """ Fake out a Flask request for testing purposes for now """
+
+ headers = {'Signaturecertchainurl': None, 'Signature': None}
+
+ def __init__(self, data):
+ self.data = json.dumps(data)
+
+
+class TestCoreRoutines(unittest.TestCase):
+ """ Tests for core Flask Ask functionality """
+
+
+ def setUp(self):
+ self.mock_app = MagicMock()
+ self.mock_app.debug = True
+ self.mock_app.config = {'ASK_VERIFY_TIMESTAMP_DEBUG': False}
+
+ # XXX: this mess implies we should think about tidying up Ask._alexa_request
+ self.patch_current_app = patch('flask_ask.core.current_app', new=self.mock_app)
+ self.patch_load_cert = patch('flask_ask.core.verifier.load_certificate')
+ self.patch_verify_sig = patch('flask_ask.core.verifier.verify_signature')
+ self.patch_current_app.start()
+ self.patch_load_cert.start()
+ self.patch_verify_sig.start()
+
+ @patch('flask_ask.core.flask_request',
+ new=FakeRequest({'request': {'timestamp': 1234},
+ 'session': {'application': {'applicationId': 1}}}))
+ def test_alexa_request_parsing(self):
+ ask = Ask()
+ ask._alexa_request()
+
+
+ def test_parse_timestamp(self):
+ utc = build_utcoffset('UTC', timedelta(hours=0))
+ result = Ask._parse_timestamp('2017-07-08T07:38:00Z')
+ self.assertEqual(datetime(2017, 7, 8, 7, 38, 0, 0, utc), result)
+
+ result = Ask._parse_timestamp(1234567890)
+ self.assertEqual(datetime(2009, 2, 13, 23, 31, 30), result)
+
+ with self.assertRaises(ValueError):
+ Ask._parse_timestamp(None)
+
+ def test_tries_parsing_on_valueerror(self):
+ max_timestamp = 253402300800
+
+ # should cause a ValueError normally
+ with self.assertRaises(ValueError):
+ datetime.utcfromtimestamp(max_timestamp)
+
+ # should safely parse, assuming scale change needed
+ # note: this assert looks odd, but Py2 handles the parsing
+ # differently, resulting in a differing timestamp
+ # due to more granularity of microseconds
+ result = Ask._parse_timestamp(max_timestamp)
+ self.assertEqual(datetime(1978, 1, 11, 21, 31, 40).timetuple()[0:6],
+ result.timetuple()[0:6])
+
+ with self.assertRaises(ValueError):
+ # still raise an error if too large
+ Ask._parse_timestamp(max_timestamp * 1000)
+
+ def tearDown(self):
+ self.patch_current_app.stop()
+ self.patch_load_cert.stop()
+ self.patch_verify_sig.stop()
diff --git a/tests/test_integration.py b/tests/test_integration.py
new file mode 100644
index 0000000..0c18c5a
--- /dev/null
+++ b/tests/test_integration.py
@@ -0,0 +1,122 @@
+import unittest
+import json
+import uuid
+
+from flask_ask import Ask, audio
+from flask import Flask
+
+
+play_request = {
+ "version": "1.0",
+ "session": {
+ "new": True,
+ "sessionId": "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000",
+ "application": {
+ "applicationId": "fake-application-id"
+ },
+ "attributes": {},
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "context": {
+ "System": {
+ "application": {
+ "applicationId": "fake-application-id"
+ },
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ },
+ "device": {
+ "supportedInterfaces": {
+ "AudioPlayer": {}
+ }
+ }
+ },
+ "AudioPlayer": {
+ "offsetInMilliseconds": 0,
+ "playerActivity": "IDLE"
+ }
+ },
+ "request": {
+ "type": "IntentRequest",
+ "requestId": "string",
+ "timestamp": "string",
+ "locale": "string",
+ "intent": {
+ "name": "TestPlay",
+ "slots": {
+ }
+ }
+ }
+}
+
+
+class AudioIntegrationTests(unittest.TestCase):
+ """ Integration tests of the Audio Directives """
+
+ def setUp(self):
+ self.app = Flask(__name__)
+ self.app.config['ASK_VERIFY_REQUESTS'] = False
+ self.ask = Ask(app=self.app, route='/ask')
+ self.client = self.app.test_client()
+ self.stream_url = 'https://fakestream'
+ self.custom_token = 'custom_uuid_{0}'.format(str(uuid.uuid4()))
+
+ @self.ask.intent('TestPlay')
+ def play():
+ return audio('playing').play(self.stream_url)
+
+ @self.ask.intent('TestCustomTokenIntents')
+ def custom_token_intents():
+ return audio('playing with custom token').play(self.stream_url,
+ opaque_token=self.custom_token)
+
+ def tearDown(self):
+ pass
+
+ def test_play_intent(self):
+ """ Test to see if we can properly play a stream """
+ response = self.client.post('/ask', data=json.dumps(play_request))
+ self.assertEqual(200, response.status_code)
+
+ data = json.loads(response.data.decode('utf-8'))
+ self.assertEqual('playing',
+ data['response']['outputSpeech']['text'])
+
+ directive = data['response']['directives'][0]
+ self.assertEqual('AudioPlayer.Play', directive['type'])
+
+ stream = directive['audioItem']['stream']
+ self.assertIsNotNone(stream['token'])
+ self.assertEqual(self.stream_url, stream['url'])
+ self.assertEqual(0, stream['offsetInMilliseconds'])
+
+ def test_play_intent_with_custom_token(self):
+ """ Test to check that custom token supplied is returned """
+
+ # change the intent name to route to our custom token for play_request
+ original_intent_name = play_request['request']['intent']['name']
+ play_request['request']['intent']['name'] = 'TestCustomTokenIntents'
+
+ response = self.client.post('/ask', data=json.dumps(play_request))
+ self.assertEqual(200, response.status_code)
+
+ data = json.loads(response.data.decode('utf-8'))
+ self.assertEqual('playing with custom token',
+ data['response']['outputSpeech']['text'])
+
+ directive = data['response']['directives'][0]
+ self.assertEqual('AudioPlayer.Play', directive['type'])
+
+ stream = directive['audioItem']['stream']
+ self.assertEqual(stream['token'], self.custom_token)
+ self.assertEqual(self.stream_url, stream['url'])
+ self.assertEqual(0, stream['offsetInMilliseconds'])
+
+ # reset our play_request
+ play_request['request']['intent']['name'] = original_intent_name
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_integration_support_entity_resolution.py b/tests/test_integration_support_entity_resolution.py
new file mode 100644
index 0000000..959e702
--- /dev/null
+++ b/tests/test_integration_support_entity_resolution.py
@@ -0,0 +1,114 @@
+import unittest
+import json
+import uuid
+
+from flask_ask import Ask, statement
+from flask import Flask
+
+
+play_request = {
+ "version": "1.0",
+ "session": {
+ "new": False,
+ "sessionId": "amzn1.echo-api.session.f6ebc0ba-9d7a-4c3f-b056-b6c3f9da0713",
+ "application": {
+ "applicationId": "amzn1.ask.skill.26338c44-65da-4d58-aa75-c86b21271eb7"
+ },
+ "user": {
+ "userId": "amzn1.ask.account.AHR7KBC3MFCX7LYT6HJBGDLIGQUU3FLANWCZ",
+ }
+ },
+ "context": {
+ "AudioPlayer": {
+ "playerActivity": "IDLE"
+ },
+ "Display": {
+ "token": ""
+ },
+ "System": {
+ "application": {
+ "applicationId": "amzn1.ask.skill.26338c44-65da-4d58-aa75-c86b21271eb7"
+ },
+ "user": {
+ "userId": "amzn1.ask.account.AHR7KBC3MFCX7LYT6HJBGDLIGQUU3FLANWCZ",
+ },
+ "device": {
+ "deviceId": "amzn1.ask.device.AELNXV4JQJMF5QALYUQXHOZJ",
+ "supportedInterfaces": {
+ "AudioPlayer": {},
+ "Display": {
+ "templateVersion": "1.0",
+ "markupVersion": "1.0"
+ }
+ }
+ },
+ "apiEndpoint": "https://api.amazonalexa.com",
+ }
+ },
+ "request": {
+ "type": "IntentRequest",
+ "requestId": "amzn1.echo-api.request.4859a7e3-1960-4ed9-ac7b-854309346916",
+ "timestamp": "2018-04-04T06:28:23Z",
+ "locale": "en-US",
+ "intent": {
+ "name": "TestCustomSlotTypeIntents",
+ "confirmationStatus": "NONE",
+ "slots": {
+ "child_info": {
+ "name": "child_info",
+ "value": "friends info",
+ "resolutions": {
+ "resolutionsPerAuthority": [
+ {
+ "authority": "amzn1.er-authority.echo-sdk.amzn1.ask.skill.26338c44-65da-4d58-aa75-c86b21271eb7.child_info_type",
+ "status": {
+ "code": "ER_SUCCESS_MATCH"
+ },
+ "values": [
+ {
+ "value": {
+ "name": "friend_info",
+ "id": "FRIEND_INFO"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "confirmationStatus": "NONE"
+ }
+ }
+ },
+ "dialogState": "STARTED"
+ }
+}
+
+
+class CustomSlotTypeIntegrationTests(unittest.TestCase):
+ """ Integration tests of the custom slot type """
+
+ def setUp(self):
+ self.app = Flask(__name__)
+ self.app.config['ASK_VERIFY_REQUESTS'] = False
+ self.ask = Ask(app=self.app, route='/ask')
+ self.client = self.app.test_client()
+
+ @self.ask.intent('TestCustomSlotTypeIntents')
+ def custom_slot_type_intents(child_info):
+ return statement(child_info)
+
+ def tearDown(self):
+ pass
+
+ def test_custom_slot_type_intent(self):
+ """ Test to see if custom slot type value is correct """
+ response = self.client.post('/ask', data=json.dumps(play_request))
+ self.assertEqual(200, response.status_code)
+
+ data = json.loads(response.data.decode('utf-8'))
+ self.assertEqual('friend_info',
+ data['response']['outputSpeech']['text'])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_samples.py b/tests/test_samples.py
new file mode 100644
index 0000000..4926029
--- /dev/null
+++ b/tests/test_samples.py
@@ -0,0 +1,171 @@
+"""
+Smoke test using the samples.
+"""
+
+import unittest
+import os
+import six
+import sys
+import time
+import subprocess
+
+from requests import post
+
+import flask_ask
+
+
+launch = {
+ "version": "1.0",
+ "session": {
+ "new": True,
+ "sessionId": "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000",
+ "application": {
+ "applicationId": "fake-application-id"
+ },
+ "attributes": {},
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "context": {
+ "System": {
+ "application": {
+ "applicationId": "fake-application-id"
+ },
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ },
+ "device": {
+ "supportedInterfaces": {
+ "AudioPlayer": {}
+ }
+ }
+ },
+ "AudioPlayer": {
+ "offsetInMilliseconds": 0,
+ "playerActivity": "IDLE"
+ }
+ },
+ "request": {
+ "type": "LaunchRequest",
+ "requestId": "string",
+ "timestamp": "string",
+ "locale": "string",
+ "intent": {
+ "name": "TestPlay",
+ "slots": {
+ }
+ }
+ }
+}
+
+
+project_root = os.path.abspath(os.path.join(flask_ask.__file__, '../..'))
+
+
+@unittest.skipIf(six.PY2, "Not yet supported on Python 2.x")
+class SmokeTestUsingSamples(unittest.TestCase):
+ """ Try launching each sample and sending some requests to them. """
+
+ def setUp(self):
+ self.python = sys.executable
+ self.env = {'PYTHONPATH': project_root,
+ 'ASK_VERIFY_REQUESTS': 'false'}
+ if os.name == 'nt':
+ self.env['SYSTEMROOT'] = os.getenv('SYSTEMROOT')
+ self.env['PATH'] = os.getenv('PATH')
+
+ def _launch(self, sample):
+ prefix = os.path.join(project_root, 'samples/')
+ path = prefix + sample
+ process = subprocess.Popen([self.python, path], env=self.env)
+ time.sleep(1)
+ self.assertIsNone(process.poll(),
+ msg='Poll should work,'
+ 'otherwise we failed to launch')
+ self.process = process
+
+ def _post(self, route='/', data={}):
+ url = 'http://127.0.0.1:5000' + str(route)
+ print('POSTing to %s' % url)
+ response = post(url, json=data)
+ self.assertEqual(200, response.status_code)
+ return response
+
+ @staticmethod
+ def _get_text(http_response):
+ data = http_response.json()
+ return data.get('response', {})\
+ .get('outputSpeech', {})\
+ .get('text', None)
+
+ @staticmethod
+ def _get_reprompt(http_response):
+ data = http_response.json()
+ return data.get('response', {})\
+ .get('reprompt', {})\
+ .get('outputSpeech', {})\
+ .get('text', None)
+
+ def tearDown(self):
+ try:
+ self.process.terminate()
+ self.process.communicate(timeout=1)
+ except Exception as e:
+ try:
+ print('[%s]...trying to kill.' % str(e))
+ self.process.kill()
+ self.process.communicate(timeout=1)
+ except Exception as e:
+ print('Error killing test python process: %s' % str(e))
+ print('*** it is recommended you manually kill with PID %s',
+ self.process.pid)
+
+ def test_helloworld(self):
+ """ Test the HelloWorld sample project """
+ self._launch('helloworld/helloworld.py')
+ response = self._post(data=launch)
+ self.assertTrue('hello' in self._get_text(response))
+
+ def test_session_sample(self):
+ """ Test the Session sample project """
+ self._launch('session/session.py')
+ response = self._post(data=launch)
+ self.assertTrue('favorite color' in self._get_text(response))
+
+ def test_audio_simple_demo(self):
+ """ Test the SimpleDemo Audio sample project """
+ self._launch('audio/simple_demo/ask_audio.py')
+ response = self._post(data=launch)
+ self.assertTrue('audio example' in self._get_text(response))
+
+ def test_audio_playlist_demo(self):
+ """ Test the Playlist Audio sample project """
+ self._launch('audio/playlist_demo/playlist.py')
+ response = self._post(data=launch)
+ self.assertTrue('playlist' in self._get_text(response))
+
+ def test_blueprints_demo(self):
+ """ Test the sample project using Flask Blueprints """
+ self._launch('blueprint_demo/demo.py')
+ response = self._post(route='/ask', data=launch)
+ self.assertTrue('hello' in self._get_text(response))
+
+ def test_history_buff(self):
+ """ Test the History Buff sample """
+ self._launch('historybuff/historybuff.py')
+ response = self._post(data=launch)
+ self.assertTrue('History buff' in self._get_text(response))
+
+ def test_spacegeek(self):
+ """ Test the Spacegeek sample """
+ self._launch('spacegeek/spacegeek.py')
+ response = self._post(data=launch)
+ # response is random
+ self.assertTrue(len(self._get_text(response)) > 1)
+
+ def test_tidepooler(self):
+ """ Test the Tide Pooler sample """
+ self._launch('tidepooler/tidepooler.py')
+ response = self._post(data=launch)
+ self.assertTrue('Which city' in self._get_reprompt(response))
diff --git a/tests/test_unicode.py b/tests/test_unicode.py
new file mode 100644
index 0000000..0c1f60f
--- /dev/null
+++ b/tests/test_unicode.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+import unittest
+from flask_ask import statement, question
+
+
+class UnicodeTests(unittest.TestCase):
+ """ Test using Unicode in responses. (Issue #147) """
+
+ unicode_string = u"Was kann ich für dich tun?"
+
+ def test_unicode_statements(self):
+ """ Test unicode statement responses """
+ stmt = statement(self.unicode_string)
+ speech = stmt._response['outputSpeech']['text']
+ print(speech)
+ self.assertTrue(self.unicode_string in speech)
+
+ def test_unicode_questions(self):
+ """ Test unicode in question responses """
+ q = question(self.unicode_string)
+ speech = q._response['outputSpeech']['text']
+ self.assertTrue(self.unicode_string in speech)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..3c6a9c1
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,8 @@
+[tox]
+envlist = py27,py3
+skipsdist = True
+
+[testenv]
+deps = -rrequirements-dev.txt
+commands = python setup.py test
+