AWS Lambda + API Gateway + DynamoDB 添加 slash command 待办清单

接上一篇AWS API Gateway + Lambda + Slack App - 新建 slash command,这一篇介绍怎么用 aws lambda + api gateway + dynamodb 添加 slash command /todo,完成 to-do list 的增、删、查等任务。两个重点,一是连接 dynamodb,二是创建 slack interactive message,具体到这篇的例子,是实现 message button。

DynamoDB configuration

Amazon DynamoDB 属于 NoSQL 数据库,支持文档和 key-value 存储模型。每一行是一个 item,item 时属性(attributes) 的集合,每个 attribute 都有各自的名称(name)值(value)。DynamoDB 提供了 item 的 4 项基本操作,创建(PutItem)/读取(GetItem)/更新(UpdateItem)/删除(DeleteItem)

Create table

第一步,完成 todolist table 的创建,aws console 进入dynamodb,选 create table,做如下设置,primary key 设为 user,一个 user 一个 item,item 有属性 todos,类型是 list,一个 user 当然可以有多个 to-do item,都保存在 todos 的 list 中。当然,之后会有更多的属性,比如说 channel, priority 等,后面再做设置。

dynamodb_create.png

建表完成后在 Items 标签下 Create item,做如下设置,方便后面的测试。

dynamoDB_editItem.png

Attach policy to IAM role

接下来是测试 lambda function 能否连接数据库,默认情况下,lambda function 是没有这个权限的,我们需要创建一个 role,或者在已有的 role 上 attach 相应的 policy,再对 lambda function 采用这个 role,才可以访问数据库。所以,先从aws console 进入IAM,选择 Roles 以及 lambda configuration 中的 role,比如说 lambda_basic_execution(这里以 kmsDecrypt role 为例),选择 inline policy 来创建并且 attach policy。

Step 1: create inline policy
IAM_attach.png

Step 2: generate policy
AIM_attach2.png

Step 3: edit permissions
IAM_attach3.png

ARN 在 DynamoDB 页面 table overview 下可以找到,这里的 permission 表示使用了这个 role 的 lambda function 只可以对 todolist 这张表进行操作,Actions 是操作类型,这里选 All Actions,表示增删改查所有操作都允许。

ARN.png

Step 4: review and apply policy
IAM_attach4.png

Policy 的具体内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stmt1498896886000",
"Effect": "Allow",
"Action": [
"dynamodb:*"
],
"Resource": [
"arn:aws:dynamodb:us-west-2:XXXXXXXXXXX:table/todolist"
]
}
]
}

Lambda configuration

写一个简单的 lambda function 看看能不能读取表中内容

1
2
3
4
5
6
import boto3
todo_table = boto3.resource('dynamodb').Table('todolist')
def lambda_handler(event, context):
print todo_table.get_item(Key={'user': 'testuser'})

连接成功
succeed.png

连接不成功可能是因为地区不一致,可以显性指定 region 来连接。

1
2
3
4
5
6
import boto3
todo_table = boto3.resource('dynamodb', 'us-west-2').Table('todolist')
def lambda_handler(event, context):
print todo_table.get_item(Key={'user': 'testuser'})

下一部分附上 debug 过程,提供可能的 debug 思路。

Possible error/Debug process

有可能出现 “module initialization error”,具体错误信息是

1
module initialization error: An error occurred (AccessDeniedException) when calling the GetItem operation: User: arn:aws:sts::xxxxxxxxxxxxx:assumed-role/kmsDecrypt/todolist is not authorized to perform: dynamodb:GetItem on resource: arn:aws:dynamodb:us-east-1:xxxxxxxxxxxxxx:table/todolist: ClientError

not_authorized.png

把上一步的 policy 改的 less restrict 一些,方便调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stmt1498896886000",
"Effect": "Allow",
"Action": [
"dynamodb:*"
],
"Resource": [
"*"
]
}
]
}

再重新 test 一下 lambda,发现错误变成了 Requested resource not found
resource_not_found.png

根本没这个 table,怎么办?那干脆把所有 table 列出来看看喽,修改 lambda function,

1
2
3
4
import boto3
def lambda_handler(event, context):
print boto3.client('dynamodb').list_tables()

发现 table list 为空。
log_output_fail.png

这时候熟悉 aws 的朋友就知道,Region! Region! Region! 一定要一致!

再来回顾下 dynamodb table overview,发现 table 所在 region 是 US WEST,而我们的 lambda ARN 是在 US EAST,所以办法是在连接 table 的时候直接指定地区。

ARN.png
1
2
3
4
5
6
import boto3
todo_table = boto3.resource('dynamodb', 'us-west-2').Table('todolist')
def lambda_handler(event, context):
print todo_table.get_item(Key={'user': 'testuser'})

连接成功。
log_output_success.png

Slash command configuration

slack app 页面新建 slash command /todo,command 以及 api gateway 的设置,具体参照AWS API Gateway + Lambda + Slack App - 新建 slash command,非常简单的过程。

Slack Message Button

先来看一下 message button 的运作流程,slack app 的 interactive messages 下有一个 Request URL,专门用来响应 message button 事件。一个 slack app 只能配置一个 action URL,它会接收所有 channel/team 下的 message button 的所有点击操作,这相当于一个 dispatch station。当用户点击 button 时,action URL 会收到一个 URL encoded request,request 的 body 参数中会包含一个 payload,记录相应的 user action,我们可以通过 payload 来获取用户的点击行为,然后做出响应。

Slack 官方教程 Making messages interactive 提供了多种响应 message action 的方法,主要说来一是直接对 action URL 的 request 进行 response,要求是 3 秒内必须做出响应;二是通过 response_url 进行对原消息的更新等,三是用 chat.update 来更新原始信息。

经尝试,发现在使用 python 以及 slackclient package 的情况下,并不能用 chat.postMessage, chat.update 来实现 message button。slackclient 作为 slack api 的一个 wrapper,确实可以实现 chat.postMessage,但只能提供 basics 的一些 response,并不能处理请求中加 attachments 的情况。

这里采用的是直接响应的方法。首先需要在 API Gateway 中配置 request url,对应的 integration 还是选 todolist lambda function,mapping templates 还是要改成 x-www-form-urlencoded 的形式。
api%20gateway1.png

deploy 后记录下 Invoke URL,在 slack app 下修改 request URL
request%20url.png

request%20url2.png

message button format:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def _respond_button(err, res=None):
return {
"text": res,
"attachments": [
{
"callback_id": "todo",
"color": "#3AA3E3",
"attachment_type": "default",
"actions": [
{
"name": "subtask",
"text": "Remind me",
"type": "button",
"value": "reminder"
},
{
"name": "subtask",
"text": "Set priority",
"type": "button",
"value": "priority"
},
{
"name": "subtask",
"text": "Set project",
"type": "button",
"value": "project",
}
]
}
]
}

上面的 message format 会产生下图的三个 button
mes%20button.png

如果用户点击 Remind me,这个行为对应的信息会发送到 request url,由于 request url 还是绑定了 todolist 这个 lambda function,所以可以在 lambda 中判断 request 是否包含 payload 参数,如果包含,说明这是一个 button 响应事件,就读取 user action 信息,并响应,否则,就响应 command 命令。

完整的 lambda function 代码,role 用 kmsDecrypt,环境变量设置好 kmsEncryptedToken,参照AWS API Gateway + Lambda + Slack App - 新建 slash command

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# -*- coding: UTF-8 -*-
import boto3
import json
import logging
import os
from base64 import b64decode
from urlparse import parse_qs
todo_table = boto3.resource('dynamodb', 'us-west-2').Table('todolist')
ENCRYPTED_EXPECTED_TOKEN = os.environ['kmsEncryptedToken']
kms = boto3.client('kms')
expected_token = kms.decrypt(CiphertextBlob=b64decode(ENCRYPTED_EXPECTED_TOKEN))['Plaintext']
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def _respond_text(err, res=None):
return {
"text": res
}
def _respond_button(err, res=None):
return {
"text": res,
"attachments": [
{
"callback_id": "todo",
"color": "#3AA3E3",
"attachment_type": "default",
"actions": [
{
"name": "subtask",
"text": "Remind me",
"type": "button",
"value": "reminder"
},
{
"name": "subtask",
"text": "Set priority",
"type": "button",
"value": "priority"
},
{
"name": "subtask",
"text": "Set project",
"type": "button",
"value": "project",
}
]
}
]
}
def _respond_button_action(params):
params = json.loads(params[0])
token = params['token']
user = params['user']['name']
channel = params['channel']['name']
action = params['actions'][0]['value']
if action == 'reminder':
return "{} set a reminder for this to-do item in {}".format(user, channel)
elif action == 'priority':
return "{} set a priority for this to-do item in {}".format(user, channel)
else:
return "{} set a project for this to-do item in {}".foramt(user, channel)
return
def _get_todo_list(user, todos):
if 'Item' not in todos or 'todos' not in todos['Item'] or not todos['Item']['todos']:
return _respond_text(None, "{} has nothing in to-do list!".format(user))
msg = ''
for i, item in enumerate(todos['Item']['todos']):
msg += '{} {} \n'.format(i, item)
return _respond_text(None, msg)
def _remove_from_list(user, index, todos):
if not index.isdigit():
return _respond_text(None, "Sorry, I didn’t quite get that. This usually works: `/todo done [to-do item index]`. Try `/todo todolist` to get to-do item index.")
todo_item = todos['Item']['todos'][int(index)]
response = todo_table.update_item(
Key={
'user': user
},
UpdateExpression="REMOVE todos[" + index + "]",
ReturnValues="UPDATED_NEW"
)
if response['ResponseMetadata']['HTTPStatusCode'] != 200:
return response
return _respond_text(None, "{} has done {} {}".format(user, index, todo_item))
def _clear(user, channel):
response = todo_table.delete_item(
Key={
'user': user,
}
)
if response['ResponseMetadata']['HTTPStatusCode'] != 200:
return response
return _respond_text(None, "{} has cleared his/her to-do list in {}".format(user, channel))
def _send_button(user, command, channel, command_text, todos):
# insert items into database
if 'Item' not in todos:
response = todo_table.put_item(Item={
'user': user,
'channel': channel,
'todos': [command_text]
})
else:
response = todo_table.update_item(
Key={
'user': user
},
UpdateExpression="SET todos = list_append(todos, :val)",
ExpressionAttributeValues={":val" : [command_text]},
ReturnValues="UPDATED_NEW"
)
if response['ResponseMetadata']['HTTPStatusCode'] != 200:
return response
return _respond_button(None, "{} added ' {} ' to the to-do list in {}".format(user, command_text, channel))
def lambda_handler(event, context):
params = parse_qs(event['body'])
# with payload, which means user clicks a button
if 'payload' in params:
return _respond_button_action(params['payload'])
# without payload
token = params['token'][0]
if token != expected_token:
logger.error("Request token (%s) does not match expected", token)
return respond(Exception('Invalid request token'))
user = params['user_name'][0]
command = params['command'][0]
channel = params['channel_name'][0]
command_text = params['text'][0]
# get todo list from database
todos = todo_table.get_item(Key={'user': user})
if command_text.startswith('todolist'):
return _get_todo_list(user, todos)
elif command_text.startswith('done '):
return _remove_from_list(user, command_text.split(' ', 1)[1], todos)
elif command_text.startswith('clear'):
return _clear(user, channel)
return _send_button(user, command, channel, command_text, todos)
# return respond(None, "%s added %s to the to-do list in %s to to-do list %s" % (user, command_text, channel, command_text, response, todo))
徐阿衡 wechat
欢迎关注:徐阿衡的微信公众号
客官,打个赏呗~