在前一章中,我们构建了一个简单的 bot,它可以利用现有的 LUIS 应用和 Bot Builder SDK 来实现日历 Bot 的对话流。就目前情况来看,这个机器人毫无用处。它用描述它从用户输入中理解的内容的文本来响应,但是它没有完成任何实质性的事情。我们正在将我们的 bot 连接到 Google Calendar API,但与此同时,我们需要弄清楚 Bot Builder SDK 为我们提供了哪些工具来创建有意义的对话体验。
在本章中,我们将详细阐述我们在第 5 章代码中使用的一些技术,并更彻底地探索 Bot Builder SDK 的一些特性。我们将弄清楚 SDK 如何存储状态、构建具有丰富内容的消息、构建动作和卡片,并允许框架定制通道行为、对话行为和用户动作处理。最后,我们将看看如何最好地将机器人功能组合成可重用的组件。
正如前面几章所提到的,一个好的对话引擎会存储每个用户和对话的状态,这样每当用户与机器人通信时,都会检索到对话流的正确状态,从而为用户提供一致的体验。在 Bot Builder SDK 中,默认情况下,这种状态通过恰当命名的 MemoryBotStorage 存储在内存中。历史上,状态存储在云端点中;然而,这已被否决。有时,我们可能会在一些旧文档中遇到对 state service 的引用,所以请注意它已经不存在了。
每个对话的状态由 bot 开发人员可以访问的三个桶组成。我们在前一章中介绍了所有这些方法,但要重申的是,它们如下:
-
userData :一个用户在一个频道的所有对话中的数据
-
privateconversiondata:会话范围内的私有用户数据
-
conversationData :会话的数据,由参与会话的任何用户共享
此外,当一个对话框正在执行时,我们可以访问它的状态对象,称为 dialogData 。每当收到来自用户的消息时,Bot Builder SDK 将从状态存储中检索用户的状态,在会话对象上填充三个数据对象和 dialogData,并执行对话中当前步骤的逻辑。一旦所有的响应都被发送出去,框架将把状态保存回状态存储器。
let entry = new et.EntityTranslator(session.dialogData.addEntry);
if (!entry.hasDateTime) {
entry.setEntity(results.response);
}
session.dialogData.addEntry = entry;在前一章的一些代码中,有些情况下我们必须从 dialogData 重新创建一个自定义对象,然后将该对象存储到 dialogData 中。这样做的原因是,将对象保存到 dialogData(或任何其他状态容器)中会将对象转换为普通的 JavaScript 对象,就像使用 JSON.stringify 一样。在重置为新对象之前,尝试调用前面代码中 session.dialogData.addEntry 上的任何方法都会导致错误。
存储机制是由一个名为 IBotStorage 的接口实现的。
export interface IBotStorage {
getData(context: IBotStorageContext, callback: (err: Error, data: IBotStorageData) => void): void;
saveData(context: IBotStorageContext, data: IBotStorageData, callback?: (err: Error) => void): void;
}我们在构建新的 bot 实例时实例化的 ChatConnector 类会安装默认的 MemoryBotStorage 实例,这对于开发来说是一个很好的选择。SDK 允许我们提供自己的实现来替换默认功能,这是您在生产部署中最想做的事情,因为这可以确保在实例重新启动时存储状态,而不是删除状态。例如,微软提供了两个额外的接口实现,一个是 Azure Cosmos DB 1 的 NoSQL 实现,另一个是 Azure Table Storage 的实现。 2 两者都是可以通过 Azure 门户获得的 Azure 服务。你可以在 botbuilder-azure Node 包中找到这两个存储实现,记录在 https://github.com/Microsoft/BotBuilder-Azure 。您还可以编写自己的 IBotStorage 实现,并将其注册到 SDK。编写自己的实现就是遵循简单的 IBotStorage 接口。
const bot = new builder.UniversalBot(connector, (session) => {
// ... Bot code ...
})
.set('storage', storageImplementation);在前一章中,我们的机器人通过使用 session.send 或 session.endDialog 方法发送文本消息来与用户通信。这很好,但它限制了我们的机器人相当数量。机器人和用户之间的消息由我们在前一章的“机器人构建器 SDK 基础”一节中遇到的各种数据组成。
Bot Builder IMessage 接口定义了消息的真正组成。
interface IEvent {
type: string;
address: IAddress;
agent?: string;
source?: string;
sourceEvent?: any;
user?: IIdentity;
}
interface IMessage extends IEvent {
timestamp?: string; // UTC Time when message was sent (set by service)
localTimestamp?: string; // Local time when message was sent (set by client or bot, Ex: 2016-09-23T13:07:49.4714686-07:00)
summary?: string; // Text to be displayed by as fall-back and as short description of the message content in e.g. list of recent conversations
text?: string; // Message text
speak?: string; // Spoken message as Speech Synthesis Markup Language (SSML)
textLocale?: string; // Identified language of the message text.
attachments?: IAttachment[]; // This is placeholder for structured objects attached to this message
suggestedActions: ISuggestedActions; // Quick reply actions that can be suggested as part of the message
entities?: any[]; // This property is intended to keep structured data objects intended for Client application e.g.: Contacts, Reservation, Booking, Tickets. Structure of these object objects should be known to Client application.
textFormat?: string; // Format of text fields [plain|markdown|xml] default:markdown
attachmentLayout?: string; // AttachmentLayout - hint for how to deal with multiple attachments Values: [list|carousel] default:list
inputHint?: string; // Hint for clients to indicate if the bot is waiting for input or not.
value?: any; // Open-ended value.
name?: string; // Name of the operation to invoke or the name of the event.
relatesTo?: IAddress; // Reference to another conversation or message.
code?: string; // Code indicating why the conversation has ended.
}对于这一章,我们将对文本、附件、建议动作和附件布局最感兴趣,因为它们构成了一个好的对话式 UX 的基础。
为了用代码创建一个消息对象,我们创建一个生成器。消息对象。此时,您可以按照下面的示例分配属性。然后可以将消息传递到 session.send 方法中。
const reply = new builder.Message(session)
.text('Here are some results for you')
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments(cards);
session.send(reply);同样,当消息进入您的 bot 时,会话对象包含一个消息对象。同样的界面。相同类型的数据。但是,这一次,它来自通道,而不是来自机器人。
const bot = new builder.UniversalBot(connector, [
(session) => {
const input = session.message.text;
}]);请注意,IMessage 继承自 IEvent,这意味着它有一个类型字段。该字段被设置为 IMessage 的消息,但也有其他事件可能来自框架或自定义应用。
基于通道支持,bot 框架支持的其他一些事件类型如下:
-
conversationUpdate :在对话中添加或删除用户,或者对话的某些元数据发生变化时引发;用于群聊管理。
-
contactRelationUpdate :在用户的联系人列表中添加或删除机器人时引发。
-
输入:用户输入消息时引发;并非所有频道都支持。
-
ping :判断 bot 端点是否可用。
-
deleteUserData :当用户请求删除他们的用户数据时引发。
-
endOfConversation :当对话结束时引发。
-
invoke :当请求机器人执行一些自定义逻辑时引发。例如,一些通道可能需要调用机器人上的一个函数并期待响应。Bot 框架将把这个请求作为 invoke 请求发送,期待一个同步的 HTTP 回复。这种情况并不常见。
我们可以通过使用 UniversalBot 上的 on 方法为每种事件类型注册一个处理程序。与处理事件的机器人的对话可以为您的用户提供更身临其境的对话体验(图 6-1 )。
图 6-1
响应输入和对话更新事件的机器人
const bot = new builder.UniversalBot(connector, [
(session) => {
}
]);
bot.on('conversationUpdate', (data) => {
if (data.membersAdded && data.membersAdded.length > 0) {
if (data.address.bot.id === data.membersAdded[0].id) return;
const name = data.membersAdded[0].name;
const msg = new builder.Message().address(data.address);
msg.text('Welcome to the conversation ' + name + '!');
msg.textLocale('en-US');
bot.send(msg);
}
});
bot.on('typing', (data) => {
const msg = new builder.Message().address(data.address);
msg.text('I see you typing... You\'ve got me hooked! Reel me in!');
msg.textLocale('en-US');
bot.send(msg);
});在消息接口中,address 属性唯一地表示对话中的用户。看起来是这样的:
interface IAddress {
channelId: string; // Unique identifier for channel
user: IIdentity; // User that sent or should receive the message
bot?: IIdentity; // Bot that either received or is sending the message
conversation?: IIdentity; // Represents the current conversation and tracks where replies should be routed to.
}地址背后的重要性在于,我们可以使用它在对话范围之外主动发送消息。例如,我们可以创建一个进程,每五秒钟向一个随机地址发送一条消息。这个消息对用户的对话框堆栈没有任何影响。
const addresses = {};
const bot = new builder.UniversalBot(connector, [
(session) => {
const userid = session.message.address.user.id;
addresses[userid] = session.message.address;
session.send('Give me a couple of seconds');
}
]);
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
setInterval(() => {
const keys = Object.keys(addresses);
if (keys.length == 0) return;
const r = getRandomInt(0, keys.length-1);
const addr = addresses[keys[r]];
const msg = new builder.Message().address(addr).text('hello from outside dialog stack!');
bot.send(msg);
}, 5000);如果我们确实想修改对话框堆栈,也许通过调用一个复杂的对话框操作,我们可以在 UniversalBot 对象上使用 beginDialog 方法。
setInterval(() => {
var keys = Object.keys(addresses);
if (keys.length == 0) return;
var r = getRandomInt(0, keys.length-1);
var addr = addresses[keys[r]];
bot.beginDialog(addr, "dialogname", { arg: true});
}, 5000);这些概念的意义在于,我们可以让不同系统中的外部事件开始影响用户在机器人内部的对话状态。在下一章中,我们将看到 OAuth web 钩子的应用。
可以使用 BotBuilder IMessage 界面中的附件功能向用户发送丰富的内容。在 Bot Builder SDK 中,附件只是一个名称、内容 URL 和 MIME 类型。3Bot Builder SDK 中的一条消息接受零个或多个附件。由 bot 连接器将消息翻译成通道能够理解的内容。并非每个频道都支持所有类型的邮件和附件。创建各种 MIME 类型的附件时要小心。
例如,要共享一个图像,我们可以使用下面的代码:
const bot = new builder.UniversalBot(connector, [
(session) => {
session.send({
text: "Here, have an apple.",
attachments: [
{
contentType: 'image/jpeg',
contentUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Red_Apple.jpg/1200px-Red_Apple.jpg',
name: 'Apple'
}
]
})
}
]);图 6-2 显示了模拟器中的用户界面,图 6-3 显示了 Facebook Messenger 中的用户界面。我们可以想象在其他平台上类似的渲染。
图 6-3
Facebook Messenger 图片附件
图 6-2
模拟器图像附件
这段代码将发送音频文件附件,可以在消息通道中直接播放。
const bot = new builder.UniversalBot(connector, [
(session) => {
session.send({
text: "Here, have some sound!",
attachments: [
{
contentType: 'audio/ogg',
contentUrl: 'https://upload.wikimedia.org/wikipedia/en/f/f4/Free_as_a_Bird_%28Beatles_song_-_sample%29.ogg',
name: 'Free as a bird'
}
]
})
}
]);图 6-4 显示的是模拟器,图 6-5 显示的是 Facebook Messenger()。
图 6-5
Facebook Messenger 中的 OGG 声音文件附件
图 6-4
模拟器中的 OGG 声音文件附件
呜呜!好像不支持 OGG 4 文件。这是一个很好的例子,说明了当我们的机器人向脸书或任何其他通道发送无效消息时,机器人框架的行为。我们将在本章后面的“通道错误”部分对此进行进一步的研究。我的控制台错误日志包含以下消息:
Error: Request to 'https://facebook.botframework.com/v3/conversations/1912213132125901-1946375382318514/activities/mid.%24cAAbqN9VFI95k_ueUOVezaJiLWZXe' failed: [400] Bad Request如果我们查看 Bot Framework Messenger Channels 页面中的错误列表,我们应该会发现如图 6-6 所示的另一条线索。
图 6-6
Messenger 上 OGG 声音文件的 Bot 框架错误
好了,它们让诊断问题变得有些容易。我们知道我们必须提供不同的文件格式。让我们试试 MP3。
const bot = new builder.UniversalBot(connector, [
(session) => {
session.send({
text: "Ok have a vulture instead!",
attachments: [
{
contentType: 'audio/mp3',
contentUrl: 'http://static1.grsites.com/archive/sounds/birds/birds004.mp3',
name: 'Vulture'
}
]
})
}
]);你可以在图 6-7 和图 6-8 中看到模拟器和 Facebook Messenger 的渲染结果。
图 6-8
Facebook Messenger MP3 文件附件
图 6-7
模拟器 MP3 文件附件
模拟器仍然产生一个链接,但 Messenger 有一个内置的音频播放器,你可以利用!上传视频的体验也是类似的。Messenger 将在对话中提供内置的视频播放器。
试验附件
这个练习的目标是编写一个简单的机器人,它可以向用户发送不同类型的附件,并观察模拟器和另一个通道(如 Facebook Messenger)的行为。
-
使用 echo bot 作为起点创建一个基本 bot。
-
通过 bot 功能,在消息中发送不同类型的附件,如 JSON、XML 或 file。尝试一些类型的富媒体,如视频。模拟器如何呈现这些类型的附件?Messenger 怎么样?
-
尝试从模拟器向机器人发送图像。传入的消息包含什么数据?这与您通过 Messenger 发送图像有什么不同吗?
附件是与用户共享各种丰富内容的一种简单方式。明智地使用它们来创造丰富多彩、引人入胜的对话体验。
机器人还可以向用户发送按钮。按钮是用户执行任务的一个独特的行动号召。每个按钮都有一个与之关联的标签,以及一个值。按钮也有一个动作类型,它将决定当按钮被点击时按钮如何处理这个值。三种最常见的操作类型是打开 URL、回发和 IM back。Open URL 通常在消息应用中打开一个 web 视图,或者在桌面设置中打开一个新的浏览器窗口。post back 和 IM back 都将按钮的值作为消息发送给机器人。两者的区别在于,点击回发不应该在聊天记录中显示来自用户的消息,而 IM 回发应该显示。并非所有通道都实现这两种类型的按钮。
const bot = new builder.UniversalBot(connector, [
(session) => {
const cardActions = [
builder.CardAction.openUrl(session, 'http://google.com', "Open Google"),
builder.CardAction.imBack(session, "Hello!", "Im Back"),
builder.CardAction.postBack(session, "Hello!", "Post Back")
];
const card = new builder.HeroCard(session).buttons(cardActions);
const msg = new builder.Message(session).text("sample actions").addAttachment(card);
session.send(msg);
}
]);注意,在前面的代码中,我们使用了一个 CardAction 对象。CardAction 是我们前面讨论过的数据的封装:动作类型、标题和值。通道连接器通常会将一个动作呈现到单个平台上的一个按钮中。
图 6-9 显示了在模拟器中运行这段代码的样子,图 6-10 显示了它在 Facebook Messenger 中的样子。如果我们点击模拟器中的 Open Google 按钮,它会在默认浏览器中打开网页。我们首先点击 Im Back,然后一旦收到回复卡,我们就点击 Post Back。请注意,Im Back 发送了一条消息,该消息出现在聊天历史中,而 Post Back 按钮发送了一条机器人会响应的消息,但该消息不会出现在聊天历史中。
图 6-9
模拟器中 Bot Builder 按钮行为的示例
Messenger 的工作方式略有不同。 5 我们来看看手机 app 行为。如果我们点击打开谷歌,一个网页视图将会出现,覆盖了大约 90%的屏幕。如果我们点击 Im Back 和 Post Back,应用会显示相同的行为。Messenger 仅支持回发;此外,消息值永远不会显示给用户。聊天记录只包含被点击按钮的标题。
图 6-10
Facebook Messenger 中的按钮行为示例
Bot Builder SDK 支持以下操作类型:
-
openUrl :在浏览器中打开一个 Url
-
imBack :从用户向机器人发送一条消息,所有对话参与者都可以看到这条消息
-
回发:从用户向机器人发送一条消息,这条消息可能对所有对话参与者都不可见
-
通话:拨打电话
-
playAudio :在 bot 界面中播放音频文件
-
playVideo :在 bot 界面中播放视频文件
-
showImage :显示机器人界面内的图像
-
下载文件:下载文件到设备
-
登录:启动 OAuth 流程
当然,并不是所有通道都支持所有类型。此外,通道本身可能支持 Bot Builder SDK 不支持的其他功能。例如,图 6-11 显示了截至本文撰写时 Messenger 通过其按钮模板支持的操作的文档。在本章的后面,我们将研究如何利用本机通道功能。
图 6-11
Messenger 按钮模板类型
在 Bot Builder SDK 中,可以通过使用 card action 类中的静态工厂方法来创建每个卡片动作。以下是来自 Bot Builder 源代码的相关代码:
CardAction.call = function (session, number, title) {
return new CardAction(session).type('call').value(number).title(title || "Click to call");
};
CardAction.openUrl = function (session, url, title) {
return new CardAction(session).type('openUrl').value(url).title(title || "Click to open website in your browser");
};
CardAction.openApp = function (session, url, title) {
return new CardAction(session).type('openApp').value(url).title(title || "Click to open website in a webview");
};
CardAction.imBack = function (session, msg, title) {
return new CardAction(session).type('imBack').value(msg).title(title || "Click to send response to bot");
};
CardAction.postBack = function (session, msg, title) {
return new CardAction(session).type('postBack').value(msg).title(title || "Click to send response to bot");
};
CardAction.playAudio = function (session, url, title) {
return new CardAction(session).type('playAudio').value(url).title(title || "Click to play audio file");
};
CardAction.playVideo = function (session, url, title) {
return new CardAction(session).type('playVideo').value(url).title(title || "Click to play video");
};
CardAction.showImage = function (session, url, title) {
return new CardAction(session).type('showImage').value(url).title(title || "Click to view image");
};
CardAction.downloadFile = function (session, url, title) {
return new CardAction(session).type('downloadFile').value(url).title(title || "Click to download file");
};另一种类型的机器人建设者附件是英雄卡。在我们之前的按钮动作的例子中,我们忽略了按钮动作需要成为英雄卡对象的一部分,但是那是什么呢?
英雄卡一词源于赛车界。卡片本身通常比棒球卡要大,旨在宣传比赛团队,特别是车手和赞助商。它包括照片、关于司机和赞助商的信息、联系信息等等。但实际上这个概念让人想起典型的棒球或神奇宝贝卡片。
在 UX 设计中,卡片是展示图像、文本和动作的一种有组织的方式。当谷歌在 Android 和网络上向世界介绍其材料设计 6 时,它给大众带来了卡片。图 6-12 显示了来自谷歌材料设计文档的两个卡片设计示例。注意图像、标题、副标题和行动号召的不同用法。
图 6-12
谷歌的材料设计卡样本
在机器人的上下文中,术语英雄卡指的是一组带有文本、动作按钮和可选默认点击行为的图像。不同的通道会叫卡不同的东西。脸书称它们为模板。其他平台只是将这种想法称为将内容附加到消息中。归根结底,UX 的概念是一样的。
在 Bot Builder SDK 中,我们可以使用以下代码创建一个卡。我们还展示了这张卡片如何在模拟器中呈现(图 6-13 )和在 Facebook Messenger 上呈现(图 6-14 )。
图 6-14
Facebook Messenger 中的相同英雄卡
图 6-13
由模拟器渲染的英雄卡
const bot = new builder.UniversalBot(connector, [
(session) => {
const cardActions = [
builder.CardAction.openUrl(session, 'http://google.com', "Open Google"),
builder.CardAction.imBack(session, "Hello!", "Im Back"),
builder.CardAction.postBack(session, "Hello!", "Post Back")
];
const card = new builder.HeroCard(session)
.buttons(cardActions)
.text('this is some text')
.title('card title')
.subtitle('card subtitle')
.images([new builder.CardImage(session).url("https://bot-framework.azureedge.net/bot-icons-v1/bot-framework-default-7.png").toImage()])
.tap(builder.CardAction.openUrl(session, "http://dev.botframework.com"));
const msg = new builder.Message(session).text("sample actions").addAttachment(card);
session.send(msg);
}
]);卡片是传达用户调用的机器人操作结果的一种很好的方式。如果你想用图像和后续行动显示一些数据,没有比使用卡片更好的方法了。事实上,您得到的只是几个不同的文本字段,具有有限的格式化能力,这意味着这种方法产生的 UX 可能有点有限。这是有意的。对于更复杂的可视化和场景,您可以利用自适应卡或渲染自定义图形。我们将在第 11 章探讨这两个主题。
下一个问题是,我们能以旋转木马的方式并排展示卡片吗?当然可以。Bot Builder SDK 中的消息有一个名为 attachmentLayout 的属性。我们将此设置为 carousel,添加更多的卡,我们就完成了!模拟器(图 6-15 )和 Facebook Messenger(图 6-16 )负责以一种友好的转盘格式将卡片展开。默认的附件布局是一个列表。使用这种布局,卡片会一张一张地出现。这不是最用户友好的方法。
图 6-16
信使上同样的英雄卡旋转木马
图 6-15
模拟器中的英雄卡转盘
const bot = new builder.UniversalBot(connector, [
(session) => {
const cardActions = [
builder.CardAction.openUrl(session, 'http://google.com', "Open Google"),
builder.CardAction.imBack(session, "Hello!", "Im Back"),
builder.CardAction.postBack(session, "Hello!", "Post Back")
];
const msg = new builder.Message(session).text("sample actions");
for(let i=0;i<3;i++) {
const card = new builder.HeroCard(session)
.buttons(cardActions)
.text('this is some text')
.title('card title')
.subtitle('card subtitle')
.images([new builder.CardImage(session).url("https://bot-framework.azureedge.net/bot-icons-v1/bot-framework-default-7.png").toImage()])
.tap(builder.CardAction.openUrl(session, "http://dev.botframework.com"));
msg.addAttachment(card);
}
msg.attachmentLayout(builder.AttachmentLayout.carousel);
session.send(msg);
}
]);卡片可能有点棘手,因为按钮和图像有很多种布局方式。每个平台都有稍微不同的规则。在某些平台上,openUrl 按钮(但不是其他的)必须指向一个 HTTPS 地址。还可能存在限制每张卡的按钮数量、转盘中的卡数量和图像纵横比的规则。微软的机器人框架将尽可能以最好的方式处理这一切,但意识到这些限制将有助于我们调试我们的机器人。
我们已经在对话式设计的背景下讨论了建议的动作;它们是特定于消息上下文的操作,可以在收到消息后立即执行。如果有另一条消息进来,上下文就会丢失,建议的操作也会消失。这与卡片操作相反,卡片操作几乎永远留在聊天记录中。典型的建议动作 UX,也称为快速回复,是沿着屏幕底部水平排列的按钮列表。
构建建议动作的代码类似于英雄卡片,除了我们需要的唯一数据是卡片动作的集合。“建议操作”区域中允许的操作类型将取决于频道。图 6-17 和图 6-18 分别显示了模拟器和 Facebook Messenger 上的效果图。
图 6-18
在 Messenger 中建议相同的操作
图 6-17
模拟器中呈现的建议操作
msg.suggestedActions(new builder.SuggestedActions(session).actions([
builder.CardAction.postBack(session, "Option 1", "Option 1"),
builder.CardAction.postBack(session, "Option 2", "Option 2"),
builder.CardAction.postBack(session, "Option 3", "Option 3")
]));建议的动作按钮很好地保持了与用户的对话,而不要求用户猜测他们可以在文本消息字段中键入什么。
卡片和 建议动作
字典和辞典是好的机器人导航体验的好灵感。用户可以输入一个单词。得到的卡片可以显示单词的图像和定义。下面的一个按钮可以让我们打开一个参考页面,比如在 https://www.merriam-webster.com/ 上。建议的动作可以是一组当前单词的同义词按钮。让我们把这种互动落实到位。
-
使用
https://dictionaryapi.com创建帐户并建立连接。这个 API 将允许您使用字典和同义词库 API。 -
创建一个机器人,它可以使用 Dictionary API 根据用户输入查找单词,并以包含单词和定义文本的 hero card 作为响应。包括一个打开词典网站上该单词页面的按钮。
-
连接到同义词库 API,返回前十个同义词作为建议操作。
-
作为奖励,使用 Bing 图像搜索 API 来填充卡片中的图像。您可以在 Azure 中获得一个访问密钥,并使用以下示例作为指南:
https://docs.microsoft.com/en-us/azure/cognitive-services/bing-image-search/image-search-sdk-node-quickstart。
现在,您已经有了将您的机器人连接到不同 API 并将这些 API 响应转换成英雄卡、按钮和建议动作的经验。干得好!
在“丰富内容”部分,我们注意到当我们的机器人向 Facebook Messenger 连接器发送错误请求时,我们的机器人将收到 HTTP 错误。这个错误也被打印在机器人的控制台输出中。似乎脸书机器人连接器从脸书 API 向我们的机器人报告了一个错误。太酷了。我们看到的额外功能是 Azure 中的频道详情页面也包含所有这些错误。虽然很小,但这是一个强大的功能。它允许我们快速查看有多少消息被 API 拒绝以及错误代码。我们遇到的情况是,不支持特定的文件类型格式,这只是许多可能的错误之一。如果消息格式不正确,如果存在身份验证问题,或者如果脸书出于任何其他原因拒绝连接器消息,我们都会看到错误。类似的想法也适用于另一组连接器。一般来说,连接器善于将 Bot 框架活动转换成不会被通道拒绝的东西,但它确实发生了。
一般来说,如果我们的 bot 向 Bot 框架连接器发送消息,而消息没有出现在接口上,那么连接器和通道之间的交互很可能有问题,这个在线错误日志将包含有关失败的信息。
我们已经多次提到,不同的通道可能会以不同的方式呈现消息,或者对某些项目有不同的规则,例如旋转木马中英雄卡的数量或英雄卡中按钮的数量。我们已经展示了 Messenger 和模拟器渲染的例子,因为这些通道通常工作良好。Skype 是另一个支持大量 Bot Builder 功能的软件(这很有意义,因为两者都属于微软)。Slack 对这些特性没有丰富的支持,但是它的可编辑消息是一个巧妙的特性,我们将在第 8 章中介绍。
为了便于说明,图 6-19 是之前讨论过的具有建议动作的转盘在松弛状态下的样子。
图 6-19
Slack 中呈现的相同 Bot 生成器对象
那不是旋转木马。Slack 里没有这个概念!也没有什么牌可言;它只是带有附件的消息。图像也不可点击;默认链接显示在图像上方。Im Back 和 Post Back 按钮都显示为回发。没有建议行动/快速回复的概念。您可以在网上找到有关松弛消息格式的更多信息。 7
然而,Bot Builder SDK 背后的团队已经考虑到了这样一个问题,即您可能希望指定确切的本机通道消息,而不是该通道的默认 Bot 框架连接器呈现。解决方案是在消息对象上提供一个包含传入消息的本机通道 JSON 数据的字段,以及一个可能包含本机通道 JSON 响应的字段。
Node SDK 中使用的术语是 source event(Bot Builder 的 C#版本将这个概念称为 channelData)。Node SDK 中的 sourceEvent 存在于 IEvent 接口上。记住,这也是 IMessage 实现的接口。这意味着来自 bot 连接器的任何事件都可能包含原始通道 JSON。
让我们看看 Facebook Messenger 中的一个特性,它并不容易被 Bot 框架支持。默认情况下,Messenger 中的卡片要求图像的宽高比为 1.91:1。 8 连接者默认的英雄卡转换利用了这个模板。然而,有能力利用 1:1 的图像比例。文档中还有其他被 Bot 框架隐藏的选项。例如,脸书有一个特殊的标志,将卡片设置为可共享。此外,您可以控制由 Messenger 中的 openURL 按钮调用的 WebView 的大小。现在,我们将坚持修改图像的纵横比。
首先,让我们看看发送相同卡片的代码,我们已经使用 hero card 对象发送了该卡片,但是使用了脸书的本地格式:
const bot = new builder.UniversalBot(connector, [
(session) => {
if (session.message.address.channelId == 'facebook') {
const msg = new builder.Message(session);
msg.sourceEvent({
facebook: {
attachment: {
type: 'template',
payload: {
template_type: 'generic',
elements: [
{
title: 'card title',
subtitle: 'card subtitle',
image_url: 'https://bot-framework.azureedge.net/bot-icons-v1/bot-framework-default-7.png',
default_action: {
type: 'web_url',
url: 'http://dev.botframework.com',
webview_height_ratio: 'tall',
},
buttons: [
{
type: "web_url",
url: "http://google.com",
title: "Open Google"
},
{
type: 'postback',
title: 'Im Back',
payload: 'Hello!'
},
{
type: 'postback',
title: 'Post Back',
payload: 'Hello!'
}
]
}
],
}
}
}
});
session.send(msg);
} else {
session.send('this bot is unsupported outside of facebook!');
}
}
]);渲染图(图 6-20 )看起来与使用英雄卡的渲染图一样。
图 6-20
在 Messenger 中呈现通用模板
我们设置 image_aspect_ratio 为正方形,现在脸书渲染为正方形(图 6-21 )!
图 6-21
在 Messenger 上呈现带有方形图像的通用模板
const msg = new builder.Message(session);
msg.sourceEvent({
facebook: {
attachment: {
type: 'template',
payload: {
template_type: 'generic',
image_aspect_ratio: 'square',
// more...
}
}
}
});
session.send(msg);就这么简单!这只是一个味道。在第 8 章中,我们将探索使用 Bot 框架来集成本机 Slack 特性。
有些类型的机器人是为了在群体环境中使用。在 Messenger、Twitter direct messages 或类似平台的环境中,用户和机器人之间的交互通常是一对一的。然而,一些频道,尤其是 Slack,专注于协作。在这种情况下,同时与多个用户对话的能力变得非常重要。让你的机器人能够有效地参与群体对话以及正确处理提及标签是非常重要的。
一些通道将允许机器人查看在通道中用户之间发送的每一条消息。其他频道只会在提到机器人时向其发送消息(例如,“嘿@szymonbot,写一本关于机器人的书好吗?”).
如果我们在一个允许我们的机器人在一个群组设置中查看所有消息的通道中,我们的机器人可以监控对话并根据讨论悄悄执行代码(因为回复群组对话中的每条消息有点烦人),或者它可以忽略没有提到机器人的所有内容。它还可以实现这两种行为的组合,通过使用某个命令来激活机器人,并使其变得健谈。
在“消息”部分,我们展示了消息的界面。我们忽略了实体列表,但它在这里变得相关。我们可能从连接器接收到的一种类型的实体是提及。该对象包括上述用户的姓名和 id,如下所示:
{
mentioned: {
id: '',
name: ''
},
text: ''
};脸书不支持这种类型的实体,但 Slack 支持。我们将在第 8 章中连接一个机器人到 Slack,但与此同时,这里的代码可以在直接消息场景中总是回复,但只有在群聊中被提到时才会回复:
const bot = new builder.UniversalBot(connector, [
(session) => {
const botMention = _.find(session.message.entities, function (e) { return e.type == 'mention' && e.mentioned.id == session.message.address.bot.id; });
if (session.message.address.conversation.isGroup && botMention) {
session.send('hello ' + session.message.user.name + '!');
}
else if (!session.message.address.conversation.isGroup) {
// 1 on 1 session
session.send('hello ' + session.message.user.name + '!');
} else {
// silently looking at non-mention messages
// session.send('bein creepy...');
}
session.send(msg);
}
]);图 6-22 是在直接对话的松弛状态下的体验。
图 6-22
Slack 中支持群聊的机器人直接发送消息
图 6-23 显示了群聊中的行为(原谅过于原始的用户名 srozga2)。
图 6-23
支持群聊的机器人忽略没有提及的消息
我们已经使用 bot.dialog(…)方法构建了我们的对话框。我们还讨论了瀑布的概念。在我们在前一章开始的日历机器人中,我们的每个对话都是通过瀑布实现的:一组按顺序执行的步骤。我们可以跳过一些步骤或在所有步骤完成之前结束对话,但预定义顺序的想法是关键。这个逻辑是由 Bot Builder SDK 中的一个名为 WaterfallDialog 的类实现的。如果我们看看对话框(…)调用背后的代码,我们会发现这一点:
if (Array.isArray(dialog) || typeof dialog === 'function') {
d = new WaterfallDialog(dialog);
} else {
d = <any>dialog;
}如果我们想要编码的对话片段不容易用瀑布式抽象来表示呢?我们有什么选择?我们可以创建一个对话框的自定义实现!
在 Bot Builder SDK 中,对话框是一个表示用户和 Bot 之间某种交互的类。对话框可以调用其他对话框,并接受这些子对话框的返回值。它们存在于对话堆栈中,与函数调用堆栈没有什么不同。使用默认的瀑布帮助器隐藏了其中的一些细节;实现一个定制的对话框让我们更接近对话框堆栈的现实。Bot Builder 中的抽象对话框类如下所示:
export abstract class Dialog extends ActionSet {
public begin<T>(session: Session, args?: T): void {
this.replyReceived(session);
}
abstract replyReceived(session: Session, recognizeResult?: IRecognizeResult): void;
public dialogResumed<T>(session: Session, result: IDialogResult<T>): void {
if (result.error) {
session.error(result.error);
}
}
public recognize(context: IRecognizeDialogContext, cb: (err: Error, result: IRecognizeResult) => void): void {
cb(null, { score: 0.1 });
}
}Dialog 只是一个我们可以继承的类,它有四个重要的方法。
-
Begin :当对话框第一次放入堆栈时调用。
-
ReplyReceived :每当用户的消息到达时调用。
-
DialogResumed :当子对话框结束,当前对话框再次激活时调用。dialogResumed 方法接收的参数之一是子对话框的结果对象。
-
识别:允许我们添加自定义对话框识别逻辑。默认情况下,BotBuilder 提供声明性方法来设置自定义全局或对话框范围的识别。但是,如果我们想添加进一步的识别逻辑,我们可以使用这种方法。我们将在“操作”部分对此进行更深入的讨论。
为了说明这些概念,我们创建了一个 BasicCustomDialog。由于 Bot Builder 是用 TypeScript 编写的, 9 是 JavaScript 的一个类型化超集,我们继续用 TypeScript 编写子类,用 TypeScript 编译器(tsc)编译成 JavaScript,然后在 app.js 中使用它
让我们看看自定义对话框的代码。这恰好是 TypeScript,因为它在使用继承时有一个更干净的接口;编译后的 JavaScript 将在后面显示。当对话开始时,它发送“开始”文本。当它收到一条消息时,它用“已收到回复”文本进行响应。如果用户发送了“提示”文本,对话框将要求用户输入一些文本。然后,它将在 dialogResumed 方法中接收文本输入,并打印结果。如果用户输入了“done”,则对话框结束并返回到根对话框。
import { Dialog, ResumeReason, IDialogResult, Session, Prompts } from 'botbuilder'
export class BasicCustomDialog extends Dialog {
constructor() {
super();
}
// called when the dialog is invoked
public begin<T>(session: Session, args?: T): void {
session.send('begin');
}
// called any time a message is received
public replyReceived(session: Session): void {
session.send('reply received');
if(session.message.text === 'prompt') {
Prompts.text(session, 'please enter any text!');
} else if(session.message.text == 'done') {
session.endDialog('dialog ending');
} else {
// no-op
}
}
public dialogResumed(session: Session, result: any): void {
session.send('dialog resumed with value: ' + result);
}
}我们在 app.js 中直接使用对话框的实例。在默认的瀑布中,我们回显任何消息,除了开始自定义对话框的“自定义”输入。
const bot = new builder.UniversalBot(connector, [
(session) => {
if(session.message.text === 'custom') {
session.beginDialog('custom');
} else {
session.send('echo ' + session.message.text);
}
}
]);
const customDialogs = require('./customdialogs');
bot.dialog('custom', new customDialogs.BasicCustomDialog());图 6-24 显示了一个示例交互的样子。
图 6-24
与自定义对话框交互
顺便提一下,Promps.text、Prompts.number 和其他提示对话框都是作为自定义对话框实现的。
接下来显示了为定制对话框编译的 JavaScript。推理起来有点困难,但归根结底,这是标准的 ES5 JavaScript 原型继承。10T3】
"use strict";
var __extends = (this && this.__extends) || (function () {
var extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
return function (d, b) {
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
exports.__esModule = true;
var botbuilder_1 = require("botbuilder");
var BasicCustomDialog = /** @class */ (function (_super) {
__extends(BasicCustomDialog, _super);
function BasicCustomDialog() {
return _super.call(this) || this;
}
// called when the dialog is invoked
BasicCustomDialog.prototype.begin = function (session, args) {
session.send('begin');
};
// called any time a message is received
BasicCustomDialog.prototype.replyReceived = function (session) {
session.send('reply received');
if (session.message.text === 'prompt') {
botbuilder_1.Prompts.text(session, 'please enter any text!');
}
else if (session.message.text == 'done') {
session.endDialog('dialog ending');
}
else {
// no-op
}
};
BasicCustomDialog.prototype.dialogResumed = function (session, result) {
session.send('dialog resumed with value: ' + result);
};
return BasicCustomDialog;
}(botbuilder_1.Dialog));
exports.BasicCustomDialog = BasicCustomDialog;实现自定义提示号
作为自定义对话框概念的练习,您现在将创建一个自定义 Prompts.number 对话框。这个练习纯粹是学术性的;了解框架级的行为是如何实现的是很有趣的。
-
创建一个具有两步瀑布的 bot,它使用标准的 Prompts.number 收集一个数值,并在第二个瀑布步骤中将该数值发送回用户。请注意,您将在瀑布函数的 args 参数上使用响应字段。
-
创建一个自定义对话框,收集用户输入,直到它收到一个数字。出于练习的目的,您可以使用 parseFloat。当收到有效的号码时,使用与 Prompts.number 返回的结构相同的对象调用 session.endDialogWithResult。如果用户的输入无效,则返回一条错误消息并再次请求号码。
-
在瀑布中,不要调用 Prompts.number,而是调用新的自定义对话框。你的瀑布应该还能用!
-
另外,在你的自定义对话框中添加逻辑,允许最多五次尝试。之后,返回一个取消的结果到你的瀑布。
您现在已经了解了 Bot Builder SDK 中所有对话框的构建块!我们可以利用这些知识来建立任何形式的互动。
我们现在对抽象对话框有多强大以及 Bot Builder SDK 如何管理对话框堆栈有了很好的了解。该框架的一个关键部分是如何将用户动作与对话堆栈的转换联系起来,我们对此没有很好的理解。在最基本的层面上,我们可以编写简单地调用 beginDialog 的代码。但是我们如何根据用户输入做出决定呢?我们如何将它与我们在前一章中学习的识别器挂钩,特别是 LUIS?这就是行动允许我们做的事情。
Bot Builder SDK 包含六种类型的操作,其中两种是全局的,四种是对话框范围的。这两个全局操作是 triggerAction 和 customAction。我们以前遇到过触发作用。它允许机器人在对话期间的任何时候当意图匹配时调用对话,假设该意图事先不匹配对话范围内的动作。每当接收到用户输入时,都会对这些进行评估。默认行为是在调用对话框之前清除整个对话框堆栈。
lib.dialog(constants.dialogNames.AddCalendarEntry, [
function (session, args, next) {
...
]).triggerAction({
matches: constants.intentNames.AddCalendarEntry
});上一章日历机器人代码中的每个主要对话框都使用默认的 triggerAction 行为,除了帮助。帮助对话框在对话框栈的顶部被调用*,所以当它完成时,我们回到用户开始所在的对话框。为了达到这个效果,我们重写 onSelectAction 方法并指定我们想要的行为。*
lib.dialog(constants.dialogNames.Help, (session, args, next) => {
...
}).triggerAction({
matches: constants.intentNames.Help,
onSelectAction: (session, args, next) => {
session.beginDialog(args.action, args);
}
});customAction 直接绑定到 bot 对象,而不是对话框。它允许我们绑定一个函数来响应用户输入。我们没有机会像对话框实现那样向用户查询更多信息。这对于简单地返回消息或根据用户输入执行一些 HTTP 调用的功能来说是很好的。事实上,我们可以像这样重写帮助对话框。代码看起来很简单,但是我们失去了对话模型的封装性和可扩展性。换句话说,我们在自己的对话框中不再有逻辑,不再有执行几个步骤、收集用户输入或向调用对象提供结果的能力。
lib.customAction({
matches: constants.intentNames.Help,
onSelectAction: (session, args, next) => {
session.send("Hi, I am a calendar concierge bot. I can help you create, delete and move appointments. I can also tell you about your calendar and check your availability!");
}
});四种类型的上下文操作是 beginDialogAction、reloadAction、cancelAction 和 endConversationAction。让我们逐一检查。
BeginDialogAction 创建一个操作,只要该操作匹配,就会在堆栈上推一个新对话框。日历机器人中的上下文帮助对话框使用了这种方法。我们创建了两个对话框:一个是 AddCalendarEntry 对话框的帮助,另一个是 RemoveCalendarEntry 对话框的帮助。
// help message when help requested during the add calendar entry dialog
lib.dialog(constants.dialogNames.AddCalendarEntryHelp, (session, args, next) => {
const msg = "To add an appointment, we gather the following information: time, subject and location. You can also simply say 'add appointment with Bob tomorrow at 2pm for an hour for coffee' and we'll take it from there!";
session.endDialog(msg);
});
// help message when help requested during the remove calendar entry dialog
lib.dialog(constants.dialogNames.RemoveCalendarEntryHelp, (session, args, next) => {
const msg = "You can remove any calendar either by subject or by time!";
session.endDialog(msg);
});然后,我们的 AddCalendarEntry 对话框可以将 beginDialogAction 绑定到相应的帮助对话框。
lib.dialog(constants.dialogNames.AddCalendarEntry, [
// code
]).beginDialogAction(constants.dialogNames.AddCalendarEntryHelp, constants.dialogNames.AddCalendarEntryHelp, { matches: constants.intentNames.Help })
.triggerAction({ matches: constants.intentNames.AddCalendarEntry });请注意,此操作的行为与手动调用 beginDialog 相同。新对话框放在对话框堆栈的顶部,当前对话框完成后继续。
reloadAction 调用执行 replaceDialog。replaceDialog 是 session 对象上的一个方法,该方法结束当前对话框并用另一个对话框的实例替换它。在新对话框完成之前,父对话框不会得到结果。在实践中,我们可以利用它来重新开始一个交互,或者在流程中间切换到一个更合适的对话。
以下是对话的代码(见图 6-25 ):
图 6-25
触发 reloadAction 的示例对话
lib.dialog(constants.dialogNames.AddCalendarEntry, [
// code
])
.beginDialogAction(constants.dialogNames.AddCalendarEntryHelp, constants.dialogNames.AddCalendarEntryHelp, { matches: constants.intentNames.Help })
.reloadAction('startOver', "Ok, let's start over...", { matches: /^restart$/i })
.triggerAction({ matches: constants.intentNames.AddCalendarEntry });CancelAction 允许我们取消当前对话框。父对话框将在其恢复处理程序中收到一个设置为 true 的取消标志。这允许对话框正确地对取消进行操作。代码如下(对话可视化如图 6-26 所示):
图 6-26
触发取消的示例对话
lib.dialog(constants.dialogNames.AddCalendarEntry, [
// code
])
.beginDialogAction(constants.dialogNames.AddCalendarEntryHelp, constants.dialogNames.AddCalendarEntryHelp, { matches: constants.intentNames.Help })
.reloadAction('startOver', "Ok, let's start over...", { matches: /^restart$/i })
.cancelAction('cancel', 'Cancelled.', { matches: /^cancel$/i})
.triggerAction({ matches: constants.intentNames.AddCalendarEntry });最后,endConversationAction 允许我们绑定到 session.endConversation 调用。结束对话意味着清除整个对话堆栈,并从状态存储中删除所有用户和对话数据。如果用户再次开始向机器人发送消息,就会创建一个新的对话,而不知道之前的交互。代码如下(图 6-27 显示对话可视化):
图 6-27
触发 endConversationAction 的示例对话
lib.dialog(constants.dialogNames.AddCalendarEntry, [
// code
])
.beginDialogAction(constants.dialogNames.AddCalendarEntryHelp, constants.dialogNames.AddCalendarEntryHelp, { matches: constants.intentNames.Help })
.reloadAction('startOver', "Ok, let's start over...", { matches: /^restart$/i })
.cancelAction('cancel', 'Cancelled.', { matches: /^cancel$/i})
.endConversationAction('end', "conversation over!", { matches: /^end!$/i })
.triggerAction({ matches: constants.intentNames.AddCalendarEntry });回想一下上一章,每个识别器接受一个用户输入,并返回一个带有意图文本值和分数的对象。我们提到了这样一个事实,即我们可以使用识别器来确定 LUIS 的意图,使用正则表达式,或者实现任何定制逻辑。我们创建的每个动作中的匹配对象是我们指定一个动作对哪个识别器意图感兴趣的一种方式。matches 对象实现以下接口:
export interface IDialogActionOptions {
matches?: RegExp|RegExp[]|string|string[];
intentThreshold?: number;
onFindAction?: (context: IFindActionRouteContext, callback: (err: Error | null, score: number, routeData?: IActionRouteData) => void) => void;
onSelectAction?: (session: Session, args?: any, next?: Function) => void;
}以下是该对象包含的内容:
-
Matches 是操作要查找的目的名称或正则表达式。
-
intentThreshold 是识别器为使此操作被调用而必须分配给意图的最低分数。
-
onFindAction 允许我们在检查一个动作是否应该被触发时调用定制逻辑。
-
onSelectAction 允许您自定义操作的行为。例如,如果您不想清除对话框堆栈,而是想将对话框放在堆栈顶部,请使用它。在之前的动作示例中,我们已经看到了这一点。
除了这种级别的定制之外,Bot Builder SDK 对操作及其优先级有非常具体的规则。回想一下,在关于自定义对话框的讨论中,我们已经看到了全局操作、对话框范围的操作以及每个对话框上可能的识别实现。消息到达时的动作解析顺序如下。首先,系统试图定位当前对话框的识别功能的实现。之后,SDK 查看对话框堆栈,从当前对话框一直到根对话框。如果该路径上没有匹配的动作,则查询全局动作。这个顺序确保最接近当前用户体验的动作被首先处理。当你设计你的机器人交互时,请记住这一点。
库是打包和分发相关机器人对话框、识别器和其他功能的一种方式。库可以引用其他库,从而产生功能高度组合的机器人。从开发人员的角度来看,库只是一个包装精美的对话框、识别器和其他 Bot Builder 对象的集合,带有一个名称,通常还有一组帮助调用对话框和其他特定于库的特性的 helper 方法。在我们第 5 章的日历礼宾机器人中,每个对话框都是与高级机器人功能相关的库的一部分。app.js 代码加载所有模块,然后通过 bot.library 调用将它们安装到主 bot 中。
const helpModule = require('./dialogs/help');
const addEntryModule = require('./dialogs/addEntry');
const removeEntryModule = require('./dialogs/removeEntry');
const editEntryModule = require('./dialogs/editEntry');
const checkAvailabilityModule = require('./dialogs/checkAvailability');
const summarizeModule = require('./dialogs/summarize');
const bot = new builder.UniversalBot(connector, [
(session) => {
// code
}
]);
bot.library(addEntryModule.create());
bot.library(helpModule.create());
bot.library(removeEntryModule.create());
bot.library(editEntryModule.create());
bot.library(checkAvailabilityModule.create());
bot.library(summarizeModule.create());这是库组合在起作用:UniversalBot 本身就是库的一个子类。我们的主 UniversalBot 库导入了其他六个库。从任何其他上下文中对对话框的引用必须使用库名作为前缀来命名空间。从根库或 UniversalBot 对象中的对话框的角度来看,调用任何其他库的对话框都必须使用格式为 libName:dialogName 的限定名。这种完全限定的对话框名称引用过程只有在跨越库边界时才是必要的。在同一库的上下文中,库前缀不是必需的。
一种常见的模式是在调用库对话框的模块中公开一个助手方法。把它想象成库封装;一个库不应该知道另一个库的内部情况。例如,我们的帮助库公开了一个方法来实现这一点。
const lib = new builder.Library('help');
exports.help = (session) => {
session.beginDialog('help:' + constants.dialogNames.Help);
};微软的 bot Builder SDK 是一个强大的 Bot 构造库和对话引擎,可以帮助我们开发各种类型的异步对话体验,从简单的来回到具有多种行为的复杂 Bot。对话抽象是一种强大的对话建模方式。识别器定义了我们的机器人用来将用户输入转换成机器可读意图的机制。动作将那些识别器结果映射到对话堆栈上的操作。一个对话框主要关心三件事:当它开始时会发生什么,当收到用户消息时会发生什么,当子对话框返回结果时会发生什么。每个对话框都利用 bot 上下文,称为会话,来检索用户消息并创建响应。响应可以由文本、视频、音频或图像组成。此外,卡片可以产生更丰富和上下文敏感的体验。建议的动作负责防止用户猜测下一步该做什么。
在下一章中,我们将应用这些概念将我们的机器人与谷歌日历 API 集成,我们将采取措施创建一个引人注目的第一版日历机器人体验。
Footnotes [1](#Fn1_source)蓝色宇宙 DB: https://azure.microsoft.com/en-us/services/cosmos-db/
天蓝色桌面存储: https://azure.microsoft.com/en-us/services/storage/tables/
MIME 类型:
OGG 格式,一个自由、开放的容器格式: https://en.wikipedia.org/wiki/Ogg
Facebook Messenger SendAPI 按钮文档: https://developers.facebook.com/docs/messenger-platform/send-messages/buttons
谷歌材质设计: https://material.io/guidelines/
时差消息: https://api.slack.com/docs/messages
脸书通用模板参考: https://developers.facebook.com/docs/messenger-platform/send-messages/template/generic
打字稿: http://www.typescriptlang.org/
JavaScript ES5 中的经典继承: https://eli.thegreenplace.net/2013/10/22/classical-inheritance-in-javascript-es5


























