Skip to content

Latest commit

 

History

History
1076 lines (781 loc) · 54.9 KB

File metadata and controls

1076 lines (781 loc) · 54.9 KB

七、构建集成的机器人体验

到目前为止,我们已经构建了一个非常好的 LUIS 应用,它一直在不断发展。我们还利用了 Bot Builder 对话引擎,该引擎采用我们的自然语言模型,从用户话语中提取相关的意图和实体,并包含围绕进入 Bot 的许多不同输入排列的条件逻辑。但是我们的代码实际上什么也不做。我们如何让它做一些有用和真实的事情?在整本书中,我们一直在探索日历机器人的概念。这意味着我们需要集成某种日历 API。出于本书的目的,我们将与 Google 的日历 API 集成。设置好之后,我们将探索如何将这些调用集成到 bot 流中。在 OAuth 时代,我们不会花时间在聊天窗口中收集用户名和密码。那是不安全的。相反,我们将使用 Google OAuth 库实现一个三条腿的 OAuth 流。然后,我们将继续对代码进行修改,以支持与 Google Calendar API 的通信。在本章的最后,我们将得到一个可以用来创建约会和查看日历条目的机器人。

注意,本章的代码可以作为代码库的一部分获得。贯穿 bot 代码和本书中的代码,您会发现许多库的使用。使用较多的一个是下划线。下划线是一个漂亮的库,它提供了一系列有用的实用函数,尤其是在集合方面。

关于 OAuth 2.0 的一句话

这不是一本关于安全性的书,但是理解基本的身份验证和授权机制对于开发人员来说是必不可少的。OAuth 2.0 是一个标准的授权协议。三足 OAuth 2.0 流允许第三方应用代表另一个实体访问服务。在我们的例子中,我们将代表用户访问用户的 Google 日历数据。在三条腿的 OAuth 流的末尾,我们以两个令牌结束:一个访问令牌和一个刷新令牌。访问令牌包含在对授权 HTTP 头中的 API 的请求中,并向 API 提供数据,声明我们正在请求哪个用户的数据。访问令牌通常是短暂的,以减少可利用受损访问令牌的窗口。当访问令牌过期时,我们可以使用刷新令牌来接收新的访问令牌。

为了启动这个流程,我们首先将用户重定向到一个他们可以验证的服务,比如说 Google。Google 提供了一个 OAuth 2.0 登录页面,在该页面中,它对用户进行身份验证,并征求用户的同意,以便机器人可以代表他们从 Google 访问用户的数据。当认证和同意成功时,谷歌通过所谓的重定向 URI 将授权码发送回机器人的 API。最后,我们的 bot 通过向 Google 的令牌端点提供授权代码来请求访问和刷新令牌。Google 的 OAuth 库将帮助我们在日历机器人中实现三足流。

设置 Google APIs

在我们开始之前,我们应该让自己能够使用 Google APIs。幸运的是,谷歌通过谷歌云平台 API 控制台使这变得非常容易。谷歌云平台是谷歌的 Azure 或者 AWS 它是谷歌供应和管理不同云服务的一站式商店。首先,我们导航到 https://console.cloud.google.com 。如果这是我们第一次访问网站,我们将被要求接受服务条款。之后,我们将被放置在仪表板中(图 7-1 )。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig1_HTML.jpg

图 7-1

谷歌云平台仪表板

我们接下来的步骤如下。我们将创建一个新项目。在该项目中,我们将要求访问日历 API。我们还将赋予我们的项目使用 OAuth2 代表用户登录的能力。一旦完成,我们将收到一个客户端 ID 和秘密。这两段数据,加上我们的重定向 URI,足以让我们在 bot 中使用 Google API 库。

单击下拉菜单选择一个 项目。你会看到一个弹出窗口,如果你以前没有使用过这个控制台,它应该是空的(图 7-2 )。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig2_HTML.jpg

图 7-2

谷歌云平台仪表板项目

单击+按钮添加新项目。为项目命名。一旦项目被创建,我们将能够通过选择项目功能导航到它(图 7-3 )。项目还被分配一个 ID,以项目名称为前缀。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig3_HTML.jpg

图 7-3

我们的项目创建完成了!

当打开项目时,我们会看到项目仪表板,最初看起来很吓人(图 7-4 )。在这里我们可以做很多事情。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig4_HTML.jpg

图 7-4

一个项目有很多事情要做

让我们从访问 Google 日历 API 开始。我们首先单击 API 和服务。我们可以在左侧导航窗格的前几个项目中找到此链接。该页面已经填充了相当多的内容。这些是默认的谷歌云平台服务。因为我们不使用它们,我们可以禁用每一个。准备就绪后,我们可以单击启用 API 和服务按钮。我们搜索日历,点击谷歌日历 API。最后,我们点击启用按钮将其添加到我们的项目中(图 7-5 )。我们将收到一条警告,指出我们可能需要凭据才能使用 API。没问题,我们接下来会这样做。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig5_HTML.jpg

图 7-5

为我们的项目启用日历 API

要设置授权,我们单击左侧窗格中的凭证链接。我们将会看到创建凭据的提示。在我们的用例中,我们将访问用户的日历,我们需要一个 OAuth 客户端 ID 1 (图 7-6 )。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig6_HTML.jpg

图 7-6

设置我们的客户凭证

我们将首先被要求设置同意屏幕(图 7-7 )。这是用户向 Google 验证时显示的屏幕。我们大多数人可能在不同的 web 应用中遇到过这些类型的屏幕。例如,每当我们通过脸书登录一个应用时,我们都会看到一个页面,告诉我们该应用需要权限才能读取你的所有联系信息和照片,甚至是最深的秘密。这是谷歌建立类似页面的方式。它要求提供产品名称、徽标、服务条款、隐私政策 URL 等数据。为了测试功能,我们至少需要一个产品名称。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig7_HTML.jpg

图 7-7

OAuth 同意配置

此时,我们将回到创建客户端 ID 功能。作为应用类型设置,我们应该选择 Web 应用,并给我们的客户端一个名称和一个重定向 URI(图 7-8 )。我们利用我们的 ngrok 代理 URI(参见第 5 章了解更多关于 ngrok 的信息)。对于本地测试,我们可以自由输入本地主机地址。比如可以输入http://localhost:3978

img/455925_1_En_7_Chapter/455925_1_En_7_Fig8_HTML.jpg

图 7-8

创建新的 OAuth 2.0 客户端 ID 并提供重定向 URI

一旦我们点击创建 按钮,我们将收到一个带有客户端 ID 和客户端密码的弹出窗口(图 7-9 )。复制它们,因为我们将需要我们的 bot 中的值。如果我们丢失了客户端 ID 和密码,我们总是可以通过导航到项目的凭证页面并选择我们在 OAuth 2.0 客户端 ID 中创建的条目来访问它们。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig9_HTML.jpg

图 7-9

我们总能找到丢失的 ID 和秘密

此时,我们已经准备好将我们的机器人连接到 Google OAuth2 提供者。

将身份验证与 Bot Builder 集成

我们将需要安装 googleapis Node 包以及 crypto-js ,一个让我们加密数据的库。当我们将用户发送到 OAuth 登录页面时,我们还在 URL 中包含一个州。状态只是一个有效负载,我们的应用可以用它来标识用户及其对话。当 Google 将一个授权码作为 OAuth 2.0 三足流的一部分发回时,它也会发回状态。状态参数应该是我们的 API 可以识别的,但恶意参与者很难猜到的,比如会话散列或我们感兴趣的其他信息。一旦我们从 Google 的 auth 页面收到它,我们就可以使用 state 参数中的数据继续用户的对话。

为了屏蔽不良行为者的数据,我们将把这个对象编码为 Base64 字符串。Base64 是二进制数据的 ASCII 表示。 2 由于一个恶意的参与者可以通过简单地从 Base64 解码来轻易地泄露这些信息,我们将使用 crypto-js 来加密状态字符串。

首先,让我们安装这两个包。

npm install googleapis crypto-js --save

其次,让我们添加三个变量。代表客户端 ID、机密和重定向 URI 的 env 文件。我们使用我们在图 7-8 中提供的重定向 URI 和我们在图 7-9 中收到的客户端 ID 和密码。

GOOGLE_OAUTH_CLIENT_ID=693978449559-8t03j8064o6hfr1f8lh47s9gvc4afed4.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=X6lzSlw500t0wmQQ2SpF6YV6
GOOGLE_OAUTH_REDIRECT_URI=https://a4b5518e.ngrok.io

第三,我们需要生成登录页面的 URL,并发送一个可以打开该 URL 的按钮。Google Auth APIs 可以为我们做很多这方面的工作。我们将在代码中做一些事情。首先,我们导入 crypto-js 和 googleapis 包。接下来,我们创建一个包含客户机数据的 OAuth2 客户机实例。我们将作为登录 URL 的一部分发送的状态包含用户的地址。如前一章所示,一个地址足以唯一地标识用户的对话,Bot Builder 包含一些工具,可以帮助我们通过简单地显示对话地址向该用户发送消息。我们使用 crypto-js 来加密状态,使用 ASE 算法。 3 AES 是一种对称密钥算法,这意味着数据使用相同的密钥或密码进行加密和解密。我们将密码短语添加到。名为 AES_PASSPHRASE 的 env 文件。

GOOGLE_OAUTH_CLIENT_ID=693978449559-8t03j8064o6hfr1f8lh47s9gvc4afed4.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=X6lzSlw500t0wmQQ2SpF6YV6
GOOGLE_OAUTH_REDIRECT_URI=https://a4b5518e.ngrok.io/oauth2callback
AES_PASSPHRASE=BotsBotsBots!!!

另一件要注意的事情是范围数组。当请求对 Google APIs 的授权时,我们使用作用域向 Google 指定我们要访问的 API。我们可以将 scopes 数组中的每一项看作是我们希望从 Google 的 API 中访问的关于用户的一段数据。当然,这个数组需要是我们的 Google 项目可能访问的 API 的子集。如果我们添加了之前没有为项目启用的范围,授权过程将会失败。

const google = require('googleapis');
const OAuth2 = google.auth.OAuth2;
const CryptoJS = require('crypto-js');

const oauth2Client = getAuthClient();
const state = {
    address: session.message.address
};
const googleApiScopes = [
    'https://www.googleapis.com/auth/calendar'
];
const encryptedState = CryptoJS.AES.encrypt(JSON.stringify(state), process.env.AES_PASSPHRASE).toString();
const authUrl = oauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: googleApiScopes,
    state: encryptedState
});

我们还需要能够发送一个按钮,让用户利用授权机器人。为此,我们使用内置的 SigninCard。

const card = new builder.SigninCard(session).button('Login to Google', authUrl).text('Need to get your credentials. Please login here.');
const loginReply = new builder.Message(session)
    .attachmentLayout(builder.AttachmentLayout.carousel)
    .attachments([card]);

仿真器根据图 7-10 渲染信号。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig10_HTML.jpg

图 7-10

在 Bot 框架模拟器中呈现的签名

此时,我们可以单击 Login 按钮登录到 Google,并授权我们的 bot 访问我们的数据,但这会失败,因为我们还没有提供代码来处理来自返回 URI 的消息。我们使用与安装 API 消息端点相同的方法为 https://a4b5518e.ngrok.io/oauth2callback 端点安装处理程序。我们还启用了 restify.queryParser ,这将把查询字符串中的每个参数公开为 req.query 对象中的一个字段。例如, redirectUri 形式的回调?state=state & code=code 会产生一个查询对象,它有两个属性,state 和 code。

const server = restify.createServer();
server.use(restify.queryParser());
server.listen(process.env.port || process.env.PORT || 3978, function () {
    console.log('%s listening to %s', server.name, server.url);
});

server.get('/oauth2callback', function (req, res, next) {
    const code = req.query.code;
    const encryptedState = req.query.state;

    ...
});

我们从回调中读取授权代码,并使用 Google OAuth2 客户端从令牌端点获取令牌。标记 JSON 看起来像下面的数据。请注意, expiry_date 是自纪元以来以毫秒为单位的日期时间。 4

{
    "access_token": "ya29.GluMBfdm6hPy9QpmimJ5qjJpJXThL1y    GcKHrOI7JCXQ46XdQaCDBcJzgp1gWcWFQNPTXjbBYoBp43BkEAyLi3    ZPsR6wKCGlOYNCQIkeLEMdRTntTKIf5CE3wkolU",
    "refresh_token": "1/GClsgQh4BvHTxPdbQgwXtLW2hBza6FPLXDC9zBJsKf4NK_N7AfItv073kssh5VHq",
    "token_type": "Bearer",
    "expiry_date": 1522261726664
}

一旦我们收到令牌,我们就在 OAuth2 对象上调用 setCredentials ,现在可以用它来访问 Google Calendar API 了!

server.get('/oauth2callback', function (req, res, next) {
    const code = req.query.code;
    const encryptedState = req.query.state;

    const oauth2Client = new OAuth2(
        process.env.GOOGLE_OAUTH_CLIENT_ID,
        process.env.GOOGLE_OAUTH_CLIENT_SECRET,
        process.env.GOOGLE_OAUTH_REDIRECT_URI
    );

    res.contentType = 'json';
    oauth2Client.getToken(code, function (error, tokens) {
        if (!error) {
            oauth2Client.setCredentials(tokens);

            // We can now use the oauth2Client to call the calendar API

            next();
        } else {
            res.send(500, {
                status: 'error',
                error: error
            });
            next();
        }
    });
});

在我们可以访问日历 API 的代码位置,我们可以编写代码来获取我们拥有的日历列表并打印出它们的名称。注意,下面代码中的 calapi 是一个 helper 对象,它用 JavaScript promises 包装了 Google Calendar API。代码可以在本章的代码库中找到。

calapi.listCalendars(oauth2Client).then(function (data) {
    const myCalendars = _.filter(data, p => p.accessRole === 'owner');
    console.log(_.map(myCalendars, p => p.summary));
});

这段代码产生了下面的控制台输出,这是一个不幸的提醒,提醒了我自从当了爸爸后就没怎么活动过的相当孤独的锻炼计划。

Array(5) ["BotCalendar", "Szymon Rozga", "Work", "Szymon WFH Schedule", "Workout schedule"]

撇开父亲的体重增加不谈,这太棒了!我们确实面临一些挑战。我们需要存储用户的 OAuth 令牌,这样我们就可以在用户向我们发送消息时随时访问它们。我们把它们存放在哪里?这个很简单:私人谈话数据。在这种情况下,我们如何访问数据字典呢?我们通过将用户的地址传递给 bot.loadSession 方法来实现这一点。

回想一下,我们将用户的地址存储到加密的状态变量中。我们可以使用与加密数据相同的密码来解密该对象。

const state = JSON.parse(CryptoJS.AES.decrypt(encryptedState, process.env.AES_PASSPHRASE).toString(CryptoJS.enc.Utf8));

收到令牌后,我们可以从该地址加载 bot 会话。此时,我们有了一个 session 对象,它包含了所有的对话方法,如 beginDialog 供我们使用。

oauth2Client.getToken(code, function (error, tokens) {
    bot.loadSession(state.address, (sessionLoadError, session) => {
        if (!error && !sessionLoadError) {
            oauth2Client.setCredentials(tokens);

            calapi.listCalendars(oauth2Client).then(function (data) {
                const myCalendars = _.filter(data, p => p.accessRole === 'owner');
                session.beginDialog('processUserCalendars', { tokens: tokens, calendars: myCalendars });

                res.send(200, {
                    status: 'success'
                });
                next();
            });

            // We can now use the oauth2Client to call the calendar API
        } else {
            res.send(500, {
                status: 'error',
                error: error
            });
            next();
        }
    });
});

processUserCalendars 对话框可能看起来像这样。它将令牌设置到私人对话数据中,让用户知道他们已经登录,并显示所有客户端日历的名称。

bot.dialog('processUserCalendars', (session, args) => {
    session.privateConversationData.userTokens = args.tokens;
    session.send('You are now logged in!');
    session.send('You own the following calendars. ' + _.map(args.calendars, p => p.summary).join(', '));
    session.endDialog();
});

交互将如图 7-11 所示。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig11_HTML.jpg

图 7-11

与对话框集成的登录流程

无缝登录流程

我们已经成功地登录并存储了访问令牌,但是我们还没有演示一个无缝的机制,当一个对话框要求我们的用户登录时,它可以重定向到登录流。更具体地说,如果在日历机器人的上下文中,用户没有登录并要求机器人添加新的日历条目,机器人应该显示登录按钮,然后在登录成功后继续添加条目对话框。

下面列出了与现有对话流集成的一些要求:

  1. 我们希望允许用户在任何时候向机器人发送文本登录注销的消息,并让机器人做正确的事情。

  2. 当需要授权的对话开始时,它需要验证用户授权是否存在。如果 auth 不存在,登录按钮应该出现,并阻止用户继续所述对话,直到用户被授权。

  3. 如果用户说注销,令牌应该从私人对话数据中清除,并用 Google 撤销。

  4. 如果用户说登录,bot 需要渲染登录按钮。该按钮将用户指向授权 URL。这与前面描述的相同。然而,我们必须确保点击按钮两次不会混淆机器人和它对用户状态的理解。

我们自然会实现一个登录对话框和一个注销对话框。注销只是检查会话状态中是否存在令牌。如果我们没有令牌,我们已经注销。如果我们这样做,我们使用谷歌的图书馆撤销用户的凭证。 5 代币不再有效。

function getAuthClientFromSession(session) {
    const auth = getAuthClient(session.privateConversationData.tokens);
    return auth;
};

function getAuthClient(tokens) {
    const auth = new OAuth2(
        process.env.GOOGLE_OAUTH_CLIENT_ID,
        process.env.GOOGLE_OAUTH_CLIENT_SECRET,
        process.env.GOOGLE_OAUTH_REDIRECT_URI
    );

    if (tokens) {
        auth.setCredentials(tokens);
    }
    return auth;
}

bot.dialog('LogoutDialog', [(session, args) => {
    if (!session.privateConversationData.tokens) {
        session.endDialog('You are already logged out!');
    } else {
        const client = getAuthClientFromSession(session);
        client.revokeCredentials();
        delete session.privateConversationData['tokens'];
        session.endDialog('You are now logged out!');
    }
}]).triggerAction({
    matches: /^logout$/i
});

登录是一个瀑布式对话框,它在进入下一步之前开始一个确保凭证对话框。在第二步中,它验证是否已登录。请参见下面的代码。它通过验证是否从确保凭证对话框接收到认证标志来做到这一点。如果是,它只是让用户知道她已经登录。否则,会向用户显示一个错误。

注意我们在这里做了什么。我们外包了判断我们是否登录、登录,然后将结果发送回不同对话框的逻辑。只要该对话框返回一个带有字段已验证和可选的错误的对象,就可以正常工作。我们将使用相同的技术将授权流注入到任何其他需要它的对话框中。

bot.dialog('LoginDialog', [(session, args) => {
    session.beginDialog(constants.dialogNames.Auth.EnsureCredentials);
}, (session, args) => {
    if (args.response.authenticated) {
        session.send('You are now logged in!');
    } else {
        session.endDialog('Failed with error: ' + args.response.error)
    }
}]).triggerAction({
    matches: /^login$/i
});

所以,最重要的问题变成了,保证凭证做什么?这段代码需要处理四种情况。前两个很简单。

  • 如果对话框需要凭据并且授权成功,会发生什么情况?

  • 如果一个对话框需要凭证,而授权失败了,会发生什么?

    后两个稍微有点微妙。我们的问题是,如果一个对话框没有等待授权,但它还是进来了,机器人应该做什么。或者换句话说,如果 EnsureCredentials 不在栈顶会发生什么?

  • 如果用户在需要登录的对话框范围之外单击登录按钮,并且授权成功,会发生什么情况?

  • 如果用户在需要登录的对话框范围之外单击登录按钮,并且授权失败,会发生什么情况?

我们在图 7-12 中说明了第一种情况的流程。一个对话框要求我们在继续之前获得用户的授权,就像前面代码中的登录对话框一样。用户将被发送到身份验证页面。一旦 auth 页面返回一个成功的授权代码,它就向我们的 oauth2callback 发送一个回调。一旦我们得到令牌,我们调用一个 StoreTokens 对话框来将令牌存储到对话数据中。该对话框将向 EnsureCredentials 返回成功消息。反过来,这将向调用对话框返回一个成功的身份验证消息。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig12_HTML.jpg

图 7-12

对话框需要授权,成功授权

如果发生错误,流程是类似的,只是我们用错误对话框替换了确保凭证对话框。然后,错误对话框将向调用对话框返回一个失败的验证消息,调用对话框可以以它认为最合适的方式处理错误(图 7-13 )。回想一下,正如我们在第 5 章中提到的, replaceDialog 是一个用另一个对话框的实例替换栈顶当前对话框的调用。调用对话不知道,也不关心这个实现细节。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig13_HTML.jpg

图 7-13

对话需要授权,授权失败

如果用户在对话框不期待回复时点击登录按钮,并且 EnsureCredentials 不在堆栈顶部,那么流程会略有不同。如果授权成功或失败,我们希望向用户显示成功或失败的消息。为了实现这一点,我们将在调用 StoreTokens 对话框之前在堆栈上放置一个确认对话框 AuthConfirmation (图 7-14 )。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig14_HTML.jpg

图 7-14

用户表示登录,授权成功

同样,在我们收到授权错误的情况下,我们在推送错误对话框之前,推送堆栈顶部的授权确认对话框(图 7-15 )。这将确保确认对话框向用户显示正确类型的消息。

img/455925_1_En_7_Chapter/455925_1_En_7_Fig15_HTML.jpg

图 7-15

用户说登录,授权失败

让我们看看这个的代码是什么样子的。登录注销对话框已经完成,但是让我们看看确保凭证存储令牌错误

确保凭证由两个步骤组成。首先,如果用户定义了一组令牌,那么对话框会传递一个结果,表明用户可以使用了。否则,我们创建 auth URL 并向用户发送一个 SigninCard ,就像我们在上一节中所做的那样。第二步也在案例 1 中执行。它只是告诉调用对话框用户已被授权。

bot.dialog('EnsureCredentials', [(session, args) => {
    if(session.privateConversationData.tokens) {
        // if we have the tokens... we're good. if we have the tokens for too long and the tokens expired
        // we'd need to somehow handle it here.
        session.endDialogWithResult({ response: { authenticated: true } });
        return;
    }

    const oauth2Client = getAuthClient();
    const state = {
        address: session.message.address
    };
    const encryptedState = CryptoJS.AES.encrypt(JSON.stringify(state), process.env.AES_PASSPHRASE).toString();
    const authUrl = oauth2Client.generateAuthUrl({
        access_type: 'offline',
        scope: googleApiScopes,
        state: encryptedState
    });

    const card = new builder.HeroCard(session)
        .title('Login to Google')
        .text("Need to get your credentials. Please login here.")
        .buttons([
            builder.CardAction.openUrl(session, authUrl, 'Login')
        ]);

    const loginReply = new builder.Message(session)
        .attachmentLayout(builder.AttachmentLayout.carousel)
        .attachments([card]);

    session.send(loginReply);
}, (session, args) => {
    session.endDialogWithResult({ response: { authenticated: true } });
}]);

StoreTokens错误类似。两者本质上都将授权结果返回给其父对话框。在存储令牌的情况下,我们也将令牌存储到对话数据中。

bot.dialog('Error', [(session, args) => {
    session.endDialogWithResult({ response: { authenticated: false, error: args.error } });
}]);

bot.dialog('StoreTokens', function (session, args) {
    session.privateConversationData.tokens = args.tokens;
    session.privateConversationData.calendarId = args.calendarId;

    session.endDialogWithResult({ response: { authenticated: true }});
});

请注意, EnsureCredentials 将使用这两者中任何一个的结果,并简单地将其传递给调用对话框。由调用对话框决定显示成功还是错误消息。甚至可能没有成功消息;调用对话框可能会直接跳到它自己的步骤中。

这涵盖了案例 1 和案例 2。为了确保案例 3 和 4 被涵盖,我们需要实现这个 AuthConfirmation 对话框。该对话框的作用是显示成功或失败消息。回想一下,我们在 AuthConfirmation 之上放置了一个错误(案例 3)或存储令牌(案例 4)对话框。这个想法是, AuthConfirmation 将接收对话框的名称并放在它自己的上面,然后当它接收到结果时向用户发送适当的消息。

bod.dialog('AuthConfirmation', [
    (session, args) => {
        session.beginDialog(args.dialogName, args);
    },
    (session, args) => {
        if (args.response.authenticated) {
            session.endDialog('You are now logged in.')
        }
        else {
            session.endDialog('Error occurred while logging in. ' + args.response.error);
        }
    }
]);

最后,我们如何改变端点回调代码?在我们到达那里之前,我们编写一些助手来调用不同的对话框。我们公开了一个名为is insure的函数,该函数验证我们是否正在从 EnsureCredentials 对话框进入这段代码。这将决定我们是否需要认证。beginErrorDialogbeginStoreTokensAndResume都利用了这种方法。最后, ensureLoggedIn 是每个需要授权的对话框必须调用来启动流程的函数。

function isInEnsure(session) {
    return _.find(session.dialogStack(), function (p) { return p.id.indexOf('EnsureCredentials') >= 0; }) != null;
}

const beginErrorDialog = (session, args) => {
    if (isInEnsure(session)) {
        session.replaceDialog('Error', args);
    }
    else {
        args.dialogName = 'Error';
        session.beginDialog('AuthConfirmation', args);
    }
};

const beginStoreTokensAndResume = (session, args) => {
    if (isInEnsure(session)) {
        session.beginDialog('StoreTokens', args);
    } else {
        args.dialogName = 'StoreTokens';
        session.beginDialog('AuthConfirmation', args);
    }
};

const ensureLoggedIn = (session) => {
    session.beginDialog('EnsureCredentials');
};

最后来看回调。代码看起来与上一节中的回调类似,只是我们需要添加逻辑来启动正确的对话框。如果我们在加载我们的会话对象时遇到任何错误,或者我们得到一个 OAuth 错误,比如用户拒绝访问我们的 bot,我们将用户重定向到错误对话框。否则,我们使用来自 Google 的授权代码来获取令牌,在 OAuth 客户端中设置凭证,并调用 StoreTokensAuthConfirmation 对话框。以下代码涵盖了本节开头突出显示的四种情况:

exports.oAuth2Callback = function (bot, req, res, next) {
    const code = req.query.code;
    const encryptedState = req.query.state;
    const oauthError = req.query.error;
    const state = JSON.parse(CryptoJS.AES.decrypt(encryptedState, process.env.AES_PASSPHRASE).toString(CryptoJS.enc.Utf8));
    const oauth2Client = getAuthClient();
    res.contentType = 'json';

    bot.loadSession(state.address, (sessionLoadError, session) => {
        if (sessionLoadError) {
            console.log('SessionLoadError:' + sessionLoadError);
            beginErrorDialog(session, { error: 'unable to load session' });
            res.send(401, {
                status: 'Unauthorized'
            });
        } else if (oauthError) {
            console.log('OAuthError:' + oauthError);
            beginErrorDialog(session, { error: 'Access Denied' });
            res.send(401, {
                status: 'Unauthorized'
            });
        } else {
            oauth2Client.getToken(code, (error, tokens) => {
                if (!error) {
                    oauth2Client.setCredentials(tokens);

                    res.send(200, {
                        status: 'success'
                    });
                    beginStoreTokensAndResume(session, {
                        tokens: tokens
                    });
                } else {
                    beginErrorDialog(session, {
                        error: error
                    });
                    res.send(500, {
                        status: 'error'
                    });
                }
            });
        }
        next();
    });
};

练习 7-1

设置 谷歌 使用 Gmail 访问权限认证

本练习的目标是创建一个允许用户根据 Gmail API 进行授权的机器人。您的目标是遵循以下步骤:

  1. 设置一个 Google 项目,并启用对 Google Gmail API 的访问。

  2. 创建 OAuth 客户端 ID 和密码。

  3. 在您的 bot 中创建一个基本工作流,允许用户使用 Gmail 范围登录 Google,并将令牌存储在用户的私人对话数据中。

在本练习结束时,您将已经创建了一个可以代表 bot 用户访问 Gmail API 的 bot。

与 Google 日历 API 集成

我们现在已经准备好与 Google 日历 API 集成了。有几件事我们应该先解决。Google 日历允许用户访问多个日历,并且每个日历有不同的权限级别。在我们的机器人中,我们假设在任何时候我们只在一个日历中查询或添加事件,尽管这看起来有缺陷。我们可以扩展 LUIS 应用和 bot,使其能够为每个话语指定一个日历。

为了解决这个问题,我们创建了一个 PrimaryCalendar 对话框,允许用户设置、重置和检索他们的主日历。类似于在每个需要认证的对话开始时调用的确保凭证对话,我们创建了一个类似的机制来保证日历被设置为主日历。

在我们到达那里之前,让我们谈论连接到 Google 日历 API。Google API Node 包包括日历 API 等。API 使用以下格式:

API.Resource.Method(args, function (error, response) {

});

日历呼叫将如下所示:

calendar.events.get({
    auth: auth,
    calendarId: calendarId,
    eventId: eventId
}, function (err, response) {
    // do stuff with the error and/or response
});

首先,我们将使其适应 JavaScript Promise6模式。承诺使异步调用变得容易。JavaScript 中的承诺表示操作的最终完成或失败,以及它的返回值。它支持一个允许我们对结果执行操作的然后方法和一个允许我们对错误对象执行操作的 catch 方法。承诺可以链接:一个承诺的结果可以传递给另一个承诺,后者产生的结果可以传递给另一个承诺,依此类推,产生如下所示的代码:

promise1()
    .then(r1 => promise2(r2))
    .then(r2 => promise3(r2))
    .catch(err => console.log('Error in promise chain. ' + err));

我们修改后的 Google Calendar Promise API 将如下所示:

gcalapi.getCalendar(auth, temp)
    .then(function (result) {
        // do something with result
    }).catch(function (err) {
        // do something with err
    });

我们将所有必要的功能包装在一个名为 calendar-api 的模块中。下面是一些代码:

const google = require('googleapis');
const calendar = google.calendar('v3');

function listEvents (auth, calendarId, start, end, subject) {
    const p = new Promise(function (resolve, reject) {
        calendar.events.list({
            auth: auth,
            calendarId: calendarId,
            timeMin: start.toISOString(),
            timeMax: end.toISOString(),
            q: subject
        }, function (err, response) {
            if (err) reject(err);
            resolve(response.items);
        });
    });
    return p;
}

function listCalendars (auth) {
    const p = new Promise(function (resolve, reject) {
        calendar.calendarList.list({
            auth: auth
        }, function (err, response) {
            if (err) reject(err);
            else resolve(response.items);
        });
    });
    return p;
};

随着 API 的工作,我们现在将焦点转向 PrimaryCalendar 对话框。这个对话框必须处理几种情况。

  • 如果用户发送诸如“获取主日历”或“设置主日历”之类的话语,会发生什么?前者应该返回日历的卡片表示,后者应该允许用户选择日历卡片。

  • 如果用户登录后没有设置主日历,会发生什么?此时,我们会自动尝试让用户选择一个日历。

  • 如果用户通过日历卡上的操作按钮选择日历,会发生什么?

  • 如果用户通过键入日历名称来选择日历,会发生什么情况?

  • 如果用户试图执行一个需要设置日历的操作(比如添加一个新的约会),会发生什么?

PrimaryCalendar 对话框是一个包含三个步骤的瀑布式对话框。步骤 1 通过调用确保凭证来确保用户登录。步骤 2 期望接收来自用户的命令。我们可以获取当前的主日历,设置日历,或者重置日历;因此,这三个命令是 get、set 或 reset。设置日历需要一个可选的日历 ID。如果没有传递日历 ID,set 命令等同于 reset 命令。Reset 只是向用户发送一个用户可以写访问的所有可用日历的列表(另一个简化的假设)。

get 案例由以下代码处理:

let temp = null;
if (calendarId) { temp = calendarId.entity; }
if (!temp) {
    temp = session.privateConversationData.calendarId;
}

gcalapi.getCalendar(auth, temp).then(result => {
    const msg = new builder.Message(session)
        .attachmentLayout(builder.AttachmentLayout.carousel)
        .attachments([utils.createCalendarCard(session, result)]);

    session.send(msg);
}).catch(err => {
    console.log(err);
    session.endDialog('No calendar found.');
});

复位盒向用户发送一系列日历卡片。如果用户输入一个文本输入,瀑布的第三步假设输入是一个日历名称,并设置正确的日历。如果输入未被识别,则会发送一条错误消息。

handleReset(session, auth);

function handleReset (session, auth) {
    gcalapi.listCalendars(auth).then(result => {
        const myCalendars = _.filter(result, p => { return p.accessRole !== 'reader'; });
        const msg = new builder.Message(session)
            .attachmentLayout(builder.AttachmentLayout.carousel)
            .attachments(_.map(myCalendars, item => { return utils.createCalendarCard(session, item); }));

        builder.Prompts.text(session, msg);
    }).catch(err => {
        console.log(err);
        session.endDialog('No calendar found.');
    });
}

createCalendarCard 方法只是发送一张带有标题、副标题和发送设置日历命令的按钮的卡片。按钮回发该值:设置主日历为{日历}

function createCalendarCard (session, calendar) {
    const isPrimary = session.privateConversationData.calendarId === calendar.id;

    let subtitle = 'Your role: ' + calendar.accessRole;
    if (isPrimary) {
        subtitle = 'Primary\r\n' + subtitle;
    }
    let buttons = [];
    if (!isPrimary) {
        let btnval = 'Set primary calendar to ' + calendar.id;
        buttons = [builder.CardAction.postBack(session, btnval, 'Set as primary')];
    }

    const heroCard = new builder.HeroCard(session)
        .title(calendar.summary)
        .subtitle(subtitle)
        .buttons(buttons);
    return heroCard;
};

这提出了一个有趣的挑战。如果在除了 PrimaryCalendar 对话框之外的任何上下文中发送日历卡片,我们需要一个完整的话语来解析一个全局动作,然后调用 PrimaryCalendar 对话框。然而,如果我们在主日历对话框的上下文中提供这样的卡片,按钮仍然会触发全局动作,因此重置我们的整个堆栈。我们不想根据哪个对话框创建了卡片来设置不同的文本,因为这些按钮保留在聊天历史中,可以随时点击。

此外,如果调用了 PrimaryCalendar 对话框,我们希望确保它不会删除当前对话框。例如,如果我正在添加一个约会,我应该能够切换日历,然后回到流程中的正确步骤。

我们覆盖了触发动作选择动作方法来确保正确的行为。如果 PrimaryCalendar 对话框的另一个实例在堆栈上,我们替换它。否则,我们将把 PrimaryCalendar 对话框推到堆栈的顶部。

.triggerAction({
    matches: constants.intentNames.PrimaryCalendar,
    onSelectAction: (session, args, next) => {
        if (_.find(session.dialogStack(), function (p) { return p.id.indexOf(constants.dialogNames.PrimaryCalendar) >= 0; }) != null) {
            session.replaceDialog(args.action, args);
        } else {
            session.beginDialog(args.action, args);
        }
    }
});

如果当用户在另一个主日历对话框的实例中时调用一个主日历对话框,我们用主日历对话框的另一个实例替换顶部的对话框。实际上,在这里请原谅我,这只会发生在重置命令中,它实际上会取代构建器。我们在中调用的提示文本对话框。

所以,本质上我们以一个 PrimaryCalendar 对话框等待一个响应对象结束,这个响应对象现在可以来自另一个 PrimaryCalendar 对话框。我们可以让最顶层的实例在完成后返回一个标志,这样当第三步继续时,另一个实例就退出了。下面是说明这一逻辑的最后一个瀑布步骤:

function (session, args) {
    // if we have a response from another primary calendar dialog, we simply finish up!
    if (args.response.calendarSet) {
        session.endDialog({ response: { calendarSet: true } });
        return;
    }

    // else we try to match the user text input to a calendar name
    var name = session.message.text;
    var auth = authModule.getAuthClientFromSession(session);

    // we try to find the calendar with a summary that matches the user's input.
    gcalapi.listCalendars(auth).then(function (result) {
        var myCalendars = _.filter(result, function (p) { return p.accessRole != 'reader'; });
        var calendar = _.find(myCalendars, function (item) { return item.summary.toUpperCase() === name.toUpperCase(); });
        if (calendar == null) {
            session.send('No such calendar found.');
            session.replaceDialog(constants.dialogNames.PrimaryCalendar);
        }
        else {
            session.privateConversationData.calendarId = result.id;
            var card = utils.createCalendarCard(session, result);
            var msg = new builder.Message(session)
                .attachmentLayout(builder.AttachmentLayout.carousel)
                .attachments([card])
                .text('Primary calendar set!');
            session.send(msg);
            session.endDialog({ response: { calendarSet: true } });
        }
    }).catch(function (err) {
        console.log(err);
        session.endDialog('No calendar found.');
    });
}

设置动作不太复杂。如果我们在收到用户消息的同时收到一个日历 ID,我们只需设置该消息并发回一张日历卡片。如果我们没有收到日历 ID,我们假设与 reset 相同的行为。

let temp = null;
if (calendarId) { temp = calendarId.entity; }
if (!temp) {
    handleReset(session, auth);
} else {
    gcalapi.getCalendar(auth, temp).then(result => {
        session.privateConversationData.calendarId = result.id;
        const card = utils.createCalendarCard(session, result);
        const msg = new builder.Message(session)
            .attachmentLayout(builder.AttachmentLayout.carousel)
            .attachments([card])
            .text('Primary calendar set!');
        session.send(msg);
        session.endDialog({ response: { calendarSet: true } });
    }).catch(err => {
        console.log(err);
        session.endDialog('this calendar does not exist');
        // this calendar id doesn't exist...
    });
}

这是一个很大的过程,但它很好地说明了一些需要发生的对话体操,以确保一致和全面的对话体验。在下一节中,我们将把认证和主日历流程集成到我们在第 6 章中开发的对话框中,并将逻辑连接到对 Google Calendar API 的调用。

实现 Bot 功能

至此,我们已经准备好将我们的 bot 代码连接到 Google Calendar API。我们的代码从它的第 5 章状态没有太大的变化。这些是我们的对话框的主要变化:

  • 我们必须确保用户已经登录。

  • 我们必须确保设置了主日历。

  • 利用谷歌日历 API 最终让事情发生!

让我们从前两项开始。为此,我们创建了保证凭证主日历对话框。在提供的代码中,我们的 authModuleprimaryCalendarModule 模块包含两个助手来调用 EnsureCredentialsPrimaryCalendar 对话框。我们的每个功能都可以利用助手来确保设置凭证和主日历。

对于那些对话来说,这是太多的责任了。我们必须在每个对话框中添加两个步骤。相反,让我们创建一个对话框,它可以按照正确的顺序评估所有的预检查,并简单地将一个结果传递给调用对话框。下面是我们实现这一目标的方法。我们创建一个名为 PreCheck 的对话框。该对话框将进行必要的检查,如果有错误,将返回一个带有错误集的响应对象,以及一个指示哪个检查失败的标志。

bot.dialog('PreCheck', [
    function (session, args) {
        authModule.ensureLoggedIn(session);
    },
    function (session, args) {
        if (!args.response.authenticated) {
            session.endDialogWithResult({ response: { error: 'You must authenticate to continue.', error_auth: true } });
        } else {
            primaryCalendarModule.ensurePrimaryCalendar(session);
        }
    },
    function (session, args, next) {
        if (session.privateConversationData.calendarId) session.endDialogWithResult({ response: { } });
        else session.endDialogWithResult({ response: { error: 'You must set a primary calendar to continue.', error_calendar: true } });
    }
]);

任何需要设置 auth 和主日历的对话框只需调用预检查对话框并确保没有错误。这里有一个来自示例代码中的 ShowCalendarSummary 对话框的例子。注意,瀑布中的第一步调用预检,第二步确保所有预检成功通过。

lib.dialog(constants.dialogNames.ShowCalendarSummary, [
    function (session, args) {
        g = args.intent;
        prechecksModule.ensurePrechecks(session);
    },
    function (session, args, next) {
        if (args.response.error) {
            session.endDialog(args.response.error);
            return;
        }
        next();
    },
    function (session, args, next) {
        // do stuff
    }
]).triggerAction({ matches: constants.intentNames.ShowCalendarSummary });

前两项就这样了。至此,只剩下第三个了;我们需要实现与谷歌日历 API 的实际集成。以下是 ShowCalendarSummary 对话框第三步的示例。注意,我们收集了 datetimeV2 实体来计算我们需要检索哪个时间段的事件,我们可以选择使用 Subject 实体来过滤日历项目,并且我们构建了一个按日期排序的事件卡片转盘。 createEventCard 方法为每个 Google 日历 API 事件对象创建一个 HeroCard 对象。

其余对话框的实现可以在本书附带的 calendar-bot-building 存储库中找到。

    function (session, args, next) {
        var auth = authModule.getAuthClientFromSession(session);
        var entry = new et.EntityTranslator();
        et.EntityTranslatorUtils.attachSummaryEntities(entry, session.dialogData.intent.entities);
        var start = null;
        var end = null;

        if (entry.hasRange) {
            if (entry.isDateTimeEntityDateBased) {
                start = moment(entry.range.start).startOf('day');
                end = moment(entry.range.end).endOf('day');
            } else {
                start = moment(entry.range.start);
                end = moment(entry.range.end);
            }
        } else if (entry.hasDateTime) {
            if (entry.isDateTimeEntityDateBased) {
                start = moment(entry.dateTime).startOf('day');
                end = moment(entry.dateTime).endOf('day');
            } else {
                start = moment(entry.dateTime).add(-1, 'h');
                end = moment(entry.dateTime).add(1, 'h');
            }
        }
        else {
            session.endDialog("Sorry I don't know what you mean");
            return;
        }

        var p = gcalapi.listEvents(auth, session.privateConversationData.calendarId, start, end);
        p.then(function (events) {

            var evs = _.sortBy(events, function (p) {
                if (p.start.date) {
                    return moment(p.start.date).add(-1, 's').valueOf();
                } else if (p.start.dateTime) {
                    return moment(p.start.dateTime).valueOf();
                }
            });

            // should also potentially filter by subject
            evs = _.filter(evs, function(p) {
                if(!entry.hasSubject) return true;

                var containsSubject = entry.subject.toLowerCase().indexOf(entry.subject.toLowerCase()) >= 0;
                return containsSubject;
            });

            var eventmsg = new builder.Message(session);
            if (evs.length > 1) {
                eventmsg.text('Here is what I found...');
            } else if (evs.length == 1) {
                eventmsg.text('Here is the event I found.');
            } else {
                eventmsg.text('Seems you have nothing going on then. What a sad existence you lead.');
            }

            if (evs.length >= 1) {
                var cards = _.map(evs, function (p) {
                    return utils.createEventCard(session, p);
                });
                                     eventmsg.attachmentLayout(builder.AttachmentLayout.carousel);
                eventmsg.attachments(cards);
            }

            session.send(eventmsg);
            session.endDialog();
        });
    }

function createEventCard(session, event) {

    var start, end, subtitle;
    if (!event.start.date) {
        start = moment(event.start.dateTime);
        end = moment(event.end.dateTime);

        var diffInMinutes = end.diff(start, "m");
        var diffInHours = end.diff(start, "h");

        var duration = diffInMinutes + ' minutes';
        if (diffInHours >= 1) {
            var hrs = Math.floor(diffInHours);
            var mins = diffInMinutes - (hrs * 60);

            if (mins == 0) {
                duration = hrs + 'hrs';
            } else {
                duration = hrs + (hrs > 1 ? 'hrs ' : 'hr ') + (mins < 10 ? ('0' + mins) : mins) + 'mins';
            }
        }
        subtitle = 'At ' + start.format('L LT') + ' for ' + duration;
    } else {
        start = moment(event.start.date);
        end = moment(event.end.date);

        var diffInDays = end.diff(start, 'd');
        subtitle = 'All Day ' + start.format('L') + (diffInDays > 1 ? end.format('L') : '');
    }

    var heroCard = new builder.HeroCard(session)
        .title(event.summary)
        .subtitle(subtitle)
        .buttons([
            builder.CardAction.openUrl(session, event.htmlLink, 'Open Google Calendar'),
            builder.CardAction.postBack(session, 'Delete event with id ' + event.id, 'Delete')
        ]);
    return heroCard;
};

练习 7-2

与 Gmail API 集成

虽然欢迎您按照上一节中的代码,然后使用随书提供的代码来组装一个日历机器人,但本练习的目标是创建一个可以从用户的 Gmail 帐户发送电子邮件的机器人。通过这种方式,您可以练习练习 7-1 中的验证逻辑,并与以前没有见过的客户端 API 集成。

  1. 以练习 7-1 中的代码为起点,创建一个包含两个对话框的机器人,一个用于发送邮件,一个用于查看未读消息。没有必要创建 LUIS 应用(尽管您当然可以自由地使用它)。使用关键字发送列表来调用对话框。

  2. 对于发送操作,创建一个名为 SendMail 的对话框。这个对话框应该收集电子邮件地址、标题和消息正文。确保该对话框与授权流集成。

  3. 与 Gmail 客户端库集成,使用在身份验证流程中收集的用户访问令牌发送电子邮件。使用这里的文档获取 messages.send API 调用: https://developers.google.com/gmail/api/v1/reference/users/messages/send

  4. 对于列表操作,创建一个名为 ListMail 的对话框。该对话框应该使用在授权流期间收集的用户访问令牌从用户的收件箱中获取所有未读邮件。使用这里的文档来调用 messages . list API:https://developers.google.com/gmail/api/v1/reference/users/messages/list

  5. 将未读邮件列表呈现为一个转盘。显示标题、接收日期和在 web 浏览器中打开电子邮件的按钮。您可以在这里找到消息对象的引用: https://developers.google.com/gmail/api/v1/reference/users/messages#resource 。消息的 URL 是 https://mail.google.com/mail/#inbox/{MESSAGE_ID }

如果你成功创造了这个机器人,恭喜你!这不是最容易的练习,但结果非常值得。现在,您已经掌握了创建 bot、将其与 OAuth 流集成、使用第三方 API 使 bot 发挥作用以及将项目呈现为卡片的技能。干得好!

结论

构建机器人既容易又具有挑战性。用一些简单的命令很容易建立一个基本的机器人。很容易获得用户话语并基于它们执行代码。然而,获得恰到好处的用户体验是相当具有挑战性的。正如我们所观察到的,开发机器人的挑战是双重的。

首先,我们需要理解自然语言话语的许多排列。我们的用户可以用不同的方式说同样的事情,只是有细微的差别。我们为这本书构建的 LUIS 应用是一个良好的开端,但是还有许多其他方式来表达相同的想法。我们需要判断什么时候 LUIS 应用足够好。Bot 测试是很多这类评估发生的地方。一旦我们在你的机器人上释放一组用户,我们将看到用户最终如何使用你的机器人,以及他们期望处理什么类型的输入和行为。这是我们提高自然语言理解和决定下一步构建什么功能所需的数据。我们将在第 13 章介绍帮助完成这项任务的分析工具。

第二,花时间在整体对话体验上是很重要的。虽然这不是本书的重点,但适当的体验是我们机器人成功的关键。我们确实花了一些时间来思考如何确保用户在进入针对日历 API 的任何操作的对话框之前登录。这是我们开发机器人时需要考虑的行为和流程类型的一个例子。一个更天真的机器人可能只是给用户发送一个错误,说他们需要先登录,然后用户不得不重复输入。一个更好的实现是通过我们在本章中创建的对话框进行重定向。幸运的是,Bot Builder SDK 及其对话模型帮助我们在代码中描述这些复杂的流程。

我们现在有技能和经验来开发复杂和惊人的机器人体验,与所有类型的 API 集成。这才是 LUIS 和微软 Bot 框架真正的合力!

Footnotes [1](#Fn1_source)

谷歌云平台支持三种类型的服务凭证。API 键是一种识别项目和接收 API 访问、配额和报告的方法。OAuth 客户端 ID 允许您的应用代表用户发出请求。最后,服务帐户允许应用代表应用发出请求。你可以在 https://support.google.com/cloud/answer/6158857?hl=en 找到更多信息。

  2

base64:https://en.wikipedia.org/wiki/Base64T2】

  3

CryptoJS 支持很多不同的哈希和密码算法。完整的列表可以在该项目的 GitHub 页面 https://github.com/jakubzapletal/crypto-js 找到。你可以在 https://en.wikipedia.org/wiki/Advanced_Encryption_Standard 找到更多关于 AES 算法的信息。

  4

UNIX 纪元时间是自 1970 年 1 月 1 日 00:00:00 UTC: https://en.wikipedia.org/wiki/Unix_time 以来经过的毫秒数

  5

OAuth 令牌撤销: https://tools.ietf.org/html/rfc7009

  6

Mozilla 开发者网:承诺对象: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise