Skip to content

Latest commit

 

History

History
1153 lines (835 loc) · 50.2 KB

File metadata and controls

1153 lines (835 loc) · 50.2 KB

八、扩展通道功能

到目前为止,我们已经花了大量的时间讨论 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 用户界面中的呈现方式。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig1_HTML.jpg

图 8-1

松弛消息生成器和预览

Slack 还为消息提供了最佳实践文档。网站上的建议之一是尽可能少地使用对我们的应用有意义的附件(图 8-2 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig2_HTML.jpg

图 8-2

好方向…

不幸的是,这似乎不是 Bot 框架的工作方式。事实上,Slack Bot 通道连接器将一个 HeroCard 对象呈现为多个附件(图 8-3 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig3_HTML.jpg

图 8-3

只是 Slack Bot 通道连接器没有完全遵守 Slack 指南

这是一个小细节,但它就是不好看。图像和按钮的默认样式是渲染图像下方的按钮(图 8-4 )。不幸的是,渲染违反了 Slack 提供的方向。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig4_HTML.jpg

图 8-4

一个格式良好的附件会是什么样子

自然,这是 Bot 框架团队将来最有可能支持的细节。在此之前,如果我们想要呈现的界面类型和平台支持的内容不匹配,我们可以使用原生 JSON 来实现我们的目标。

Slack 还包括一些我们作为 bot 服务中的一等公民无法访问的功能。Slack 支持临时消息,即在组设置中仅对一个用户可见的消息。Bot Builder SDK 没有提供实现这一点的简单方法。此外,Slack 支持交互式消息的概念,即带有按钮和菜单的消息,用户可以对其进行操作。更好的是,用户的动作可以触发消息呈现的更新!一条消息可以包括按钮,作为从用户那里收集数据的一种方式(如图 8-38-4 所示),或者一条消息可以包括菜单来选择一个选项(图 8-5 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig5_HTML.jpg

图 8-5

简单的菜单

在本节中,我们将探讨如何通过本机消息紧密集成来实现交互式消息效果。

首先,我们将把我们的机器人与一个 Slack 工作空间集成起来。其次,我们将创建一个一步到位的交互式消息。第三,我们将创建一个多步骤的交互式消息,提供丰富的、松散的本地数据收集体验。

在我们继续之前,让我们回顾一些基本规则。本章并不打算让你深入了解 Slack 的消息传递 API 和特性。我们鼓励你自己去阅读这些;Slack 有关于这个主题的非常丰富的文档。我们想要展示的是我们如何利用 bot 服务来提供与 Slack 的更深层次的集成。你可能会问,为什么不直接用 Slack 的 Node 开发工具包开发一个原生 Slackbot 呢?当然可以,但是使用 Bot Builder 库有两个主要原因。第一,您可以获得对话和对话引擎来帮助指导用户完成对话;第二,如果您在多个消息传递通道上公开体验,一个代码库可以实现代码重用。

连接到时差

让我们假设你从来没有使用过 Slack。我们首先需要创建一个宽松的工作空间。工作空间只是一个团队协作的宽松环境。我们可以免费创造这些。有一些限制,但自由团队仍然非常实用,肯定会允许我们开发和演示 Slack 机器人。转到 https://slack.com/create 创建一个工作区。Slack 会要求发邮件(图 8-6 )并发送确认码来验证我们的身份。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig6_HTML.jpg

图 8-6

创建新的可宽延工作空间

一旦我们输入确认码,它将要求我们输入我们的姓名、密码、(组)工作区名称、目标受众和工作区 URL。我们可以向工作区发送邀请,但现在我们将跳过这一步。我们不会被重定向到工作区。出于演示的目的,我的名字是 https://srozgaslacksample.slack.com

此时,我们应该整合 bot 服务和 Slack。在 Azure 上的 Bot 服务条目中,单击 Slack 频道。我们将看到松弛配置屏幕(图 8-7 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig7_HTML.jpg

图 8-7

配置我们的机器人的松散集成

该界面类似于 Facebook Messenger 频道配置界面,但要求不同的数据。我们需要来自 Slack 的三条信息:客户机 ID、客户机秘密和验证令牌。

https://api.slack.com/apps 登录 Slack,新建一个 app。输入应用名称并选择我们刚刚创建的开发工作区(图 8-8 )。最后点击创建应用按钮。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig8_HTML.jpg

图 8-8

创建 Slack 应用

创建应用后,我们将被重定向到应用页面。点击权限设置重定向网址(图 8-9 )。你将被带到一个名为 OAuth &权限的页面。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig9_HTML.jpg

图 8-9

设置 bot 服务重定向 URI

点击添加新的重定向网址,输入 https://slack.botframework.com 。接下来选择左侧工具条中的机器人用户项,并为机器人添加一个用户。这允许我们给机器人分配一个用户名,并指示它是否应该总是在线出现(图 8-10 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig10_HTML.jpg

图 8-10

创建在通道中代表机器人的机器人用户

接下来,我们将订阅几个事件,这些事件将被发送到 bot 服务 web 钩子。这将确保 bot 服务能够正确地将相关的 Slack 事件发送到我们的 bot 中。导航到事件订阅,通过右边的开关启用事件,输入 https://slack.botframework.com/api/Events/{YourBotHandle} 作为请求 URL。在第 5 章中,一个机器人句柄被分配给我们的机器人频道注册,可以在设置页面中找到。一旦进入,Slack 将建立到端点的连接。最后,在下订阅 Bot 事件(不是工作区事件!)添加以下事件:

  • 会员 _ 加入 _ 通道

  • 成员 _ 左 _ 频道

  • 消息.通道

  • 消息.组

  • message.im

  • message.er

8-11 显示了最终的配置。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig11_HTML.jpg

图 8-11

为我们的机器人订阅空闲事件

我们还需要启用交互式组件来支持通过菜单、按钮或交互式对话框接收消息。在左侧菜单中选择交互组件,点击启用交互消息,输入如下请求 URL: https://slack.botframework.com/api/Actions (图 8-12 )。点击启用交互组件并保存更改。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig12_HTML.jpg

图 8-12

在我们的机器人中启用交互式组件。这意味着按钮和菜单!

最后,我们从应用凭证部分(可通过基本信息菜单项访问)提取凭证,并将客户端 ID、客户端密码和验证令牌输入 Azure 门户中 bot 通道注册的通道刀片内的配置备用屏幕。提交后,您将被要求登录到您的 Slack 工作区并验证该应用。授权后,你的 bot 会出现在你的 Slack workspace 界面(在 Apps 类别下),你就可以和它交流了(图 8-13 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig13_HTML.jpg

图 8-13

我们已经连接到 Azure bot 服务

记得运行 ngrok!在图 8-13 中,你可以看出我忘记了运行我的 ngrok。

练习 8-1

基本松弛度整合和 消息渲染

本练习的目标是将一个机器人连接到 Slack,这样您就可以熟悉作为消息传递和机器人平台的 Slack。你的目标是将你在第 57 章节中创建的日历机器人部署到 Slack。部署完成后,您可以对比模拟器或 Facebook Messenger 检查不同元素在 Slack 中的呈现方式。

  1. 创建一个测试松弛工作空间。

  2. 按照上一节中的步骤将 Azure Bot 服务 Bot 连接到工作区。

  3. 确认你可以通过 Slack 和你的机器人交流。

  4. 测试机器人并回答以下问题:机器人如何呈现登录按钮?机器人如何渲染主卡选择卡?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

img/455925_1_En_8_Chapter/455925_1_En_8_Fig14_HTML.jpg

图 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 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig17_HTML.jpg

图 8-17

完全按照设计

img/455925_1_En_8_Chapter/455925_1_En_8_Fig16_HTML.jpg

图 8-16

似乎在起作用…

img/455925_1_En_8_Chapter/455925_1_En_8_Fig15_HTML.jpg

图 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 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig20_HTML.jpg

图 8-20

我现在通过使用/giphy mean girls,在 Slack conversation 中使 2004 年的邪教经典《Mean Girls》不朽

img/455925_1_En_8_Chapter/455925_1_En_8_Fig19_HTML.jpg

图 8-19

一个酷妈妈意味着女孩 GIF 预览

img/455925_1_En_8_Chapter/455925_1_En_8_Fig18_HTML.jpg

图 8-18

调用/giphy 斜杠命令

我们可以使用 postEphemeral 消息只给某些用户反馈。当然,delete 使我们能够从 bot 中删除旧消息。从可用性的角度来看,删除功能并不有趣。用一个修正来更新一个消息,或者通知用户一个消息已经被删除了,这是一个更好的体验,而不是简单地删除它而不做任何解释。

简单的交互式消息

Slack 允许我们使用所谓的交互式消息来实现更好的对话体验。 5 交互消息是包括通常的消息数据加上按钮和菜单的消息。此外,当用户与用户界面元素交互时,消息可以改变以反映这一点。

下面是一个例子:机器人会发送一条请求批准的消息,当用户单击“是”或“否”按钮时,我们的机器人会修改消息以反映选择(图 8-21 ,图 8-22 ,图 8-23 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig23_HTML.jpg

图 8-23

请求未被批准

img/455925_1_En_8_Chapter/455925_1_En_8_Fig22_HTML.jpg

图 8-22

请求已批准

img/455925_1_En_8_Chapter/455925_1_En_8_Fig21_HTML.jpg

图 8-21

简单的互动信息

当然,我们可以使用 postMessageupdateMessage 来编排这种类型的行为,但是有一种更简单、更集成的方式来实现。首先,我们定义了一个名为 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 显示了如果用户没有回复消息时的情况。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig24_HTML.jpg

图 8-24

嗯…似乎我有两组按钮来回答同一个问题

好的,我们有两组按钮。有道理。如果我们点击按钮,该信息将根据图 8-25 进行修改。对话框结束,bot 瀑布的第二步发送“完成!!!"消息。然而,谈话处于一种奇怪的状态;似乎原始请求仍未完成。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig25_HTML.jpg

图 8-25

第一条消息不也应该更新吗?

现在,对话框堆栈顶部不再包含选择提示。这意味着,如果我们点击上方消息中的 YesNo 按钮,我们将会遇到问题,因为我们的代码不期望这种类型的响应(图 8-26 )。事实上,我们将收到另一个提示,因为机器人再次调用 beginDialog 。拥有多个未解决的交互消息而没有能力解决所有这些消息是糟糕的 UX。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig26_HTML.jpg

图 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 中,我们在第一组按钮上点击是。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig28_HTML.jpg

图 8-28

有效。我们现在可以在不造成 UX 混乱的情况下使用旧的交互信息。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig27_HTML.jpg

图 8-27

好,回到这个场景

有几个我们应该提到的警告。首先,如果您尝试使用文本而不是单击按钮来响应提示,所提供的代码将会失败。这是为什么?Slack 不发送包含消息交互细节的有效负载对象。这只会被认为是文本输入,我们没有办法正确地将消息更新为被批准或被拒绝。处理这个问题的一种方法是只需要按钮输入,而不是文本输入。另一种方法是接受它,但将确认作为新消息发送。以下是在图 8-29 中用一条文本消息响应后产生的对话的行为代码:

img/455925_1_En_8_Chapter/455925_1_En_8_Fig29_HTML.jpg

图 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 更新交互消息的逻辑。

  1. 创建一个通用机器人,它启动一个名为sendbenseapproval的对话框。

  2. 创建一个名为sendpenseapproval的对话框。对话框应该创建一个随机费用对象,有四个字段: ID用户类型金额。该对象将表示用户在类型类型的商品上花费了$ 金额的事实。ID 应该只是一个随机的唯一标识符。例如,创建一个对象,表示 Szymon 花了 60 美元乘出租车,或者 Bob 花了 20 美元买了一箱加味汽水。生成随机费用后,向用户发送一张总结费用的英雄卡和两个标签为批准拒绝的按钮。使用 session.send 发送响应后,结束对话。

  3. 此时,机器人不做任何事情。修改英雄卡中的批准和拒绝按钮,以便发送到机器人的值是 id 为{ID}的批准请求和 ID 为{ID}的拒绝请求。

  4. 创建一个自定义识别器来匹配这些模式并提取 ID。您的自定义识别器应该根据输入返回意图 ApproveRequestIntentRejectRequestIntent 。确保在结果识别器对象中包含 ID。

  5. 创建两个对话框,一个名为 ApproveRequestDialog ,一个名为 RejectRequestDialog 。使用触发动作将对话框连接到相应的意图。

  6. 确保两个对话框向 response_url 发送正确的批准或拒绝响应,以便更新原始 hero 卡。

本练习中使用的全局处理所有交互消息的技术是强大的和可扩展的。您可以轻松地为任何未来行为添加更多的消息类型、意图和对话框。实际上,您最终可能会得到阻塞和非阻塞消息的混合。您现在已经准备好处理这两种风格。

多步体验

在上一节中,我们创建了一个单步交互式消息。我们将通过一个更复杂的多步骤交互来继续探索 Slack 上的交互消息。假设我们想引导用户通过一个多步骤的过程来选择一种比萨饼、一些配料和一个尺寸。我们将使用多步互动信息来构建体验。本部分的代码包含在本书的 git repos 中;我们将在接下来的几页中分享最相关的内容。

我们的经验如下。该机器人将首先要求用户为他们的比萨饼提供一种酱料类型(图 8-30 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig30_HTML.jpg

图 8-30

你想要什么比萨饼调味汁?

如果用户回答番茄酱,我们的有限机器人将要求用户从两种馅饼中选择一种:普通馅饼或意大利香肠馅饼(图 8-31 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig31_HTML.jpg

图 8-31

番茄酱披萨

如果用户选择了油和大蒜酱,他们将得到一组不同的选项(图 8-32 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig32_HTML.jpg

图 8-32

油大蒜比萨的额外配料选择

最后一步要求用户选择一个尺寸。我们为这一步渲染一个菜单(图 8-33 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig33_HTML.jpg

图 8-33

你想要多大的?

一旦完成,信息将变成订单的总结(图 8-34 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig34_HTML.jpg

图 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-358-36 )。

img/455925_1_En_8_Chapter/455925_1_En_8_Fig36_HTML.jpg

图 8-36

哎呀!

img/455925_1_En_8_Chapter/455925_1_En_8_Fig35_HTML.jpg

图 8-35

到目前为止一切顺利 ‘

发生了什么事?事实证明,我们之前创建的用于拒绝不期望的交互消息响应的识别器开始工作,并告诉我们该操作已经过期。似乎提示代码抢占了全局识别器,而如果我们使用瀑布对话框,我们就没有办法控制识别过程。

在第 6 章中,当我们讨论自定义对话框时,我们简要地提到了一种叫做识别的方法。这个方法允许我们向 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 分。这意味着这些回调将会上升到全局识别器,正如在第 6 章中所讨论的。任何其他回拨 ID 将被视为过期。

我们可以轻松地使用这个类,如下所示:

bot.dialog('multi-step-flow', new WaterfallWithRecognizeDialog(['pizzatype', 'ingredient', 'finish'], [
    ...
]));

如果我们现在运行代码,我们会得到与图 8-308-33 中相同的结果流。

练习 8-3

互动消息

在本练习中,您将创建一个多步交互式流来支持一个可以过滤服装产品的机器人。目标是利用与上一节类似的方法来指导用户完成多步数据输入过程。

  1. 分两步创建一个通用机器人。第一步调用一个名为 filterClothing 的对话框,第二步将对话框的结果打印到控制台并结束对话。

  2. 按照最新章节的结构创建一个名为 filterClothing 的多步交互式消息对话框。收集三条数据来过滤一个假设的服装集合:服装类型、尺码和颜色。独占使用菜单。

  3. 确保利用针对 response_url 的 HTTP 请求来更新交互消息。

现在,您已经非常熟悉为多步交互消息使用 Slack API 了,这是一个更酷的 Slack 特性。

结论

本章演示的代码只是触及了我们的 Bot Builder bots 和不同通道之间的集成可能性的表面。尽管我们有意地将重点放在松弛的用例上,但我们希望很清楚,在一系列不同的体验中,无论是一般的还是特定于平台的,都有很多机会重用我们的 bot 代码。

对话框、状态和识别器的强大抽象可以应用于所有通道,甚至在使用本机机制调用对话框时也是如此。我们还没有探索为自定义通道创建连接器。我们将在下一章对此进行研究。

Footnotes [1](#Fn1_source)

松弛消息指南: 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