NodeJS 机器人开发实践指南(三)
七、构建集成的机器人体验
到目前为止,我们已经构建了一个非常好的 LUIS 应用,它一直在不断发展。我们还利用了 Bot Builder 对话引擎,该引擎采用我们的自然语言模型,从用户话语中提取相关的意图和实体,并包含围绕进入 Bot 的许多不同输入排列的条件逻辑。但是我们的代码实际上什么也不做。我们如何让它做一些有用和真实的事情?在整本书中,我们一直在探索日历机器人的概念。这意味着我们需要集成某种日历 API。出于本书的目的,我们将与 Google 的日历 API 集成。设置好之后,我们将探索如何将这些调用集成到 bot 流中。在 OAuth 时代,我们不会花时间在聊天窗口中收集用户名和密码。那是不安全的。相反,我们将使用 Google OAuth 库实现一个三条腿的 OAuth 流。然后,我们将继续对代码进行修改,以支持与 Google Calendar API 的通信。在本章的最后,我们将得到一个可以用来创建约会和查看日历条目的机器人。
注意,本章的代码可以作为代码库的一部分获得。贯穿 bot 代码和本书中的代码,您会发现许多库的使用。使用较多的一个是下划线。下划线是一个漂亮的库,它提供了一系列有用的实用函数,尤其是在集合方面。
关于 OAuth 2.0 的一句话
这不是一本关于安全性的书,但是理解基本的身份验证和授权机制对于开发人员来说是必不可少的。OAuth 2.0 是一个标准的授权协议。三足 OAuth 2.0 流允许第三方应用代表另一个实体访问服务。在我们的例子中,我们将代表用户访问用户的 Google 日历数据。在三条腿的 OAuth 流的末尾,我们以两个令牌结束:一个访问令牌和一个刷新令牌。访问令牌包含在对授权 HTTP 头中的 API 的请求中,并向 API 提供数据,声明我们正在请求哪个用户的数据。访问令牌通常是短暂的,以减少可利用受损访问令牌的窗口。当访问令牌过期时,我们可以使用刷新令牌来接收新的访问令牌。
为了启动这个流程,我们首先将用户重定向到一个他们可以验证的服务,比如说 Google。Google 提供了一个 OAuth 2.0 登录页面,在该页面中,它对用户进行身份验证,并征求用户的同意,以便机器人可以代表他们从 Google 访问用户的数据。当认证和同意成功时,谷歌通过所谓的重定向 URI 将授权码发送回机器人的 API。最后,我们的 bot 通过向 Google 的令牌端点提供授权代码来请求访问和刷新令牌。Google 的 OAuth 库将帮助我们在日历机器人中实现三足流。
设置 Google APIs
在我们开始之前,我们应该让自己能够使用 Google APIs。幸运的是,谷歌通过谷歌云平台 API 控制台使这变得非常容易。谷歌云平台是谷歌的 Azure 或者 AWS 它是谷歌供应和管理不同云服务的一站式商店。首先,我们导航到 https://console.cloud.google.com 。如果这是我们第一次访问网站,我们将被要求接受服务条款。之后,我们将被放置在仪表板中(图 7-1 )。
图 7-1
谷歌云平台仪表板
我们接下来的步骤如下。我们将创建一个新项目。在该项目中,我们将要求访问日历 API。我们还将赋予我们的项目使用 OAuth2 代表用户登录的能力。一旦完成,我们将收到一个客户端 ID 和秘密。这两段数据,加上我们的重定向 URI,足以让我们在 bot 中使用 Google API 库。
单击下拉菜单选择一个 项目。你会看到一个弹出窗口,如果你以前没有使用过这个控制台,它应该是空的(图 7-2 )。
图 7-2
谷歌云平台仪表板项目
单击+按钮添加新项目。为项目命名。一旦项目被创建,我们将能够通过选择项目功能导航到它(图 7-3 )。项目还被分配一个 ID,以项目名称为前缀。
图 7-3
我们的项目创建完成了!
当打开项目时,我们会看到项目仪表板,最初看起来很吓人(图 7-4 )。在这里我们可以做很多事情。
图 7-4
一个项目有很多事情要做
让我们从访问 Google 日历 API 开始。我们首先单击 API 和服务。我们可以在左侧导航窗格的前几个项目中找到此链接。该页面已经填充了相当多的内容。这些是默认的谷歌云平台服务。因为我们不使用它们,我们可以禁用每一个。准备就绪后,我们可以单击启用 API 和服务按钮。我们搜索日历,点击谷歌日历 API。最后,我们点击启用按钮将其添加到我们的项目中(图 7-5 )。我们将收到一条警告,指出我们可能需要凭据才能使用 API。没问题,我们接下来会这样做。
图 7-5
为我们的项目启用日历 API
要设置授权,我们单击左侧窗格中的凭证链接。我们将会看到创建凭据的提示。在我们的用例中,我们将访问用户的日历,我们需要一个 OAuth 客户端 ID 1 (图 7-6 )。
图 7-6
设置我们的客户凭证
我们将首先被要求设置同意屏幕(图 7-7 )。这是用户向 Google 验证时显示的屏幕。我们大多数人可能在不同的 web 应用中遇到过这些类型的屏幕。例如,每当我们通过脸书登录一个应用时,我们都会看到一个页面,告诉我们该应用需要权限才能读取你的所有联系信息和照片,甚至是最深的秘密。这是谷歌建立类似页面的方式。它要求提供产品名称、徽标、服务条款、隐私政策 URL 等数据。为了测试功能,我们至少需要一个产品名称。
图 7-7
OAuth 同意配置
此时,我们将回到创建客户端 ID 功能。作为应用类型设置,我们应该选择 Web 应用,并给我们的客户端一个名称和一个重定向 URI(图 7-8 )。我们利用我们的 ngrok 代理 URI(参见第五章了解更多关于 ngrok 的信息)。对于本地测试,我们可以自由输入本地主机地址。比如可以输入http://localhost:3978。
图 7-8
创建新的 OAuth 2.0 客户端 ID 并提供重定向 URI
一旦我们点击创建 按钮,我们将收到一个带有客户端 ID 和客户端密码的弹出窗口(图 7-9 )。复制它们,因为我们将需要我们的 bot 中的值。如果我们丢失了客户端 ID 和密码,我们总是可以通过导航到项目的凭证页面并选择我们在 OAuth 2.0 客户端 ID 中创建的条目来访问它们。
图 7-9
我们总能找到丢失的 ID 和秘密
此时,我们已经准备好将我们的机器人连接到 Google OAuth2 提供者。
将身份验证与 Bot Builder 集成
我们将需要安装 googleapis Node 包以及 crypto-js ,一个让我们加密数据的库。当我们将用户发送到 OAuth 登录页面时,我们还在 URL 中包含一个州。状态只是一个有效负载,我们的应用可以用它来标识用户及其对话。当 Google 将一个授权码作为 OAuth 2.0 三足流的一部分发回时,它也会发回状态。状态参数应该是我们的 API 可以识别的,但恶意参与者很难猜到的,比如会话散列或我们感兴趣的其他信息。一旦我们从 Google 的 auth 页面收到它,我们就可以使用 state 参数中的数据继续用户的对话。
为了屏蔽不良行为者的数据,我们将把这个对象编码为 Base64 字符串。Base64 是二进制数据的 ASCII 表示。 2 由于一个恶意的参与者可以通过简单地从 Base64 解码来轻易地泄露这些信息,我们将使用 crypto-js 来加密状态字符串。
首先,让我们安装这两个包。
npm install googleapis crypto-js --save
其次,让我们添加三个变量。代表客户端 ID、机密和重定向 URI 的 env 文件。我们使用我们在图 7-8 中提供的重定向 URI 和我们在图 7-9 中收到的客户端 ID 和密码。
GOOGLE_OAUTH_CLIENT_ID=693978449559-8t03j8064o6hfr1f8lh47s9gvc4afed4.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=X6lzSlw500t0wmQQ2SpF6YV6
GOOGLE_OAUTH_REDIRECT_URI=https://a4b5518e.ngrok.io
第三,我们需要生成登录页面的 URL,并发送一个可以打开该 URL 的按钮。Google Auth APIs 可以为我们做很多这方面的工作。我们将在代码中做一些事情。首先,我们导入 crypto-js 和 googleapis 包。接下来,我们创建一个包含客户机数据的 OAuth2 客户机实例。我们将作为登录 URL 的一部分发送的状态包含用户的地址。如前一章所示,一个地址足以唯一地标识用户的对话,Bot Builder 包含一些工具,可以帮助我们通过简单地显示对话地址向该用户发送消息。我们使用 crypto-js 来加密状态,使用 ASE 算法。 3 AES 是一种对称密钥算法,这意味着数据使用相同的密钥或密码进行加密和解密。我们将密码短语添加到。名为 AES_PASSPHRASE 的 env 文件。
GOOGLE_OAUTH_CLIENT_ID=693978449559-8t03j8064o6hfr1f8lh47s9gvc4afed4.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=X6lzSlw500t0wmQQ2SpF6YV6
GOOGLE_OAUTH_REDIRECT_URI=https://a4b5518e.ngrok.io/oauth2callback
AES_PASSPHRASE=BotsBotsBots!!!
另一件要注意的事情是范围数组。当请求对 Google APIs 的授权时,我们使用作用域向 Google 指定我们要访问的 API。我们可以将 scopes 数组中的每一项看作是我们希望从 Google 的 API 中访问的关于用户的一段数据。当然,这个数组需要是我们的 Google 项目可能访问的 API 的子集。如果我们添加了之前没有为项目启用的范围,授权过程将会失败。
const google = require('googleapis');
const OAuth2 = google.auth.OAuth2;
const CryptoJS = require('crypto-js');
const oauth2Client = getAuthClient();
const state = {
address: session.message.address
};
const googleApiScopes = [
'https://www.googleapis.com/auth/calendar'
];
const encryptedState = CryptoJS.AES.encrypt(JSON.stringify(state), process.env.AES_PASSPHRASE).toString();
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: googleApiScopes,
state: encryptedState
});
我们还需要能够发送一个按钮,让用户利用授权机器人。为此,我们使用内置的 SigninCard。
const card = new builder.SigninCard(session).button('Login to Google', authUrl).text('Need to get your credentials. Please login here.');
const loginReply = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments([card]);
仿真器根据图 7-10 渲染信号。
图 7-10
在 Bot 框架模拟器中呈现的签名
此时,我们可以单击 Login 按钮登录到 Google,并授权我们的 bot 访问我们的数据,但这会失败,因为我们还没有提供代码来处理来自返回 URI 的消息。我们使用与安装 API 消息端点相同的方法为 https://a4b5518e.ngrok.io/oauth2callback 端点安装处理程序。我们还启用了 restify.queryParser ,这将把查询字符串中的每个参数公开为 req.query 对象中的一个字段。例如, redirectUri 形式的回调?state=state & code=code 会产生一个查询对象,它有两个属性,state 和 code。
const server = restify.createServer();
server.use(restify.queryParser());
server.listen(process.env.port || process.env.PORT || 3978, function () {
console.log('%s listening to %s', server.name, server.url);
});
server.get('/oauth2callback', function (req, res, next) {
const code = req.query.code;
const encryptedState = req.query.state;
...
});
我们从回调中读取授权代码,并使用 Google OAuth2 客户端从令牌端点获取令牌。标记 JSON 看起来像下面的数据。请注意, expiry_date 是自纪元以来以毫秒为单位的日期时间。 4
{
"access_token": "ya29.GluMBfdm6hPy9QpmimJ5qjJpJXThL1y GcKHrOI7JCXQ46XdQaCDBcJzgp1gWcWFQNPTXjbBYoBp43BkEAyLi3 ZPsR6wKCGlOYNCQIkeLEMdRTntTKIf5CE3wkolU",
"refresh_token": "1/GClsgQh4BvHTxPdbQgwXtLW2hBza6FPLXDC9zBJsKf4NK_N7AfItv073kssh5VHq",
"token_type": "Bearer",
"expiry_date": 1522261726664
}
一旦我们收到令牌,我们就在 OAuth2 对象上调用 setCredentials ,现在可以用它来访问 Google Calendar API 了!
server.get('/oauth2callback', function (req, res, next) {
const code = req.query.code;
const encryptedState = req.query.state;
const oauth2Client = new OAuth2(
process.env.GOOGLE_OAUTH_CLIENT_ID,
process.env.GOOGLE_OAUTH_CLIENT_SECRET,
process.env.GOOGLE_OAUTH_REDIRECT_URI
);
res.contentType = 'json';
oauth2Client.getToken(code, function (error, tokens) {
if (!error) {
oauth2Client.setCredentials(tokens);
// We can now use the oauth2Client to call the calendar API
next();
} else {
res.send(500, {
status: 'error',
error: error
});
next();
}
});
});
在我们可以访问日历 API 的代码位置,我们可以编写代码来获取我们拥有的日历列表并打印出它们的名称。注意,下面代码中的 calapi 是一个 helper 对象,它用 JavaScript promises 包装了 Google Calendar API。代码可以在本章的代码库中找到。
calapi.listCalendars(oauth2Client).then(function (data) {
const myCalendars = _.filter(data, p => p.accessRole === 'owner');
console.log(_.map(myCalendars, p => p.summary));
});
这段代码产生了下面的控制台输出,这是一个不幸的提醒,提醒了我自从当了爸爸后就没怎么活动过的相当孤独的锻炼计划。
Array(5) ["BotCalendar", "Szymon Rozga", "Work", "Szymon WFH Schedule", "Workout schedule"]
撇开父亲的体重增加不谈,这太棒了!我们确实面临一些挑战。我们需要存储用户的 OAuth 令牌,这样我们就可以在用户向我们发送消息时随时访问它们。我们把它们存放在哪里?这个很简单:私人谈话数据。在这种情况下,我们如何访问数据字典呢?我们通过将用户的地址传递给 bot.loadSession 方法来实现这一点。
回想一下,我们将用户的地址存储到加密的状态变量中。我们可以使用与加密数据相同的密码来解密该对象。
const state = JSON.parse(CryptoJS.AES.decrypt(encryptedState, process.env.AES_PASSPHRASE).toString(CryptoJS.enc.Utf8));
收到令牌后,我们可以从该地址加载 bot 会话。此时,我们有了一个 session 对象,它包含了所有的对话方法,如 beginDialog 供我们使用。
oauth2Client.getToken(code, function (error, tokens) {
bot.loadSession(state.address, (sessionLoadError, session) => {
if (!error && !sessionLoadError) {
oauth2Client.setCredentials(tokens);
calapi.listCalendars(oauth2Client).then(function (data) {
const myCalendars = _.filter(data, p => p.accessRole === 'owner');
session.beginDialog('processUserCalendars', { tokens: tokens, calendars: myCalendars });
res.send(200, {
status: 'success'
});
next();
});
// We can now use the oauth2Client to call the calendar API
} else {
res.send(500, {
status: 'error',
error: error
});
next();
}
});
});
processUserCalendars 对话框可能看起来像这样。它将令牌设置到私人对话数据中,让用户知道他们已经登录,并显示所有客户端日历的名称。
bot.dialog('processUserCalendars', (session, args) => {
session.privateConversationData.userTokens = args.tokens;
session.send('You are now logged in!');
session.send('You own the following calendars. ' + _.map(args.calendars, p => p.summary).join(', '));
session.endDialog();
});
交互将如图 7-11 所示。
图 7-11
与对话框集成的登录流程
无缝登录流程
我们已经成功地登录并存储了访问令牌,但是我们还没有演示一个无缝的机制,当一个对话框要求我们的用户登录时,它可以重定向到登录流。更具体地说,如果在日历机器人的上下文中,用户没有登录并要求机器人添加新的日历条目,机器人应该显示登录按钮,然后在登录成功后继续添加条目对话框。
下面列出了与现有对话流集成的一些要求:
-
我们希望允许用户在任何时候向机器人发送文本登录或注销的消息,并让机器人做正确的事情。
-
当需要授权的对话开始时,它需要验证用户授权是否存在。如果 auth 不存在,登录按钮应该出现,并阻止用户继续所述对话,直到用户被授权。
-
如果用户说注销,令牌应该从私人对话数据中清除,并用 Google 撤销。
-
如果用户说登录,bot 需要渲染登录按钮。该按钮将用户指向授权 URL。这与前面描述的相同。然而,我们必须确保点击按钮两次不会混淆机器人和它对用户状态的理解。
我们自然会实现一个登录对话框和一个注销对话框。注销只是检查会话状态中是否存在令牌。如果我们没有令牌,我们已经注销。如果我们这样做,我们使用谷歌的图书馆撤销用户的凭证。 5 代币不再有效。
function getAuthClientFromSession(session) {
const auth = getAuthClient(session.privateConversationData.tokens);
return auth;
};
function getAuthClient(tokens) {
const auth = new OAuth2(
process.env.GOOGLE_OAUTH_CLIENT_ID,
process.env.GOOGLE_OAUTH_CLIENT_SECRET,
process.env.GOOGLE_OAUTH_REDIRECT_URI
);
if (tokens) {
auth.setCredentials(tokens);
}
return auth;
}
bot.dialog('LogoutDialog', [(session, args) => {
if (!session.privateConversationData.tokens) {
session.endDialog('You are already logged out!');
} else {
const client = getAuthClientFromSession(session);
client.revokeCredentials();
delete session.privateConversationData['tokens'];
session.endDialog('You are now logged out!');
}
}]).triggerAction({
matches: /^logout$/i
});
登录是一个瀑布式对话框,它在进入下一步之前开始一个确保凭证对话框。在第二步中,它验证是否已登录。请参见下面的代码。它通过验证是否从确保凭证对话框接收到认证标志来做到这一点。如果是,它只是让用户知道她已经登录。否则,会向用户显示一个错误。
注意我们在这里做了什么。我们外包了判断我们是否登录、登录,然后将结果发送回不同对话框的逻辑。只要该对话框返回一个带有字段已验证和可选的错误的对象,就可以正常工作。我们将使用相同的技术将授权流注入到任何其他需要它的对话框中。
bot.dialog('LoginDialog', [(session, args) => {
session.beginDialog(constants.dialogNames.Auth.EnsureCredentials);
}, (session, args) => {
if (args.response.authenticated) {
session.send('You are now logged in!');
} else {
session.endDialog('Failed with error: ' + args.response.error)
}
}]).triggerAction({
matches: /^login$/i
});
所以,最重要的问题变成了,保证凭证做什么?这段代码需要处理四种情况。前两个很简单。
-
如果对话框需要凭据并且授权成功,会发生什么情况?
-
如果一个对话框需要凭证,而授权失败了,会发生什么?
后两个稍微有点微妙。我们的问题是,如果一个对话框没有等待授权,但它还是进来了,机器人应该做什么。或者换句话说,如果 EnsureCredentials 不在栈顶会发生什么?
-
如果用户在需要登录的对话框范围之外单击登录按钮,并且授权成功,会发生什么情况?
-
如果用户在需要登录的对话框范围之外单击登录按钮,并且授权失败,会发生什么情况?
我们在图 7-12 中说明了第一种情况的流程。一个对话框要求我们在继续之前获得用户的授权,就像前面代码中的登录对话框一样。用户将被发送到身份验证页面。一旦 auth 页面返回一个成功的授权代码,它就向我们的 oauth2callback 发送一个回调。一旦我们得到令牌,我们调用一个 StoreTokens 对话框来将令牌存储到对话数据中。该对话框将向 EnsureCredentials 返回成功消息。反过来,这将向调用对话框返回一个成功的身份验证消息。
图 7-12
对话框需要授权,成功授权
如果发生错误,流程是类似的,只是我们用错误对话框替换了确保凭证对话框。然后,错误对话框将向调用对话框返回一个失败的验证消息,调用对话框可以以它认为最合适的方式处理错误(图 7-13 )。回想一下,正如我们在第五章中提到的, replaceDialog 是一个用另一个对话框的实例替换栈顶当前对话框的调用。调用对话不知道,也不关心这个实现细节。
图 7-13
对话需要授权,授权失败
如果用户在对话框不期待回复时点击登录按钮,并且 EnsureCredentials 不在堆栈顶部,那么流程会略有不同。如果授权成功或失败,我们希望向用户显示成功或失败的消息。为了实现这一点,我们将在调用 StoreTokens 对话框之前在堆栈上放置一个确认对话框 AuthConfirmation (图 7-14 )。
图 7-14
用户表示登录,授权成功
同样,在我们收到授权错误的情况下,我们在推送错误对话框之前,推送堆栈顶部的授权确认对话框(图 7-15 )。这将确保确认对话框向用户显示正确类型的消息。
图 7-15
用户说登录,授权失败
让我们看看这个的代码是什么样子的。登录和注销对话框已经完成,但是让我们看看确保凭证、存储令牌和错误。
确保凭证由两个步骤组成。首先,如果用户定义了一组令牌,那么对话框会传递一个结果,表明用户可以使用了。否则,我们创建 auth URL 并向用户发送一个 SigninCard ,就像我们在上一节中所做的那样。第二步也在案例 1 中执行。它只是告诉调用对话框用户已被授权。
bot.dialog('EnsureCredentials', [(session, args) => {
if(session.privateConversationData.tokens) {
// if we have the tokens... we're good. if we have the tokens for too long and the tokens expired
// we'd need to somehow handle it here.
session.endDialogWithResult({ response: { authenticated: true } });
return;
}
const oauth2Client = getAuthClient();
const state = {
address: session.message.address
};
const encryptedState = CryptoJS.AES.encrypt(JSON.stringify(state), process.env.AES_PASSPHRASE).toString();
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: googleApiScopes,
state: encryptedState
});
const card = new builder.HeroCard(session)
.title('Login to Google')
.text("Need to get your credentials. Please login here.")
.buttons([
builder.CardAction.openUrl(session, authUrl, 'Login')
]);
const loginReply = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments([card]);
session.send(loginReply);
}, (session, args) => {
session.endDialogWithResult({ response: { authenticated: true } });
}]);
StoreTokens 和错误类似。两者本质上都将授权结果返回给其父对话框。在存储令牌的情况下,我们也将令牌存储到对话数据中。
bot.dialog('Error', [(session, args) => {
session.endDialogWithResult({ response: { authenticated: false, error: args.error } });
}]);
bot.dialog('StoreTokens', function (session, args) {
session.privateConversationData.tokens = args.tokens;
session.privateConversationData.calendarId = args.calendarId;
session.endDialogWithResult({ response: { authenticated: true }});
});
请注意, EnsureCredentials 将使用这两者中任何一个的结果,并简单地将其传递给调用对话框。由调用对话框决定显示成功还是错误消息。甚至可能没有成功消息;调用对话框可能会直接跳到它自己的步骤中。
这涵盖了案例 1 和案例 2。为了确保案例 3 和 4 被涵盖,我们需要实现这个 AuthConfirmation 对话框。该对话框的作用是显示成功或失败消息。回想一下,我们在 AuthConfirmation 之上放置了一个错误(案例 3)或存储令牌(案例 4)对话框。这个想法是, AuthConfirmation 将接收对话框的名称并放在它自己的上面,然后当它接收到结果时向用户发送适当的消息。
bod.dialog('AuthConfirmation', [
(session, args) => {
session.beginDialog(args.dialogName, args);
},
(session, args) => {
if (args.response.authenticated) {
session.endDialog('You are now logged in.')
}
else {
session.endDialog('Error occurred while logging in. ' + args.response.error);
}
}
]);
最后,我们如何改变端点回调代码?在我们到达那里之前,我们编写一些助手来调用不同的对话框。我们公开了一个名为is insure的函数,该函数验证我们是否正在从 EnsureCredentials 对话框进入这段代码。这将决定我们是否需要认证。beginErrorDialog 和beginStoreTokensAndResume都利用了这种方法。最后, ensureLoggedIn 是每个需要授权的对话框必须调用来启动流程的函数。
function isInEnsure(session) {
return _.find(session.dialogStack(), function (p) { return p.id.indexOf('EnsureCredentials') >= 0; }) != null;
}
const beginErrorDialog = (session, args) => {
if (isInEnsure(session)) {
session.replaceDialog('Error', args);
}
else {
args.dialogName = 'Error';
session.beginDialog('AuthConfirmation', args);
}
};
const beginStoreTokensAndResume = (session, args) => {
if (isInEnsure(session)) {
session.beginDialog('StoreTokens', args);
} else {
args.dialogName = 'StoreTokens';
session.beginDialog('AuthConfirmation', args);
}
};
const ensureLoggedIn = (session) => {
session.beginDialog('EnsureCredentials');
};
最后来看回调。代码看起来与上一节中的回调类似,只是我们需要添加逻辑来启动正确的对话框。如果我们在加载我们的会话对象时遇到任何错误,或者我们得到一个 OAuth 错误,比如用户拒绝访问我们的 bot,我们将用户重定向到错误对话框。否则,我们使用来自 Google 的授权代码来获取令牌,在 OAuth 客户端中设置凭证,并调用 StoreTokens 或 AuthConfirmation 对话框。以下代码涵盖了本节开头突出显示的四种情况:
exports.oAuth2Callback = function (bot, req, res, next) {
const code = req.query.code;
const encryptedState = req.query.state;
const oauthError = req.query.error;
const state = JSON.parse(CryptoJS.AES.decrypt(encryptedState, process.env.AES_PASSPHRASE).toString(CryptoJS.enc.Utf8));
const oauth2Client = getAuthClient();
res.contentType = 'json';
bot.loadSession(state.address, (sessionLoadError, session) => {
if (sessionLoadError) {
console.log('SessionLoadError:' + sessionLoadError);
beginErrorDialog(session, { error: 'unable to load session' });
res.send(401, {
status: 'Unauthorized'
});
} else if (oauthError) {
console.log('OAuthError:' + oauthError);
beginErrorDialog(session, { error: 'Access Denied' });
res.send(401, {
status: 'Unauthorized'
});
} else {
oauth2Client.getToken(code, (error, tokens) => {
if (!error) {
oauth2Client.setCredentials(tokens);
res.send(200, {
status: 'success'
});
beginStoreTokensAndResume(session, {
tokens: tokens
});
} else {
beginErrorDialog(session, {
error: error
});
res.send(500, {
status: 'error'
});
}
});
}
next();
});
};
练习 7-1
设置 谷歌 使用 Gmail 访问权限认证
本练习的目标是创建一个允许用户根据 Gmail API 进行授权的机器人。您的目标是遵循以下步骤:
-
设置一个 Google 项目,并启用对 Google Gmail API 的访问。
-
创建 OAuth 客户端 ID 和密码。
-
在您的 bot 中创建一个基本工作流,允许用户使用 Gmail 范围登录 Google,并将令牌存储在用户的私人对话数据中。
在本练习结束时,您将已经创建了一个可以代表 bot 用户访问 Gmail API 的 bot。
与 Google 日历 API 集成
我们现在已经准备好与 Google 日历 API 集成了。有几件事我们应该先解决。Google 日历允许用户访问多个日历,并且每个日历有不同的权限级别。在我们的机器人中,我们假设在任何时候我们只在一个日历中查询或添加事件,尽管这看起来有缺陷。我们可以扩展 LUIS 应用和 bot,使其能够为每个话语指定一个日历。
为了解决这个问题,我们创建了一个 PrimaryCalendar 对话框,允许用户设置、重置和检索他们的主日历。类似于在每个需要认证的对话开始时调用的确保凭证对话,我们创建了一个类似的机制来保证日历被设置为主日历。
在我们到达那里之前,让我们谈论连接到 Google 日历 API。Google API Node 包包括日历 API 等。API 使用以下格式:
API.Resource.Method(args, function (error, response) {
});
日历呼叫将如下所示:
calendar.events.get({
auth: auth,
calendarId: calendarId,
eventId: eventId
}, function (err, response) {
// do stuff with the error and/or response
});
首先,我们将使其适应 JavaScript Promise6模式。承诺使异步调用变得容易。JavaScript 中的承诺表示操作的最终完成或失败,以及它的返回值。它支持一个允许我们对结果执行操作的然后方法和一个允许我们对错误对象执行操作的 catch 方法。承诺可以链接:一个承诺的结果可以传递给另一个承诺,后者产生的结果可以传递给另一个承诺,依此类推,产生如下所示的代码:
promise1()
.then(r1 => promise2(r2))
.then(r2 => promise3(r2))
.catch(err => console.log('Error in promise chain. ' + err));
我们修改后的 Google Calendar Promise API 将如下所示:
gcalapi.getCalendar(auth, temp)
.then(function (result) {
// do something with result
}).catch(function (err) {
// do something with err
});
我们将所有必要的功能包装在一个名为 calendar-api 的模块中。下面是一些代码:
const google = require('googleapis');
const calendar = google.calendar('v3');
function listEvents (auth, calendarId, start, end, subject) {
const p = new Promise(function (resolve, reject) {
calendar.events.list({
auth: auth,
calendarId: calendarId,
timeMin: start.toISOString(),
timeMax: end.toISOString(),
q: subject
}, function (err, response) {
if (err) reject(err);
resolve(response.items);
});
});
return p;
}
function listCalendars (auth) {
const p = new Promise(function (resolve, reject) {
calendar.calendarList.list({
auth: auth
}, function (err, response) {
if (err) reject(err);
else resolve(response.items);
});
});
return p;
};
随着 API 的工作,我们现在将焦点转向 PrimaryCalendar 对话框。这个对话框必须处理几种情况。
-
如果用户发送诸如“获取主日历”或“设置主日历”之类的话语,会发生什么?前者应该返回日历的卡片表示,后者应该允许用户选择日历卡片。
-
如果用户登录后没有设置主日历,会发生什么?此时,我们会自动尝试让用户选择一个日历。
-
如果用户通过日历卡上的操作按钮选择日历,会发生什么?
-
如果用户通过键入日历名称来选择日历,会发生什么情况?
-
如果用户试图执行一个需要设置日历的操作(比如添加一个新的约会),会发生什么?
PrimaryCalendar 对话框是一个包含三个步骤的瀑布式对话框。步骤 1 通过调用确保凭证来确保用户登录。步骤 2 期望接收来自用户的命令。我们可以获取当前的主日历,设置日历,或者重置日历;因此,这三个命令是 get、set 或 reset。设置日历需要一个可选的日历 ID。如果没有传递日历 ID,set 命令等同于 reset 命令。Reset 只是向用户发送一个用户可以写访问的所有可用日历的列表(另一个简化的假设)。
get 案例由以下代码处理:
let temp = null;
if (calendarId) { temp = calendarId.entity; }
if (!temp) {
temp = session.privateConversationData.calendarId;
}
gcalapi.getCalendar(auth, temp).then(result => {
const msg = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments([utils.createCalendarCard(session, result)]);
session.send(msg);
}).catch(err => {
console.log(err);
session.endDialog('No calendar found.');
});
复位盒向用户发送一系列日历卡片。如果用户输入一个文本输入,瀑布的第三步假设输入是一个日历名称,并设置正确的日历。如果输入未被识别,则会发送一条错误消息。
handleReset(session, auth);
function handleReset (session, auth) {
gcalapi.listCalendars(auth).then(result => {
const myCalendars = _.filter(result, p => { return p.accessRole !== 'reader'; });
const msg = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments(_.map(myCalendars, item => { return utils.createCalendarCard(session, item); }));
builder.Prompts.text(session, msg);
}).catch(err => {
console.log(err);
session.endDialog('No calendar found.');
});
}
createCalendarCard 方法只是发送一张带有标题、副标题和发送设置日历命令的按钮的卡片。按钮回发该值:设置主日历为{日历} 。
function createCalendarCard (session, calendar) {
const isPrimary = session.privateConversationData.calendarId === calendar.id;
let subtitle = 'Your role: ' + calendar.accessRole;
if (isPrimary) {
subtitle = 'Primary\r\n' + subtitle;
}
let buttons = [];
if (!isPrimary) {
let btnval = 'Set primary calendar to ' + calendar.id;
buttons = [builder.CardAction.postBack(session, btnval, 'Set as primary')];
}
const heroCard = new builder.HeroCard(session)
.title(calendar.summary)
.subtitle(subtitle)
.buttons(buttons);
return heroCard;
};
这提出了一个有趣的挑战。如果在除了 PrimaryCalendar 对话框之外的任何上下文中发送日历卡片,我们需要一个完整的话语来解析一个全局动作,然后调用 PrimaryCalendar 对话框。然而,如果我们在主日历对话框的上下文中提供这样的卡片,按钮仍然会触发全局动作,因此重置我们的整个堆栈。我们不想根据哪个对话框创建了卡片来设置不同的文本,因为这些按钮保留在聊天历史中,可以随时点击。
此外,如果调用了 PrimaryCalendar 对话框,我们希望确保它不会删除当前对话框。例如,如果我正在添加一个约会,我应该能够切换日历,然后回到流程中的正确步骤。
我们覆盖了触发动作和选择动作方法来确保正确的行为。如果 PrimaryCalendar 对话框的另一个实例在堆栈上,我们替换它。否则,我们将把 PrimaryCalendar 对话框推到堆栈的顶部。
.triggerAction({
matches: constants.intentNames.PrimaryCalendar,
onSelectAction: (session, args, next) => {
if (_.find(session.dialogStack(), function (p) { return p.id.indexOf(constants.dialogNames.PrimaryCalendar) >= 0; }) != null) {
session.replaceDialog(args.action, args);
} else {
session.beginDialog(args.action, args);
}
}
});
如果当用户在另一个主日历对话框的实例中时调用一个主日历对话框,我们用主日历对话框的另一个实例替换顶部的对话框。实际上,在这里请原谅我,这只会发生在重置命令中,它实际上会取代构建器。我们在中调用的提示文本对话框。
所以,本质上我们以一个 PrimaryCalendar 对话框等待一个响应对象结束,这个响应对象现在可以来自另一个 PrimaryCalendar 对话框。我们可以让最顶层的实例在完成后返回一个标志,这样当第三步继续时,另一个实例就退出了。下面是说明这一逻辑的最后一个瀑布步骤:
function (session, args) {
// if we have a response from another primary calendar dialog, we simply finish up!
if (args.response.calendarSet) {
session.endDialog({ response: { calendarSet: true } });
return;
}
// else we try to match the user text input to a calendar name
var name = session.message.text;
var auth = authModule.getAuthClientFromSession(session);
// we try to find the calendar with a summary that matches the user's input.
gcalapi.listCalendars(auth).then(function (result) {
var myCalendars = _.filter(result, function (p) { return p.accessRole != 'reader'; });
var calendar = _.find(myCalendars, function (item) { return item.summary.toUpperCase() === name.toUpperCase(); });
if (calendar == null) {
session.send('No such calendar found.');
session.replaceDialog(constants.dialogNames.PrimaryCalendar);
}
else {
session.privateConversationData.calendarId = result.id;
var card = utils.createCalendarCard(session, result);
var msg = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments([card])
.text('Primary calendar set!');
session.send(msg);
session.endDialog({ response: { calendarSet: true } });
}
}).catch(function (err) {
console.log(err);
session.endDialog('No calendar found.');
});
}
设置动作不太复杂。如果我们在收到用户消息的同时收到一个日历 ID,我们只需设置该消息并发回一张日历卡片。如果我们没有收到日历 ID,我们假设与 reset 相同的行为。
let temp = null;
if (calendarId) { temp = calendarId.entity; }
if (!temp) {
handleReset(session, auth);
} else {
gcalapi.getCalendar(auth, temp).then(result => {
session.privateConversationData.calendarId = result.id;
const card = utils.createCalendarCard(session, result);
const msg = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments([card])
.text('Primary calendar set!');
session.send(msg);
session.endDialog({ response: { calendarSet: true } });
}).catch(err => {
console.log(err);
session.endDialog('this calendar does not exist');
// this calendar id doesn't exist...
});
}
这是一个很大的过程,但它很好地说明了一些需要发生的对话体操,以确保一致和全面的对话体验。在下一节中,我们将把认证和主日历流程集成到我们在第六章中开发的对话框中,并将逻辑连接到对 Google Calendar API 的调用。
实现 Bot 功能
至此,我们已经准备好将我们的 bot 代码连接到 Google Calendar API。我们的代码从它的第五章状态没有太大的变化。这些是我们的对话框的主要变化:
-
我们必须确保用户已经登录。
-
我们必须确保设置了主日历。
-
利用谷歌日历 API 最终让事情发生!
让我们从前两项开始。为此,我们创建了保证凭证和主日历对话框。在提供的代码中,我们的 authModule 和 primaryCalendarModule 模块包含两个助手来调用 EnsureCredentials 和 PrimaryCalendar 对话框。我们的每个功能都可以利用助手来确保设置凭证和主日历。
对于那些对话来说,这是太多的责任了。我们必须在每个对话框中添加两个步骤。相反,让我们创建一个对话框,它可以按照正确的顺序评估所有的预检查,并简单地将一个结果传递给调用对话框。下面是我们实现这一目标的方法。我们创建一个名为 PreCheck 的对话框。该对话框将进行必要的检查,如果有错误,将返回一个带有错误集的响应对象,以及一个指示哪个检查失败的标志。
bot.dialog('PreCheck', [
function (session, args) {
authModule.ensureLoggedIn(session);
},
function (session, args) {
if (!args.response.authenticated) {
session.endDialogWithResult({ response: { error: 'You must authenticate to continue.', error_auth: true } });
} else {
primaryCalendarModule.ensurePrimaryCalendar(session);
}
},
function (session, args, next) {
if (session.privateConversationData.calendarId) session.endDialogWithResult({ response: { } });
else session.endDialogWithResult({ response: { error: 'You must set a primary calendar to continue.', error_calendar: true } });
}
]);
任何需要设置 auth 和主日历的对话框只需调用预检查对话框并确保没有错误。这里有一个来自示例代码中的 ShowCalendarSummary 对话框的例子。注意,瀑布中的第一步调用预检,第二步确保所有预检成功通过。
lib.dialog(constants.dialogNames.ShowCalendarSummary, [
function (session, args) {
g = args.intent;
prechecksModule.ensurePrechecks(session);
},
function (session, args, next) {
if (args.response.error) {
session.endDialog(args.response.error);
return;
}
next();
},
function (session, args, next) {
// do stuff
}
]).triggerAction({ matches: constants.intentNames.ShowCalendarSummary });
前两项就这样了。至此,只剩下第三个了;我们需要实现与谷歌日历 API 的实际集成。以下是 ShowCalendarSummary 对话框第三步的示例。注意,我们收集了 datetimeV2 实体来计算我们需要检索哪个时间段的事件,我们可以选择使用 Subject 实体来过滤日历项目,并且我们构建了一个按日期排序的事件卡片转盘。 createEventCard 方法为每个 Google 日历 API 事件对象创建一个 HeroCard 对象。
其余对话框的实现可以在本书附带的 calendar-bot-building 存储库中找到。
function (session, args, next) {
var auth = authModule.getAuthClientFromSession(session);
var entry = new et.EntityTranslator();
et.EntityTranslatorUtils.attachSummaryEntities(entry, session.dialogData.intent.entities);
var start = null;
var end = null;
if (entry.hasRange) {
if (entry.isDateTimeEntityDateBased) {
start = moment(entry.range.start).startOf('day');
end = moment(entry.range.end).endOf('day');
} else {
start = moment(entry.range.start);
end = moment(entry.range.end);
}
} else if (entry.hasDateTime) {
if (entry.isDateTimeEntityDateBased) {
start = moment(entry.dateTime).startOf('day');
end = moment(entry.dateTime).endOf('day');
} else {
start = moment(entry.dateTime).add(-1, 'h');
end = moment(entry.dateTime).add(1, 'h');
}
}
else {
session.endDialog("Sorry I don't know what you mean");
return;
}
var p = gcalapi.listEvents(auth, session.privateConversationData.calendarId, start, end);
p.then(function (events) {
var evs = _.sortBy(events, function (p) {
if (p.start.date) {
return moment(p.start.date).add(-1, 's').valueOf();
} else if (p.start.dateTime) {
return moment(p.start.dateTime).valueOf();
}
});
// should also potentially filter by subject
evs = _.filter(evs, function(p) {
if(!entry.hasSubject) return true;
var containsSubject = entry.subject.toLowerCase().indexOf(entry.subject.toLowerCase()) >= 0;
return containsSubject;
});
var eventmsg = new builder.Message(session);
if (evs.length > 1) {
eventmsg.text('Here is what I found...');
} else if (evs.length == 1) {
eventmsg.text('Here is the event I found.');
} else {
eventmsg.text('Seems you have nothing going on then. What a sad existence you lead.');
}
if (evs.length >= 1) {
var cards = _.map(evs, function (p) {
return utils.createEventCard(session, p);
});
eventmsg.attachmentLayout(builder.AttachmentLayout.carousel);
eventmsg.attachments(cards);
}
session.send(eventmsg);
session.endDialog();
});
}
function createEventCard(session, event) {
var start, end, subtitle;
if (!event.start.date) {
start = moment(event.start.dateTime);
end = moment(event.end.dateTime);
var diffInMinutes = end.diff(start, "m");
var diffInHours = end.diff(start, "h");
var duration = diffInMinutes + ' minutes';
if (diffInHours >= 1) {
var hrs = Math.floor(diffInHours);
var mins = diffInMinutes - (hrs * 60);
if (mins == 0) {
duration = hrs + 'hrs';
} else {
duration = hrs + (hrs > 1 ? 'hrs ' : 'hr ') + (mins < 10 ? ('0' + mins) : mins) + 'mins';
}
}
subtitle = 'At ' + start.format('L LT') + ' for ' + duration;
} else {
start = moment(event.start.date);
end = moment(event.end.date);
var diffInDays = end.diff(start, 'd');
subtitle = 'All Day ' + start.format('L') + (diffInDays > 1 ? end.format('L') : '');
}
var heroCard = new builder.HeroCard(session)
.title(event.summary)
.subtitle(subtitle)
.buttons([
builder.CardAction.openUrl(session, event.htmlLink, 'Open Google Calendar'),
builder.CardAction.postBack(session, 'Delete event with id ' + event.id, 'Delete')
]);
return heroCard;
};
练习 7-2
与 Gmail API 集成
虽然欢迎您按照上一节中的代码,然后使用随书提供的代码来组装一个日历机器人,但本练习的目标是创建一个可以从用户的 Gmail 帐户发送电子邮件的机器人。通过这种方式,您可以练习练习 7-1 中的验证逻辑,并与以前没有见过的客户端 API 集成。
-
以练习 7-1 中的代码为起点,创建一个包含两个对话框的机器人,一个用于发送邮件,一个用于查看未读消息。没有必要创建 LUIS 应用(尽管您当然可以自由地使用它)。使用关键字发送和列表来调用对话框。
-
对于发送操作,创建一个名为 SendMail 的对话框。这个对话框应该收集电子邮件地址、标题和消息正文。确保该对话框与授权流集成。
-
与 Gmail 客户端库集成,使用在身份验证流程中收集的用户访问令牌发送电子邮件。使用这里的文档获取 messages.send API 调用:
https://developers.google.com/gmail/api/v1/reference/users/messages/send。 -
对于列表操作,创建一个名为 ListMail 的对话框。该对话框应该使用在授权流期间收集的用户访问令牌从用户的收件箱中获取所有未读邮件。使用这里的文档来调用 messages . list API:
https://developers.google.com/gmail/api/v1/reference/users/messages/list。 -
将未读邮件列表呈现为一个转盘。显示标题、接收日期和在 web 浏览器中打开电子邮件的按钮。您可以在这里找到消息对象的引用:
https://developers.google.com/gmail/api/v1/reference/users/messages#resource。消息的 URL 是https://mail.google.com/mail/#inbox/{MESSAGE_ID}。
如果你成功创造了这个机器人,恭喜你!这不是最容易的练习,但结果非常值得。现在,您已经掌握了创建 bot、将其与 OAuth 流集成、使用第三方 API 使 bot 发挥作用以及将项目呈现为卡片的技能。干得好!
结论
构建机器人既容易又具有挑战性。用一些简单的命令很容易建立一个基本的机器人。很容易获得用户话语并基于它们执行代码。然而,获得恰到好处的用户体验是相当具有挑战性的。正如我们所观察到的,开发机器人的挑战是双重的。
首先,我们需要理解自然语言话语的许多排列。我们的用户可以用不同的方式说同样的事情,只是有细微的差别。我们为这本书构建的 LUIS 应用是一个良好的开端,但是还有许多其他方式来表达相同的想法。我们需要判断什么时候 LUIS 应用足够好。Bot 测试是很多这类评估发生的地方。一旦我们在你的机器人上释放一组用户,我们将看到用户最终如何使用你的机器人,以及他们期望处理什么类型的输入和行为。这是我们提高自然语言理解和决定下一步构建什么功能所需的数据。我们将在第十三章介绍帮助完成这项任务的分析工具。
第二,花时间在整体对话体验上是很重要的。虽然这不是本书的重点,但适当的体验是我们机器人成功的关键。我们确实花了一些时间来思考如何确保用户在进入针对日历 API 的任何操作的对话框之前登录。这是我们开发机器人时需要考虑的行为和流程类型的一个例子。一个更天真的机器人可能只是给用户发送一个错误,说他们需要先登录,然后用户不得不重复输入。一个更好的实现是通过我们在本章中创建的对话框进行重定向。幸运的是,Bot Builder SDK 及其对话模型帮助我们在代码中描述这些复杂的流程。
我们现在有技能和经验来开发复杂和惊人的机器人体验,与所有类型的 API 集成。这才是 LUIS 和微软 Bot 框架真正的合力!
Footnotes 1谷歌云平台支持三种类型的服务凭证。API 键是一种识别项目和接收 API 访问、配额和报告的方法。OAuth 客户端 ID 允许您的应用代表用户发出请求。最后,服务帐户允许应用代表应用发出请求。你可以在 https://support.google.com/cloud/answer/6158857?hl=en 找到更多信息。
2
base64:https://en.wikipedia.org/wiki/Base64
3
CryptoJS 支持很多不同的哈希和密码算法。完整的列表可以在该项目的 GitHub 页面 https://github.com/jakubzapletal/crypto-js 找到。你可以在 https://en.wikipedia.org/wiki/Advanced_Encryption_Standard 找到更多关于 AES 算法的信息。
4
UNIX 纪元时间是自 1970 年 1 月 1 日 00:00:00 UTC: https://en.wikipedia.org/wiki/Unix_time 以来经过的毫秒数
5
OAuth 令牌撤销: https://tools.ietf.org/html/rfc7009
6
Mozilla 开发者网:承诺对象: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
八、扩展通道功能
到目前为止,我们已经花了大量的时间讨论 NLU 系统、对话体验,以及我们如何通过 Bot Builder SDK 使用通用格式以通用方式开发 Bot。Bot Builder SDK 让我们可以快速启动并运行。这是为什么它是如此强大的抽象的部分原因。但坦率地说,该领域的许多创新来自各种消息平台。例如,Slack 在协作软件方面处于领先地位。Slack 编辑消息的能力非常强大,支持交互式工作流。
在这一章中,我们将探索从一个机器人框架内调用本机功能的能力。我们将学习调用 Slack 的特性,将简单的基于文本的工作流转换成丰富的基于按钮和菜单的体验。在这个过程中,我们将注册一个 Slack 集成,将我们的 bot 连接到我们的 Slack 工作区,然后使用本地 Slack 调用来创建一个引人注目的简单工作流。让我们开始吧。
更深层次的松散集成
Slack 是一个丰富的平台,允许内部和外部团队的不同成员之间紧密协作。界面很简单,但消息传递框架与 Facebook Messenger 之类的东西非常不同。例如,虽然有一个名为 attachments 的工具可以产生一个类似于卡片的用户界面,但它并没有被以同样的方式对待。没有旋转木马,对图像的长宽比没有要求。
Slack 中的消息只是一个带有文本属性的 JSON 对象,其中的文本可以有引用用户、通道或团队的特殊序列。这些引用名为*@提及*,是类似*@频道的文本串,通知一个频道的所有用户关注一条消息。其他例子还有@这里和@大家*。一封邮件最多可以包含 20 个附件。附件只是一个为邮件提供附加上下文的对象。JSON 对象如下所示:
{
"attachments": [
{
"fallback": "Required plain-text summary of the attachment.",
"color": "#36a64f",
"pretext": "Optional text that appears above the attachment block",
"author_name": "Bobby Tables",
"author_link": "http://flickr.com/bobby/",
"author_icon": "http://flickr.com/icons/bobby.jpg",
"title": "Slack API Documentation",
"title_link": "https://api.slack.com/",
"text": "Optional text that appears within the attachment",
"fields": [
{
"title": "Priority",
"value": "High",
"short": false
}
],
"image_url": "http://my-website.com/path/to/image.jpg",
"thumb_url": "http://example.com/path/to/thumb.png",
"footer": "Slack API",
"footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png",
"ts": 123456789
}
]
}
像英雄卡片一样,我们可以包含标题、文本和图片。此外,我们还可以为 Slack 提供各种其他参数。我们可以引用消息作者、数据字段或主题颜色。
为了帮助处理附件的细微差别,Slack 包含了一个消息生成器(图 8-1 ,它可以用来可视化 JSON 对象在 Slack 用户界面中的呈现方式。
图 8-1
松弛消息生成器和预览
Slack 还为消息提供了最佳实践文档。网站上的建议之一是尽可能少地使用对我们的应用有意义的附件(图 8-2 )。
图 8-2
好方向…
不幸的是,这似乎不是 Bot 框架的工作方式。事实上,Slack Bot 通道连接器将一个 HeroCard 对象呈现为多个附件(图 8-3 )。
图 8-3
只是 Slack Bot 通道连接器没有完全遵守 Slack 指南
这是一个小细节,但它就是不好看。图像和按钮的默认样式是渲染图像下方的按钮(图 8-4 )。不幸的是,渲染违反了 Slack 提供的方向。
图 8-4
一个格式良好的附件会是什么样子
自然,这是 Bot 框架团队将来最有可能支持的细节。在此之前,如果我们想要呈现的界面类型和平台支持的内容不匹配,我们可以使用原生 JSON 来实现我们的目标。
Slack 还包括一些我们作为 bot 服务中的一等公民无法访问的功能。Slack 支持临时消息,即在组设置中仅对一个用户可见的消息。Bot Builder SDK 没有提供实现这一点的简单方法。此外,Slack 支持交互式消息的概念,即带有按钮和菜单的消息,用户可以对其进行操作。更好的是,用户的动作可以触发消息呈现的更新!一条消息可以包括按钮,作为从用户那里收集数据的一种方式(如图 8-3 和 8-4 所示),或者一条消息可以包括菜单来选择一个选项(图 8-5 )。
图 8-5
简单的菜单
在本节中,我们将探讨如何通过本机消息紧密集成来实现交互式消息效果。
首先,我们将把我们的机器人与一个 Slack 工作空间集成起来。其次,我们将创建一个一步到位的交互式消息。第三,我们将创建一个多步骤的交互式消息,提供丰富的、松散的本地数据收集体验。
在我们继续之前,让我们回顾一些基本规则。本章并不打算让你深入了解 Slack 的消息传递 API 和特性。我们鼓励你自己去阅读这些;Slack 有关于这个主题的非常丰富的文档。我们想要展示的是我们如何利用 bot 服务来提供与 Slack 的更深层次的集成。你可能会问,为什么不直接用 Slack 的 Node 开发工具包开发一个原生 Slackbot 呢?当然可以,但是使用 Bot Builder 库有两个主要原因。第一,您可以获得对话和对话引擎来帮助指导用户完成对话;第二,如果您在多个消息传递通道上公开体验,一个代码库可以实现代码重用。
连接到时差
让我们假设你从来没有使用过 Slack。我们首先需要创建一个宽松的工作空间。工作空间只是一个团队协作的宽松环境。我们可以免费创造这些。有一些限制,但自由团队仍然非常实用,肯定会允许我们开发和演示 Slack 机器人。转到 https://slack.com/create 创建一个工作区。Slack 会要求发邮件(图 8-6 )并发送确认码来验证我们的身份。
图 8-6
创建新的可宽延工作空间
一旦我们输入确认码,它将要求我们输入我们的姓名、密码、(组)工作区名称、目标受众和工作区 URL。我们可以向工作区发送邀请,但现在我们将跳过这一步。我们不会被重定向到工作区。出于演示的目的,我的名字是 https://srozgaslacksample.slack.com 。
此时,我们应该整合 bot 服务和 Slack。在 Azure 上的 Bot 服务条目中,单击 Slack 频道。我们将看到松弛配置屏幕(图 8-7 )。
图 8-7
配置我们的机器人的松散集成
该界面类似于 Facebook Messenger 频道配置界面,但要求不同的数据。我们需要来自 Slack 的三条信息:客户机 ID、客户机秘密和验证令牌。
在 https://api.slack.com/apps 登录 Slack,新建一个 app。输入应用名称并选择我们刚刚创建的开发工作区(图 8-8 )。最后点击创建应用按钮。
图 8-8
创建 Slack 应用
创建应用后,我们将被重定向到应用页面。点击权限设置重定向网址(图 8-9 )。你将被带到一个名为 OAuth &权限的页面。
图 8-9
设置 bot 服务重定向 URI
点击添加新的重定向网址,输入 https://slack.botframework.com 。接下来选择左侧工具条中的机器人用户项,并为机器人添加一个用户。这允许我们给机器人分配一个用户名,并指示它是否应该总是在线出现(图 8-10 )。
图 8-10
创建在通道中代表机器人的机器人用户
接下来,我们将订阅几个事件,这些事件将被发送到 bot 服务 web 钩子。这将确保 bot 服务能够正确地将相关的 Slack 事件发送到我们的 bot 中。导航到事件订阅,通过右边的开关启用事件,输入 https://slack.botframework.com/api/Events/{YourBotHandle} 作为请求 URL。在第五章中,一个机器人句柄被分配给我们的机器人频道注册,可以在设置页面中找到。一旦进入,Slack 将建立到端点的连接。最后,在下订阅 Bot 事件(不是工作区事件!)添加以下事件:
-
会员 _ 加入 _ 通道
-
成员 _ 左 _ 频道
-
消息.通道
-
消息.组
-
message.im
-
message.er
图 8-11 显示了最终的配置。
图 8-11
为我们的机器人订阅空闲事件
我们还需要启用交互式组件来支持通过菜单、按钮或交互式对话框接收消息。在左侧菜单中选择交互组件,点击启用交互消息,输入如下请求 URL: https://slack.botframework.com/api/Actions (图 8-12 )。点击启用交互组件并保存更改。
图 8-12
在我们的机器人中启用交互式组件。这意味着按钮和菜单!
最后,我们从应用凭证部分(可通过基本信息菜单项访问)提取凭证,并将客户端 ID、客户端密码和验证令牌输入 Azure 门户中 bot 通道注册的通道刀片内的配置备用屏幕。提交后,您将被要求登录到您的 Slack 工作区并验证该应用。授权后,你的 bot 会出现在你的 Slack workspace 界面(在 Apps 类别下),你就可以和它交流了(图 8-13 )。
图 8-13
我们已经连接到 Azure bot 服务
记得运行 ngrok!在图 8-13 中,你可以看出我忘记了运行我的 ngrok。
练习 8-1
基本松弛度整合和 消息渲染
本练习的目标是将一个机器人连接到 Slack,这样您就可以熟悉作为消息传递和机器人平台的 Slack。你的目标是将你在第 5 和 7 章节中创建的日历机器人部署到 Slack。部署完成后,您可以对比模拟器或 Facebook Messenger 检查不同元素在 Slack 中的呈现方式。
-
创建一个测试松弛工作空间。
-
按照上一节中的步骤将 Azure Bot 服务 Bot 连接到工作区。
-
确认你可以通过 Slack 和你的机器人交流。
-
测试机器人并回答以下问题:机器人如何呈现登录按钮?机器人如何渲染主卡选择卡?bot 在多用户对话中如何表现(您可能需要向工作区添加一个新的测试用户)?
干得好。现在,您可以将一个现有的 bot 连接到 Slack,并且了解 Slack、它的消息和附件。
试用 Slack APIs
我们只是使用 Bot Builder SDK 和 Bot 框架向 Slack 发送消息,但是我们也可以直接访问 Slack APIs。我们对几个 Slack API 方法感兴趣。 2
-
Chat.postMessage :在空闲频道发布新消息
-
Chat.update :更新 Slack 中的现有消息
-
Chat.postEphemeral :在 Slack 频道中发布一条新的短暂消息,只有一个用户可以看到
-
Chat.delete :删除一条松弛消息
要调用这些,我们需要一个访问令牌。例如,假设我们有一个令牌,我们可以使用下面的 Node.js 代码来创建一个新消息:
function postMessage(token, channel, text, attachments) {
return new Promise((resolve, reject) => {
let client = restify.createJsonClient({
url: 'https://slack.com/api/chat.postMessage',
headers: {
Authorization: 'Bearer ' + token
}
});
client.post('',
{
channel: channel,
text: text,
attachments: attachments
},
function (err, req, res, obj) {
if (err) {
console.log('%j', err);
reject(err);
return;
}
console.log('%d -> %j', res.statusCode, res.headers);
console.log('%j', obj);
resolve(obj);
});
});
}
一个自然的问题是我们如何获得令牌?如果我们检查来自 bot 服务通道连接器的消息,我们会注意到我们拥有所有这些信息。来自 Slack 的完整传入消息如下所示:
{
"type": "message",
"timestamp": "2017-11-23T17:27:13.5973326Z",
"text": "hi",
"attachments": [],
"entities": [],
"sourceEvent": {
"SlackMessage": {
"token": "fffffffffffffffffffffff",
"team_id": "T84FFFFF",
"api_app_id": "A84SFFFFF",
"event": {
"type": "message",
"user": "U85MFFFFF",
"text": "hi",
"ts": "1511458033.000193",
"channel": "D85TN0231",
"event_ts": "1511458033.000193"
},
"type": "event_callback",
"event_id": "Ev84PDKPCK",
"event_time": 1511458033,
"authed_users": [
"U84A79YTB"
]
},
"ApiToken": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"address": {
"id": "ffffffffffffffffffffffffffffffffff",
"channelId": "slack",
"user": {
"id": "U85M9EQJ2:T84V64ML5",
"name": "szymon.rozga"
},
"conversation": {
"isGroup": false,
"id": "B84SQJLLU:T84V64ML5:D85TN0231"
},
"bot": {
"id": "B84SQJLLU:T84V64ML5",
"name": "szymontestbot"
},
"serviceUrl": "https://slack.botframework.com"
},
"source": "slack",
"agent": "botbuilder",
"user": {
"id": "U85M9EQJ2:T84V64ML5",
"name": "szymon.rozga"
}
}
请注意, sourceEvent 包括一个 ApiToken 和一个 SlackMessage ,其中包含了关于机器人位于哪个通道以及原始消息来自哪个用户的所有详细信息。本例中,通道为 D85TN0231,用户为 U85M9EQJ2。此外,我们可以找到团队、机器人、机器人用户和应用的 id。传入消息在 Slack 中实际上没有 ID;每条消息都有一个唯一的每通道时间戳,称为 ts 。
因此,一旦我们收到来自用户的第一条消息,我们可以通过使用 Bot Builder 的 session.send 方法或者直接使用 chat.postMessage 端点来轻松地做出响应(图 8-14 )。当然, session.send 通过调用 Slack channel 连接器来为我们做所有的令牌工作,然后 Slack channel 连接器调用 chat.postMessage 。
图 8-14
使用本机松弛调用进行响应
const bot = new builder.UniversalBot(connector, [
session => {
let token = session.message.sourceEvent.ApiToken;
let channel = session.message.sourceEvent.SlackMessage.event.channel;
postMessage(token, channel, 'POST!');
}
]);
除了 chat.postMessage 返回消息的原生 ts 值,而 session.send 不返回之外,postMessage 并没有比 session.send 更好的东西。非常酷。这意味着我们现在可以更新消息了!我们定义一个 updateMessage 方法如下:
function updateMessage(token, channel, ts, text, attachments) {
return new Promise((resolve, reject) => {
let client = restify.createJsonClient({
url: 'https://slack.com/api/chat.update',
headers: {
Authorization: 'Bearer ' + token
}
});
client.post('',
{
channel: channel,
ts: ts,
text: text,
attachments: attachments
},
function (err, req, res, obj) {
if (err) {
console.log('%j', err);
reject(err);
return;
}
console.log('%d -> %j', res.statusCode, res.headers);
console.log('%j', obj);
resolve(obj);
});
});
};
现在,我们可以编写代码来发送消息,并在任何其他响应到来时更新它(参见图 8-15 ,图 8-16 ,图 8-17 )。
图 8-17
完全按照设计
图 8-16
似乎在起作用…
图 8-15
到目前为止一切顺利…
let msgts = null;
const bot = new builder.UniversalBot(connector, [
session => {
let token = session.message.sourceEvent.ApiToken;
let channel = session.message.sourceEvent.SlackMessage.event.channel;
let user = session.message.sourceEvent.SlackMessage.event.user;
if (msgts) {
updateMessage(token, channel, msgts, '<@' + user + '> said ' + session.message.text);
} else {
postMessage(token, channel, 'A placeholder...').then(r => {
msgts = r.ts;
});
}
}
]);
现在这是一个虚构的例子,但是它说明了我们调用一个 postMessage 后跟一个更新来修改消息内容的能力。关于 update 到底能做什么有一些规则,但是我们把阅读文档 3 作为开发人员的练习。
我们可以用 API 完成的另一个例子是发布和删除短暂的消息。短暂的消息仅对消息的接收者可见。例如,机器人可以向用户提供反馈,而不在频道中显示结果,直到收集了所有必要的数据。虽然交互模型略有不同,但是 giphy 4 斜杠命令是这种模型的一个很好的例子。
使用/ giphy 允许我们搜索任何文本,并在短暂的消息中显示一些 GIF 选项。在利用集成之前,您可能必须首先启用它。一旦我们决定使用哪一个并点击发送,GIF 就会以我们的名义发送到频道(图 8-18 ,图 8-19 ,图 8-20 )。
图 8-20
我现在通过使用/giphy mean girls,在 Slack conversation 中使 2004 年的邪教经典《Mean Girls》不朽
图 8-19
一个酷妈妈意味着女孩 GIF 预览
图 8-18
调用/giphy 斜杠命令
我们可以使用 postEphemeral 消息只给某些用户反馈。当然,delete 使我们能够从 bot 中删除旧消息。从可用性的角度来看,删除功能并不有趣。用一个修正来更新一个消息,或者通知用户一个消息已经被删除了,这是一个更好的体验,而不是简单地删除它而不做任何解释。
简单的交互式消息
Slack 允许我们使用所谓的交互式消息来实现更好的对话体验。 5 交互消息是包括通常的消息数据加上按钮和菜单的消息。此外,当用户与用户界面元素交互时,消息可以改变以反映这一点。
下面是一个例子:机器人会发送一条请求批准的消息,当用户单击“是”或“否”按钮时,我们的机器人会修改消息以反映选择(图 8-21 ,图 8-22 ,图 8-23 )。
图 8-23
请求未被批准
图 8-22
请求已批准
图 8-21
简单的互动信息
当然,我们可以使用 postMessage 和 updateMessage 来编排这种类型的行为,但是有一种更简单、更集成的方式来实现。首先,我们定义了一个名为 simpleflow 的对话框,它使用选择提示来发送带有按钮的消息。
const bot = new builder.UniversalBot(connector, [
session => {
session.beginDialog('simpleflow');
},
session => {
session.send('done!!!');
session.endConversation();
}
]);
bot.dialog('simpleflow',
[
(session, arg) =>{
builder.Prompts.choice(session, 'A request for access to /SYS13/ABD has come in. Do you want to approve?', 'Yes|No');
},
... // next code snippet goes here
]);
然后,我们通过向 response_url 发出 POST 请求来处理对按钮点击的响应。
(session, arg) =>{
let r = arg.response.entity;
let responseUrl = session.message.sourceEvent.Payload.response_url;
let token = session.message.sourceEvent.Payload.token;
let client = restify.createJsonClient({
url: responseUrl
});
let userId = session.message.sourceEvent.Payload.user.id;
let attachment ={
color: 'danger',
text: 'Rejected by <@' + userId + '>'
};
if (r === 'No'){} else if (r === 'Yes'){
attachment ={
color: 'good',
text: 'Approved by <@' + userId + '>'
};
}
client.post('',
{
token: token,
text: 'Request for access to /SYS13/ABD',
attachments: [attachment
]
}, function (err, req, res, obj){
if (err) console.log('Error -> %j', err);
console.log('%d -> %j', res.statusCode, res.headers);
console.log('%j', obj);
session.endDialog();
});
}
这里发生了一些事情。首先,我们从 Slack 获取响应,该响应被解析为实体值。其次,我们从 Slack 消息中获取所谓的 response_url。response_url 是一个 url,它允许我们修改用户刚刚响应的交互消息,或者在频道中创建新消息。接下来,我们获取授权我们向 response_url 发送 POST 请求的令牌。最后,我们向 response_url 发送更新后的消息。
我们将围绕交互式消息结构讨论更多的细节,但是让我们讨论用户体验。在开发利用这种功能的机器人时,我们必须做出决定:当机器人呈现交互消息时,用户是必须立即回答它,还是可以在用户和机器人讨论其他话题时将交互消息保留在历史中?在后一种情况下,在对话后期的任何时候,用户都可以向上滚动并单击一个按钮来完成该操作。前一个示例使用了前一种方法;这就是 Bot Builder 提示的工作方式。图 8-24 显示了如果用户没有回复消息时的情况。
图 8-24
嗯…似乎我有两组按钮来回答同一个问题
好的,我们有两组按钮。有道理。如果我们点击是或否按钮,该信息将根据图 8-25 进行修改。对话框结束,bot 瀑布的第二步发送“完成!!!"消息。然而,谈话处于一种奇怪的状态;似乎原始请求仍未完成。
图 8-25
第一条消息不也应该更新吗?
现在,对话框堆栈顶部不再包含选择提示。这意味着,如果我们点击上方消息中的 Yes 或 No 按钮,我们将会遇到问题,因为我们的代码不期望这种类型的响应(图 8-26 )。事实上,我们将收到另一个提示,因为机器人再次调用 beginDialog 。拥有多个未解决的交互消息而没有能力解决所有这些消息是糟糕的 UX。
图 8-26
哦,那没有意义…
这种经历会很快变得复杂。这是任何平台上呈现按钮的问题:按钮留在聊天记录中,可以随时点击。作为开发人员,我们的角色是确保机器人能够在任何时候处理按钮和它们的有效载荷。
这里有一种方法可以解决前面的问题。我们保留默认行为不变,但是我们创建了一个自定义识别器,它处理交互式消息输入并将消息重定向到一个对话框,告诉用户操作已经过期,如果这些输入不是预期的。让我们从对话开始。它将读取交互式消息的 response_url,并简单地发布一条“对不起,此操作已过期。”给它发信息。当机器人解析意图 practicalbot.expire 时,该对话框被调用。这样的命名约定允许我们区分 LUIS 意图和机器人内部意图。
bot.dialog('remove_action',
[
(session, arg) =>{
let responseUrl = session.message.sourceEvent.Payload.response_url;
let token = session.message.sourceEvent.Payload.token;
let client = restify.createJsonClient({
url: responseUrl
});
client.post('',
{
token: token,
text: 'Sorry, this action has expired.'
}, function (err, req, res, obj){
if (err) console.log('Error -> %j', err);
console.log('%d -> %j', res.statusCode, res.headers);
console.log('%j', obj);
session.endDialog();
});
}
]).triggerAction({ matches: 'practicalbot.expire'
});
自定义识别器如下所示:
bot.recognizer({
recognize: function (context, done){
let intent = { score: 0.0 };
if (context.message.sourceEvent &&
context.message.sourceEvent.Payload &&
context.message.sourceEvent.Payload.response_url)
{
intent = { score: 1.0, intent: 'practicalbot.expire' };
}
done(null, intent);
}
});
简而言之,我们说如果我们的对话框不能显式地处理来自用户的动作响应,那么全局 practicalbot.expire 意图将会被触及。在这种情况下,我们只需告诉用户操作已经过期。净效果如图 8-27 和图 8-28 所示。我们首先进入这样一个场景,有两条交互消息要求我们输入是或否。我们赞成第二个。在图 8-28 中,我们在第一组按钮上点击是。
图 8-28
有效。我们现在可以在不造成 UX 混乱的情况下使用旧的交互信息。
图 8-27
好,回到这个场景
有几个我们应该提到的警告。首先,如果您尝试使用文本而不是单击按钮来响应提示,所提供的代码将会失败。这是为什么?Slack 不发送包含消息交互细节的有效负载对象。这只会被认为是文本输入,我们没有办法正确地将消息更新为被批准或被拒绝。处理这个问题的一种方法是只需要按钮输入,而不是文本输入。另一种方法是接受它,但将确认作为新消息发送。以下是在图 8-29 中用一条文本消息响应后产生的对话的行为代码:
图 8-29
我们现在也可以处理文本回复
(session, arg) => {
let r = arg.response.entity;
let userId = null;
const isTextMessage = session.message.sourceEvent.SlackMessage; // this means we receive a slack message
if (isTextMessage) {
userId = session.message.sourceEvent.SlackMessage.event.user;
} else {
userId = session.message.sourceEvent.Payload.user.id;
}
Let attachment = {
color: 'danger',
text: 'Rejected by <@' + userId + '>'
};
if (r === 'No') {
} else if (r === 'Yes') {
attachment = {
color: 'good',
text: 'Approved by <@' + userId + '>'
};
}
if (isTextMessage) {
// if we got a text message, reply using
// session.send with the confirmation message
let msg = new builder.Message(session).sourceEvent({
'slack': {
text: 'Request for access to /SYS13/ABD',
attachments: [attachment]
}
});
session.send(msg);
} else {
let responseUrl = session.message.sourceEvent.Payload.response_url;
let token = session.message.sourceEvent.Payload.token;
let client = restify.createJsonClient({
url: responseUrl
});
client.post('', {
token: token,
text: 'Request for access to /SYS13/ABD',
attachments: [attachment]
}, function (err, req, res, obj) {
if (err) console.log('Error -> %j', err);
console.log('%d -> %j', res.statusCode, res.headers);
console.log('%j', obj);
session.endDialog();
});
}
}
}
第二点需要注意的是,在前面的例子中,我们使用了 choice 提示,该提示会阻止对话,直到用户发出 yes 或 no 响应。我们希望避免这种行为,以便用户可以继续使用机器人,而不必立即回答提示。更好的方法是安装一个全局识别器,它能够将交互式消息响应映射到意图,而意图又映射到完成某些动作的对话框。我们将在练习 8-2 中看到这一点。
练习 8-2
探索 Slack 中的非阻塞交互消息
在上一节中,我们探讨了如何利用选择提示来要求用户使用交互式消息进行输入。在本练习中,您将创建一个自定义识别器来将交互式消息响应映射到对话框。对话框将包含使用 Slack 提供的 response_url 更新交互消息的逻辑。
-
创建一个通用机器人,它启动一个名为sendbenseapproval的对话框。
-
创建一个名为sendpenseapproval的对话框。对话框应该创建一个随机费用对象,有四个字段: ID 、用户、类型、金额。该对象将表示用户在类型类型的商品上花费了$ 金额的事实。ID 应该只是一个随机的唯一标识符。例如,创建一个对象,表示 Szymon 花了 60 美元乘出租车,或者 Bob 花了 20 美元买了一箱加味汽水。生成随机费用后,向用户发送一张总结费用的英雄卡和两个标签为批准和拒绝的按钮。使用 session.send 发送响应后,结束对话。
-
此时,机器人不做任何事情。修改英雄卡中的批准和拒绝按钮,以便发送到机器人的值是 id 为{ID}的批准请求和 ID 为{ID}的拒绝请求。
-
创建一个自定义识别器来匹配这些模式并提取 ID。您的自定义识别器应该根据输入返回意图 ApproveRequestIntent 或 RejectRequestIntent 。确保在结果识别器对象中包含 ID。
-
创建两个对话框,一个名为 ApproveRequestDialog ,一个名为 RejectRequestDialog 。使用触发动作将对话框连接到相应的意图。
-
确保两个对话框向 response_url 发送正确的批准或拒绝响应,以便更新原始 hero 卡。
本练习中使用的全局处理所有交互消息的技术是强大的和可扩展的。您可以轻松地为任何未来行为添加更多的消息类型、意图和对话框。实际上,您最终可能会得到阻塞和非阻塞消息的混合。您现在已经准备好处理这两种风格。
多步体验
在上一节中,我们创建了一个单步交互式消息。我们将通过一个更复杂的多步骤交互来继续探索 Slack 上的交互消息。假设我们想引导用户通过一个多步骤的过程来选择一种比萨饼、一些配料和一个尺寸。我们将使用多步互动信息来构建体验。本部分的代码包含在本书的 git repos 中;我们将在接下来的几页中分享最相关的内容。
我们的经验如下。该机器人将首先要求用户为他们的比萨饼提供一种酱料类型(图 8-30 )。
图 8-30
你想要什么比萨饼调味汁?
如果用户回答番茄酱,我们的有限机器人将要求用户从两种馅饼中选择一种:普通馅饼或意大利香肠馅饼(图 8-31 )。
图 8-31
番茄酱披萨
如果用户选择了油和大蒜酱,他们将得到一组不同的选项(图 8-32 )。
图 8-32
油大蒜比萨的额外配料选择
最后一步要求用户选择一个尺寸。我们为这一步渲染一个菜单(图 8-33 )。
图 8-33
你想要多大的?
一旦完成,信息将变成订单的总结(图 8-34 )。
图 8-34
用户订单摘要
作为练习,我们将利用本机 Slack APIs。Bot Builder SDK 需要一个对话框步骤来明确使用提示从一个步骤前进到下一个步骤。因为我们将直接使用 Slack API,所以我们将有一个单步瀑布式对话框。这意味着相同的函数将被反复调用,直到识别出不同的全局动作,或者我们的对话框调用 endDialog 。
您可能还记得,在前面的例子中,我们利用 Bot Builder 的提示发送回按钮,并将结果收集回 Bot 中的逻辑。Bot 框架为我们抽象出的一件事情是,向用户发送提示实际上是发送一个带有附件的 Slack 消息,其中包含一组操作,每个按钮都是不同的操作。当用户点击或点击一个按钮时,我们的机器人会有一个回调,回调 ID 用来标识这个动作。
例如,如果我们将这条消息发送给 Slack,它将呈现一条类似图 8-31 的消息。
pizzatype: {
text: 'Sauce',
attachments: [
{
callback_id: 'pizzatype',
title: 'Choose a Pizza Sauce',
actions: [
{
name: 'regular',
value: 'regular',
text: 'Tomato Sauce',
type: 'button'
},
{
name: 'step2b',
value: 'oilandgarlic',
text: 'Oil & Garlic',
type: 'button'
}
]
}
]
}
当点击其中一个按钮时,我们的机器人将收到一个回调 ID 为 pizzatype 和所选值的消息。下面是我们点击番茄酱时收到的消息的相关 JSON 片段:
"sourceEvent": {
"Payload": {
"type": "interactive_message",
"actions": [
{
"name": "regular",
"type": "button",
"value": "regular"
}
],
"callback_id": "pizzatype",
...
},
"ApiToken": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
所以,判断我们是否得到一个类型的回调的逻辑很简单。事实上,该代码类似于前面展示的识别器代码。我们创建了一个 isCallbackResponse 函数,它可以告诉我们消息是否是回调,或者,它是否是某种类型的回调。
const isCallbackResponse = function (context, callbackId){
const msg = context.message;
let result = msg.sourceEvent &&
msg.sourceEvent.Payload &&
msg.sourceEvent.Payload.response_url;
if (callbackId){
result = result && msg.sourceEvent.Payload.callback_id === callbackId;
}
return result;
};
然后我们可以配置我们的识别器来使用这个函数。
bot.recognizer({
recognize: function (context, done) {
let intent = { score: 0.0 };
if (isCallbackResponse(context)) {
intent = { score: 1.0, intent: 'practicalbot.expire' };
}
done(null, intent);
}
});
现在,我们可以构建一个能够引导用户完成整个过程的对话框。我们首先声明我们将为每个步骤发送的消息。我们将发送以下五条消息之一:
-
选择比萨饼类型的第一条消息
-
根据选择的比萨饼类型,两种配料选择之一
-
披萨尺寸的选择
-
最终确认消息
下面是我们使用的 JSON:
exports.multiStepData = {
pizzatype: {
text: 'Sauce',
attachments: [
{
callback_id: 'pizzatype',
title: 'Choose a Pizza Sauce',
actions: [
{
name: 'regular',
value: 'regular',
text: 'Tomato Sauce',
type: 'button'
},
{
name: 'step2b',
value: 'oilandgarlic',
text: 'Oil & Garlic',
type: 'button'
}
]
}
]
},
regular: {
text: 'Pizza Type',
attachments: [
{
callback_id: 'ingredient',
title: 'Do you want a regular or pepperoni pie?',
actions: [
{
name: 'regular',
value: 'regular',
text: 'Regular',
type: 'button'
},
{
name: 'pepperoni',
value: 'pepperoni',
text: 'Pepperoni',
type: 'button'
}
]
}
]
},
oilandgarlic: {
text: 'Extra Ingredients',
attachments: [
{
callback_id: 'ingredient',
title: 'Do you want ricotta or caramelized onions?',
actions: [
{
name: 'ricotta',
value: 'ricotta',
text: 'Ricotta',
type: 'button'
},
{
name: 'carmelizedonions',
value: 'carmelizedonions',
text: 'Caramelized Onions',
type: 'button'
}
]
}
]
},
collectsize: {
text: 'Size',
attachments: [
{
text: 'Which size would you like?',
callback_id: 'finish',
actions: [
{
name: 'size_list',
text: 'Pick a pizza size...',
type: 'select',
options: [
{
text: 'Small',
value: 'small'
},
{
text: 'Medium',
value: 'medium'
},
{
text: 'Large',
value: 'large'
}
]
}
]
}
]
},
finish: {
attachments: [{
color: 'good',
text: 'Well done'
}]
}
};
然后我们用一个步骤创建一个水流对话框。如果我们从用户那里收到的消息不是回调,我们使用 postMessage 发送第一步。
let apiToken = session.message.sourceEvent.ApiToken;
let channel = session.message.sourceEvent.SlackMessage.event.channel;
let user = session.message.sourceEvent.SlackMessage.event.user;
let typemsg = multiFlowSteps.pizzatype;
session.privateConversationData.workflowData ={};
postMessage(apiToken, channel, typemsg.text, typemsg.attachments).then(function (){
console.log('created message');
});
否则,如果消息是回调,我们将确定回调类型,获取消息中传递的数据(根据消息是来自按钮还是菜单,传递的数据会略有不同),适当地保存响应数据,并使用下一个相关消息进行响应。我们使用privateconversiondata来跟踪该状态。一个警告是,我们需要显式地保存状态。
session.save();
通常,状态会被保存为 session.send 调用的一部分。因为我们不再使用这种机制,因为我们直接使用 Slack API,所以我们将在方法的最后显式调用它。我们检测用户是否说“退出”来退出流程。下面是整个方法的样子:
(session, arg, next) => {
if (session.message.text === 'quit') {
session.endDialog();
return;
}
if (isCallbackResponse(session)) {
let responseUrl = session.message.sourceEvent.Payload.response_url;
let token = session.message.sourceEvent.Payload.token;
console.log(JSON.stringify(session.message));
let client = restify.createJsonClient({
url: responseUrl
});
let text = '';
let attachments = [];
let val = null;
const payload = session.message.sourceEvent.Payload;
const callbackChannel = payload.channel.id;
if (payload.actions && payload.actions.length > 0) {
val = payload.actions[0].value;
if (!val) {
val = payload.actions[0].selected_options[0].value;
}
}
if (isCallbackResponse(session, 'pizzatype')) {
session.privateConversationData.workflowData.pizzatype = val;
let ingredientStep = multiFlowSteps[val
];
text = ingredientStep.text;
attachments = ingredientStep.attachments;
}
else if (isCallbackResponse(session, 'ingredient')) {
session.privateConversationData.workflowData.ingredient = val;
var ingredientstep = multiFlowSteps.collectsize;
text = ingredientstep.text;
attachments = ingredientstep.attachments;
}
else if (isCallbackResponse(session, 'finish')) {
session.privateConversationData.workflowData.size = val;
text = 'Flow completed with data: ' + JSON.stringify(session.privateConversationData.workflowData);
attachments = multiFlowSteps.finish.attachments;
}
client.post('',
{
token: token,
text: text,
attachments: attachments
}, function (err, req, res, obj) {
if (err) console.log('Error -> %j', err);
console.log('%d -> %j', res.statusCode, res.headers);
console.log('%j', obj);
if (isCallbackResponse(session, 'finish')) {
session.send('The flow is completed!');
session.endDialog();
return;
}
});
} else {
let apiToken = session.message.sourceEvent.ApiToken;
let channel = session.message.sourceEvent.SlackMessage.event.channel;
let user = session.message.sourceEvent.SlackMessage.event.user;
// we are beginning the flow... so we send an ephemeral message
let typemsg = multiFlowSteps.pizzatype;
session.privateConversationData.workflowData = {};
postMessage(apiToken, channel, typemsg.text, typemsg.attachments).then(function () {
console.log('created message');
});
}
session.save();
}
写完所有代码后,让我们看看会发生什么(图 8-35 和 8-36 )。
图 8-36
哎呀!
图 8-35
到目前为止一切顺利 ‘
发生了什么事?事实证明,我们之前创建的用于拒绝不期望的交互消息响应的识别器开始工作,并告诉我们该操作已经过期。似乎提示代码抢占了全局识别器,而如果我们使用瀑布对话框,我们就没有办法控制识别过程。
在第六章中,当我们讨论自定义对话框时,我们简要地提到了一种叫做识别的方法。这个方法允许我们向 Bot Builder SDK 表明,我们希望当前对话框在解释用户消息时排在第一位。在这种情况下,我们有来自 Slack 的特定回调。这是识别功能的一个很好的用例。但是我们如何访问它呢?原来,我们可以创建一个定制的 WaterfallDialog 的子类,并定义一个定制的识别实现。
class WaterfallWithRecognizeDialog extends builder.WaterfallDialog {
constructor(callbackId, steps) {
super(steps);
this.callbackId = callbackId;
}
recognize(context, done) {
var cb = this.callbackId;
if (_.isFunction(this.callbackId)) {
cb = this.callbackId();
// callback can be a function that returns an ID
}
if (!_.isArray(cb)) cb = [cb]; // or a list of IDs
let intent = { score: 0.0 };
// lastly we evaluate each ID to see if it matches the message.
// if yes, handle within this dialog
for (var i = 0; i < cb.length; i++) {
if (isCallbackResponse(context, cb[i])) {
intent = { score: 1.0 };
break;
}
}
done(null, intent);
}
}
简而言之,识别在任何消息进来的时候都会被调用。我们从 this.callbackId 对象解析对话框中支持的回调。我们支持单个回调值、回调值数组或返回回调值的函数。如果回调是任何支持的回调 id,我们返回 1.0 分,这意味着我们的对话框将处理消息。否则,我们通过 0.0 分。这意味着这些回调将会上升到全局识别器,正如在第六章中所讨论的。任何其他回拨 ID 将被视为过期。
我们可以轻松地使用这个类,如下所示:
bot.dialog('multi-step-flow', new WaterfallWithRecognizeDialog(['pizzatype', 'ingredient', 'finish'], [
...
]));
如果我们现在运行代码,我们会得到与图 8-30 到 8-33 中相同的结果流。
练习 8-3
互动消息
在本练习中,您将创建一个多步交互式流来支持一个可以过滤服装产品的机器人。目标是利用与上一节类似的方法来指导用户完成多步数据输入过程。
-
分两步创建一个通用机器人。第一步调用一个名为 filterClothing 的对话框,第二步将对话框的结果打印到控制台并结束对话。
-
按照最新章节的结构创建一个名为 filterClothing 的多步交互式消息对话框。收集三条数据来过滤一个假设的服装集合:服装类型、尺码和颜色。独占使用菜单。
-
确保利用针对 response_url 的 HTTP 请求来更新交互消息。
现在,您已经非常熟悉为多步交互消息使用 Slack API 了,这是一个更酷的 Slack 特性。
结论
本章演示的代码只是触及了我们的 Bot Builder bots 和不同通道之间的集成可能性的表面。尽管我们有意地将重点放在松弛的用例上,但我们希望很清楚,在一系列不同的体验中,无论是一般的还是特定于平台的,都有很多机会重用我们的 bot 代码。
对话框、状态和识别器的强大抽象可以应用于所有通道,甚至在使用本机机制调用对话框时也是如此。我们还没有探索为自定义通道创建连接器。我们将在下一章对此进行研究。
Footnotes 1松弛消息指南: https://api.slack.com/docs/message-guidelines
2
松弛 API 方法: https://api.slack.com/methods
3
slack API chat . update:https://api.slack.com/methods/chat.update
4
Giphy for Slack: https://get.slack.help/hc/en-us/articles/204714258-Giphy-for-Slack
5
松弛交互消息: https://api.slack.com/interactive-messages