原文地址:https://blog.cloudflare.com/building-a-serverless-slack-bot-using-cloudflare-workers/
原文标题:Building a serverless Slack bot using Cloudflare Workers
翻译水平有限,有不通顺的语句,请见谅。
原作者:Rita Kozlov
写于 2018/6/22 GMT+8 下午9:00:00

译者:驱蚊器喵#ΦωΦ

译者注:

最近在研究 Cloudflare Workers,可能会搬运和翻译有关 Cloudflare Workers 的很多文章。

Slack app 可能很少人会用,但是既然能搭建 Slack Bot,能不能搭建别的呢?

比如…

Telegram Bot

Alpha Vantage API 文档,API 免费版的频率限制为

up to 5 API requests per minute and 500 requests per day

每分钟最多 5 次,并且每天最多 500 次

感觉 500 次,连覆盖一天的每一分钟都不够,所以这个例子看看就好,slack bot 也没有 Telegram bot 方便易用,配置太多了。

注意:原文有些不完善,我根据目前的情况做了一些修改。

如果你不想看原理和废话,只想看代码和部署教程,可以直接跳转到译者注:部署教程


我们的 Workers 平台 可用于许多有用的用途: A/B(多变量)测试,存储桶身份验证,合并来自多个 API 的响应,等等。但是,我们还可以在 “HTTP 中间件”之外使用 Workers:Worker 本身可以有效地成为 Web 应用程序。鉴于“聊天机器人”的兴起,我们还可以使用 Cloudflare Workers 构建 Slack 应用程序,并且无需服务器(嗯,至少不是需要您的服务器!)。

workers_slack_bot2-1

我们要构建什么

我们将构建一个 Slack 机器人(作为一个外部的 webhook)来获取最新的股价。

这个 Worker 也可以修改来从 GitHub 的 API 获取开启的 issues;用于发现下班后可以看什么电影; 以及任何可以使用 REST API 进行查询的工作。

然而,我们这次要做的是 “股票价格机器人”:

  • 通过 Alpha Vantage API 来获取股票价格。

  • 将最有价值的股票和他们的公开代码缓存下来,这样你就可以请求/stocks MSFT作为速记(译者注:MSFT 是 Microsoft 公司的上市代码)。

  • 利用 Cloudflare 的缓存来最大程度地减少每次调用 API 所需的时间,同时仍可提供最新的价格数据。

使用缓存,可以让你缩短所有调用 Worker 的响应时间。而且,尽可能减少对 API 的多余调用(以免你受到频率限制!)也是一种有礼貌的行为,所以,这是一举两得的好处。

准备工作

为了开始构建,您需要准备以下:

  • 一个 Cloudflare 账号, 并启用 Workers 功能 (见备注)
  • 一些基本的编程经验.
  • 一个现有的 Slack 工作区. 如果你没有,请按照 Slack 的帮助指南进行操作。

注意:您可以在 Cloudflare 仪表板中的 “Workers” 应用程序启用 Workers 功能。

译者注:启用后,如果你没有域名,引导程序会为你分配一个 workers.dev 的子域名,用于 worker 的 webhook 使用,例如 subdomain.workers.dev,subdomain是你自定义的子域名。

创建 Worker 程序

我们将首先启动运行 Worker,在 Slack 之外对其进行测试,然后再进行连接。我们的工人需要满足以下步骤:

  1. 处理来自 Slack 传入的 Webhook 请求(HTTP POST请求),包括对其进行身份验证,确认请求实际来自 Slack 。
  2. 从用户的消息( Webhook 内容)中解析请求的标识。
  3. 向 Alpha vantage API 发出请求,并处理出现的任何错误(无效标识,无法访问 API 等)。
  4. 组装我们的响应信息,并在 3 秒内(否则会超时)将其发送回 Slack。

我们将逐步介绍每个需求及其关联的代码,将 Worker 部署到路由中,然后将其连接到 Slack。

Webhook 的处理程序

像所有 Cloudflare Workers 一样,我们需要为 fetch 事件添加一个 hook ,并将入口点(entry point)连接到我们的 Worker 上。slackWebhookHandler 函数将负责触发其余逻辑,并对 Slack 的请求返回 Response

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
// SLACK_TOKEN is used to authenticate requests are from Slack.
// Keep this value secret.
// SLACK_TOKEN 用于验证请求来自 Slack.
// 这个 token 需要保密.
const SLACK_TOKEN = "SLACK 的 token 填在这"
const BOT_NAME = "Stock-bot 🤖"
const ALPHA_VANTAGE_KEY = ""


let jsonHeaders = new Headers([["Content-Type", "application/json"]])

addEventListener("fetch", event => {
event.respondWith(slackWebhookHandler(event.request))
})

/**
* simpleResponse generates a simple JSON response
* with the given status code and message.
*
* @param {Number} statusCode
* @param {String} message
*/
function simpleResponse(statusCode, message) {
let resp = {
message: message,
status: statusCode
}

return new Response(JSON.stringify(resp), {
headers: jsonHeaders,
status: statusCode
})
}

/**
* slackWebhookHandler handles an incoming Slack
* webhook and generates a response.
* @param {Request} request
*/
async function slackWebhookHandler(request) {
// As per: https://api.slack.com/slash-commands
// - Slash commands are outgoing webhooks (POST requests)
// - Slack authenticates via a verification token.
// - The webhook payload is provided as POST form data

if (request.method !== "POST") {
return simpleResponse(
200,
`Hi, I'm ${BOT_NAME}, a Slack bot for fetching the latest stock prices`
)
}

try {
let formData = await request.formData()
if (formData.get("token") !== SLACK_TOKEN) {
return simpleResponse(403, "invalid Slack verification token")
}

let parsed = parseMessage(formData)


let reply = await stockRequest(parsed.stock)
let line = `Current price (*${parsed.stock}*): 💵 USD $${reply.USD} (Last updated on ${reply.updated}).`

return slackResponse(line)
} catch (e) {
return simpleResponse(
200,
`Sorry, I had an issue retrieving anything for that stock: ${e}`
)
}
}

我们的处理程序非常简单:

  1. 如果传入的请求不是 POST 请求(Slack webhook 接受的是 POST 请求),我们将返回一些有用的(调试)信息。
  2. 对于 POST 请求,我们检查 POST 表单数据中提供的 token 是否与我们的 token 匹配:这是我们验证 Webhook 来自 Slack 的方式。
  3. 然后,我们解析用户消息,发出请求获取最新价格,并组装我们的响应信息。
  4. 如果在此过程中发生任何故障,我们将向用户返回错误信息。

在进行讨论的同时,我们还构建了一些有用的辅助函数:simpleResponse,用于将生成的错误返回客户端,以及 slackResponse(稍后将介绍)以 Slack 的预期格式生成响应信息。

SLACK_TOKEN, BOT_NAME, 和 ALPHA_VANTAGE_KEY 这些常量,不需要在每个请求中都进行计算,因此我们在请求处理逻辑之外,将它们设为全局变量。

注意:如果要重复使用 Worker 实例本身,则在 Worker 中的请求处理程序之外缓存(通常称为“存储”)静态数据,可在请求之间重用静态数据。尽管这种情况下,性能提升可以忽略不计,但这是一种很好的做法,并且不会影响我们工人的可读性。

解析用户消息

下一步,我们需要解析 Slack 在 POST 请求中发送的消息。这是我们获取请求中股票信息的位置,然后准备将其传递给 Alpha Vantage API。

为了从 API 接收正确的股票信息,我们将解析从 Slack 接收到的输入,以便我们可以将其传递给 API ,来收集有关所需的股票信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* parseMessage parses the selected stock from the Slack message.
*
* @param {FormData} message - the message text
* @return {Object} - an object containing the stock name.
*/
function parseMessage(message) {
// 1. Parse the message (trim whitespace, uppercase)
// 2. Return stock that we are looking for
return {
stock: message.get("text").trim().toUpperCase()
}
}

让我们逐步完成我们要做的事情:

  1. 我们传递将用户的消息传到 FormData
  2. 清洗FormData(即去除周围的空格,转换为大写)

将来,如果我们想从 Bot 解析更多的值(也许用户感兴趣的货币汇率或日期),我们可以轻松地向对象添加其他值。

现在有了想要查找的股票信息,我们可以继续进行 API 请求!

发出 API 请求

我们希望确保,我们的机器人不必每次都向 Alpha Vantage API 发出请求:如果每分钟有成千上万的用户向我们发送请求,那么没必要每次都获取(相同的)价格。我们可以按每个 Cloudflare PoP 获取一次,在 Cloudflare 缓存中存储一​​小段时间(例如1分钟),然后从缓存中拷贝一份提供给用户提。这是双赢的:我们的 bot 响应速度更快,并且我们对所使用的 API 更加友善。

对于订阅了 Enterprise 套餐的客户,您还可以使用 cacheTtlByStatus 功能,这个功能可以让你根据响应状态设置不同的 TTL。这样的情况下,如果你收到了错误代码,则只将其缓存 1 秒钟,或者压根儿就不缓存,这样后续请求(一旦 API 被更新)也不会失败。

确定所请求的股票信息后,我们将向 API 发出 HTTP 请求,确认我们收到可接受的响应(HTTP 200),然后返回的是包含我们需要字段的对象:

1
2
3
4
let resp = await fetch(
endpoint,
{ cf: { cacheTtl: 60} } // Cache our responses for 60s.
)

API 输出的信息,为我们提供了两个元素:元数据和以某个时间间隔的一行数据。就我们的机器人而言,我们将保留 API 提供的最新间隔,并丢弃其余间隔。在将来的迭代中,我们也许会调用月度的 API endpoint,并提供月度高点和低点的信息以进行比较,但是现在我们的需求很简单,只需要最新时间间隔。

我们将使用 Alpha Vantage 提供的每日间隔 API 端点。这将使我们能够为每个单独的股票查询缓存响应,以便下一个向我们的 bot 发出请求的用户可以更快地收到缓存的版本(并且,帮助我们避免受到 API 的频率限制)。在这里,我们将调整为,用来获取最新数据,而不是缓存更长的时间。

你可以看到,我们将向 API 请求以1分钟为间隔的数据。

1
curl -s "https://www.alphavantage.co/query?function=TIME_SERIES_INTRADAY&symbol=MSFT&interval=1min&apikey=KEY" | jq

输出如下所示:

1
2
3
4
5
6
7
{
"1. open": "99.8950",
"2. high": "99.8950",
"3. low": "99.8300",
"4. close": "99.8750",
"5. volume": "34542"
},

要仅获取最后1分钟间隔的数据,我们需要获取 API 提供给我们的最后一个值,并获取其当前的开盘价。

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
/**
* stockRequest makes a request to the Alpha Vantage API for the
* given stock request.
* Endpoint: https://www.alphavantage.co/documentation/*
* @param {string} stock - the stock to fetch the price for
* @returns {Object} - an Object containing the stock, price in USD.
*/
async function stockRequest(stock) {
let endpoint = new URL("https://www.alphavantage.co/query")

endpoint.search = new URLSearchParams({"function" : "TIME_SERIES_INTRADAY" ,
"interval" : "1min",
"apikey": ALPHA_VANTAGE_KEY,
"symbol": stock
})


try {
let resp = await fetch(
endpoint,
{ cf: { cacheTtl: 60} } // Cache our responses for 60s.
)

if (resp.status !== 200) {
throw new Error(`bad status code from Alpha Vantage: HTTP ${resp.status}`)
}

let data = await resp.json()
let timeSeries = data["Time Series (1min)"]

// We want to use the last value (1 minute interval) that is provided by the API
let timestamp = Object.keys(timeSeries)[1]
let usd = timeSeries[timestamp]["1. open"]

let reply = {
stock: stock,
USD: usd,
updated: timestamp
}

return reply
} catch (e) {
throw new Error(`could not fetch the selected symbol: ${e}`)
}
}

我们构建一个对象,来代表我们的响应。我们同样需要谨慎处理任何从 API 返回的响应错误:也许是非 HTTP 200 的响应,或者是 非 JSON 的响应。在依赖第三方服务/API 时,必须考虑假设响应的格式或是正确性,这些假设可能会导致程序的异常,从而引起中断,例如,在 HTML 正文上调用 resp.json()。

此外,请注意,后续请求将遵循整个域名的 SSL 模式设置。因此,如果 SSL 模式设置为 flexible,Cloudflare 将尝试通过 80 端口和 HTTP 连接到 API,并且请求将失败(您将看到 525 错误)。

给 Slack 回复响应

Slack 仅对两种格式进行响应:纯文本字符串,或是简单的 JSON 结构。因此,我们需要接受答复,并为 Slack 构建响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* slackResponse builds a message for Slack with the given text
* and optional attachment text
*
* @param {string} text - the message text to return
*/
function slackResponse(text) {
let content = {
response_type: "in_channel",
text: text,
attachments: []
}

return new Response(JSON.stringify(content), {
headers: jsonHeaders,
status: 200
})
}

slackWebhookHandler用于处理交易的相应部分,获取我们的回复对象,并将其传递给 slackResponse -

1
2
3
4
let reply = await stockRequest(parsed.stock)
let line = `Current price (*${parsed.stock}*): 💵 USD $${reply.USD} (Last updated on ${reply.updated}).`

return slackResponse(line)

这将返回对 Slack 的响应,如下所示:

1
2
3
4
5
{
"response_type": "in_channel",
"text": "Current price (*MSFT*): 💵 USD $101.8300 (Last updated on 2018-06-20 11:52:00).",
"attachments": []
}

配置 Slack 并测试我们的机器人

我们的机器人准备好后,让我们配置 Slack 以便与我们选择的斜杠命令进行对话。首先,登录 Slack 并转到 app management(应用程序管理) 仪表板。

然后,您需要单击 “Create an App(创建应用程序)”,并填写字段,包括指定将其附加到哪个工作区:

Slack: Create App modal
Slack: Create App modal

然后,我们为他设置为斜杠命令:

Slack: Select Slash Command
Slack: Select Slash Command

填写详细信息:请求 URL 是最重要的,这将反映您将 Worker 附加到的路由。在我们我的情况下,URL 是https://bots.subdomain.workers.dev/

Slack: Create New Command
Slack: Create New Command

Basic Information(基本信息) 标签中获取您的 App Credentials(应用凭据):特别是 Verification Token(验证 token)

Slack: Fetch App Credentials
Slack: Fetch App Credentials

将该值作为 SLACK_TOKEN 变量的值,粘贴到 Worker 机器人中:

1
2
3
// SLACK_TOKEN is used to authenticate requests are from Slack.
// Keep this value secret.
let SLACK_TOKEN = "PUTYOURTOKENHERE"

在将我们的机器人连接到 Slack 之前,我们可以进行测试以确保其正确响应。我们可以通过 curl -模拟 Slack ,发出带有 token 和消息文本的 POST 请求。

1
2
# Replace with the hostname/route your Worker is running on
➜ ~ curl -X POST -F "token=SLACKTOKENGOESHERE" -F "text=MSFT" "https://https://bots.subdomain.workers.dev/"

SLACKTOKENGOESHERE 为正确的 token

bot 回复:

1
{"response_type":"in_channel","text":"Current price (MSFT): 💵 USD $101.7300","attachments":[]}

正确的答复应该使我们获得预期的答复。如果我们故意发送无效的 token,则我们的 bot 应做出相应的响应:

1
2
curl -X POST https://https://bots.subdomain.workers.dev
-F "token=OBVIOUSLYINCORRECTTOKEN" -F "text=MSFT"

OBVIOUSLYINCORRECTTOKEN 为明显错误的 token

bot 回复:

1
{"message":"invalid Slack verification token","status":403}%

或无效的股票代码:

1
➜  ~  curl -X POST https://https://bots.subdomain.workers.dev -F "token=SLACKTOKENGOESHERE" -F "text=BADSYMBOL"

bot 回复:

1
{"message":"Sorry, I had an issue retrieving anything for that symbol: Error: could not fetch the selected symbol: Error: bad status code from Alpha Vantage: HTTP 404","status":200}%

如果运行过程中遇到问题,请确保令牌正确(大小写敏感),并且 Alpha Vantage 存在我们要查询的股票。除此之外,我们现在可以将应用安装到我们的 Workspace(工作区)(Slack 会要求您授权机器人):

Slack: Add to Workspace

现在,我们可以通过分配的斜杠命令来调用我们的机器人!

stockbot
stockbot

总结

使用 Cloudflare Worker,我们能够建立一个有用的聊天机器人,借助 Cloudflare 的缓存,该机器人可以快速响应(在 Slack 允许的3秒内)。我们对 Alpha Vantage API 也很友善,因为如果我们刚刚才获取了数据,就不必因为请求而再次调用 API。

译者注:部署教程

stock-slack-bot 的代码如下:

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
// SLACK_TOKEN 用于验证请求来自 Slack.
// 这个 token 需要保密.
const SLACK_TOKEN = "SLACK 的token"
const BOT_NAME = "Stock-bot 名字🤖"
const ALPHA_VANTAGE_KEY = "alpha vantage api key"


let jsonHeaders = new Headers([["Content-Type", "application/json"]])

addEventListener("fetch", event => {
event.respondWith(slackWebhookHandler(event.request))
})

/**
* simpleResponse generates a simple JSON response
* with the given status code and message.
*
* @param {Number} statusCode
* @param {String} message
*/
function simpleResponse(statusCode, message) {
let resp = {
message: message,
status: statusCode
}

return new Response(JSON.stringify(resp), {
headers: jsonHeaders,
status: statusCode
})
}

/**
* slackWebhookHandler handles an incoming Slack
* webhook and generates a response.
* @param {Request} request
*/
async function slackWebhookHandler(request) {
// As per: https://api.slack.com/slash-commands
// - Slash commands are outgoing webhooks (POST requests)
// - Slack authenticates via a verification token.
// - The webhook payload is provided as POST form data

if (request.method !== "POST") {
return simpleResponse(
200,
`Hi, I'm ${BOT_NAME}, a Slack bot for fetching the latest stock prices`
)
}

try {
let formData = await request.formData()
if (formData.get("token") !== SLACK_TOKEN) {
return simpleResponse(403, "invalid Slack verification token")
}

let parsed = parseMessage(formData)


let reply = await stockRequest(parsed.stock)
let line = `Current price (*${parsed.stock}*): 💵 USD $${reply.USD} (Last updated on ${reply.updated}).`

return slackResponse(line)
} catch (e) {
return simpleResponse(
200,
`Sorry, I had an issue retrieving anything for that stock: ${e}`
)
}
}


/**
* parseMessage parses the selected stock from the Slack message.
*
* @param {FormData} message - the message text
* @return {Object} - an object containing the stock name.
*/
function parseMessage(message) {
// 1. Parse the message (trim whitespace, uppercase)
// 2. Return stock that we are looking for
return {
stock: message.get("text").trim().toUpperCase()
}
}
/**
* stockRequest makes a request to the Alpha Vantage API for the
* given stock request.
* Endpoint: https://www.alphavantage.co/documentation/*
* @param {string} stock - the stock to fetch the price for
* @returns {Object} - an Object containing the stock, price in USD.
*/
async function stockRequest(stock) {
let endpoint = new URL("https://www.alphavantage.co/query")

endpoint.search = new URLSearchParams({"function" : "TIME_SERIES_INTRADAY" ,
"interval" : "1min",
"apikey": ALPHA_VANTAGE_KEY,
"symbol": stock
})


try {
let resp = await fetch(
endpoint,
{ cf: { cacheTtl: 60} } // Cache our responses for 60s.
)

if (resp.status !== 200) {
throw new Error(`bad status code from Alpha Vantage: HTTP ${resp.status}`)
}

let data = await resp.json()
let timeSeries = data["Time Series (1min)"]

// We want to use the last value (1 minute interval) that is provided by the API
let timestamp = Object.keys(timeSeries)[1]
let usd = timeSeries[timestamp]["1. open"]

let reply = {
stock: stock,
USD: usd,
updated: timestamp
}

return reply
} catch (e) {
throw new Error(`could not fetch the selected symbol: ${e}`)
}
}

/**
* slackResponse builds a message for Slack with the given text
* and optional attachment text
*
* @param {string} text - the message text to return
*/
function slackResponse(text) {
let content = {
response_type: "in_channel",
text: text,
attachments: []
}

return new Response(JSON.stringify(content), {
headers: jsonHeaders,
status: 200
})
}

在 cloudflare 管理面板,Workers Product 页面,创建一个 worker,将原有的代码替换为以上代码,取个名字,比如叫 stock-slack-bot。

你可以将 stock-slack-bot 绑定给分配的 subdomain.workers.dev,那么 worker 的 URL 为 stock-slack-bot.subdomain.workers.dev.

或者如果你已经有一个域名,比如叫 example.com。切换到你的域名管理界面,有个 worker 标签,设置一条路由规则,stock-slack-bot 到 stock-slack-bot.example.com。

设置好了后,你可以使用上方文章内的 curl 测试 bot 是否可用。