Skip to content

Commit fe40764

Browse files
authored
Merge pull request #243 from ag1le/master
Added in-skill-purchase capability
2 parents 6df0570 + ecfab43 commit fe40764

File tree

7 files changed

+374
-1
lines changed

7 files changed

+374
-1
lines changed

flask_ask/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,8 @@
2323
delegate,
2424
elicit_slot,
2525
confirm_slot,
26-
confirm_intent
26+
confirm_intent,
27+
buy,
28+
upsell,
29+
refund
2730
)

flask_ask/core.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,37 @@ def wrapper(*args, **kw):
297297
return f
298298

299299

300+
def on_purchase_completed(self, mapping={'payload': 'payload','name':'name','status':'status','token':'token'}, convert={}, default={}):
301+
"""Decorator routes an Connections.Response to the wrapped function.
302+
303+
Request is sent when Alexa completes the purchase flow.
304+
See https://developer.amazon.com/docs/in-skill-purchase/add-isps-to-a-skill.html#handle-results
305+
306+
307+
The wrapped view function may accept parameters from the Request.
308+
In addition to locale, requestId, timestamp, and type
309+
310+
311+
@ask.on_purchase_completed( mapping={'payload': 'payload','name':'name','status':'status','token':'token'})
312+
def completed(payload, name, status, token):
313+
logger.info(payload)
314+
logger.info(name)
315+
logger.info(status)
316+
logger.info(token)
317+
318+
"""
319+
def decorator(f):
320+
self._intent_view_funcs['Connections.Response'] = f
321+
self._intent_mappings['Connections.Response'] = mapping
322+
self._intent_converts['Connections.Response'] = convert
323+
self._intent_defaults['Connections.Response'] = default
324+
@wraps(f)
325+
def wrapper(*args, **kwargs):
326+
self._flask_view_func(*args, **kwargs)
327+
return f
328+
return decorator
329+
330+
300331
def on_playback_started(self, mapping={'offset': 'offsetInMilliseconds'}, convert={}, default={}):
301332
"""Decorator routes an AudioPlayer.PlaybackStarted Request to the wrapped function.
302333
@@ -779,6 +810,8 @@ def _flask_view_func(self, *args, **kwargs):
779810
result = self._map_player_request_to_func(self.request.type)()
780811
# routes to on_playback funcs
781812
# user can also access state of content.AudioPlayer with current_stream
813+
elif 'Connections.Response' in request_type:
814+
result = self._map_purchase_request_to_func(self.request.type)()
782815

783816
if result is not None:
784817
if isinstance(result, models._Response):
@@ -817,6 +850,21 @@ def _map_player_request_to_func(self, player_request_type):
817850

818851
return partial(view_func, *arg_values)
819852

853+
def _map_purchase_request_to_func(self, purchase_request_type):
854+
"""Provides appropriate parameters to the on_purchase functions."""
855+
856+
if purchase_request_type in self._intent_view_funcs:
857+
view_func = self._intent_view_funcs[purchase_request_type]
858+
else:
859+
raise NotImplementedError('Request type "{}" not found and no default view specified.'.format(purchase_request_type))
860+
861+
argspec = inspect.getargspec(view_func)
862+
arg_names = argspec.args
863+
arg_values = self._map_params_to_view_args(purchase_request_type, arg_names)
864+
865+
print('_map_purchase_request_to_func', arg_names, arg_values, view_func, purchase_request_type)
866+
return partial(view_func, *arg_values)
867+
820868
def _get_slot_value(self, slot_object):
821869
slot_name = slot_object.name
822870
slot_value = getattr(slot_object, 'value', None)

flask_ask/models.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,59 @@ def reprompt(self, reprompt):
201201
return self
202202

203203

204+
class buy(_Response):
205+
206+
def __init__(self, productId=None):
207+
self._response = {
208+
'shouldEndSession': True,
209+
'directives': [{
210+
'type': 'Connections.SendRequest',
211+
'name': 'Buy',
212+
'payload': {
213+
'InSkillProduct': {
214+
'productId': productId
215+
}
216+
},
217+
'token': 'correlationToken'
218+
}]
219+
}
220+
221+
222+
class refund(_Response):
223+
224+
def __init__(self, productId=None):
225+
self._response = {
226+
'shouldEndSession': True,
227+
'directives': [{
228+
'type': 'Connections.SendRequest',
229+
'name': 'Cancel',
230+
'payload': {
231+
'InSkillProduct': {
232+
'productId': productId
233+
}
234+
},
235+
'token': 'correlationToken'
236+
}]
237+
}
238+
239+
class upsell(_Response):
240+
241+
def __init__(self, productId=None, msg=None):
242+
self._response = {
243+
'shouldEndSession': True,
244+
'directives': [{
245+
'type': 'Connections.SendRequest',
246+
'name': 'Upsell',
247+
'payload': {
248+
'InSkillProduct': {
249+
'productId': productId
250+
},
251+
'upsellMessage': msg
252+
},
253+
'token': 'correlationToken'
254+
}]
255+
}
256+
204257
class delegate(_Response):
205258

206259
def __init__(self, updated_intent=None):

samples/purchase/IntentSchema.json

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
{
2+
"interactionModel": {
3+
"languageModel": {
4+
"invocationName": "demo",
5+
"intents": [
6+
{
7+
"name": "AMAZON.FallbackIntent",
8+
"samples": []
9+
},
10+
{
11+
"name": "AMAZON.CancelIntent",
12+
"samples": []
13+
},
14+
{
15+
"name": "AMAZON.HelpIntent",
16+
"samples": []
17+
},
18+
{
19+
"name": "AMAZON.StopIntent",
20+
"samples": []
21+
},
22+
{
23+
"name": "BuySkillItemIntent",
24+
"slots": [
25+
{
26+
"name": "ProductName",
27+
"type": "LIST_OF_PRODUCT_NAMES"
28+
}
29+
],
30+
"samples": [
31+
"{ProductName}",
32+
"buy",
33+
"shop",
34+
"buy {ProductName}",
35+
"purchase {ProductName}",
36+
"want {ProductName}",
37+
"would like {ProductName}"
38+
]
39+
},
40+
{
41+
"name": "RefundSkillItemIntent",
42+
"slots": [
43+
{
44+
"name": "ProductName",
45+
"type": "LIST_OF_PRODUCT_NAMES"
46+
}
47+
],
48+
"samples": [
49+
"cancel {ProductName}",
50+
"return {ProductName}",
51+
"refund {ProductName}",
52+
"want a refund for {ProductName}",
53+
"would like to return {ProductName}"
54+
]
55+
}
56+
],
57+
"types": [
58+
{
59+
"name": "LIST_OF_PRODUCT_NAMES",
60+
"values": [
61+
{
62+
"name": {
63+
"value": "monthly subscription"
64+
}
65+
},
66+
{
67+
"name": {
68+
"value": "start smoking"
69+
}
70+
},
71+
{
72+
"name": {
73+
"value": "stop smoking"
74+
}
75+
}
76+
]
77+
}
78+
]
79+
}
80+
}
81+
}

samples/purchase/model.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import requests
2+
from flask import json
3+
from flask_ask import logger
4+
5+
class Product():
6+
'''
7+
Object model for inSkillProducts and methods to access products.
8+
9+
{"inSkillProducts":[
10+
{"productId":"amzn1.adg.product.your_product_id",
11+
"referenceName":"product_name",
12+
"type":"ENTITLEMENT",
13+
"name":"product name",
14+
"summary":"This product has helped many people.",
15+
"entitled":"NOT_ENTITLED",
16+
"purchasable":"NOT_PURCHASABLE"}],
17+
"nextToken":null,
18+
"truncated":false}
19+
20+
'''
21+
22+
def __init__(self, apiAccessToken):
23+
self.token = apiAccessToken
24+
self.product_list = self.query()
25+
26+
27+
def query(self):
28+
# Information required to invoke the API is available in the session
29+
apiEndpoint = "https://api.amazonalexa.com"
30+
apiPath = "/v1/users/~current/skills/~current/inSkillProducts"
31+
token = "bearer " + self.token
32+
language = "en-US" #self.event.request.locale
33+
34+
url = apiEndpoint + apiPath
35+
headers = {
36+
"Content-Type" : 'application/json',
37+
"Accept-Language" : language,
38+
"Authorization" : token
39+
}
40+
#Call the API
41+
res = requests.get(url, headers=headers)
42+
logger.info('PRODUCTS:' + '*' * 80)
43+
logger.info(res.status_code)
44+
logger.info(res.text)
45+
if res.status_code == 200:
46+
data = json.loads(res.text)
47+
return data['inSkillProducts']
48+
else:
49+
return None
50+
51+
def list(self):
52+
""" return list of purchasable and not entitled products"""
53+
mylist = []
54+
for prod in self.product_list:
55+
if self.purchasable(prod) and not self.entitled(prod):
56+
mylist.append(prod)
57+
return mylist
58+
59+
def purchasable(self, product):
60+
""" return True if purchasable product"""
61+
return 'PURCHASABLE' == product['purchasable']
62+
63+
def entitled(self, product):
64+
""" return True if entitled product"""
65+
return 'ENTITLED' == product['entitled']
66+
67+
68+
def productId(self, name):
69+
print(self.product_list)
70+
for prod in self.product_list:
71+
if name == prod['name'].lower():
72+
return prod['productId']
73+
return None
74+
75+
def productName(self, id):
76+
for prod in self.product_list:
77+
if id == prod['productId']:
78+
return prod['name']
79+
return None

samples/purchase/purchase.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import logging
2+
import os
3+
import requests
4+
5+
from flask import Flask, json, render_template
6+
from flask_ask import Ask, request, session, question, statement, context, buy, upsell, refund, logger
7+
from model import Product
8+
9+
app = Flask(__name__)
10+
ask = Ask(app, "/")
11+
logging.getLogger('flask_ask').setLevel(logging.DEBUG)
12+
13+
14+
PRODUCT_KEY = "PRODUCT"
15+
16+
17+
18+
@ask.on_purchase_completed( mapping={'payload': 'payload','name':'name','status':'status','token':'token'})
19+
def completed(payload, name, status, token):
20+
products = Product(context.System.apiAccessToken)
21+
logger.info('on-purchase-completed {}'.format( request))
22+
logger.info('payload: {} {}'.format(payload.purchaseResult, payload.productId))
23+
logger.info('name: {}'.format(name))
24+
logger.info('token: {}'.format(token))
25+
logger.info('status: {}'.format( status.code == 200))
26+
product_name = products.productName(payload.productId)
27+
logger.info('Product name'.format(product_name))
28+
if status.code == '200' and ('ACCEPTED' in payload.purchaseResult):
29+
return question('To listen it just say - play {} '.format(product_name))
30+
else:
31+
return question('Do you want to buy another product?')
32+
33+
@ask.launch
34+
def launch():
35+
products = Product(context.System.apiAccessToken)
36+
question_text = render_template('welcome', products=products.list())
37+
reprompt_text = render_template('welcome_reprompt')
38+
return question(question_text).reprompt(reprompt_text).simple_card('Welcome', question_text)
39+
40+
41+
@ask.intent('BuySkillItemIntent', mapping={'product_name': 'ProductName'})
42+
def buy_intent(product_name):
43+
products = Product(context.System.apiAccessToken)
44+
logger.info("PRODUCT: {}".format(product_name))
45+
buy_card = render_template('buy_card', product=product_name)
46+
productId = products.productId(product_name)
47+
if productId is not None:
48+
session.attributes[PRODUCT_KEY] = productId
49+
else:
50+
return statement("I didn't find a product {}".format(product_name))
51+
raise NotImplementedError()
52+
return buy(productId).simple_card('Welcome', question_text)
53+
54+
#return upsell(product,'get this great product')
55+
56+
57+
@ask.intent('RefundSkillItemIntent', mapping={'product_name': 'ProductName'})
58+
def refund_intent(product_name):
59+
refund_card = render_template('refund_card')
60+
logger.info("PRODUCT: {}".format(product_name))
61+
62+
products = Product(context.System.apiAccessToken)
63+
productId = products.productId(product_name)
64+
65+
if productId is not None:
66+
session.attributes[PRODUCT_KEY] = productId
67+
else:
68+
raise NotImplementedError()
69+
return refund(productId)
70+
71+
72+
@ask.intent('AMAZON.FallbackIntent')
73+
def fallback_intent():
74+
return statement("FallbackIntent")
75+
76+
77+
@ask.session_ended
78+
def session_ended():
79+
return "{}", 200
80+
81+
82+
if __name__ == '__main__':
83+
if 'ASK_VERIFY_REQUESTS' in os.environ:
84+
verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower()
85+
if verify == 'false':
86+
app.config['ASK_VERIFY_REQUESTS'] = False
87+
app.run(debug=True)
88+

0 commit comments

Comments
 (0)