现在应该很清楚,用内置的 bot 服务支持集成各种通道是可行的。Bot Builder SDK 设计人员意识到,Bot 服务并不能处理每个通道的每个功能,因此保持了 SDK 的灵活性以支持可扩展性。
bot 服务支持相当多的通道,但是如果我们的 bot 需要支持像 Twitter Direct Messages API 这样的通道呢?如果我们需要集成一个直接与 Facebook Messenger 集成的实时聊天平台,而我们不能利用 Bot 框架脸书通道连接器,该怎么办?机器人服务包括通过 Twilio 支持短信,但如果我们想将其扩展到 Twilio 的语音 API,这样我们就可以真正地与我们的机器人交谈了,该怎么办?
所有这些都可以通过微软提供的一种叫做直线 API 的工具来实现。在这一章中,我们将介绍这是什么,如何构建一个自定义的 web 聊天界面来与我们的机器人通信,以及如何将我们的机器人与 Twilio 的语音 API 挂钩。在本章结束时,我们将会拨打一个电话号码,对我们的机器人说话,并听它回应我们!
如果您浏览了 bot 服务条目中的 channels 部分,您可能会遇到一种叫做 Direct Line 的东西。直线通道只是我们通过一个易于使用的 API 从客户端应用调用 bot 的一种方式,这些客户端应用没有能力托管 webhook 来接收响应。那是一口。我们来复习一下。通常,如图 9-1 所示,通道通过调用机器人的消息端点与机器人通信。传入的消息由机器人处理。在创建响应时,我们的 bot 将消息和响应消息一起发送到通道的响应 URL。回想一下,传入的消息包括一个服务 Url 。这是响应 HTTP 端点所在的位置。如果我们要编写一个定制的客户端应用,比如一个移动应用,这个 URL 必须是由用户手机上的客户端应用托管的一个端点。这个异步模型相当强大;对于消息必须何时返回以及需要返回多少消息,没有任何限制。当然,缺点是我们的客户端应用需要托管一个 web 服务器。在许多环境中,这是不可能的。人们甚至可以在 iOS 设备上托管 HTTP 服务器吗?
图 9-1
客户端应用和机器人框架机器人之间的交互
微软提供的解决方案是为我们创建一个封装 HTTP 服务器的通道。Direct Line 可以轻松地将消息发送到我们的 bot 中,并为我们的客户端应用提供了一个接口来轮询 bot 发送回用户的任何响应。微软的直接线 API,目前在其第三个版本中,也支持 WebSockets, 1 所以开发者不需要使用轮询机制。图 9-2 给出了总体设计。
图 9-2
避免了客户端托管 HTTP 服务器的需要
直线通道也很方便,因为它为我们处理 bot 身份验证。我们只需要将一个直接线路密钥作为承载令牌传递到直接线路通道中。
Direct Line v3 API 包含以下与对话相关的操作:
-
StartConversation :开始与机器人的新对话。机器人将收到必要的消息,表明一个新的对话正在开始。
-
GetConversation :获取现有对话的详细信息,包括客户端可以用来通过 WebSocket 连接的 streamUrl。
-
GetActivities :获取机器人和用户之间交换的所有活动。这提供了传递水印的可选能力,以便只获取水印之后的活动。
-
PostActivity :从用户向机器人发送一个新的活动。
-
上传文件:从用户上传一个文件到机器人。
该 API 还包含两种身份验证方法。
我们可以使用共享的直线密码访问直线 API。但是,如果恶意参与者获得了密钥,他可以作为新用户或已知用户与我们的机器人开始任意数量的新对话。如果我们只是进行服务器到服务器的通信,只要我们正确地管理密钥,这应该不是一个巨大的风险。然而,如果我们希望客户端应用与 API 对话,我们需要另一个解决方案。直线提供了两个令牌端点供我们使用。
-
生成令牌 :
POST /v3/directline/tokens/generate -
刷新令牌 :
POST /v3/directline/tokens/refresh
生成端点生成一个用于一个且仅一个会话的令牌。该响应还包括一个 expires_in 字段。如果需要延长时间线,API 提供刷新端点来刷新令牌,每次刷新另一个 expires_in 值。在撰写本文时,的值在中到期是 30 分钟。
API 作为 REST 调用被调用到以下端点(全部托管在 https://directline.botframework.com ):
-
开始对话 :
POST /v3/conversations -
获取对话 :
GET /v3/conversations/{conversationId}?watermark={watermark} -
活动 :
GET /v3/conversations/{conversationId}/activities?watermark={watermark} -
活动后 :
POST /v3/conversations/{conversationId}/activities -
上传文件 :
POST /v3/conversations/{conversationId}?userId={userId}
您可以在在线文档中找到更多关于直线 API 的详细信息。 2
网上有很多直线样品;一个控制台 Node 应用的上下文可以在这里找到: https://github.com/Microsoft/BotBuilder-Samples/tree/master/Node/core-DirectLine/DirectLineClient 。
我们将以这段代码为模板,创建一个定制的 web 聊天界面,来讨论如何从客户端应用连接到机器人。虽然 Bot Builder SDK 已经包含了一个网络聊天的组件化版本,但我们自己构建它将会是一个很好的直接体验。
首先,我们需要启用直线电话。在我们的 bot 的 Channels blade 中,单击直线按钮(图 9-3 )进入直线配置屏幕。
图 9-3
直线频道图标
我们可以创建多个密钥来验证我们的客户对直线。在本例中,我们将简单地使用默认的站点密钥(图 9-4 )。
图 9-4
直接线路配置界面
现在我们已经准备好了密钥,我们将创建一个 Node 包,其中包含一个 bot 和一个简单的支持 jQuery 的 web 页面,以演示如何将 bot 与客户端应用连接在一起。以下工作的完整代码包含在我们的 git repo 中。
我们将创建一个可以响应一些简单输入的基本机器人,因此我们将创建一个托管我们的 web 聊天组件的index.html页面。bot 的.env文件应该像往常一样包含 MICROSOFT_APP_ID 和 MICROSOFT_APP_PASSWORD 值。我们还添加了 DL_KEY,这是图 9-4 中我们共享的直线键的值。当页面打开时,代码将从机器人获取一个令牌,这样我们就不会向客户端暴露秘密。这需要在我们的 bot 上实现端点。
首先,用我们典型的依赖项设置一个空的 bot。基本的对话代码如下所示。我们支持一些愚蠢的事情,如“你好”、“退出”、“生命的意义”、“沃尔多在哪里”和“苹果”如果输入与这些都不匹配,我们默认为不屑一顾的“哦,这很酷。”
const bot = new builder.UniversalBot(connector, [
session => {
session.beginDialog('sampleConversation');
},
session => {
session.send('conversation over');
session.endConversation();
}
]);
bot.dialog('sampleConversation', [
(session, arg) => {
console.log(JSON.stringify(session.message));
if (session.message.text.indexOf('hello') >= 0 || session.message.text.indexOf('hi') >= 0)
session.send('hey!');
else if (session.message.text === 'quit') {
session.send('ok, we\'re done');
return;
} else if (session.message.text.indexOf('meaning of life') >= 0) {
session.send('42');
} else if (session.message.text.indexOf('waldo') >= 0) {
session.send('not here');
} else if (session.message.text === 'apple') {
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'
}
]
});
}
else {
session.send('oh that\'s cool');
}
}
]);其次,我们想要创建一个 web 聊天页面index.html页面,其中包含来自 CDN 的 jQuery 和 Bootstrap。
server.get(/\/?.*/, restify.serveStatic({
directory: './app',
default: 'index.html'
}))我们的index.html提供了简单的用户体验。我们将有一个包含两个元素的聊天客户端容器:一个聊天历史视图,它将呈现用户和机器人之间的任何消息,以及一个文本输入框。我们将假设按回车键发送消息。对于聊天历史,我们将插入聊天条目元素,并使用 CSS 和 JavaScript 来正确调整条目元素的大小和位置。我们将使用消息传递范例,左边是来自用户的消息,右边是来自另一方的消息。
<!doctype html>
<html lang="en">
<head>
<title>Direct Line Test</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" type="text/css" />
<link rel="stylesheet" href="app/chat.css" type="text/css" />
</head>
<body>
<script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js"></script>
<script src="app/chat.js"></script>
<h1>Sample Direct Line Interface</h1>
<div class="chat-client">
<div class="chat-history">
</div>
<div class="chat-controls">
<input type="text" class="chat-text-entry" />
</div>
</div>
</body>
</html>chat.css样式表如下所示:
body {
font-family: Helvetica, Arial, sans-serif;
margin: 10px;
}
.chat-client {
max-width: 600px;
margin: 20px;
font-size: 16px;
}
.chat-history {
border: 1px solid lightgray;
height: 400px;
overflow-x: hidden;
overflow-y: scroll;
}
.chat-controls {
height: 20px;
}
.chat-img {
background-size: contain;
height: 160px;
max-width: 400px;
}
.chat-text-entry {
width: 100%;
border: 1px solid lightgray;
padding: 5px;
}
.chat-entry-container {
position: relative;
margin: 5px;
min-height: 40px;
}
.chat-entry {
color: #666666;
position: absolute;
padding: 10px;
min-width: 10px;
max-width: 400px;
overflow-y: auto;
word-wrap: break-word;
border-radius: 10px;
}
.chat-from-bot {
right: 10px;
background-color: #2198F4;
border: 1px solid #2198F4;
color: white;
text-align:right;
}
.chat-from-user {
background-color: #E5E4E9;
border: 1px solid #E5E4E9;
}我们的客户端逻辑存在于chat.js中。在这个文件中,我们声明了几个函数来帮助我们调用必要的直线端点。
const pollInterval = 1000;
const user = 'user';
const baseUrl = 'https://directline.botframework.com/v3/directline';
const conversations = baseUrl + '/conversations';
function startConversation(token) {
// POST to conversations endpoint
return $.ajax({
url: conversations,
type: 'POST',
data: {},
datatype: 'json',
headers: {
'authorization': 'Bearer ' + token
}
});
}
function postActivity(token, conversationId, activity) {
// POST to conversations endpoint
const url = conversations + '/' + conversationId + '/activities';
return $.ajax({
url: url,
type: 'POST',
data: JSON.stringify(activity),
contentType: 'application/json; charset=utf-8',
datatype: 'json',
headers: {
'authorization': 'Bearer ' + token
}
});
}
function getActivities(token, conversationId, watermark) {
// GET activities from conversations endpoint
let url = conversations + '/' + conversationId + '/activities';
if (watermark) {
url = url + '?watermark=' + watermark;
}
return $.ajax({
url: url,
type: 'GET',
data: {},
datatype: 'json',
headers: {
'authorization': 'Bearer ' + token
}
});
}
function getToken() {
return $.getJSON('/api/token').then(function (data) {
// we need to refresh the token every 30 minutes at most.
// we'll try to do it every 25 minutes to be sure
window.setInterval(function () {
console.log('refreshing token');
refreshToken(data.token);
}, 1000 * 60 * 25);
return data.token;
});
}
function refreshToken(token) {
return $.ajax({
url: '/api/token/refresh',
type: 'POST',
data: token,
datatype: 'json',
contentType: 'text/plain'
});
}为了支持 getToken ()和 refreshToken ()客户端功能,我们在 bot 上公开了两个端点。/api/token生成一个新令牌,/api/token/refresh接受一个令牌作为输入并刷新它,延长它的生命周期。
server.use(restify.bodyParser({ mapParams: false }));
server.get('/api/token', (req, res, next) => {
// make a request to get a token from the secret key
const jsonClient = restify.createStringClient({ url: 'https://directline.botframework.com/v3/directline/tokens/generate' });
jsonClient.post({
path: '',
headers: {
authorization: 'Bearer ' + process.env.DL_KEY
}
}, null, function (_err, _req, _res, _data) {
let jsonData = JSON.parse(_data);
console.log('%d -> %j', _res.statusCode, _res.headers);
console.log('%s', _data);
res.send(200, {
token: jsonData.token
});
next();
});
});
server.post('/api/token/refresh', (req, res, next) => {
// make a request to get a token from the secret key
const token = req.body;
const jsonClient = restify.createStringClient({ url: 'https://directline.botframework.com/v3/directline/tokens/refresh' });
jsonClient.post({
path: '',
headers: {
authorization: 'Bearer ' + token
}
}, null, function (_err, _req, _res, _data) {
let jsonData = JSON.parse(_data);
console.log('%d -> %j', _res.statusCode, _res.headers);
console.log('%s', _data);
res.send(200, {
success: true
});
next();
});
});当页面加载到浏览器上时,我们开始一个对话,获取一个令牌,并监听传入的消息。
getToken().then(function (token){
startConversation(token)
.then(function (response){
return response.conversationId;
})
.then(function (conversationId){
sendMessagesFromInputBox(conversationId, token);
pollMessages(conversationId, token);
});
});下面是sendmessagessfrominputbox的样子:
function sendMessagesFromInputBox(conversationId, token) {
$('.chat-text-entry').keypress(function (event) {
if (event.which === 13) {
const input = $('.chat-text-entry').val();
if (input === '') return;
const newEntry = buildUserEntry(input);
scrollToBottomOfChat();
$('.chat-text-entry').val('');
postActivity(token, conversationId, {
textFormat: 'plain',
text: input,
type: 'message',
from: {
id: user,
name: user
}
}).catch(function (err) {
$('.chat-history').remove(newEntry);
console.error('Error sending message:', err);
});
}
});
}
function buildUserEntry(input) {
const c = $('<div/>');
c.addClass('chat-entry-container');
const entry = $('<div/>');
entry.addClass('chat-entry');
entry.addClass('chat-from-user');
entry.text(input);
c.append(entry);
$('.chat-history').append(c);
const h = entry.height();
entry.parent().height(h);
return c;
}
function scrollToBottomOfChat() {
const el = $('.chat-history');
el.scrollTop(el[0].scrollHeight);
}该代码侦听文本框上的 Return 键。如果用户输入不为空,它会将消息发送给机器人,并将用户的消息添加到聊天历史中。如果发送给机器人的消息由于任何原因失败,用户的消息将从聊天历史中删除。我们还确保聊天历史控件滚动到底部,以便最新的消息可见。在接收端,我们直接轮询消息。下面是支持代码:
function pollMessages(conversationId, token) {
console.log('Starting polling message for conversationId: ' + conversationId);
let watermark = null;
setInterval(function () {
getActivities(token, conversationId, watermark)
.then(function (response) {
watermark = response.watermark;
return response.activities;
})
.then(insertMessages);
}, pollInterval);
}
function insertMessages(activities) {
if (activities && activities.length) {
activities = activities.filter(function (m) { return m.from.id !== user });
if (activities.length) {
activities.forEach(function (a) {
buildBotEntry(a);
});
scrollToBottomOfChat();
}
}
}
function buildBotEntry(activity) {
const c = $('<div/>');
c.addClass('chat-entry-container');
const entry = $('<div/>');
entry.addClass('chat-entry');
entry.addClass('chat-from-bot');
entry.text(activity.text);
if (activity.attachments) {
activity.attachments.forEach(function (attachment) {
switch (attachment.contentType) {
case 'application/vnd.microsoft.card.hero':
console.log('hero card rendering not supported');
// renderHeroCard(attachment, entry);
break;
case 'image/png':
case 'image/jpeg':
console.log('Opening the requested image ' + attachment.contentUrl);
entry.append("<div class='chat-img' style='background-size: cover; background-image: url(" + attachment.contentUrl + ")' />");
break;
}
});
}
c.append(entry);
$('.chat-history').append(c);
const h = entry.height();
entry.parent().height(h);
}请注意,Direct Line API 返回用户和 bot 之间的所有消息,因此我们必须过滤掉用户发送的任何内容,因为我们已经在最初发送消息时附加了这些内容。除此之外,我们还有自定义逻辑来支持图像附件。
entry.append("<div class='chat-img' style='background-size: cover; background-image: url(" + attachment.contentUrl + ")' />");我们可以扩展它来支持 hero(在我们的代码中已经有了一个 switch case,但是我们还没有实现一个 renderHeroCard 函数)或者自适应卡、音频附件,或者我们应用需要的任何其他类型的自定义渲染。
简要说明:由于我们使用的是直线 API 和定制的客户端应用,我们可以选择定义定制的附件。因此,如果我们的机器人需要在网络聊天中呈现一些应用用户界面,我们可以通过使用我们自己的附件来指定这个呈现逻辑。 buildBotEntry 中的代码将简单地知道如何去做。
如果我们构建机器人并在localhost:3978上运行它,我们可以通过将浏览器指向http://localhost:3978来访问我们的网络聊天。当我们如图 9-5 运行时,界面看起来很简单。图 9-6 显示了与我们的机器人进行几次交互后的对话。
图 9-6
哦,等等,我们走吧!那很酷
图 9-5
纯空聊天界面
Node 控制台界面
在本练习中,您将使用一些返回文本的基本命令创建一个 bot,并创建一个命令行界面来与它通信。目标是同时使用轮询客户端和 web sockets 客户端,并比较性能。
-
创建一个简单的机器人,它可以用文本响应几个用户话语选项。通过使用模拟器确保 bot 按预期工作。
-
将您的 bot 配置为接受 bot 通道注册通道刀片上的直接线路输入。
-
编写一个 Node 命令行应用,它侦听用户的控制台输入,并在用户按下 Return 键时将输入发送到 Direct Line。
-
对于收到的消息,编写轮询消息的代码,并在屏幕上打印出来。每 1 到 2 秒钟轮询一次。使用控制台应用向机器人发送多条消息,并查看它的响应速度。
-
作为第二个练习,编写利用 streamUrl 初始化新的 WebSocket 连接的代码。可以使用 ws Node.js 包,这里记载:
https://github.com/websockets/ws。将收到的消息打印到屏幕上。 -
与 WebSocket 选项相比,轮询解决方案的性能如何?
现在,您已经非常精通与直接线 API 的集成。如果您正在开发自定义通道适配器,这是开始的地方。
好的,所以我们在 Bot 框架上有很大的灵活性。关于通道,我们还计划解决另一个领域,那就是定制通道实现。比方说,你正在为一个客户构建一个机器人,一切都进展顺利,按计划进行。在一个周五的下午,客户过来问你,“嘿,机器人开发者女士,用户可以拨打 800 号码与我们的机器人通话吗?”
嗯,当然,我想只要有足够的时间和金钱,任何事情都是可能的,但是我们如何开始呢?有一次非常类似的事情发生在我身上,我最初的反应是“不可能,这太疯狂了。有太多的问题。语音和聊天不一样。”其中一些保留意见仍然存在;在消息和语音通道之间重用 bot 是一个需要非常小心的棘手领域,因为这两个接口非常不同。当然,这并不意味着我们不打算尝试!
事实证明,Twilio 是一家可靠且易于使用的语音通话和短信 API 提供商。幸运的是,不久前,Twilio 在其平台上添加了语音识别功能,现在它可以将用户的语音翻译成文本。未来,意图识别将被集成到系统中。与此同时,现在有什么应该足够我们的目的。事实上,Bot 框架已经通过 Twilio 集成到 SMS 中;也许有一天我们也会有完全的语音支持。
在我们进入 bot 代码之前,让我们先谈谈 Twilio 及其工作原理。Twilio 的产品之一叫做可编程语音。任何时候一个注册的电话号码打来电话,Twilio 服务器都会向开发者定义的端点发送一条消息。端点必须作出响应,通知 Twilio 它应该执行的动作,例如说一句话、拨另一个号码进入呼叫、收集数据、暂停等等。每当交互发生时,比如 Twilio 通过语音识别收集用户输入,Twilio 就会呼叫这个端点来接收下一步该做什么的指令。这对我们有好处。这意味着我们的代码不需要知道任何关于电话的事情。只是 API 而已!
我们指导 Twilio 做什么的方式是通过一种叫做 TwiML 的 XML 标记语言。 4 这里显示了一个例子:
<?xml version="1.0" encoding="UTF-8"?
<Response>
<Say voice="woman">Please leave a message after the tone.</Say>
<Record maxLength="20" />
</Response>在这个上下文中,名为 Say 和 Record 的 XML 元素被称为动词。在撰写本文时,Twilio 总共包含了 13 个动词。
-
说出:对来电者说出文字
-
播放:为来电者播放音频文件
-
拨打:将另一方加入通话
-
录音:记录来电者的声音
-
收集:收集来电者在键盘上输入的数字,或将语音翻译成文本
-
SMS :在通话过程中发送短信
-
挂断:挂断电话
-
入队:将呼叫者添加到呼叫者队列中
-
离开:从呼叫者队列中删除一个呼叫者
-
重定向:将调用流重定向到不同的 TwiML 文档
-
暂停:等待执行更多指令
-
拒绝:拒绝来电而不计费
-
消息:发送彩信或短信回复
TwiML 响应可以有一个或多个动词。对于系统上的特定行为,可以嵌套一些动词。如果您的 TwiML 文档包含多个动词,Twilio 将依次执行每个动词。例如,我们可以创建以下 TwiML 文档:
<?xml version="1.0" encoding="UTF-8"?
<!-- page located at http://example.com/complex_gather.xml -->
<Response>
<Gather action="/process_gather.php" method="GET">
<Say>
Please enter your account number,
followed by the pound sign
</Say>
</Gather>
<Say>We didn't receive any input. Goodbye!</Say>
</Response>本文档将从尝试收集用户输入开始。它将首先提示用户输入他们的账号,然后是井号。 Say 在集合中的嵌套行为意味着用户可以在 Say 语音内容完成之前说出他们的响应。这对于回头客来说是一个很棒的功能。如果 Gather 谓词没有导致用户输入,Twilio 将继续处理下一个元素,这是一个 Say 元素,通知用户 Twilio 没有收到响应。此时,由于不再有动词,电话通话结束。
每个动词都有详细的文档和示例,正如我们所料,一个成熟的 TwiML 应用会变得很复杂。与所有用户界面一样,有许多细节。出于我们的目的,我们将创建一个基本的集成,这样我们就可以与刚刚为我们的自定义 web 聊天创建的同一个 bot 对话。
我们将从向 Twilio 注册我们的应用开始。首先,我们需要用 Twilio 创建一个试用帐户。参观 www.twilio.com ,点击报名。根据图 9-7 将相关信息填入表格。完成后,您将输入您的电话号码和验证码。
图 9-7
注册一个 Twilio 账户
接下来,Twilio 将询问我们的项目名称。请随意提供比图 9-8 中的名称更有趣的内容。
图 9-8
创建新的 Twilio 项目
我们将被重定向到 Twilio 仪表盘(图 9-9 )。
图 9-9
Twilio 项目仪表板
我们的下一个任务是设置一个电话号码并指向我们的机器人。点击左侧窗格中的号码导航项,我们将被带到电话号码仪表盘(图 9-10 )。
图 9-10
让我们为我们的项目找一个电话号码吧!
点击获取一个号码。Twilio 会给你分配一个号码。因为我们只是测试,任何数字都可以。您也可以购买一个免费号码或从不同的服务机构转移一个号码。 5 之后,点击管理号码然后点击你刚刚被分配的号码。找到在来电时要联系的 URL 的字段,并复制到您的 bot 的 ngrok 端点中(图 9-11 )。我们将在接下来的页面中创建这个端点。
图 9-11
配置端点 Twilio 将在有来电时向发送消息
现在,任何时候任何人呼叫该号码,我们的端点都会收到一个 HTTP POST 请求,其中包含与该呼叫相关的所有信息。我们将能够接受这个调用,并使用 TwiML 文档进行响应,就像我们之前讨论的那样。
好吧,那现在怎么办?在我们的 bot 代码中,我们可以添加/api/voice端点来开始接受调用。目前,我们只是添加了一个日志,但没有返回任何响应。让我们看看从 Twilio 得到什么样的数据。
server.post('/api/voice', (req, res, next) => {
console.log('%j', req.body);
});
{
"Called": "+1xxxxxxxxxx",
"ToState": "NJ",
"CallerCountry": "US",
"Direction": "inbound",
"CallerState": "NY",
"ToZip": "07050",
"CallSid": "xxxxxxxxxxxxxxxxxxxxxx",
"To": "+1xxxxxxxxxx",
"CallerZip": "10003",
"ToCountry": "US",
"ApiVersion": "2010-04-01",
"CalledZip": "07050",
"CalledCity": "ORANGE",
"CallStatus": "ringing",
"From": "+1xxxxxxxxxx",
"AccountSid": "xxxxxxxxxxxxxxxxxxxxx",
"CalledCountry": "US",
"CallerCity": "MANHATTAN",
"Caller": "+1xxxxxxxxxx",
"FromCountry": "US",
"ToCity": "ORANGE",
"FromCity": "MANHATTAN",
"CalledState": "NJ",
"FromZip": "10003",
"FromState": "NY"
}Twilio 发送了一些有趣的数据。因为我们获得了呼叫者号码,所以在与我们的机器人交互时,我们可以很容易地使用它作为用户 ID。让我们创建一个对 API 调用的响应。让我们首先安装 Twilio Node API。
npm install twilio –-save然后,我们可以将相关类型导入到我们的 Node 应用中。
const twilio = require('twilio');
const VoiceResponse = twilio.twiml.VoiceResponse;VoiceResponse 是一种方便的类型,有助于生成响应 XML。以下是我们如何返回基本 TwiML 响应的示例:
server.post('/api/voice', (req, res, next) => {
let twiml = new VoiceResponse();
twiml.say('Hi, I\'m Direct Line bot!', { voice: 'Alice' });
let response = twiml.toString();
res.writeHead(200, {
'Content-Length': Buffer.byteLength(response),
'Content-Type': 'text/html'
});
res.write(response);
next();
});现在,当我们拨打 Twilio 提供的电话号码时,在一个免责声明之后,我们应该会看到一个对我们的 API 端点的请求,一个女性声音应该会通过电话对我们说话,然后挂断。恭喜你!您已经建立了连接!
当我们的机器人几乎立即挂断时,这不是一个很好的体验,但我们可以改善这一点。首先,让我们从用户那里收集一些信息。
收集动词包括几个不同的选项,但我们主要关心的是这样一个事实,即收集可以用于接受来自用户电话的语音或双音多频(DTMF)信号。DTMF 只是当你按下手机上的一个键时发出的信号。这就是电话系统如何在用户不说话的情况下可靠地收集诸如信用卡号之类的信息。出于这个例子的目的,我们只关心收集语音。
这是一个收集样本,就像我们将要使用的一样:
<?xml version="1.0" encoding="UTF-8"?
<Response>
<Gather input="speech" action="/api/voice/gather" method="POST">
<Say>
Tell me what's on your mind
</Say>
</Gather>
<Say>We didn't receive any input. Goodbye!</Say>
</Response>这个片段告诉 Twilio 从用户那里收集语音,并让 Twilio 使用 POST 向/api/voice/gather发送识别出的语音。就这样! Gather 还有许多关于超时和发送部分语音识别结果的其他选项,但这些对于我们的目的来说是不必要的。 6
让我们建立一个 echo Twilio 集成。我们扩展了针对/api/voice的代码,使其包含了收集动词,然后为/api/voice/gather创建了端点,该端点回显用户所说的内容并收集更多信息,从而建立了一个实际上永无止境的对话循环。
server.post('/api/voice', (req, res, next) => {
let twiml = new VoiceResponse();
twiml.say('Hi, I\'m Direct Line bot!', { voice: 'Alice' });
let gather = twiml.gather({ input: 'speech', method: 'POST', action: '/api/voice/gather' });
gather.say('Tell me what is on your mind', { voice: 'Alice' });
let response = twiml.toString();
res.writeHead(200, {
'Content-Length': Buffer.byteLength(response),
'Content-Type': 'text/html'
});
res.write(response);
next();
});
server.post('/api/voice/gather', (req, res, next) => {
let twiml = new VoiceResponse();
const input = req.body.SpeechResult;
twiml.say('Oh hey! That is so interesting. ' + input, { voice: 'Alice' });
let gather = twiml.gather({ input: 'speech', method: 'POST', action: '/api/voice/gather' });
gather.say('Tell me what is on your mind', { voice: 'Alice' });
let response = twiml.toString();
res.writeHead(200, {
'Content-Length': Buffer.byteLength(response),
'Content-Type': 'text/html'
});
res.write(response);
next();
});继续在您的 bot 中运行这段代码。拨打电话号码。跟你聊天机器人。很酷,对吧?太好了。这没什么用,但我们已经在 Twilio 电话对话和我们的机器人之间建立了一个有效的对话循环。
最后,让我们通过使用直线将它集成到我们的 bot 中。在我们进入代码之前,我们写一些函数来帮助我们的机器人调用直线。
const baseUrl = 'https://directline.botframework.com/v3/directline';
const conversations = baseUrl + '/conversations';
function startConversation (token) {
return new Promise((resolve, reject) => {
let client = restify.createJsonClient({
url: conversations,
headers: {
'Authorization': 'Bearer ' + token
}
});
client.post('', {},
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);
});
});
}
function postActivity (token, conversationId, activity) {
// POST to conversations endpoint
const url = conversations + '/' + conversationId + '/activities';
return new Promise((resolve, reject) => {
let client = restify.createJsonClient({
url: url,
headers: {
'Authorization': 'Bearer ' + token
}
});
client.post('', activity,
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);
});
});
}
function getActivities (token, conversationId, watermark) {
// GET activities from conversations endpoint
let url = conversations + '/' + conversationId + '/activities';
if (watermark) {
url = url + '?watermark=' + watermark;
}
return new Promise((resolve, reject) => {
let client = restify.createJsonClient({
url: url,
headers: {
'Authorization': 'Bearer ' + token
}
});
client.get('',
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);
});
});
}我们将 TwiML 响应的创建和发送提取到它自己的函数buildandsendtimlresponse中。我们在监听输入的行为中加入了更多的结构,如果没有收到输入,就在挂断之前再次请求输入。
function buildAndSendTwimlResponse(req, res, next, userId, text) {
const twiml = new VoiceResponse();
twiml.say(text, { voice: 'Alice' });
twiml.gather({ input: 'speech', action: '/api/voice/gather', method: 'POST' });
twiml.say('I didn\'t quite catch that. Please try again.', { voice: 'Alice' });
twiml.gather({ input: 'speech', action: '/api/voice/gather', method: 'POST' });
twiml.say('Ok, call back anytime!');
twiml.hangup();
const response = twiml.toString();
console.log(response);
res.writeHead(200, {
'Content-Length': Buffer.byteLength(response),
'Content-Type': 'text/html'
});
res.write(response);
next();
}当一个呼叫第一次开始时,我们需要为我们的机器人创建一个直线对话。我们还需要缓存用户 ID(呼叫者电话号码)到对话 ID 的映射。我们在本地 JavaScript 对象中这样做( cachedConversations )。如果我们将这项服务扩展到多台服务器,这种方法将会失败;我们可以通过使用 Redis 这样的缓存来解决这个问题。
server.post('/api/voice', (req, res, next) => {
let userId = req.body.Caller;
console.log('starting convo for user id %s', userId);
startConversation(process.env.DL_KEY).then(conv => {
cachedConversations[userId] = { id: conv.conversationId, watermark: null, lastAccessed: moment().format() };
console.log('%j', cachedConversations);
buildAndSendTwimlResponse(req, res, next, userId, 'Hello! Welcome to Direct Line bot!');
});
});Gather 元素的代码应该检索对话 ID,获取用户输入,通过直接线路 API 将活动发送给机器人,然后等待响应返回,然后作为 TwiML 发送回 Twilio。因为我们需要轮询新消息,所以我们需要使用 setInterval 直到我们得到机器人的响应。代码不包括任何类型的超时,但我们肯定应该考虑它,以防机器人出现问题。我们也只支持每个消息有一个来自机器人的响应。语音交互不是锻炼机器人异步发送多个响应的能力的地方,尽管我们当然可以尝试。一种方法是包含自定义的通道数据,以传达预期返回的消息数,或者等待预定义的秒数,然后发送回所有消息。
server.post('/api/voice/gather', (req, res, next) => {
const input = req.body.SpeechResult;
let userId = req.body.Caller;
console.log('user id: %s | input: %s', userId, input);
let conv = cachedConversations[userId];
console.log('got convo: %j', conv);
conv.lastAccessed = moment().format();
postActivity(process.env.DL_KEY, conv.id, {
from: { id: userId, name: userId },
type: 'message',
text: input
}).then(() => {
console.log('posted activity to bot with input %s', input);
console.log('setting interval');
let interval = setInterval(function () {
console.log('getting activities...');
getActivities(process.env.DL_KEY, conv.id, conv.watermark).then(activitiesResponse => {
console.log("%j", activitiesResponse);
let temp = _.filter(activitiesResponse.activities, (m) => m.from.id !== userId);
if (temp.length > 0) {
clearInterval(interval);
let responseActivity = temp[0];
console.log('got response %j', responseActivity);
conv.watermark = activitiesResponse.watermark;
buildAndSendTwimlResponse(req, res, next, userId, responseActivity.text);
conv.lastAccessed = moment().format();
} else {
console.log('no activities for you...');
}
});
}, 500);
});
});如果你运行这个,你现在应该可以和我们通过 Twilio 的 webchat 暴露的同一个机器人对话了!
Twilio 语音集成
本练习的目标是创建一个 bot,并通过与 Twilio 集成来调用它。
-
注册一个试用 Twilio 帐户,并获得一个测试电话号码。
-
输入您的 bot 语音端点,以便 Twilio 在您的电话号码收到来电时使用。
-
将带有直线呼叫的语音端点集成到您的机器人中。返回你从你的机器人那里收到的第一个回复。
-
探索 Twilio 的语音仪表盘。仪表板提供了关于每个呼叫的信息,更重要的是,提供了查看所有错误和警告的功能。如果你的机器人看起来工作正常,但是打电话给你的机器人失败了,“错误和警告”部分是开始调查可能发生了什么的好地方。
-
将收集动词添加到您的响应中,以便用户可以与机器人进行对话。在对一个不会说话的机器人的新鲜感消失之前,你可以进行多长时间的对话,然后你想要实现一些有意义的事情?
-
像你在练习 9-1 中做的那样,用轮询机制代替 WebSocket。对这个解决方案有帮助吗?
-
玩一玩 Twilio 的语音识别。有多好?认自己名字的能力有多强?它有多容易被打破?
-
将语音识别应用于任意的语音数据已经够有挑战性了,更不用说应用于电话质量的语音数据了。Twilio 的收集动词允许提示 7 为语音识别引擎 8 准备单词或短语的词汇表。通常,这可以提高语音识别性能。继续添加一些包含您的机器人支持的单词的提示。语音识别是否表现得更好?
您刚刚创建了自己的语音聊天机器人,并尝试了一些有趣的 Twilio 功能。您可以使用类似的技术为任何其他通道创建连接器。
回想一下,像谷歌助手和亚马逊的 Alexa 这样的系统支持通过语音合成标记语言(SSML)进行语音输出。使用这种标记语言,开发人员可以在机器人的语音响应中指定音调、速度、强调和暂停。不幸的是,在撰写本文时,Twilio 并不支持 SSML。幸运的是,微软有一些 API 可以使用 SSML 将文本转换成语音。
其中一个 API 是微软的 Bing 语音 API。 9 该服务提供语音到文本和文本到语音的功能。对于文本到语音的功能,我们提供了一个 SSML 文档,并接收一个音频文件作为响应。我们对输出格式有一些控制,尽管对于我们的示例,我们将接收一个 wave 文件。一旦我们有了文件,我们就可以利用播放动词来播放电话呼叫的音频。让我们看看这是如何工作的。
我们将首先引入 bing-speech client-API node . js 包。
npm install --save bingspeech-api-client一个示例 Play TwiML 文档如下所示:
<?xml version="1.0" encoding="UTF-8"?
<Response>
<Play loop="10">https://api.twilio.com/cowbell.mp3</Play>
</Response>Twilio 在 Play 动词中接受了 URI。因此,我们需要将 Bing Speech API 的输出保存到文件系统上的一个文件中,并生成一个 Twilio 可以用来检索音频文件的 URI。我们将把所有输出音频文件写入一个名为 audio 的目录中。我们还将建立一条新的 restify 路线来检索这些文件。
首先,让我们创建我们的函数来生成音频文件并将其存储在正确的位置。给定一些文本,我们想返回一个 URI 供调用函数使用。我们将使用文本的 MD5 散列作为音频文件的标识符。
npm install md5 --save这是生成一个音频文件并保存在本地的代码。有两个前提。首先,我们需要生成一个 API 密匙来利用微软的 Bing 语音 API。我们可以通过在 Azure 门户中创建一个新的 Bing 语音 API 资源来实现这一点。这个 API 有一个免费的计划版本。一旦我们有了密钥,我们就将它添加到.env文件中,并将其命名为 MICROSOFT_BING_SPEECH_KEY。其次,我们将我们的基本 ngrok URI 作为 BASE_URI 添加到.env文件中。
const md5 = require('md5');
const BingSpeechClient = require('bingspeech-api-client').BingSpeechClient;
const fs = require('fs');
const bing = new BingSpeechClient(process.env.MICROSOFT_BING_SPEECH_KEY);
function generateAudio (text) {
const id = md5(text);
const file = 'public\\audio\\' + id + '.wav';
const resultingUri = process.env.BASE_URI + '/audio/' + id + '.wav';
if (!fs.existsSync('public')) fs.mkdirSync('public');
if (!fs.existsSync('public/audio')) fs.mkdirSync('public/audio');
return bing.synthesize(text).then(result => {
const wstream = fs.createWriteStream(file);
wstream.write(result.wave);
console.log('created %s', resultingUri);
return resultingUri;
});
}为了测试这一点,我们创建了一个测试端点,它创建了一个音频文件并用 URI 进行响应。然后,我们可以使用浏览器指向 URI,下载生成的声音文件。下面的 SSML 是从谷歌的 SSML 文档中借来的,我用 Date()添加了当前时间。getTime(),这样我们每次都会生成一个唯一的 MD5。
server.get('/api/audio-test', (req, res, next) => {
const sample = 'Here are <say-as interpret-as="characters">SSML</say-as> samples. I can pause <break time="3s"/>.' +
'I can speak in cardinals. Your number is <say-as interpret-as="cardinal">10</say-as>.' +
'Or I can even speak in digits. The digits for ten are <say-as interpret-as="characters">10</say-as>.' +
'I can also substitute phrases, like the <sub alias="World Wide Web Consortium">W3C</sub>.' +
'Finally, I can speak a paragraph with two sentences.' +
'<p><s>This is sentence one.</s><s>This is sentence two.</s></p>';
generateAudio(sample + ' ' + new Date().getTime()).then(uri => {
res.send(200, {
uri: uri
});
next();
});
});如果我们从 curl 调用 URL,我们会得到下面的结果。URI 引用的音频文件显然是 SSML 文档的语音合成。
$ curl https://botbook.ngrok.io/api/audio-test
{"uri":"https://botbook.ngrok.io/audio/1ce776f3560e54064979c4eb69bbc308.wav"}最后,我们将它集成到我们的代码中。我们更改buildandsendtimlresponse函数来为我们发送的任何文本生成音频文件。我们还在 generateAudio 函数中做了一个更改,使用任何之前基于 MD5 散列生成的音频文件。这意味着我们只能为每个输入生成一个音频文件。
function buildAndSendTwimlResponse(req, res, next, userId, text) {
const twiml = new VoiceResponse();
Promise.all(
[
generateAudio(text),
generateAudio('I didn\'t quite catch that. Please try again.'),
generateAudio('Ok, call back anytime!')]).then(
uri => {
let msgUri = uri[0];
let firstNotCaughtUri = uri[1];
let goodbyeUri = uri[2];
twiml.play(msgUri);
twiml.gather({ input: 'speech', action: '/api/voice/gather', method: 'POST' });
twiml.play(firstNotCaughtUri);
twiml.gather({ input: 'speech', action: '/api/voice/gather', method: 'POST' });
twiml.play(goodbyeUri);
twiml.hangup();
const response = twiml.toString();
console.log(response);
res.writeHead(200, {
'Content-Length': Buffer.byteLength(response),
'Content-Type': 'text/html'
});
res.write(response);
next();
});
}
function generateAudio (text) {
const id = md5(text);
const file = 'public\\audio\\' + id + '.wav';
const resultingUri = process.env.BASE_URI + '/audio/' + id + '.wav';
if (!fs.existsSync('public')) fs.mkdirSync('public');
if (!fs.existsSync('public/audio')) fs.mkdirSync('public/audio');
if (fs.existsSync(file)) {
return Promise.resolve(resultingUri);
}
return bing.synthesize(text).then(result => {
const wstream = fs.createWriteStream(file);
wstream.write(result.wave);
console.log('created %s', resultingUri);
return resultingUri;
});
}我们差不多完成了。我们还没有做的一件事是让机器人用 SSML 来响应,而不是使用文本。我们没有利用 Bot Builder 的所有语音功能。如第 6 章所示,我们可以让每条消息填充input int来帮助确定应该使用哪些 TwiML 动词,甚至合并来自机器人的多个响应。我们坚持简单地用适当的 SSML 填充每个消息中的 speak 字段。我们还必须修改连接器代码,使用 speak 字段,而不是 text 字段。
bot.dialog('sampleConversation', [
(session, arg) => {
console.log(JSON.stringify(session.message));
if (session.message.text.toLowerCase().indexOf('hello') >= 0 || session.message.text.indexOf('hi') >= 0)
session.send({
text: 'hey!',
speak: '<emphasis level="strong">really like</emphasis> hey!</emphasis>'
});
else if (session.message.text.toLowerCase() === 'quit') {
session.send({
text: 'ok, we\'re done!',
speak: 'ok, we\'re done',
sourceEvent: {
hangup: true
}
});
session.endDialog();
return;
} else if (session.message.text.toLowerCase().indexOf(' meaning of life') >= 0) {
session.send({
text: '42',
speak: 'It is quite clear that the meaning of life is <break time="2s" /><emphasis level="strong">42</emphasis>'
});
} else if (session.message.text.toLowerCase().indexOf('waldo') >= 0) {
session.send({
text: 'not here',
speak: '<emphasis level="strong">Definitely</emphasis> not here'
});
} else if (session.message.text.toLowerCase() === 'apple') {
session.send({
text: "Here, have an apple.",
speak: "Apples are delicious!",
attachments: [
{
contentType: 'image/jpeg',
contentUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Red_Apple.jpg/1200px-Red_Apple.jpg',
name: 'Apple'
}
]
});
}
else {
session.send({ text: 'oh that\'s cool', speak: 'oh that\'s cool' });
}
}
]);注意,我们还添加了一个额外的元数据控制字段。对输入 quit 的响应包括一个名为 hangup 的字段,设置为 true。这表明我们的连接器包含了挂断动词。我们创建一个名为 buildAndSendHangup 的函数来生成响应。
function buildAndSendHangup(req, res, next) {
const twiml = new VoiceResponse();
Promise.all([generateAudio('Ok, call back anytime!')]).then(
(uri) => {
twiml.play(uri[0]);
twiml.hangup();
const response = twiml.toString();
console.log(response);
res.writeHead(200, {
'Content-Length': Buffer.byteLength(response),
'Content-Type': 'text/html'
});
res.write(response);
next();
});
}我们修改了/api/voice/gather处理程序来使用 speak 属性,并正确解释了 hangup 字段。
server.post('/api/voice/gather', (req, res, next) => {
const input = req.body.SpeechResult;
let userId = req.body.Caller;
console.log('user id: %s | input: %s', userId, input);
let conv = cachedConversations[userId];
console.log('got convo: %j', conv);
conv.lastAccessed = moment().format();
postActivity(process.env.DL_KEY, conv.id, {
from: { id: userId, name: userId }, // required (from.name is optional)
type: 'message',
text: input
}).then(() => {
console.log('posted activity to bot with input %s', input);
console.log('setting interval');
let interval = setInterval(function () {
console.log('getting activities...');
getActivities(process.env.DL_KEY, conv.id, conv.watermark).then(activitiesResponse => {
console.log("%j", activitiesResponse);
let temp = _.filter(activitiesResponse.activities, (m) => m.from.id !== userId);
if (temp.length > 0) {
clearInterval(interval);
let responseActivity = temp[0];
console.log('got response %j', responseActivity);
conv.watermark = activitiesResponse.watermark;
if (responseActivity.channelData && responseActivity.channelData.hangup) {
buildAndSendHangup(req, res, next);
} else {
buildAndSendTwimlResponse(req, res, next, userId, responseActivity.speak);
conv.lastAccessed = moment().format();
}
} else {
console.log('no activities for you...');
}
});
}, 500);
});
});现在,我们可以打电话和一个机智的机器人进行一场精彩的对话,这个机器人在说生命的意义是 42 岁之前停顿一下,并强调这样一个事实:沃尔多绝对不在机器人所在的地方!
直线是一个强大的功能,是从客户端应用调用我们的机器人的主要接口。我们可以创建自定义的通道连接器,因为我们能够将其他通道视为某种客户端应用。我们在本章中完成的一个更有趣的任务是将 SSML 支持添加到我们的 bot 集成中。这种集成只是我们可以开始构建到我们的机器人体验中的智能的一种尝试。我们使用的 Bing 语音 API 只是众多被称为认知服务 API 的微软 API 之一。在下一章中,我们将着眼于将该家族中的其他 API 应用于我们在机器人领域可能遇到的任务。
Footnotes [1](#Fn1_source)网络套接字协议: https://en.wikipedia.org/wiki/WebSocket
Bot 框架中的关键概念直线 API: https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-direct-line-3-0-concepts
Bot 框架 WebChat 是一个 React 组件。可以扩展代码以提供不同的呈现行为或更改控件的样式。你可以在 https://github.com/Microsoft/BotFramework-WebChat 找到更多信息。
TwiML 文档: https://www.twilio.com/docs/api/twiml
从 Twilio 购买免费号码: https://support.twilio.com/hc/en-us/articles/223183168-Buying-a-toll-free-number-with-Twilio
https://www.twilio.com/docs/voice/twiml/gather
TwiML 聚集动词提示属性: https://www.twilio.com/docs/voice/twiml/gather#attributes-hints
Bot 框架 Bot: https://docs.microsoft.com/en-us/azure/bot-service/bot-service-manage-speech-priming 背景下的语音启动
必应语音 API: https://azure.microsoft.com/en-us/services/cognitive-services/speech/










