Slack-机器人构建指南-全-

Slack 机器人构建指南(全)

原文:zh.annas-archive.org/md5/0681154b74e0b40f4cc425ac29f24c2c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

聊天机器人已经成为商业和软件开发领域的大热门话题。在团队通信的前沿是 Slack,一个可以与同事和朋友谈论任何事情的交流平台。Slack 的工程师看到了潜力,并设计了一个系统,允许任何人构建自己的 Slack 机器人,以提高生产力、易用性或纯粹为了娱乐。

本书将教你如何使用众多工具来构建 Slack 平台上的最佳机器人。无论你是编程新手还是有经验的资深人士,到本书结束时,你将能够创建高质量的机器人,其唯一限制是你的想象力。你也许还会在过程中学到一些技巧。

本书涵盖的内容

第一章, Slack 入门,展示了 Slack 是什么以及为什么我们应该关注 Slack 机器人。

第二章, 你的第一个机器人,带你构建你的第一个机器人,并解释它是如何工作的。

第三章, 增加复杂性,帮助我们通过新的和有用的功能来扩展我们的第一个机器人。

第四章, 使用数据,教你如何使用持久数据与你的 Slack 机器人一起使用。

第五章, 理解和响应用户自然语言,教你关于自然语言处理以及如何开发能够理解和以自然语言响应的机器人。

第六章, Webhooks 和 Slash 命令,带我们了解在 Slack 环境中 Webhooks 和 Slash 命令的用法。

第七章, 发布你的应用,教你如何发布你的应用或机器人,以便其他人可以在公司外部使用。

为本书所需的条件

你应该对 JavaScript 和编程概念有中级理解。对于本书,我们将使用 Node.js 版本 5.0.0。这意味着本书包含的 JavaScript 代码示例将使用 ECMAScript 2015(ES2015,更常被称为 ES6)功能,这些功能已在 Node v5.0.0 中启用。要查看 Node.js 版本 5 及以上版本中启用的 ES6 功能的完整列表,请访问 Node.js 网站 (nodejs.org/en/docs/es6/)。

本书、其技术和其中的代码示例都是操作系统无关的,尽管为了调试目的,需要 Google Chrome 或 Opera 浏览器。

本书面向的对象

这本书是为那些想要为自己的公司或客户构建 Slack 机器人的软件开发者而写的。

术语约定

在这本书中,你会找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理方式如下所示:“保存文件,然后在终端通过iron-node运行代码。”

代码块设置如下:

  if (user && user.is_bot) {
    return;
  }

当我们希望引起你对代码块特定部分的注意时,相关的行或项目会被设置为粗体:

  if (user && user.is_bot) {
 return;
  }

任何命令行输入或输出都如下所示:

npm install -g iron-node

新术语重要词汇会以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中会像这样显示:“要么点击右上角的步骤跳过按钮,其符号为一个围绕点的曲线箭头,要么按F10跳到下一行。”

注意

警告或重要提示会以这样的框出现。

小技巧

小技巧和窍门会像这样出现。

读者反馈

我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。

要发送给我们一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果你在一个主题上有所专长,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 书籍的骄傲拥有者,我们有许多事情可以帮助你从购买中获得最大收益。

下载示例代码

你可以从 Packt 出版网站上的账户下载这本书的示例代码文件。www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍名称。

  5. 选择你想要下载代码文件的书籍。

  6. 从下拉菜单中选择你购买这本书的地方。

  7. 点击代码下载

你也可以通过点击 Packt 出版网站书籍网页上的代码文件按钮下载代码文件。可以通过在搜索框中输入书籍名称来访问此页面。请注意,你需要登录到你的 Packt 账户。

文件下载完成后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • Windows 上的 WinRAR / 7-Zip

  • Mac 上的 Zipeg / iZip / UnRarX

  • Linux 上的 7-Zip / PeaZip

本书的相关代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Building-Slack-Bots。我们还有其他来自我们丰富图书和视频目录的代码包可供使用,网址为 github.com/PacktPublishing/。请查看它们!

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上对版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上遇到任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面提供的帮助。

询问

如果您对本书的任何方面有问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。

第一章. Slack 入门

本书将使初学者能够创建自己的 Slack 机器人,无论是为了娱乐还是专业目的。

本书最终的目标是让您将 Slack 视为一个具有巨大潜力的开发平台,而不仅仅是简单的聊天客户端。随着 Slack 在开发者社区中的流行度迅速上升,Slack 应用中包含的可能性和机会将成为任何开发者工具箱中的宝贵工具。

在本章中,我们向您介绍 Slack 及其可能性。我们将涵盖:

  • Slack 简介

  • Slack 作为平台

  • 最终目标

Slack 简介

Slack 于 2013 年 8 月推出,最初作为小型团队使用的内部沟通工具,但已迅速演变成为一个多功能的通信平台,被许多团体使用,包括开源社区和大型企业。

Slack 是一款实时消息应用,专注于团队沟通。在众多生产力应用中,Slack 通过提供与流行第三方应用的广泛集成,将自己与其他应用区分开来,并为用户提供构建自己集成平台的平台。

截至 2016 年初,Slack 每天约有 200 万用户使用,覆盖 60,000 个团队,每月发送 8 亿条消息 (expandedramblings.com/index.php/slack-statistics/)。一些使用 Slack 的知名公司包括 Airbnb、LinkedIn 和《纽约时报》。这项服务之所以如此受欢迎,很大程度上归功于其令人印象深刻的 99.9% 以上的正常运行时间率。使 Slack 与 HipChat 或 Skype for Business 等竞争对手区分开来的是,公司决定以应用程序程序接口(API)的形式向第三方开发者开放其平台。为了推动其作为平台的服务增长,Slack 于 2015 年 12 月承诺投资 8000 万美元用于使用其技术的软件项目 (fortune.com/2015/12/15/slack-app-investment-fund/)。加上公司筹集的超过 3.2 亿美元资金,可以肯定地说,Slack 将继续在未来几年内成为团队生产力领域的推动力量。

Slack 作为平台

许多用户可能不知道的是,在 Slack 的消息客户端之下,存在一个高度可扩展的平台,可以用来创建应用程序和商业工具,这些工具可以简化开发周期,执行复杂任务,或者仅仅是很有趣。

Slack 作为平台

Slack 的用户界面及其 Slack 机器人在运行中的样子

该平台或 API 可以用于将第三方服务集成到 Slack 平台中,并利用其广泛的覆盖范围和用户友好的界面。所述第三方应用程序可以通过传入 webhooks 将数据发送到 Slack,使用命令在 Slack 外部执行操作,或作为机器人用户对命令做出响应。机器人用户或机器人是最有趣的;它们之所以被称为机器人,是因为它们可以通过执行任何人类都可以执行的动作来模仿人类用户。

注意

Slack 机器人是运行在 Slack 实时消息RTM)平台上的软件应用程序。机器人可以以对话方式与外部应用程序或您的自定义代码进行交互。

一些更受欢迎的机器人包括 GitHub 的多任务 Hubot (hubot.github.com/) 和 Meekan 的调度机器人 (meekan.com/slack/),但每天都有许多不同复杂度的机器人被开发出来。

最明显且广为人知的机器人是 Slack 自家的 Slack 机器人,用于内置的 Slack 功能,例如:

  • 向 Slack 发送反馈

  • 设置提醒

  • 打印频道中所有用户的列表

另一个广受欢迎的机器人是 Hubot。最初由 GitHub 开发,并由 Slack 本身移植到 Slack,Hubot 可以提供有用的功能,例如 GitHub 活动跟踪,这可以帮助您了解 GitHub 仓库的最新动态。

Slack 作为平台

GitHub 集成显示分支和拉取请求活动

您还可以通过 Jenkins 添加基础设施监控:

Slack 作为平台

Jenkins 集成机器人,在 Slack 中显示构建自动化日志

机器人可以将 Slack 从一个简单的消息客户端转变为重要的业务工具,为使用自定义机器人的任何公司带来好处。Slack 平台的美妙之处在于任何人都可以通过几个简单的步骤创建一个功能性的机器人。

最终目标

完成这本书后,读者将能够构建一个复杂的 Slack 机器人,它可以执行以下任务(以及其他任务):

  • 接收和发送在 Slack 中发送的消息

  • 对用户命令做出响应

  • 处理自然语言

  • 根据命令执行有用任务(例如,从外部来源获取数据)

  • 通过 webhooks 和斜杠命令将自定义数据插入 Slack

摘要

本章为您概述了 Slack 是什么,为什么它值得关注,以及如何利用其平台创建众多有用的应用程序。下一章将向您展示如何构建您的第一个简单 Slack 机器人。

第二章 你的第一个机器人

读者将会对在他们的 Slack 环境中仅需要少量代码行就能启动一个基本的机器人感到惊讶。本章将引导读者了解构建 Slack 机器人的基础知识:

  • 准备你的环境

  • 创建 Slack API 令牌

  • 连接你的机器人

  • 加入频道

  • 向频道发送消息

  • 基本响应

  • 发送直接消息

  • 限制访问

  • 调试你的机器人

虽然一些概念可能对更高级的读者来说已知,但仍然建议阅读本章的前几节,以确保你的环境已经准备好并可以运行。

在本章中,我们将构建一个执行以下操作的机器人:

  • 连接到你的 Slack 团队

  • 在成功连接后向频道的所有成员打招呼,区分真实用户和机器人用户

  • 对说“你好”的用户做出回应

  • 向询问机器人总运行时间(也称为运行时间)的用户发送直接消息

  • 确保只有管理员用户可以请求机器人的运行时间

准备你的环境

在我们开始编写第一个机器人之前,编程环境必须设置并配置为运行 Node.js 应用程序和包。让我们从 Node.js 开始。

简而言之,Node.js(也称为 Node)是建立在 Chrome 的 v8 JavaScript 引擎之上的 JavaScript 运行时环境。在实践中,这意味着 JavaScript 可以在常规浏览器环境之外运行,使 JavaScript 成为一种既可用于前端也可用于后端的语言。

Google Chrome 的 v8 JavaScript 引擎确保你的 JavaScript 代码运行得既快又高效。与浏览器世界(以及 Node 版本)不同,Node 由一个单一的开源基金会维护,拥有数百名志愿者开发者。这使得为 Node 开发比浏览器简单得多,因为你不会遇到跨平台 JavaScript 实现不一致的问题。

在这本书中,我们将使用主要版本 5(任何以 5 开头的版本)的 Node。这使我们能够使用新实施的 ECMAScript 2015(更广为人知为ES2015ES6)的新特性。每当本书首次使用 ES6 特性时,请查找相应的代码注释以获得该特性的简要说明。

注意

虽然许多特性已经实现,但并非所有 ES6 特性目前都在 Node 中可用,其中一些仅在严格模式下可用。有关更多信息,请访问 Node ES6 指南:nodejs.org/en/docs/es6/

本节将简要说明如何在开发机器上安装 Node.js 和 Slack API。

安装 Node.js

要安装 Node.js,请访问官方 Node 网站,nodejs.org/,下载一个 v5 版本并遵循屏幕上的说明。

要测试安装是否成功,打开终端,输入以下内容,然后按Enter

node

如果节点安装正确,你应该能够输入 JavaScript 命令并看到结果:

安装 Node.js

Node.js 中的 Hello World

注意

Ctrl + C 两次退出 Node。

使用 NPM 安装开发工具

Node 包管理器 (NPM) 是 Node.js 的包生态系统,也是用于安装 Node 包的工具。截至写作时,有超过 240,000 个 NPM 包可供下载,并且每天都有更多被添加。

幸运的是,一旦 Node 安装完成,NPM 就会自动安装。让我们首先安装一个有用的 Node 开发工具,名为 nodemon (nodemon.io/)。在你的终端或命令提示符中运行以下命令:

npm install -g nodemon

此命令将全局安装 nodemon 包(注意 -g 标志),这意味着它可以从电脑上的任何位置运行。在 install 命令之后,你必须指定你希望安装的包,并且可以选择一些配置包安装方式的标志。稍后,我们将探讨 --save--save-dev 标志及其用法。

nodemon 是一个 Node 工具,它将监视你的代码中的任何更改,并自动重新启动你的 Node 进程。对我们来说,这将使我们不必每次更改代码时都停止并重新启动 Node 进程。

为了演示 nodemon,让我们看一个例子。在你的代码编辑器中粘贴以下内容,并将其保存为 hello-world.js

console.log('Hello World!');

在你的终端中运行以下命令:

nodemon hello-world.js

你的输出应该看起来像这样:

使用 NPM 安装开发工具

与之前相同的 Hello World,但使用 nodemon

注意控制台命令的运行方式以及程序如何退出。nodemon 然后进入“监视模式”,在此模式下,它将等待任何文件(由 *.* 通配符表示)的更改,然后随后重新启动 Node 进程。nodemon 可以进一步自定义以监视或忽略特定文件。有关更多信息,请访问网站 nodemon.io/

小贴士

要在不更改 nodemon 监视的文件的情况下手动重新启动 Node 进程,请输入 rs 然后按 Enter 键。

创建一个新的项目

现在已经了解了 Node 和 NPM 的基础知识,我们将探讨创建一个新的 Node 项目并扩展我们对 NPM 的了解。

一个 Node 项目可以包含依赖项和开发依赖项。前者是运行项目所需的代码段(或包),而后者是仅用于开发的代码段。在我们的上一个例子中,nodemon 被视为开发依赖项,因为它不会在生产环境中使用。

Node 项目的依赖项存储在一个名为package.jsonJavaScript 对象表示法JSON)文件中。该 JSON 文件包含有关 Node 项目的信息,包括依赖项列表、依赖项的版本以及包作者的信息。这使得通过 NPM 轻松安装项目成为可能。

让我们创建一个自己的。打开一个终端窗口,通过输入以下内容并按Enter键来创建一个新的文件夹:

mkdir helloWorld && cd helloWorld

这将创建一个新的目录并导航到该目录。接下来,输入以下内容:

npm init

按照屏幕上的提示操作,您最终会得到类似以下内容:

创建新项目

NPM init 运行成功的示例

完成后,您会发现您的目录中已创建了一个package.json文件;请参阅前面的截图以了解该 JSON 文件包含的内容。

现在我们已经为我们的应用程序创建了一个模板,让我们创建一个入口点 JavaScript 文件并安装一些依赖项:

touch index.js
npm install @slack/client –-save

这些命令创建了一个名为index的空 JavaScript 文件并安装了 Slack实时消息传递RTM)客户端。注意@slack/client现在出现在package.json的依赖项下。这是由于在最后一个命令中使用了--save标志。保存标志表示此 NPM 包是运行此应用程序所必需的。

注意

自版本 2 起,Slack 客户端 API 已迁移到使用 NPM 组织。通过包名中的@字符表示,这意味着 Slack(公司)可以在@slack的母组织下发布包。除了额外的字符外,该包与其他非组织包没有区别。

如果您希望分发您的机器人并允许其他人对其进行或与之工作,您可以通过在项目目录中运行npm install来轻松安装package.json中指定的所有必需的包。

除了保存标志之外,您还可以通过使用--save-dev标志来指定一个包仅用于开发。这将把该包添加到package.json中的devDependencies部分。这允许我们指定,如果用户打算进行一些开发,则此包才需要安装。

这对于运行您代码的服务器特别有用,您可能希望完全省略开发包。

您的package.json文件现在可能看起来像这样:

{
  "name": "helloworld",
  "version": "1.0.0",
  "description": "My first Slack bot!",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Paul Asjes",
  "license": "ISC",
  "dependencies": {
    "@slack/client": "².0.6",
  }
}

现在 Slack 客户端被列为依赖项,当从这个目录运行以下命令时,它将被自动安装:

npm install

您可以通过删除node_modules文件夹然后运行前面的命令来测试这一点:

创建新项目

所有依赖项都已安装

注意slack-client包有自己的依赖项,这些依赖项已自动安装到node_modules文件夹中。

现在,我们将向我们的入口点 JavaScript 文件添加一些代码。打开index.js并输入以下代码:

// Enable strict mode, this allows us to use ES6 specific syntax
// such as 'const' and 'let'
'use strict';

// Import the Real Time Messaging (RTM) client
// from the Slack API in node_modules
const RtmClient = require('@slack/client').RtmClient;

// The memory data store is a collection of useful functions we // can
// include in our RtmClient
const MemoryDataStore = require('@slack/client').MemoryDataStore;

// Import the RTM event constants from the Slack API
const RTM_EVENTS = require('@slack/client').RTM_EVENTS;

// Import the client event constants from the Slack API
const CLIENT_EVENTS = require('@slack/client').CLIENT_EVENTS;

const token = '';

// The Slack constructor takes 2 arguments:
// token - String representation of the Slack token
// opts - Objects with options for our implementation
let slack = new RtmClient(token, {
  // Sets the level of logging we require
  logLevel: 'debug', 
  // Initialize a data store for our client, this will 
  // load additional helper functions for the storing 
  // and retrieval of data
  dataStore: new MemoryDataStore(),
  // Boolean indicating whether Slack should automatically 
  // reconnect after an error response
  autoReconnect: true,
  // Boolean indicating whether each message should be marked as // read 
  // or not after it is processed 
  autoMark: true 
});

// Add an event listener for the RTM_CONNECTION_OPENED 
// event, which is called 
// when the bot connects to a channel. The Slack API can 
// subscribe to events by using the 'on' method
slack.on(CLIENT_EVENTS.RTM.RTM_CONNECTION_OPENED, () => {
  // Get the user's name
  let user = slack.dataStore.getUserById(slack.activeUserId);

  // Get the team's name
  let team = slack.dataStore.getTeamById(slack.activeTeamId);

  // Log the slack team name and the bot's name, using ES6's // template
  // string syntax
  console.log(`Connected to ${team.name} as ${user.name}`);
});

// Start the login process
slack.start();

保存文件并执行以下命令来运行程序:

node index.js

您应该立即注意到有问题:

创建新项目

调试和错误日志显示

注意内置的日志记录器如何输出调试和错误信息。错误表明由于认证错误,Slack 无法连接。这是因为我们没有提供 Slack API 令牌。访问令牌是为您的机器人生成的唯一 ID。通过使用它,您使您的机器人能够通过 Slack 服务器进行认证并与 Slack 客户端交互。

在我们的示例中,令牌被设置为空字符串,这是不起作用的。那么,让我们从 Slack 获取一个访问令牌。

小贴士

在本书的 前言 中提到了下载代码包的详细步骤。请查看。

本书代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Building-Slack-Bots。我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing 获取。查看它们!

创建 Slack API 令牌

打开浏览器并导航到 my.slack.com/apps/build/custom-integration

按照以下步骤操作:

  1. 从可用的自定义集成列表中选择 Bots创建 Slack API 令牌

    自定义集成列表

  2. 选择一个名称并点击 添加机器人集成。您的机器人名称可以在以后更改,所以不必担心立即选择一个经过深思熟虑的名称。创建 Slack API 令牌

    添加机器人集成

  3. 复制下新生成的 API 令牌。作为一个可选步骤,您可以选择在此屏幕上进一步自定义机器人。创建 Slack API 令牌

    机器人可选设置

    注意

    虽然是可选的,但建议为您的机器人选择一个图标。在这个例子中,我们将使用 robot_face 表情符号;然而,一个好的机器人应该有一个代表其目的的图标。

    虽然您可以给机器人提供重复的首字母和姓氏,但机器人的用户名必须对您的团队是唯一的。提供首字母、姓氏和描述是可选的,但建议这样做,因为它可以提供有关机器人做什么的快速信息。

  4. 点击页面底部的 保存 集成

小贴士

如果您希望在以后阶段删除或禁用此机器人,您可以从同一页面进行操作。

连接机器人

现在我们已经生成了一个 API 令牌,请将 index.js 中分配给 token 的空字符串替换,然后再次运行程序。

注意

现在是使用 nodemon 而不是 node 的好时机,以确保代码更改时自动重启。

您可能会在终端中看到一整页的调试信息。虽然这些信息很有用,但它们也可能阻碍我们的进度,因为我们的控制台日志可能难以找到。与其玩捉迷藏,不如首先更改客户端的日志设置。

将此行切换:

logLevel: 'debug',

使用以下行:

logLevel: 'error',

这将指示客户端仅在程序崩溃或发生语法错误时输出错误消息。

重新启动程序(或者只保存文件,让 nodemon 做工作):

[Thu Jan 07 2016 20:56:07 GMT-0500 (EST)] INFO Connecting...
Connected to Building Bots as awesomebot

如果您在终端中看到类似前面的输出,恭喜!您的第一个机器人已成功连接到 Slack!现在您将在 Slack 客户端的 直接消息 部分看到您的机器人;点击机器人的名字以打开一个私人的直接消息。

注意

在本书的整个过程中,您将遇到标题 Building Bots。这仅仅是作者使用的 Slack 团队的名称,并且可能与您的不一样。

连接机器人

向您的机器人发送直接消息(DM)

您的机器人正在正常运行。然而,它的能力相当有限。我们将很快解决这个问题,但首先让我们确保机器人可以与更广泛的受众互动。

加入一个频道

机器人不能通过编程方式加入频道;这是一个设计选择,因为机器人不应该在没有被邀请的情况下进入私人频道。当机器人加入频道时,机器人可以监控频道中的所有活动。机器人可能保存所有频道消息,这是一种潜在的恶意活动,不应该自动发生。

有关机器人可以和不能执行的操作的完整列表,请参阅 Slack 机器人用户文档,网址为 api.slack.com/bot-users

机器人对其自身可以执行的操作有限制。因此,机器人需要通过 Slack 客户端中的 invite 命令被邀请到频道中:

/invite [BOT_NAME]

之后,您将收到机器人进入频道的确认,如下所示:

加入频道

机器人进入世界

注意,当机器人加入频道时,即使机器人的 Node 进程停止,它也会留在那里。它表现出与离线用户相同的特征。这确保了每个机器人每个频道只需要进行一次邀请。

要从频道中移除机器人,请在 Slack 客户端中使用移除命令:

/remove [BOT_NAME]

注意

虽然所有用户都可以邀请进入频道,但只有管理员可以主动从频道中移除用户和机器人。

为了使测试更容易,并且不干扰您团队中的其他用户,创建一个机器人测试频道并邀请您的机器人是一个好主意。在本书的范围内,测试频道被命名为 bot-test

向频道发送消息

我们现在有一个已连接的机器人,但诚然,它相当无用了。让我们通过让我们的机器人向它所在的每个频道说“你好”来解决这个问题。

Slack 对象

您可能已经注意到,在前面的代码示例的第 18 行有如下内容:

let user = slack.dataStore.getUserById(slack.activeUserId);

在这里,我们看到 slack 对象包含了关于机器人当前环境的各种信息。让我们探索其中包含的数据。将第 18 行替换为以下修改后的 console.log 方法:

console.log(slack);

您应该在终端中看到一个大型对象被打印出来。虽然我们不会遍历所有值,但以下是一些有趣的值:

名称 类型 功能
activeUserId 字符串 内部用户 ID。这可以用来获取更多关于当前用户的信息。
activeUserId 字符串 内部团队 ID。同样,它可以用来获取更多关于团队的信息。
dataStore 对象 如果初始化了数据存储,则此对象包含 Slack API 中存储的大量信息。
channelsdataStore 的子对象) 对象 包含该团队中所有可用频道的列表。
channelchannels 的子对象) 对象 包含关于频道的更多信息。是否请求此信息的用户是成员可以通过 is_member 属性来获取
dmsdataStore 的子对象) 对象 该用户是其中一员的直接消息频道的列表。注意:即使从未发送过消息,直接消息频道仍然被认为是开放的。
usersdataStore 的子对象) 对象 该团队中所有用户的列表。

获取所有频道

您会注意到,从前面的表格中,channels 对象返回了该团队中的所有频道。就我们的目的而言,我们只想获取我们的机器人所在的频道。为了实现这一点,我们可以遍历 channels 对象,并返回我们确切需要的。将以下内容添加到 index.js 的末尾:

// Returns an array of all the channels the bot resides in
function getChannels(allChannels) {
  let channels = [];

  // Loop over all channels
  for (let id in allChannels) {
    // Get an individual channel
    let channel = allChannels[id];

    // Is this user a member of the channel?
    if (channel.is_member) {
      // If so, push it to the array
      channels.push(channel);
    }
  }

  return channels;
}

现在,将 Slack open 事件监听器替换为以下内容:

// Add an event listener for the RTM_CONNECTION_OPENED event,
//  which is called when the bot
// connects to a channel. The Slack API can subscribe to 
// events by using the 'on' method
slack.on(CLIENT_EVENTS.RTM.RTM_CONNECTION_OPENED, () => {
  // Get the user's name
  let user = slack.dataStore.getUserById(slack.activeUserId);

  // Get the team's name
  let team = slack.dataStore.getTeamById(slack.activeTeamId);

  // Log the slack team name and the bot's name, using ES6's 
  // template string syntax
  console.log(`Connected to ${team.name} as ${user.name}`);

  // Note how the dataStore object contains a list of all 
  // channels available
  let channels = getChannels(slack.dataStore.channels);

  // Use Array.map to loop over every instance and return an 
  // array of the names of each channel. Then chain Array.join 
  // to convert the names array to a string
  let channelNames = channels.map((channel) => {
    return channel.name;
  }).join(', ');

  console.log(`Currently in: ${channelNames}`)
});

切换到您的终端,您应该看到以下输出:

获取所有频道

列出机器人所在的频道

现在,您的机器人知道它所在的频道,它就可以开始发送消息了。我们将从机器人向频道中的每个人发送一个简单的 "Hello" 消息开始。

获取频道中的所有成员

我们已经有了频道对象,因此获取其中的成员很容易。将其添加到 RTM_CONNECTION_OPENED 事件监听器中:

// log the members of the channel
channels.forEach((channel) => {
  console.log('Members of this channel: ', channel.members);
});

这是结果:

获取频道中的所有成员

用户 ID 列表

好吧,这并不完全符合我们的预期。Slack API 返回了一个用户 ID 列表,而不是成员对象的数组。这对于包含数百个用户的频道来说是有意义的,因为这会导致一个庞大且庞大的成员对象数组。不用担心,Slack API 提供了我们需要的工具,通过使用这些用户 ID 来获取更多信息。用以下代码替换之前的片段,然后保存文件:

  // log the members of the channel
  channels.forEach((channel) => {
    // get the members by ID using the data store's 
    //'getUserByID' function
    let members = channel.members.map((id) => {
      return slack.dataStore.getUserById(id);
    });

    // Each member object has a 'name' property, so let's 
    // get an array of names and join them via Array.join
    let memberNames = members.map((member) => {
      return member.name;
    }).join(', ');

    console.log('Members of this channel: ', memberNames);
  });

此代码的输出可以在以下屏幕截图中看到:

获取频道中的所有成员

使用用户名在该频道中的用户

注意机器人也被列在频道成员列表中。我们当前的目标是向频道中的每个人打招呼;然而,我们应该尽量避免机器人自言自语。

我们可以使用 member 对象上的 is_bot 属性来确定用户是否是机器人:

  // log the members of the channel
  channels.forEach((channel) => {
    // get the members by ID using the data store's 
    // 'getUserByID' function
    let members = channel.members.map((id) => {
      return slack.dataStore.getUserById(id);
    });

    // Filter out the bots from the member list using Array.filter
    members = members.filter((member) => {
      return !member.is_bot;
    });

    // Each member object has a 'name' property, so let's 
    // get an array of names and join them via Array.join
    let memberNames = members.map((member) => {
      return member.name;
    }).join(', ');

    console.log('Members of this channel: ', memberNames);
  });

获取频道中所有成员

频道中的用户,不包括机器人

太棒了!现在我们已经完成了这个步骤,下一步是将消息发送到频道。

向频道发送消息

频道对象包含机器人通信所需的所有工具。在下面的代码中,我们将基于之前的代码片段,在机器人连接后向频道中的每个人发送一条“Hello”消息。所有这些操作都将发生在公开的事件监听器中。以下是它的全部内容:

// Add an event listener for the RTM_CONNECTION_OPENED event, 
// which is called when the bot connects to a channel. The Slack API 
// can subscribe to events by using the 'on' method
slack.on(CLIENT_EVENTS.RTM.RTM_CONNECTION_OPENED, () => {
  // Get the user's name
  let user = slack.dataStore.getUserById(slack.activeUserId);

  // Get the team's name
  let team = slack.dataStore.getTeamById(slack.activeTeamId);

  // Log the slack team name and the bot's name, using ES6's 
  // template string syntax
  console.log(`Connected to ${team.name} as ${user.name}`);

  // Note how the dataStore object contains a list of all 
  // channels available
  let channels = getChannels(slack.dataStore.channels);

  // Use Array.map to loop over every instance and return an 
  // array of the names of each channel. Then chain Array.join 
  // to convert the names array to a string
  let channelNames = channels.map((channel) => {
    return channel.name;
  }).join(', ');

  console.log(`Currently in: ${channelNames}`)

  // log the members of the channel
  channels.forEach((channel) => {
    // get the members by ID using the data store's 
    // 'getUserByID' function
    let members = channel.members.map((id) => {
      return slack.dataStore.getUserById(id);
    });

    // Filter out the bots from the member list using Array.filter
    members = members.filter((member) => {
      return !member.is_bot;
    });

    // Each member object has a 'name' property, so let's 
    // get an array of names and join them via Array.join
    let memberNames = members.map((member) => {
      return member.name;
    }).join(', ');

    console.log('Members of this channel: ', memberNames);

    // Send a greeting to everyone in the channel
    slack.sendMessage(`Hello ${memberNames}!`, channel.id);
  });
});

一旦你运行了代码,你应该会收到 Slack 客户端的提示通知,显示你被提及在一条消息中,如下面的截图所示:

向频道发送消息

我们的机器人说出了它的第一句话

让我们通过让机器人能够响应消息来提高它的复杂性。

基本响应

Slack API 可以配置为在派发某些事件时执行方法,就像之前用 RTM_CONNECTION_OPENED 事件所看到的那样。现在,我们将深入了解我们提供的其他有用的事件。

认证事件

到目前为止,我们已经看到了如何为机器人进入频道并发生错误时触发的 Slack 的 RTM_CONNECTION_OPENED 事件添加功能。如果你希望在机器人登录但尚未连接到频道之前执行一些代码,你可以使用 AUTHENTICATED 事件:

slack.on(CLIENT_EVENTS.RTM.AUTHENTICATED, (rtmStartData) => {
  console.log(`Logged in as ${rtmStartData.self.name} of team ${rtmStartData.team.name}, but not yet connected to a channel`);
});

这将产生以下输出:

[Mon Jan 18 2016 21:37:24 GMT-0500 (EST)] INFO Connecting...
Logged in as awesomebot of team Building Bots, but not yet connected to a channel

现在,我们将介绍 消息 事件。

使用消息事件

消息事件将在每次向机器人所在的频道或直接向机器人发送消息时触发。消息对象包含有用的数据,例如发送用户、发送频道和发送时间戳。

将以下内容粘贴到 index.js 中,然后向你的机器人是成员的频道发送消息“Hello bot!”:

slack.on(RTM_EVENTS.MESSAGE, (message) => {
  let user = slack.dataStore.getUserById(message.user)

  if (user && user.is_bot) {
    return;
  }

  let channel = slack.dataStore.getChannelGroupOrDMById(message.channel);

  if (message.text) {
    let msg = message.text.toLowerCase();

    if (/(hello|hi) (bot|awesomebot)/g.test(msg)) {
      slack.sendMessage(`Hello to you too, ${user.name}!`, channel.id);
    }
  }
});

这应该会产生类似以下的结果:

使用消息事件

机器人更个性化的问候

让我们再次详细查看代码,从顶部开始:

slack.on(RTM_EVENTS.MESSAGE, (message) => {
  let user = slack.dataStore.getUserById(message.user)

  if (user && user.is_bot) {
    return;
}

这应该很熟悉,因为它与我们之前使用的类似,只是我们现在使用的是 RTM_EVENTS 对象中的 MESSAGE 事件。我们还确保消息发送者不是机器人:

  let channel = slack.dataStore.getChannelGroupOrDMById(message.channel);

getChannelGroupOrDMById 方法让我们能够获取每个消息发送的频道。如果我们的机器人恰好居住在多个频道中,这尤其有用。代码如下:

if (message.text) {
  let msg = message.text.toLowerCase();

  if (/(hello|hi) (bot|awesomebot)/g.test(msg)) {
    slack.sendMessage(`Hello to you too, ${user.name}!`, channel.id);
     }
}

消息不一定包含文本;它也可能是文件、图片或甚至表情符号。因此,我们必须进行一些类型检查,以确保接收到的消息确实是基于文本的。一旦确认文本类型,我们使用正则表达式来测试接收到的消息是否包含特定顺序的关键词。当接收到的消息包含“Hello”或“Hi”后跟“bot”或“awesomebot”时,RegExp.test 方法将返回 true。如果是真的,将使用熟悉的 slack.sendMessage 方法将响应发送回通道。

注意

在评估传入的文本时,几乎总是先将文本消息的主体转换为小写,以避免大小写敏感的错误。

避免垃圾邮件

在开发过程中偶尔会发生无限循环;完全有可能不小心编写了一个机器人,使其在无限循环中发送消息,从而在通道中产生垃圾邮件。观察以下代码:

if (/(hello|hi) (bot|awesomebot)/g.test(msg)) {
  // Infinite loop spamming the channel every 100 milliseconds
  setInterval(() => {
    slack.sendMessage('Spam!', channel.id);
  }, 100);
}

查看结果的截图:

避免垃圾邮件

机器人正在垃圾邮件通道

在终端或命令提示符中,你应该看到以下内容:

避免垃圾邮件

Slack API 处理垃圾邮件

幸运的是,Slack API 内置了对这类不幸事件的防护。如果单个用户在非常短的时间内发送了 20 条消息,Slack 服务器将拒绝发布更多消息并返回错误。这还有一个附加效果,就是导致我们的机器人卡住并最终崩溃。

Slack 平台将保护通道免受垃圾邮件攻击;然而,可能被攻击的机器人会崩溃。

为了防止这种情况发生,强烈建议永远不要在循环或 setInterval 方法中放置 slack.sendMessage 方法调用。

用户众多且因此流量大的通道可能会意外触发 Slack 平台的“减速”响应。为了防止这种情况,跟踪消息之间的时间差:

if (/(hello|hi) (bot|awesomebot)/g.test(msg)) {
  // Get the timestamp when the above message was sent
  let sentTime = Date.now();

  setInterval(() => {
    // Get the current timestamp
    let currentTime = Date.now();

    // Make sure we only allow a message once a full second has // passed 
    if ((currentTime - sentTime) > 1000) {

      slack.sendMesssage('Limiting my messages to 1 per second', channel.id);

      // Set the new sentTime
      sentTime = Date.now();
    }
  }, 100);
}

避免垃圾邮件

限制机器人的消息

每次调用 setInterval 函数时,我们都会生成一个新的时间戳,称为 currentTime。通过比较 currentTime 与消息的时间戳(定义为 sentTime),我们可以通过确保两者之间的差异超过 1,000 毫秒来人工限制机器人端发送的消息。

Slack API 在 channel.latest.ts 对象上提供了一个时间戳;这提供了通道中接收到的最新消息的时间戳。虽然仍然有用,但建议使用本地时间戳,因为 Slack API 提供的是接收到的最新消息的信息,而不是机器人发送的最新消息。

发送直接消息

直接 消息 (DM) 频道是仅存在于两个用户之间的频道。按照设计,它不能有更多或更少的用户,并且旨在进行私人通信。发送私信与向频道发送消息非常相似,因为 dm 对象几乎与 channel 对象相同。

考虑以下代码片段:

slack.on(RTM_EVENTS.MESSAGE, (message) => {
  let user = slack.dataStore.getUserById(message.user)

  if (user && user.is_bot) {
    return;
  }

  let channel = slack.dataStore.getChannelGroupOrDMById(message.channel);

  if (message.text) {
    let msg = message.text.toLowerCase();

    if (/uptime/g.test(msg)) {
      let dm = slack.dataStore.getDMByName(user.name);

      let uptime = process.uptime();

      // get the uptime in hours, minutes and seconds
      let minutes = parseInt(uptime / 60, 10),
          hours = parseInt(minutes / 60, 10),
          seconds = parseInt(uptime - (minutes * 60) - ((hours * 60) * 60), 10);

      slack.sendMessage(`I have been running for: ${hours} hours, ${minutes} minutes and ${seconds} seconds.`, dm.id);
  }
});

在此示例中,我们的机器人将向任何使用关键词 uptime 的用户发送包含当前 Uptime 的私信:

发送私信

Uptime 可以是一个非常有用的统计数据

注意,无论命令 uptime 是在哪个频道发送,只要机器人作为该频道或 DM 的成员能够听到该命令,它都会向用户发送私信。在上面的图像中,命令是在 DM 中发出的。这是因为频道和 DM 都订阅了 message 事件;当发送针对频道而非 DM 的响应,反之亦然时,这一点非常重要。

限制访问

有时,您可能希望将机器人命令限制为您的 Slack 团队管理员。一个很好的例子是控制项目部署过程的机器人。这可以非常强大,但可能不是您希望每个用户都能访问的功能。只有管理员(也称为管理员)才有权访问此类功能。管理员是拥有 Slack 团队管理权限的特殊用户。幸运的是,通过用户对象附加的 is_admin 属性,限制此类访问很容易。

在以下示例中,我们将限制在前一个主题中演示的 uptime 命令,使其仅对管理员用户有效,并通知受限用户他们无法使用该命令:

slack.on(RTM_EVENTS.MESSAGE, (message) => {
  let user = slack.dataStore.getUserById(message.user)

  if (user && user.is_bot) {
    return;
  }

  let channel = slack.dataStore.getChannelGroupOrDMById(message.channel);

  if (message.text) {
    let msg = message.text.toLowerCase();

    if (/uptime/g.test(msg)) {
      if (!user.is_admin) {        
        slack.sendMessage(`Sorry ${user.name}, but that functionality is only for admins.`, channel.id);
        return;
      }

      let dm = slack.dataStore.getDMByName(user.name);

      let uptime = process.uptime();

      // get the uptime in hours, minutes and seconds
      let minutes = parseInt(uptime / 60, 10),
          hours = parseInt(minutes / 60, 10),
          seconds = parseInt(uptime - (minutes * 60) - ((hours * 60) * 60), 10);

      slack.sendMessage(`I have been running for: ${hours} hours, ${minutes} minutes and ${seconds} seconds.`, dm.id);
  }
});

现在,当非管理员用户发出 uptime 命令时,他们将收到以下消息:

限制访问

限制机器人只对管理员用户

注意

使用 user.is_admin 是为了确定用户是否是管理员。

添加和删除管理员

要添加或删除管理员到您的团队,请访问 my.slack.com/admin#active 并点击一个用户。

管理员和所有者有权将其他成员从频道中踢出,并删除不属于他们的消息。尽管这些是默认设置,但可以在 my.slack.com/admin/settings#channel_management_restrictions 编辑。

机器人不能是管理员或所有者;有关团队权限的更多信息,请访问 get.slack.help/hc/en-us/articles/201314026-Understanding-r

调试机器人

最终您可能会遇到难以解决的机器人中的错误。最糟糕的是那些不会导致程序崩溃的错误,因此不会提供有关崩溃发生位置的有用的堆栈跟踪和行号。对于大多数问题,console.log() 方法就足够帮助您追踪错误,但对于更顽固的错误,我们需要一个真正的调试环境。本节将向您介绍 iron-node (s-a.github.io/iron-node/),这是一个基于 Chrome 开发工具的跨平台 JavaScript 调试环境。

首先安装 iron-node:

npm install -g iron-node

再次注意 -g 标志的使用,它将应用程序全局安装。

在我们开始调试之前,我们需要在我们的代码中添加一个断点,这将告诉调试器停止代码并允许进行更深入的检查。将 debugger 语句添加到我们之前的代码中的 slack.openDM() 代码块内:

if (/uptime/g.test(msg)) {
  debugger;

  if (!user.is_admin) {        
    slack.sendMessage(`Sorry ${user.name}, but that functionality is only for admins.`, channel.id);
    return;
  } 

  let dm = slack.dataStore.getDMByName(user.name);

  let uptime = process.uptime();

  // get the uptime in hours, minutes and seconds
  let minutes = parseInt(uptime / 60, 10),
      hours = parseInt(minutes / 60, 10),
      seconds = parseInt(uptime - (minutes * 60) - ((hours * 60) * 60), 10);

  slack.sendMessage(`I have been running for: ${hours} hours, ${minutes} minutes and ${seconds} seconds.`, dm.id);
}

保存文件,然后在终端通过 iron-node 运行代码:

iron-node index.js

立即,您应该会看到 iron-node 界面弹出:

调试机器人

iron-node 界面

Chrome 用户可能会注意到这个界面与 Chrome 的开发者工具窗口完全一样。如果您以前没有使用过它,建议花些时间熟悉这个界面。让我们讨论一些基本功能,以便您开始。

您可以切换到控制台标签以查看节点输出,或者也可以按 Esc 显示屏幕底部的控制台。

我们的调试器被放置在消息事件监听器中,因此向机器人发送命令(在最后一个例子中是 uptime)并观察接下来会发生什么。

调试机器人

使用 "debugger" 语句设置断点

机器人的执行已被调试器暂停,因此您可以检查属性并确定错误的来源。

要么点击右上角的 Step over 按钮,其符号为一个围绕点的弯曲箭头,要么按 F10 跳到下一行。

使用鼠标悬停在代码这一行中的不同对象上以获取有关它们更多信息。

调试机器人

检查暂停程序中的属性

持续点击 Step over 按钮以逐步通过代码,或者点击 Step over 按钮左侧的 Resume script execution 按钮以允许程序继续执行,直到遇到另一个调试器断点。

不仅可以在机器人执行时检查变量和属性,还可以编辑它们的值,产生不同的输出。观察我们如何编辑代码中的 uptime 变量并将其设置为 1000:

调试机器人

uptime 被程序设置为 40.064

在控制台区域,我们可以在程序运行时编辑 JavaScript 变量:

调试机器人

在控制台,我们再次检查了运行时间的值,并将其设置为 1000。现在当我们回顾变量时,我们应该看到更新的值:

调试机器人

新的运行时间值反映在接下来的几行中

当我们恢复程序时,我们的机器人将根据我们更新的变量发送其消息:

调试机器人

我们继续程序,机器人将新的值发送到频道。

注意

为了最佳调试实践,要么禁用机器人发送消息的能力,要么邀请你的机器人加入一个私人频道,以避免向其他用户发送垃圾邮件。

由于iron-node基于 Chrome 的开发者工具,你可以与 Chrome 互换使用之前的技术。

为了调试和修复内存问题,你可以使用开发者工具的性能分析器和堆快照工具。有关这些主题的更多信息,请访问以下链接:

摘要

在本章中,我们了解了如何安装先决技术,如何为机器人获取 Slack 令牌,以及如何设置一个新的 Slack 机器人项目。因此,你可以重用所学知识,轻松构建新的机器人项目。你现在应该能够编写一个可以向频道发送消息、发送直接消息以及编写基本响应的机器人。最后,我们讨论了如何使用iron-node调试器调试基于 Node.js 的机器人。

在下一章中,我们将看到如何通过添加第三方 API 支持和编写我们的第一个机器人命令来使我们的机器人更加复杂。

第三章。增加复杂性

第一个机器人完成之后,是时候学习如何使用其他 应用程序程序接口API)来扩展我们的机器人了。这意味着教我们的机器人如何监听关键词、响应命令以及处理错误(无论是人为的还是其他原因)。在本章中,我们将涵盖以下内容:

  • 响应关键词

  • 机器人命令

  • 外部 API 集成

  • 错误处理

响应关键词

在上一章中,我们使用正则表达式来测试消息内容与预定义关键词的匹配。一旦关键词被确认,我们就可以执行操作并返回结果。这工作得很好;然而,对于功能更丰富的机器人,它可能会导致一个很大的 if else 块。相反,我们现在将查看将上一章的最终结果重构为一个更模块化的设计。在本节中,我们将通过使用 ES6 的新 class 语法和 Node 的 export 方法来实现这一点。

使用类

首先创建一个新的 JavaScript 文件,并将其命名为 bot.js。将以下内容粘贴到 bot.js 中并保存文件:

'use strict';

const RtmClient = require('@slack/client').RtmClient;
const MemoryDataStore = require('@slack/client').MemoryDataStore;
const CLIENT_EVENTS = require('@slack/client').CLIENT_EVENTS;
const RTM_EVENTS = require('@slack/client').RTM_EVENTS;

class Bot {
  constructor(opts) {
    let slackToken = opts.token;
    let autoReconnect = opts.autoReconnect || true;
    let autoMark = opts.autoMark || true;

    this.slack = new RtmClient(slackToken, { 
      // Sets the level of logging we require
      logLevel: 'error', 
      // Initialize a data store for our client, 
      // this will load additional helper
      // functions for the storing and retrieval of data
      dataStore: new MemoryDataStore(),
      // Boolean indicating whether Slack should automatically 
      // reconnect after an error response
      autoReconnect: autoReconnect,
      // Boolean indicating whether each message should be marked
      // as read or not after it is processed
      autoMark: autoMark
    });

    this.slack.on(CLIENT_EVENTS.RTM.RTM_CONNECTION_OPENED, () => {
      let user = this.slack.dataStore.getUserById(this.slack.activeUserId)
      let team = this.slack.dataStore.getTeamById(this.slack.activeTeamId);

      this.name = user.name;

      console.log(`Connected to ${team.name} as ${user.name}`);      
    });

    this.slack.start();
  }
}

// Export the Bot class, which will be imported when 'require' is 
// used
module.exports = Bot;

让我们深入查看代码,从 class 结构开始。Mozilla 开发者网络MDN)将 JavaScript 类定义为:

JavaScript 类是在 ECMAScript 6 中引入的,并且是 JavaScript 现有基于原型的继承的语法糖。类语法并没有向 JavaScript 引入一个新的面向对象的继承模型。JavaScript 类提供了一个更简单、更清晰的语法来创建对象和处理继承。

简而言之,JavaScript 类是原型类模式的替代方案,实际上在底层以完全相同的方式工作。使用类的优点在于当你希望扩展或继承特定类,或者提供一个更清晰的概述说明你的类做什么时。

在代码示例中,我们使用类以便于稍后扩展,如果我们想添加更多功能。类独有的特性是 constructor 方法,这是一个用于创建和初始化由类创建的对象的特殊方法。当一个类用 new 关键字调用时,这个构造函数函数首先被执行:

constructor(opts) {
    let slackToken = opts.token;
    let autoReconnect = opts.autoReconnect || true;
    let autoMark = opts.autoMark || true;

    this.slack = new RtmClient(slackToken, { 
      logLevel: 'error', 
      dataStore: new MemoryDataStore(),
      autoReconnect: autoReconnect,
      autoMark: autoMark
    });

    this.slack.on(CLIENT_EVENTS.RTM.RTM_CONNECTION_OPENED, () => {
      let user = this.slack.dataStore.getUserById(this.slack.activeUserId)
      let team = this.slack.dataStore.getTeamById(this.slack.activeTeamId);

      this.name = user.name;

      console.log(`Connected to ${team.name} as ${user.name}`);      
    });

    this.slack.start();
  }

查看我们的构造函数,我们看到熟悉的 Slack RTM 客户端的使用:客户端被初始化,并使用 RTM_CONNECTION_OPENED 事件在连接时记录团队和用户名。我们将 slack 变量附加到 this 对象作为属性,使其在整个类中可访问。同样,我们将机器人的名称分配给一个变量,以便在需要时轻松访问。

最后,我们通过 Node 模块系统导出机器人类:

module.exports = Bot;

这指示 Node 在使用 require 方法导入此文件时返回我们的类。

bot.js 同一文件夹中创建一个新文件,并将其命名为 index.js。将以下内容粘贴到其中:

'use strict';

let Bot = require('./Bot');

const bot = new Bot({
  token: process.env.SLACK_TOKEN,
  autoReconnect: true,
  autoMark: true
});

保存文件后,从终端运行以下命令以启动机器人:

SLACK_TOKEN=[YOUR_TOKEN_HERE] node index.js

你可以使用上一章创建的 Slack 令牌,或者为这个机器人生成一个新的令牌。

注意

通常来说,不要在代码中硬编码敏感信息,如令牌或 API 密钥(如 Slack 令牌)。相反,使用 Node 的 process.env 对象从命令行传递变量到你的代码。特别是,要小心将 API 密钥存储在公共源代码控制库中,如 GitHub。

一旦你确认你的机器人成功连接到你的 Slack 团队,让我们努力使 Bot 类更加模块化。

反应式机器人

我们迄今为止在机器人示例中描述的所有功能都有一个共同点:机器人对人类用户提供的刺激做出反应。发送包含关键词的消息,机器人以动作做出响应。这类机器人可以称为反应式机器人;它们对输入做出输出。大多数机器人都可以归类为反应式机器人,因为大多数机器人需要一些输入才能完成动作。主动机器人与这相反;主动机器人不是对输入做出反应,而是在不需要任何人类刺激的情况下产生输出。我们将在第六章(Chapter 6)中介绍主动机器人,Webhooks 和 Slack 命令。现在,让我们看看我们如何优化我们的反应式机器人。

我们已经定义了反应式机器人的基本机制:对刺激做出反应。由于这是反应式机器人的核心概念,因此有一个机制来轻松调用所需的行为是有意义的。

要做到这一点,让我们给我们的 Bot 类添加一些功能,形式是一个 respondsTo 函数。在之前的例子中,我们使用了 if 语句来确定机器人何时应该对一条消息做出响应:

if (/(hello|hi) (bot|awesomebot)/g.test(msg)) {
  // do stuff...
}

if (/uptime/g.test(msg)) {
  // do more stuff...
}

这种方法没有问题。如果我们希望编写一个具有多个关键词的机器人,我们的 Bot 类可能会变得非常复杂,并且很快就会变得杂乱无章。相反,让我们将这种行为抽象到我们的 respondsTo 函数中。该函数应至少接受两个参数:我们希望监听的关键词和一个当关键词在消息中识别时执行的回调函数。

bot.js 中,向构造函数添加以下内容:

// Create an ES6 Map to store our regular expressions
this.keywords = new Map();

this.slack.on(RTM_EVENTS.MESSAGE, (message) => {
  // Only process text messages
  if (!message.text) {
    return;
  }

  let channel = this.slack.dataStore.getChannelGroupOrDMById(message.channel);
  let user = this.slack.dataStore.getUserById(message.user);

  // Loop over the keys of the keywords Map object and test each
  // regular expression against the message's text property
  for (let regex of this.keywords.keys()) {    
    if (regex.test(message.text)) {
      let callback = this.keywords.get(regex);
      callback(message, channel, user);
    }
  }
});

这个片段使用了新的 ES6 Map 对象,它是一个简单的键/值存储,类似于其他语言中的字典。MapObject 的不同之处在于 Map 没有默认键(因为 Object 有原型),这意味着你可以遍历 Map 而不必显式检查 Map 是否包含值或其原型。例如,使用 Maps,你不再需要在遍历时使用 Object.hasOwnProperty

正如我们稍后将要看到的,keywords Map 对象使用正则表达式作为键,回调函数作为值。在构造函数下面插入以下代码:

respondTo(keywords, callback, start) {
  // If 'start' is truthy, prepend the '^' anchor to instruct the
  // expression to look for matches at the beginning of the string
  if (start) {
    keywords = '^' + keywords;
  }

  // Create a new regular expression, setting the case 
  // insensitive (i) flag
  let regex = new RegExp(keywords, 'i');

  // Set the regular expression to be the key, with the callback
  // function as the value
  this.keywords.set(regex, callback);
}

这个函数接受三个参数:keywordscallbackstartkeywords是我们希望以正则表达式形式采取行动的单词或短语。callback是一个函数,如果关键词与消息匹配,则会调用它,而start是一个可选的布尔值,表示我们是否希望仅在消息字符串的开头搜索。

回顾我们新更新的构造函数,并特别注意我们message事件监听器中的以下行:

// Loop over the keys of the keywords Map object and test each
// regular expression against the message's text property
for (let regex of this.keywords.keys()) {    
  if (regex.test(message.text)) {
    let callback = this.keywords.get(regex);
    callback(message, channel, user);
  }
}

在这里,我们遍历关键词Map对象,其键是正则表达式。我们将每个正则表达式与接收到的消息进行测试,并使用消息、频道和发送消息的用户调用我们的回调函数。

最后,让我们给我们的机器人类添加一个sendMessage功能。这将作为 Slack 的sendMessage的包装器。我们不再需要暴露整个 Slack 对象。在我们的构造函数下面添加以下函数:

  // Send a message to a channel, with an optional callback
  send(message, channel, cb) {
    this.slack.sendMessage(message, channel.id, () => {
      if (cb) {
        cb();
      }
    });
  }

尽管我们的send函数的参数名为channel,但它也可以用于 DM(两个人之间的私密频道),此外,它还通过 Slack API 的sendMessage函数提供了一个回调。

现在我们有一个可以订阅消息及其内容的函数,打开index.js,让我们添加一个简单的“Hello World”实现:

'use strict';

let Bot = require('./Bot');

const bot = new Bot({
  token: process.env.SLACK_TOKEN,
  autoReconnect: true,
  autoMark: true
});

bot.respondTo('hello', (message, channel, user) => {
  bot.send(`Hello to you too, ${user.name}!`, channel)
}, true);

保存文件,重新启动你的 node 进程,并测试你的机器人。它应该看起来像这样:

反应式机器人

测试我们的重构

当我们的消息包含字符串"hello"时,机器人会做出响应,但只有在它出现在消息开头,由于我们在回调后传递的true值,才会如此。

我们现在已经重构了机器人的代码,将 Slack 事件系统抽象出来,并在过程中使我们的代码更简洁。让我们用我们的新系统做一些更令人印象深刻的事情,并实现一个简单的游戏。

机器人命令

到目前为止,我们的机器人已经对消息中的关键词做出了响应,说“你好”或告诉我们它们已经运行了多久。这些关键词对于简单任务很有用,但对于更复杂的操作,我们需要给机器人一些参数来处理。一个跟在参数或参数后面的关键词可以称为机器人命令。类似于命令行,我们可以发出尽可能多的参数来充分利用我们的机器人。

让我们通过给我们的机器人一个新的函数来测试这个:一个机会游戏,roll命令的发布者玩一个掷骰子比谁掷出的数字高的游戏。

将以下代码添加到index.js中:

bot.respondTo('roll', (message, channel, user) => {
  // get the arguments from the message body
  let args = getArgs(message.text);

  // Roll two random numbers between 0 and 100
  let firstRoll = Math.round(Math.random() * 100);
  let secondRoll = Math.round(Math.random() * 100);

  let challenger = user.name;
  let opponent = args[0];

  // reroll in the unlikely event that it's a tie
  while (firstRoll === secondRoll) {
    secondRoll = Math.round(Math.random() * 100);
  }

  let winner = firstRoll > secondRoll ? challenger : opponent;

  // Using new line characters (\n) to format our response
  bot.send(
    `${challenger} fancies their chances against ${opponent}!\n
    ${challenger} rolls: ${firstRoll}\n
    ${opponent} rolls: ${secondRoll}\n\n
    *${winner} is the winner!*`
  , channel);

}, true);

// Take the message text and return the arguments
function getArgs(msg) {
  return msg.split(' ').slice(1);
}

命令非常简单:用户发送关键词roll,后面跟着他们想要挑战的用户名。这在上面的屏幕截图中显示:

机器人命令

机器人滚动命令的直接实现

它工作得很好,但如果我们省略了roll命令的任何参数会发生什么?

机器人命令

undefined赢得了游戏,这不是预期的行为

没有提供参数;因此,我们的args数组中索引 0 的值是undefined。显然,我们的 bot 缺少一些基本功能:无效参数错误处理。

注意

使用 bot 命令时,用户输入必须始终被清理并检查错误,否则 bot 可能会执行一些不希望的操作。

清理输入

在我们的getArgs方法调用下添加此块以防止发生空投掷:

  // if args is empty, return with a warning
  if (args.length < 1) {
    channel.send('You have to provide the name of the person you wish to challenge!');
    return;
  }

这里是结果:

清理输入

Awesomebot 提供了一些必要的清理

一个用例已经完成,但如果有人试图挑战不在通道中的人怎么办?目前,bot 将针对你作为第一个参数放入的任何内容进行投掷,无论是通道成员还是完全的虚构。这是我们想要进一步清理和限制用户输入以使其有用的一个例子。

为了解决这个问题,让我们确保只有来自roll命令发起的通道的成员可以被针对。

首先,让我们向我们的Bot类添加以下方法:

getMembersByChannel(channel) {
    // If the channel has no members then that means we're in a DM
    if (!channel.members) {
      return false;
    }

    // Only select members which are active and not a bot
    let members = channel.members.filter((member) => {
      let m = this.slack.dataStore.getUserById(member);
      // Make sure the member is active (i.e. not set to 'away' status)
      return (m.presence === 'active' && !m.is_bot);
    });

    // Get the names of the members
    members = members.map((member) => {
      return this.slack.dataStore.getUserById(member).name;
    });

    return members;
  }

此功能简单地检查channelmembers属性是否存在,并返回按名称排序的活跃非 bot 用户列表。在index.js中,将你的roll命令块替换为以下代码:

bot.respondTo('roll', (message, channel, user) => {
  // get the members of the channel
  const members = bot.getMembersByChannel(channel);

  // make sure there actually members to interact with. If there
  // aren't then it usually means that the command was given in a 
  // direct message
  if (!members) {
    bot.send('You have to challenge someone in a channel, not a direct message!', channel);
    return;
  }

  // get the arguments from the message body
  let args = getArgs(message.text);

  // if args is empty, return with a warning
  if (args.length < 1) {
    bot.send('You have to provide the name of the person you wish to challenge!', channel);
    return;
  }

  // does the opponent exist in this channel?
  if (members.indexOf(args[0]) < 0) {
    bot.send(`Sorry ${user.name}, but I either can't find ${args[0]} in this channel, or they are a bot!`, channel);
    return;
  }

  // Roll two random numbers between 0 and 100
  let firstRoll = Math.round(Math.random() * 100);
  let secondRoll = Math.round(Math.random() * 100);

  let challenger = user.name;
  let opponent = args[0];

  // reroll in the unlikely event that it's a tie
  while (firstRoll === secondRoll) {
    secondRoll = Math.round(Math.random() * 100);
  }

  let winner = firstRoll > secondRoll ? challenger : opponent;

  // Using new line characters (\n) to format our response
  bot.send(
    `${challenger} fancies their changes against ${opponent}!\n
    ${challenger} rolls: ${firstRoll}\n
    ${opponent} rolls: ${secondRoll}\n\n
    *${winner} is the winner!*`
  , channel);

}, true);

我们在这里最大的变化是,bot 现在将检查以确保给出的命令是有效的。它将通过检查以下内容(按顺序列出)来确保:

  1. 通道中有可用的成员。

  2. 命令后提供了参数。

  3. 无论参数是否有效,通过确保提供的名称在通道的成员列表中,或者该名称不是 bot 的名称。

从这个练习中要吸取的重要教训是通过确保所有用例都得到正确处理来最小化中断。需要进行充分的测试以确保你处理了所有用例。例如,在我们的roll命令示例中,我们错过了一个重要的用例:用户可以使用roll命令针对自己:

清理输入

与自己投掷可能不是最有用的功能

为了解决这个问题,我们需要对我们的命令进行简单的添加。在之前的清理检查中添加以下代码:

// the user shouldn't challenge themselves
if (args.indexOf(user.name) > -1) {
  bot.send(`Challenging yourself is probably not the best use of your or my time, ${user.name}`, channel);
  return;
}

注意

在开发 bot 时,应采取一切预防措施以确保 bot 输入被清理,并且错误响应提供有关错误的信息。这在与外部 API 一起工作时尤其如此,错误的输入可能导致极其不准确的结果。

外部 API 集成

永恒 API 是托管在我们 bot 结构之外的第三方服务。它们有多种类型,用于解决许多不同的问题,但与 bot 一起使用时遵循相同的数据流结构。

外部 API 集成

Slack、bot 和 API 服务之间的 API 调用数据流结构

我们将使用一个常见且免费使用的 API 构建一个具有 API 集成的示例机器人,即维基媒体基金会的 API。

注意

虽然许多 API 是免费的,但也有很多 API 在达到一定数量的请求时会收费。在将它们集成到你的机器人中之前,请务必检查是否有费用。

维基媒体基金会 API 是一个表示状态转移REST)服务的例子,它使用标准的超文本传输协议HTTP)协议,如 GET 或 POST 进行通信。许多 RESTful 服务要求你在请求中传输一个令牌,以确保安全,并通过跟踪请求量来通过货币化服务。维基媒体 API 是一个免费的 RESTful 服务,这意味着我们不需要令牌就可以使用它。

我们的新机器人 wikibot 将允许用户搜索维基百科页面,如果找到页面则返回页面摘要,如果没有找到则返回错误信息。

要开始,你应该遵循第二章中“你的第一个机器人”的步骤,通过 Slack 网络服务创建一个新的 Slack 机器人集成并开始一个新的项目。这个新项目将重用本章中创建的Bot类,而我们的新index.js入口点将是一个新的空文件。

我们将从注释和解释的index.js代码开始。在章节结束时,将提供完整的代码,以便更容易访问。以下是代码:

'use strict';

const Bot = require('./Bot');
const request = require('superagent');

在这里,我们导入我们自己的Bot类以及一个名为superagent的新库,该库用于进行异步 JavaScript 和 XML(AJAX)调用。

在运行此代码之前,请确保使用 NPM 安装superagent

npm install superagent --save

使用 –save 标志安装了 superagent,因为没有它程序无法运行。

让我们回到我们的代码:

const wikiAPI = "https://en.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exintro=&explaintext=&titles="
const wikiURL = 'https://en.wikipedia.org/wiki/';

这些常量分别是 RESTful API 的统一资源链接URL)和基本维基百科页面 URL。你可以通过复制 URL,将其粘贴到浏览器的地址字段中,并在末尾添加一个主题来测试前者。你可以检查以下 URL:en.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exintro=&explaintext=&titles=duck

你应该会看到以JavaScript 对象表示法JSON)格式返回的数据,这为你提供了请求的主题和返回的页面的概述。返回的数据和数据类型由 URL 查询字符串中的参数确定。在先前的 URL 中,我们查询了页面的extracts属性,特别是标题为duck的页面的简介(exintro)和解释(explaintext),以 JSON 格式返回。

后者常量用于稍后返回请求的维基百科页面的 URL:

const bot = new Bot({
  token: process.env.SLACK_TOKEN,
  autoReconnect: true,
  autoMark: true
});

如前所述,我们使用我们的选项和 Slack 令牌启动一个新的Bot实例。你可以重用第二章中创建的第一个令牌,你的第一个机器人。然而,建议生成一个新的令牌。代码如下:

function getWikiSummary(term, cb) {
  // replace spaces with unicode
  let parameters = term.replace(/ /g, '%20');

这个函数是对访问维基媒体 API 的请求的包装,我们通过替换搜索词中的空格为 Unicode 格式来格式化请求,并通过superagent库进行 GET 请求。代码如下:

  request
    .get(wikiAPI + parameters)
    .end((err, res) => {
      if (err) {
        cb(err);
        return;
      }

      let url = wikiURL + parameters;

      cb(null, JSON.parse(res.text), url);
    });
}

由于这是一个异步请求,我们提供了一个回调函数,当GET请求返回所需数据时将被调用。在返回之前,我们确保将数据解析成 JavaScript 对象形式以便于访问。代码如下:

bot.respondTo('help', (message, channel) => {  
  bot.send(`To use my Wikipedia functionality, type \`wiki\` followed by your search query`, channel); 
}, true);

外部 API 集成

Wikibot 解释如何使用它

我们实现的第一条命令是一个简单的help命令;它的唯一功能是解释如何使用机器人的维基百科功能:

bot.respondTo('wiki', (message, channel, user) => {
  if (user && user.is_bot) {
    return;
  }

使用关键词wiki设置我们的新机器人命令,并确保如果命令发送者是机器人则返回:

  // grab the search parameters, but remove the command 'wiki' // from
  // the beginning of the message first
  let args = message.text.split(' ').slice(1).join(' ');

这将提取命令的搜索查询。例如,如果命令是wiki fizz buzz,则args的输出将是一个包含"fizz buzz"的字符串:

  getWikiSummary(args, (err, result, url) => {
    if (err) {
      bot.send(`I\'m sorry, but something went wrong with your query`, channel);
      console.error(err);
      return;
    }

在这里,我们调用我们的getWikiSummary函数,使用机器人命令提供的参数,并提供匿名函数回调。如果发生错误,立即发送错误消息并在控制台记录错误。命令如下:

    let pageID = Object.keys(result.query.pages)[0];

通过 RESTful API 调用返回的数据对象包含一个名为query的嵌套对象,它又包含一个名为pages的嵌套对象。在pages对象内部,有更多使用维基百科内部页面 ID 作为键的对象,这些键是一系列以字符串格式表示的数字。让我们看一个例子:

{
  "batchcomplete": "",
  "query": {
    "normalized": [
      {
        "from": "duck",
        "to": "Duck"
      }
    ],
    "pages": {
      "37674": {
        "pageid": 37674,
        "ns": 0,
        "title": "Duck",
        "extract": "Duck is the common name for a large number of species in the waterfowl family Anatidae, which also includes swans and geese. The ducks are divided among several subfamilies in the family Anatidae; they do not represent a monophyletic group (the group of all descendants of a single common ancestral species) but a form taxon, since swans and geese are not considered ducks. Ducks are mostly aquatic birds, mostly smaller than the swans and geese, and may be found in both fresh water and sea water.\nDucks are sometimes confused with several types of unrelated water birds with similar forms, such as loons or divers, grebes, gallinules, and coots.\n\n"
      }
    }
  }
}

Object.keys是一个有用的技巧,可以在不知道属性名称的情况下从对象中检索数据。我们在这里使用它,因为我们不知道我们想要的页面的键 ID,但我们知道我们想要第一个值。Object.keys将返回result.query.pages对象的键名称数组。然后我们选择索引 0 处的值,因为我们只对第一个结果感兴趣。代码如下:

    // -1 indicates that the article doesn't exist
    if (parseInt(pageID, 10) === -1) {
      bot.send('That page does not exist yet, perhaps you\'d like to create it:', channel);
      bot.send(url, channel);
      return;
    }

维基百科页面 ID 为-1 表示该文章根本不存在。我们不会尝试解析不存在的数据,而是通知用户问题并返回。代码如下:

    let page = result.query.pages[pageID];
    let summary = page.extract;

    if (/may refer to/i.test(summary)) {
      bot.send('Your search query may refer to multiple things, please be more specific or visit:', channel);
      bot.send(url, channel);
      return;
    }

如果摘要文本包含短语可能指,那么我们可以得出结论,提供的搜索词可能导致多个维基百科条目。由于我们无法猜测用户的意图,我们简单地要求他们提供更具体的信息并返回。代码如下:

    if (summary !== '') {
      bot.send(url, channel);

不幸的是,有可能 API 请求返回一个空的摘要。这是 Wikimedia API 端的问题,其中某个术语返回一个页面,但摘要文本缺失。在这种情况下,我们在 if 语句的 else 条件块中通知用户问题。代码如下:

      let paragraphs = summary.split('\n');

摘要可能跨越几个段落,为了便于使用,我们使用新的换行 ASCII 操作符 \n 作为分割标准,将文本块转换为段落数组。代码如下:

paragraphs.forEach((paragraph) => {
  if (paragraph !== '') {
    bot.send(`> ${paragraph}`, channel);
  }
});

就像普通用户一样,机器人在发送消息时可以使用 Slack 的格式化选项。在这个例子中,我们在段落前加上 > 操作符,以指示引用块。代码如下:

} else {
      bot.send('I\'m sorry, I couldn\'t find anything on that subject. Try another one!', channel);
    }
  });
}, true);

如前所述,我们将 true 布尔值传递给 Bot 类的 respondsTo 方法,以指示我们希望我们的关键字 wiki 仅在消息开头时触发响应。

一旦你将所有代码输入到 index.js 中,使用 Node 运行程序,并在你的 Slack 客户端中测试它:

外部 API 集成

Wikibot 正在运行

这是一个如何将外部 API 调用集成到你的机器人中的基本示例。在我们进入下一节之前,我们应该考虑复杂 API 请求的后果。如果一个 API 请求需要花费相当长的时间(例如,一个服务需要执行复杂的计算),那么对于用户来说,看到机器人正在处理命令的指示将是有用的。为了实现这一点,我们可以在机器人等待响应时显示一个输入指示器。当一个人在发送消息之前开始输入时,会显示输入指示器。将以下方法添加到 bot.js 中的 Bot 类:

  setTypingIndicator(channel) {
    this.slack.send({ type: 'typing', channel: channel.id });
  }

要测试我们的指示器,将以下内容添加到 index.js

bot.respondTo('test', (message, channel) => {
  bot.setTypingIndicator(message.channel);
  setTimeout(() => {
    bot.send('Not typing anymore!', channel);
  }, 1000);
}, true);

现在,在你的 Slack 频道中发送消息 test 并观察指示器出现:

外部 API 集成

Wikibot 正在输入

1000 毫秒后,我们得到以下结果:

外部 API 集成

机器人完成了操作,输入指示器已移除

在发送指示器后,一旦机器人向频道发送了一条消息,指示器将自动消失。

要在我们的示例机器人中使用输入指示器,在 getWikiSummary 方法调用上方插入以下行:

bot.setTypingIndicator(message.channel);

请记住,由于 Wikimedia API 调用非常快,你不太可能看到输入指示器超过几毫秒。

错误处理

从上一个主题继续,让你的机器人看起来更自然的一个好方法是让它提供如何使用它的明确说明。对于命令的输入错误,机器人决不应该崩溃。

注意

机器人决不应该因为用户输入而崩溃。要么发送错误消息,要么请求静默失败。

通过对用户的输入进行有效的类型和内容检查,你可以消除你机器人命令中 99% 的所有错误。在编写新命令时,请观察以下清单:

  • 如果需要参数,是否有任何参数未定义?

  • 参数的类型是否符合机器人期望的类型?例如,当期望数字时是否提供了字符串?

  • 如果目标是频道成员,该成员是否存在?

  • 命令是否在 DM 中发送?如果是,那么命令是否应该被执行?

  • 命令是否通过了一个“合理性”检查?例如,请求的数据或操作是否有意义?

作为前面清单的一个例子,让我们回顾一下本章前面用 roll 命令所做的检查:

  • 是否有非机器人成员在频道中可以与之交互?

  • 是否提供了论点?

  • 提供的参数是否有效?

  • 发出命令的指定对手是否在频道中?

每一点都是一个障碍,命令的输入必须克服这些障碍才能返回所需的结果。如果这些问题的任何一个回答是否定的,那么将发送错误消息并终止命令过程。

这些检查可能看起来很冗长且多余,但它们绝对有必要为机器人提供自然的体验。

最后一点,请注意,尽管你尽了最大努力,但用户仍然有一种不可思议的能力,无论是故意还是无意地导致崩溃。

你的机器人越复杂,出现漏洞和边缘情况的可能性就越大。彻底测试你的机器人将让你走得更远,但始终确保你在程序方面捕捉并记录错误。一个好的调试日志将节省你许多试图找到难以解决的错误所花费的时间。

摘要

在本章中,我们看到了如何通过使用 ES6 的新类结构将核心 Slack API 方法抽象成可重用的模块。我们概述了反应性机器人与主动机器人的区别,以及关键词与机器人命令的区别。通过应用本章概述的外部 API 的基本知识,你应该能够创建一个可以与任何提供 RESTful API 的第三方应用程序交互的机器人。

在下一章中,我们将学习关于 Redis 数据存储服务以及如何编写与持久数据源交互的机器人。

第四章:使用数据

现在我们已经看到了如何处理关键词、命令和 API 调用,我们将探讨构建机器人的下一个逻辑步骤:持久数据存储和检索。可以通过将数据分配给变量来在 JavaScript 中保留对数据的引用;然而,其使用仅限于程序运行时。如果程序停止或重启,我们就会丢失数据。因此,对于某些任务,需要持久数据存储。

这使得我们可以构建能够执行诸如跟踪排行榜或存储待办事项列表等任务的机器人。

在本章中,我们将介绍:

  • Redis 简介

  • 连接到 Redis

  • 保存和检索数据

  • 最佳实践

  • 错误处理

Redis 简介

在上一章中,我们发现了如何创建一个具有竞争力的掷骰子机器人,允许用户玩“谁掷得最高”的游戏。尽管它工作得很好,但缺少的功能是某种排行榜,其中存储了每个用户的胜负,并保持了一个总体赢家列表。

这样的功能并不难实现;然而,最大的问题在于数据的存储。任何存储在 JavaScript 变量中的数据,一旦程序结束或崩溃就会丢失。因此,维护一个持久数据库将是一个更好的解决方案,我们的机器人可以从中写入和读取数据。

有许多数据库服务可供选择;你可能已经熟悉 MySQL 或 MongoDB。对于本章中的示例机器人,我们将选择一个易于设置和使用的服务。

我们将使用的数据库服务是 Redis:redis.io/.

Redis 网站对这项技术的描述如下:

"Redis 是一个开源(BSD 许可),内存数据结构存储,用作数据库、缓存和消息代理。它支持字符串、散列、列表、集合、有序集合(带范围查询)、位图、HyperLogLogs 和地理空间索引(带半径查询)等数据结构。Redis 具有内置的复制、Lua 脚本、LRU 过期、事务和不同级别的磁盘持久性,并通过 Redis Sentinel 提供高可用性,以及通过 Redis Cluster 自动分区。"

一个更简单的解释是,Redis 是一个高效的内存键值存储。键可以是简单的字符串、散列、列表(有序集合)、集合(无序的非重复值集合)或有序集合(有序或排名的非重复值集合)。尽管官方描述很复杂,但设置和使用 Redis 是一个快速且痛苦的过程。

Redis 的优势是其令人印象深刻的速度、跨平台通信和简单性。

注意

开始使用 Redis 简单,但我们只会探索 Redis 冰山一角。有关 Redis 高级使用的更多信息,请访问 Redis 网站。

有许多用各种语言编写的 Redis 客户端实现(redis.io/clients),但我们将使用基于 Node 的 Redis 客户端。

请记住,Redis 只是持久化数据问题的一个解决方案。其他解决方案可能包括使用 MySQL 关系型数据库或 MongoDB 非关系型数据库。

安装 Redis

为了连接到 Redis,我们将使用 Node Redis 包。首先,我们必须安装并运行我们的 Redis 服务器,这样 Node 就有东西可以连接了。请按照您选择的操作系统的说明进行操作。

Mac OS X

安装 Redis 最简单的方法是通过homebrew包管理器。homebrew通过命令行轻松安装应用程序和服务。

如果您无法使用homebrew,请访问 Redis 快速入门指南以手动安装 Redis:(redis.io/topics/quickstart)。

如果您不确定是否已安装 Homebrew,请打开终端并运行以下命令:

which brew

如果没有返回任何内容,请在您的终端中运行以下命令:

/usr/bin/ruby -e "$(curl –fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

按照屏幕提示操作,直到成功安装homebrew。要安装 Redis,请运行以下命令:

brew install redis

安装完成后,您可以在终端中使用以下命令启动 Redis 服务器:

redis-server

Windows

访问 Redis 的官方 Microsoft GitHub 项目并在此处获取最新版本:github.com/MSOpenTech/redis/releases。解压后,您可以通过运行redis-server.exe来启动服务,并通过 shell 运行redis-cli.exe来连接到服务器。

Unix

请参考 Redis 快速入门页面获取在 Linux/Unix 系统上安装的说明:redis.io/topics/quickstart

安装完成后,您可以使用redis-server命令启动服务器,并通过redis-cli连接到服务器。这些命令在 OS X 上以完全相同的方式工作。

现在 Redis 已安装,启动服务,您应该会看到类似以下内容:

Unix

Redis 成功启动服务器

Redis 现在已在默认端口 6379 上启动并准备好使用。也可以使用其他端口,但默认端口对于我们的目的来说已经足够。

连接到 Redis

为了演示如何连接到 Redis,我们将创建一个新的机器人项目(包括在第三章中定义的Bot类,增加复杂性)。我们将首先安装 Redis Node 客户端,执行以下操作:

npm install redis

现在,创建一个新的index.js文件,并粘贴以下代码:

'use strict';

const redis = require('redis');
const Bot = require('./Bot');

const client = redis.createClient();

const bot = new Bot({
  token: process.env.SLACK_TOKEN,
  autoReconnect: true,
  autoMark: true
});

client.on('error', (err) => {
    console.log('Error ' + err);
});

client.on('connect', () => {
  console.log('Connected to Redis!');
});

此代码片段将导入 Redis 客户端并通过createClient()方法连接到本地实例。如果不提供任何参数,上述方法将假定服务在本地默认端口 6379 上运行。如果您想连接到不同的主机和端口组合,则可以按照以下方式提供:

let client = redis.createClient(port, host);

注意

为了本书的目的,我们将使用一个不安全的 Redis 服务器。在没有身份验证或其他安全措施的情况下,任何人都可以访问和编辑连接到您的数据服务的数据。如果您打算在生产环境中使用 Redis,强烈建议您阅读有关 Redis 安全性的资料。

接下来,确保您已经在不同的终端窗口中运行了 Redis 客户端,并按照常规方式启动我们的机器人:

SLACK_TOKEN=[your_token_here] node index.js

如果一切顺利,您应该会看到以下快乐的问候信息:

连接到 Redis

我们的 Node 应用程序已成功连接到本地 Redis 服务器

正如承诺的那样,设置和连接到 Redis 是一项简单快捷的任务。接下来,我们将查看如何实际上设置和从我们的服务器获取数据。

保存和检索数据

首先,让我们看看 Redis 客户端能为我们提供什么。将以下行添加到 index.js

client.set('hello', 'Hello World!');

client.get('hello', (err, reply) => {
  if (err) {
    console.log(err);
    return;
  }

  console.log(`Retrieved: ${reply}`);
});

在这个例子中,我们将使用键 hello 在 Redis 中设置值 "Hello world!"。在 get 命令中,我们指定了我们希望用来检索值的键。

注意

Node Redis 客户端完全是异步的。这意味着如果您希望处理数据,就必须为每个命令提供一个回调函数。

一个常见的错误是使用 Node Redis 客户端进行同步操作。以下是一个例子:

let val = client.get('hello');
console.log('val:', val);

这,也许有些令人困惑,结果是:

val: false

这是因为在向 Redis 服务器发出请求之前,get 函数将返回布尔值 false

运行正确的代码,您应该会看到成功检索到 Hello world! 数据:

保存和检索数据

我们存储的值成功检索

小贴士

Redis 字符串的最大文件大小为 512 兆字节。如果您需要存储比这更大的内容,请考虑使用多个键/值对。

在开发 Redis 功能时,一个不错的建议是使用 Redis 客户端的内置 print 命令来进行简单的调试和测试:

client.set('hello', 'Hello World!', redis.print);

client.get('hello', redis.print);

这将在终端中打印以下内容:

Reply: OK
Reply: Hello World!

随着我们进入本章,我们将介绍 Redis 客户端提供的更多有用函数和方法。有关完整列表和文档,请访问 github.com/NodeRedis/node_redis

连接机器人

在我们的 Redis 服务器设置完毕并覆盖了基本命令之后,让我们将所学应用到简单的机器人中。在这个例子中,我们将编写一个机器人,它根据给定的键值来记住一个短语。

将以下代码添加到您的 index.js 文件中:

bot.respondTo('store', (message, channel, user) => {
  let msg = getArgs(message.text);

  client.set(user.name, msg, (err) => {
    if (err) {
      channel.send('Oops! I tried to store that but something went wrong :(');
    } else {
      channel.send(`Okay ${user.name}, I will remember that for you.`);
    }
  });
}, true);

bot.respondTo('retrieve', (message, channel, user) => {
  bot.setTypingIndicator(message.channel);

  client.get(user.name, (err, reply) => {
    if (err) {
     console.log(err);
     return;
    }

    channel.send('Here\'s what I remember: ' + reply);
  });
});

使用上一章中 Bot 类中引入的熟悉 respondTo 命令,我们设置我们的机器人监听关键词 store,然后使用消息发送者的名字作为键,将该值设置在 Redis 数据存储中。让我们看看这是如何实现的:

连接机器人

我们的机器人记得我们告诉它的

注意我们如何使用设置方法的回调函数来确保数据被正确保存,并在数据未保存正确时通知用户。

虽然这种行为并不十分令人印象深刻,但重要的是要认识到我们的机器人已经成功地在 Redis 数据存储中存储了值。Redis 将在本地磁盘上存储键值对,这意味着即使机器人或/和 Redis 服务器停止并重新启动,数据也会持续存在。

动态存储

再次,让我们增加一点复杂性。在上一个例子中,用于存储数据的键总是命令发出者的名字。在现实中,这并不实用,因为它意味着用户每次发出命令时只能存储一件事情,每次都会覆盖值。在接下来的这一节中,我们将增强我们的机器人,允许用户指定要存储的值的键,从而允许存储多个值。

删除之前的 respondsTo 命令,粘贴以下片段,注意高亮代码:

bot.respondTo('store', (message, channel, user) => {
  let args = getArgs(message.text);

 let key = args.shift();
 let value = args.join(' ');

 client.set(key, value, (err) => {
 if (err) {
 channel.send('Oops! I tried to store something but something went wrong :(');
 } else {
 channel.send(`Okay ${user.name}, I will remember that for you.`);
 }
 });
}, true);

bot.respondTo('retrieve', (message, channel, user) => {
  bot.setTypingIndicator(message.channel);

  let key = getArgs(message.text).shift();

  client.get(key, (err, reply) => {
    if (err) {
     console.log(err);
     channel.send('Oops! I tried to retrieve something but something went wrong :(');
     return;
    }

    channel.send('Here\'s what I remember: ' + reply);
  });
});

在这种解释中,我们期望用户以以下格式提供命令:

store [key] [value]

要从命令中提取键和值,我们首先使用 JavaScript 的 Array.shift 来移除并返回 args 数组的索引 0 的值。然后,通过使用 Array.join 收集其余的参数作为值。现在,我们将上一节学到的知识应用到存储用户定义的键和值到 Redis 实例中。

当给出 retrieve 命令时,我们使用相同的 Array.shift 技巧来提取请求的键。然后,我们将使用它来检索存储的数据。让我们看看它是如何工作的:

动态存储

存储和检索多个实体

注意

消息文本中的表情符号将被转换为它们的文本组件。例如,点赞表情符号被转换为 :+1。这种转换是双向的,这意味着 Slack 将自动渲染机器人发送的任何表情文本。

哈希、列表和集合

到目前为止,我们为键和值使用了单一的数据类型:字符串。虽然键限制为字符串值,但 Redis 允许值是多种不同的数据类型。不同类型如下:

  • 字符串

  • 哈希

  • 列表

  • 集合

  • 有序集合

我们已经熟悉字符串了,所以让我们继续列表并解释不同的数据类型。

哈希

哈希类似于 JavaScript 对象。然而,它们在 Redis 哈希不支持嵌套对象方面有所不同。哈希的所有属性值都将转换为字符串。以下是一个 JavaScript 对象的例子:

let obj = {
  foo: 'bar',
  baz: {
    foobar: 'bazfoo'
  }
};

baz 属性包含一个对象,我们可以通过使用 hmset 函数将 obj 对象存储在 Redis 中:

client.hmset('obj', obj);

然后,我们使用 hgetall 来检索数据:

client.hgetall('obj', (err, object) => {
  console.log(object);
});

这将在我们的终端中记录以下行:

{ foo: 'bar', baz: '[object Object]' }

Redis 首先通过调用 Object.toString() 函数来存储嵌套的 baz 对象,这意味着当我们执行 hgetall 函数时,返回的是字符串值。

一种解决方案是利用 JavaScript 的 JSON 对象在存储之前将嵌套对象序列化,然后解析从 Redis 返回的对象。观察以下示例:

let obj = {
  foo: 'bar',
  baz: {
    foobar: 'bazfoo'
  }
};

function stringifyNestedObjects(obj) {
  for (let k in obj) {
    if (obj[k] instanceof Object) {
      obj[k] = JSON.stringify(obj[k]);  
    }
  }

  return obj;
}

function parseNestedObjects(obj) {
  for (let k in obj) {
    if (typeof obj[k] === 'string' || obj[k] instanceof String) {
      try {
        obj[k] = JSON.parse(obj[k]);
      } catch(e) {
        // string wasn't a stringified object, so fail silently
      }      
    }
  }

  return obj;
}

client.hmset('obj', stringifyNestedObjects(obj));

client.hgetall('obj', (err, object) => {
  console.log(parseNestedObjects(object));
});

执行后,我们看到记录的结果:

{ foo: 'bar', baz: { foobar: 'bazfoo' } }

注意

这里给出的示例仅序列化和解析了一级嵌套的对象。为了序列化和解析深度为 N 的对象,请查看递归编程技术。一个很好的例子可以在 msdn.microsoft.com/en-us/library/wwbyhkx4(v=vs.94).aspx 找到。

列表

Redis 列表在功能上与 JavaScript 数组相同。与对象一样,每个索引的值在存储时都会转换为字符串。当处理多维数组(例如,包含子数组的数组)时,toString 函数将在存储到 Redis 之前被调用。可以使用简单的 Array.join(',') 将此字符串值转换回数组。

可以使用 lpushrpush 命令来存储我们的列表:

client.rpush('heroes', ['batman', 'superman', 'spider-man']);

在前面的代码片段中,我们正在将英雄数组推送到列表的右侧。这与 JavaScript 的 Array.push 完全相同,其中新值被追加到现有数组中。在这种情况下,这意味着之前为空的列表现在包含我们的 heroes 数组。

我们可以向数组的左侧推送以向列表中添加元素:

client.lpush('heroes', 'iron-man');

这将使我们的列表看起来像这样:

[ 'iron-man', 'batman', 'superman', 'spider-man' ]

最后,要访问我们的 Redis 列表,我们可以使用 lrange 方法:

client.lrange('heroes', 0, -1, (err, list) => {
  console.log(list);
});

传递给 lrange 的第二个和第三个参数是选择起始和结束位置。要返回列表中的所有元素而不是子集,我们可以提供 -1 作为结束位置。

集合

集合类似于 Redis 列表,但有一个非常有用的区别:集合不允许重复。考虑以下示例:

client.sadd('fruits', ['apples', 'bananas', 'oranges']);
client.sadd('fruits', 'bananas');

client.smembers('fruits', (err, set) => {
  console.log(set);
});

在这里,我们使用 Redis 客户端的 sadd 来存储集合,并使用 smembers 来检索它。在第二行,我们尝试将 'bananas' 水果添加到 'fruits' 列表中,但由于该值已存在,sadd 调用将静默失败。检索到的集合与预期一致:

[ 'oranges', 'apples', 'bananas' ]

注意

你可能会注意到检索到的 'fruits' 集合的顺序与存储时的顺序不同。这是因为集合是通过 HashTable 构建的,这意味着没有保证元素的顺序。如果你想要以特定的顺序存储你的元素,你必须使用列表或有序集合。

有序集合

作为列表和集合的某种混合体,有序集合具有特定的顺序且不能包含重复项。以下是一个示例:

client.zadd('scores', [3, 'paul', 2, 'caitlin', 1, 'alex']);

client.zrange('scores', 0, -1, (err, set) => {
  console.log(set);
});

client.zrevrange('scores', 0, -1, 'withscores', (err, set) => {
  console.log(set);
});

使用 zadd 方法,我们指定排序集合的键和值数组。数组通过以下格式指示存储集合的顺序:

[ score, value, score, value ... ]

zrange 方法使用与 lrange 相似的参数,我们指定要返回的集合的起始和结束位置。此方法将按升序返回集合:

[ 'alex', 'caitlin', 'paul' ]

我们可以通过使用zrevrange来反转这一点。注意我们如何也提供withscores字符串作为参数。此参数将返回每个元素的分数:

[ 'paul', '3', 'caitlin', '2', 'alex', '1' ]

注意

可以使用withscores参数来获取所有排序集合的检索方法。

如您可能已经意识到的,排序集合在用于跟踪游戏分数或排行榜时特别出色。考虑到这一点,让我们回顾第三章,增加复杂性中的“roll”机器人,并添加一个获胜者排行榜。

最佳实践

任何用户都应该能够通过机器人命令在 Redis 中存储数据;然而,建议您确保数据存储方法不容易被滥用。意外的滥用可能以短时间内大量不同的 Redis 调用形式发生。有关 Slack 频道垃圾邮件和补救措施的更多信息,请回顾第二章,您的第一个机器人

通过限制机器人流量,我们可以确保 Redis 不会接收到过多的写入和检索操作。如果您发现 Redis 延迟不如预期,请访问此网页以帮助排查:redis.io/topics/latency

让我们现在看看如何通过添加 Redis 数据存储来改进熟悉的机器人行为。

首先,这是我们的roll命令,新的 Redis 存储代码被突出显示:

bot.respondTo('roll', (message, channel, user) => {
  // get the members of the channel
  const members = bot.getMembersByChannel(channel);

  // make sure there actually members to interact with. If there
  // aren't then it usually means that the command was given in a  
  // direct message
  if (!members) {
    channel.send('You have to challenge someone in a channel, not a direct message!');
    return;
  }

  // get the arguments from the message body
  let args = getArgs(message.text);

  // if args is empty, return with a warning
  if (args.length < 1) {
    channel.send('You have to provide the name of the person you wish to challenge!');
    return;
  }

  // the user shouldn't challenge themselves
  if (args.indexOf(user.name) > -1) {
    channel.send(`Challenging yourself is probably not the best use of your or my time, ${user.name}`);
    return;
  }

  // does the opponent exist in this channel?
  if (members.indexOf(args[0]) < 0) {
    channel.send(`Sorry ${user.name}, but I either can't find ${args[0]} in this channel, or they are a bot!`);
    return;
  }

  // Roll two random numbers between 0 and 100
  let firstRoll = Math.round(Math.random() * 100);
  let secondRoll = Math.round(Math.random() * 100);

  let challenger = user.name;
  let opponent = args[0];

  // reroll in the unlikely event that it's a tie
  while (firstRoll === secondRoll) {
    secondRoll = Math.round(Math.random() * 100);
  }

  let winner = firstRoll > secondRoll ? challenger : opponent;

 client.zincrby('rollscores', 1, winner);

  // Using new line characters (\n) to format our response
  channel.send(
    `${challenger} fancies their changes against ${opponent}!\n
    ${challenger} rolls: ${firstRoll}\n
    ${opponent} rolls: ${secondRoll}\n\n
    *${winner} is the winner!*`
  );

}, true);

要存储用户的胜利,我们使用 Redis 客户端的zincrby方法,该方法将获胜者的分数增加一。注意我们如何在第二个参数中指定增加的数量。如果键(这里的获胜者姓名)在集合中不存在,它将自动创建并带有分数 0,然后按指定数量增加。

要检索计分板,请添加以下内容:

bot.respondTo('scoreboard', (message, channel) => {
  client.zrevrange('rollscores', 0, -1, 'withscores', (err, set) => {
    if (err) {
      channel.send('Oops, something went wrong! Please try again later');
      return;
    }

    let scores = [];

    // format the set into something a bit easier to use
    for (let i = 0; i < set.length; i++) {
      scores.push([set[i], set[i + 1]]);
      i++;
    }

    channel.send('The current scoreboard is:');
    scores.forEach((score, index) => {
      channel.send(`${index + 1}. ${score[0]} with ${score[1]} points.`);
    });
  });
}, true);

一旦输入scoreboard命令,我们立即使用zrevrange方法查找反向范围。这将异步返回一个数组,其格式如下:

[ NAME, SCORE, NAME2, SCORE2, NAME3, SCORE3, …]

接下来,我们将该数组转换为一个二维数组,通过将名称和分数拆分为嵌套数组,看起来是这样的:

[ [NAME, SCORE], [NAME2, SCORE2], [NAME3, SCORE3], …]

以这种方式格式化数据使我们能够轻松地将姓名和分数发送到频道,前面是计分板上的位置(数组索引加一)。

Slack 中的最终结果显示了一个正常工作的计分板:

最佳实践

通过持久化数据存储实现的计分板

在继续下一个示例之前,让我们看看如何删除 Redis 键/值对。将您的scoreboard命令替换为以下内容,注意突出显示的代码:

bot.respondTo('scoreboard', (message, channel, user) => {
  let args = getArgs(message.text);

 if (args[0] === 'wipe') {
 client.del('rollscores');
 channel.send('The scoreboard has been wiped!');
 return;
 }

  client.zrevrange('rollscores', 0, -1, 'withscores', (err, set) => {
    if (err) {
      channel.send('Oops, something went wrong! Please try again later');
      return;
    }

 if (set.length < 1) {
 channel.send('No scores yet! Challenge each other with the \`roll\` command!');
 return;
 }

    let scores = [];

    // format the set into something a bit easier to use
    for (let i = 0; i < set.length; i++) {
      scores.push([set[i], set[i + 1]]);
      i++;
    }

    channel.send('The current scoreboard is:');
    scores.forEach((score, index) => {
      channel.send(`${index + 1}. ${score[0]} with ${score[1]} points.`);
    });
  });
}, true);

现在如果输入scoreboard wipe命令,我们使用 Redis 客户端的del函数通过指定键来擦除键/值对。

我们还添加了一些错误处理,如果没有分数,它会发送错误消息:

最佳实践

删除数据应谨慎使用

注意

在现实世界的例子中,计分板和其他敏感数据结构应该只由具有管理员权限的用户删除。请记住,您可以通过检查 user.is_admin 属性来确认命令发送者是否是管理员。

简单待办示例

在了解了 Redis 的基础知识后,我们现在将创建一个简单的待办 Slack 机器人。这个机器人的目的是允许用户创建待办列表,让他们在日常生活中可以添加、完成和删除列表中的任务。

这次,我们将从一个我们想要的基本框架开始,逐步构建每个功能。首先,将这个新命令添加到您的机器人中:

bot.respondTo('todo', (message, channel, user) => {
  let args = getArgs(message.text);

  switch(args[0]) {
    case 'add':

      break;

    case 'complete':

      break;

    case 'delete':

      break;

    case 'help':
      channel.send('Create tasks with \`todo add [TASK]\`, complete them with \`todo complete [TASK_NUMBER]\` and remove them with \`todo delete [TASK_NUMBER]\` or \`todo delete all\`');
      break;

    default:
      showTodos(user.name, channel);
      break;
  }
}, true);

function showTodos(name, channel) {
  client.smembers(name, (err, set) => {
    if (err || set.length < 1) {
      channel.send(`You don\'t have any tasks listed yet, ${name}!`);
      return;
    }

    channel.send(`${name}'s to-do list:`);

    set.forEach((task, index) => {
      channel.send(`${index + 1}. ${task}`);
    });
  });
}

机器人的行为将根据在初始 todo 命令之后给出的第二个命令而改变。在这种情况下,使用 switch 语句是理想的。我们允许五种选项:addcompletedeletehelp 以及一个默认选项,当传递任何其他内容时会被触发。

help 和默认行为已经完成,因为它们相当直接。在后者的例子中,我们检索 Redis 集合,如果它不存在或没有项目,则发送错误,否则发送待办列表的总数。

简单待办示例

如果没有待办事项,显示一条消息

添加待办任务是同样简单的。我们使用 Redis 集合,因为我们不希望在列表中允许重复。要添加一个项目,我们使用之前引入的 sadd 命令。为了使我们的 switch 语句不那么杂乱,所有代码都将移动到一个单独的函数中:

case 'add':
  addTask(user.name, args.slice(1).join(' '), channel);
     break;

以及 addTask 函数:

function addTask(name, task, channel) {
  if (task === '') {
    channel.send('Usage: \`todo add [TASK]\`');
    return;
  }

  client.sadd(name, task);
  channel.send('You added a task!');
  showTodos(name, channel);
}

除了前两个参数(todo add)之外的所有参数都将合并成一个字符串,并使用用户的名称作为键添加到我们的集合中。记住,Redis 集合中不允许重复,因此可以安全地存储任务而无需进行任何先前的检查。我们确实检查了任务参数是否为空,如果为空,则发送一个关于如何使用 "add" 函数的温和提醒。

在任务设置之后,我们显示确认信息和整个待办列表。这是我们将为每个动作实现的行为,因为展示用户已经做了什么以及它如何影响数据是一种良好的实践。

这里是一个向我们的待办列表添加任务的示例:

简单待办示例

Redis 的集合会为我们处理索引

接下来是 complete 命令,它接受一个任务的编号作为参数:

case 'complete':
  completeTask(user.name, parseInt(args[1], 10), channel);
  break;

这里是相应的 completeTask 函数:

function completeTask(name, taskNum, channel) {
  if (Number.isNaN(taskNum)) {
    channel.send('Usage: \`todo complete [TASK_NUMBER]\`');
    return;
  }

  client.smembers(name, (err, set) => {
    if (err || set.length < 1) {
      channel.send(`You don\'t have any tasks listed yet, ${user.name}!`);
      return;
    }

    // make sure no task numbers that are out of bounds are given
    if (taskNum > set.length || taskNum <= 0) {
      channel.send('Oops, that task doesn\'t exist!');
      return;
    }

    let task = set[taskNum - 1];

    if (/~/i.test(task)) {
      channel.send('That task has already been completed!');
      return;
    }

    // remove the task from the set
    client.srem(name, task);

    // re-add the task, but with a strikethrough effect
    client.sadd(name, `~${task}~`);

    channel.send('You completed a task!');
    showTodos(name, channel);
  });
}

这个动作稍微复杂一些,因为我们一开始需要进行一些错误处理。首先,我们确保提供的参数是一个有效的数字,使用 ES6 的 Number.isNaN 方法。

注意

使用 ES5 的isNaN方法或 ES6 的Number.isNaN方法时要小心,因为它们可能会令人困惑。这些方法回答的问题是“值是否等于类型 NaN?”而不是“值是否为数字?”更多信息请访问ponyfoo.com/articles/es6-number-improvements-in-depth#numberisnan

在从 Redis 检索集合后,我们确保任务存在,提供的数字有意义(例如,不小于 1 或大于集合的长度),并且任务尚未完成。后者是通过检查任务是否包含任何波浪号(~)操作符来确定的。包含波浪号作为第一个和最后一个字符的消息将在 Slack 中以删除线样式显示。

要完成任务,我们在将任务分配给task变量后,使用srem从 Redis 集合中删除该任务,然后再次以删除线样式将其添加到 Redis 中。

简单的待办事项示例

通过引用任务编号来完成任务

最后,让我们看看delete函数:

case 'delete':
      removeTaskOrTodoList(user.name, args[1], channel);
      break;

这是相应的函数:

function removeTaskOrTodoList(name, target, channel) {
  if (typeof target === 'string' && target === 'all') {
    client.del(name);
    channel.send('To-do list cleared!');
    return;
  }

  let taskNum = parseInt(target, 10);

  if (Number.isNaN(taskNum)) {
    channel.send('Usage: \`todo delete [TASK_NUMBER]\` or \`todo delete all\`');
    return;
  }

  // get the set and the exact task
  client.smembers(name, (err, set) => {
    if (err || set.length < 1) {
      channel.send(`You don\'t have any tasks to delete, ${name}!`);
      return;
    }

    if (taskNum > set.length || taskNum <= 0) {
      channel.send('Oops, that task doesn\'t exist!');
      return;
    }

    client.srem(name, set[taskNum - 1]);
    channel.send('You deleted a task!');
    showTodos(name, channel);
  });
}

在这个函数中需要注意的第一件事是我们如何使用一种类型的函数重载来实现两种不同的结果,这取决于传入的参数。

由于 JavaScript 是一种弱类型语言,我们可以根据target参数是字符串还是数字来执行不同的操作。在字符串的情况下(并且假设该字符串等于all),我们使用del命令从 Redis 中删除整个集合,清除整个待办事项列表。

在数字的情况下,我们只删除指定的任务,前提是目标是我们可以使用的有效数字(例如,不小于 1 且不大于集合的长度)。

这里是delete命令的多功能演示:

简单的待办事项示例

列出待办事项,删除一个任务,添加另一个,然后删除整个列表

摘要

在本章中,读者已经学习了持久化数据存储 Redis 的基础知识以及如何通过 Node Redis 客户端使用它。我们概述了为什么 Redis 非常适合与机器人一起使用,尤其是在保持分数列表或存储多个小项目时。

在下一章中,我们将介绍自然语言处理NLP)的概念,并了解如何评估和生成用于机器人的自然语言。

第五章:理解和回应自然语言

我们已经构建了能够玩游戏、存储数据和提供有用信息的机器人。下一步不是信息收集,而是处理。本章将介绍自然语言处理NLP),并展示我们如何利用它进一步增强我们的机器人。

在本章中,我们将涵盖:

  • 自然语言的简要介绍

  • 节点实现

  • 自然语言处理

  • 自然语言生成

  • 以自然的方式展示数据

自然语言的简要介绍

你应该始终努力使你的机器人尽可能有用。在我们迄今为止制作的机器人中,我们都是通过用户的关键词来等待明确的指令,然后按照机器人能够做到的范围内执行这些指令。如果我们能够从用户那里推断出指令,而他们实际上并没有提供关键词怎么办?这就引入了自然语言处理NLP)。

自然语言处理可以描述为计算机科学的一个领域,它致力于理解计算机与人类(自然)语言之间的沟通和互动。

用通俗易懂的话来说,自然语言处理是计算机解释对话语言并通过执行命令或以同样对话的语气回复用户的过程。

自然语言处理(NLP)的例子包括 iPhone 的 Siri 这样的数字助手。用户可以提出问题或下达命令,并以自然语言接收答案或确认,似乎来自人类。

使用自然语言处理(NLP)的更著名的项目之一是 IBM 的 Watson 系统。2011 年,Watson 在电视节目《危险边缘》中与人类对手竞争,并赢得了第一名。

自然语言处理领域是一个庞大而复杂的领域,许多知名学术机构和大型科技公司进行了多年的研究。仅 Watson 就花费了 5 年时间,300 万美元,以及一支由学者和工程师组成的小型军队来构建。在本章中,我们将简要介绍主要概念,并给出一个实际例子。

首先,让我们退一步,看看自然语言处理如何使我们的机器人受益。如果我们构建了一个可以检索天气预报的机器人,我们可以想象命令看起来可能像这样:

weather amsterdam tomorrow

这将返回阿姆斯特丹市的天气预报。如果机器人能够在不发出命令的情况下检索天气预报怎么办?例如,如果 Slack 用户发送消息“明天会下雨吗?”,那么机器人就会回复明天的天气预报。这就是自然语言处理(NLP)在发挥作用;它是将自然语言分解成程序可以解释为命令的指令的过程。

为了帮助我们理解自然语言处理(NLP),我们将使用一个辅助库,该库将更复杂的算法抽象化。一个好的 NLP 框架是基于 Python 语言的自然语言****工具包NLTK),可在www.nltk.org/找到。

幸运的是,一个将 NLTK 的主要功能移植到 Node 的项目已经存在了一段时间,并且已经达到了足够成熟的水平,我们可以无缝地将其与现有的 JavaScript 项目一起使用。这个库被称为 Natural (github.com/NaturalNode/natural),它将成为我们进入 NLP 世界的关键入口点。

让我们先介绍一些更常见的 NLP 算法。之后,我们将利用我们新获得的知识,通过构建一个简单的天气机器人来实现,正如之前概述的那样。

NLP 基础

自然语言处理(NLP)的核心是通过将一段文本(也称为语料库)分割成单个片段或标记,然后对这些标记进行分析。这些标记可能是单个单词,也可能是单词缩写。让我们看看计算机如何解释这个短语:我给植物浇水了

如果我们将这个语料库分割成标记,它可能看起来像这样:

['I', 'have', 'watered', 'the', 'plants']

在我们的语料库中,单词 the 是不必要的,因为它并不能帮助我们理解短语的意图——同样,单词 have 也是如此。因此,我们应该删除这些多余的单词:

['I', 'watered', 'plants']

已经,这开始看起来更有用。我们有一个以演员形式出现的第一人称代词(I),一个动作或动词(watered),以及一个接受者或名词(plants)。从这些信息中,我们可以推断出确切的动作是由谁对什么进行的。此外,通过动词 watered 的变化形式,我们可以确定这个动作发生在过去。考虑一下当我们对短语进行微小改变时,短语的内容和意义是如何变化的:我们在给植物浇水

通过使用与之前相同的过程,我们得到以下结果:

['We', 'watering', 'plant']

短语的意义发生了戏剧性的变化:涉及多个演员,动作是现在时态,接受者是单数。NLP 的挑战在于分析这样的细微差别,以足够高的置信度得出结论,然后根据这个结论采取行动。

就像人一样,计算机通过实践和识别模式来学习这种细微差别。一个常见的 NLP 术语是训练你的系统在语料库中识别上下文。通过向我们的系统提供大量预定义的短语,我们可以分析这些短语,并在其他语料库中寻找类似的短语。我们将在稍后更多地讨论如何使用这种训练或分类技术。

现在我们来看看如何实际执行本节开头所解释的动作,首先是将语料库分割成一系列标记,也称为分词

分词器

首先使用 npm init 创建一个新的项目。将你的机器人命名为 "weatherbot"(或类似名称),并使用以下命令安装 Slack 和 Natural API:

npm install @slack/client natural –save

将上一章中的 Bot 类复制到 index.js 文件中,并输入以下内容:

'use strict';

// import the natural library
const natural = require('natural');

const Bot = require('./Bot');

// initalize the tokenizer
const tokenizer = new natural.WordTokenizer();

const bot = new Bot({
  token: process.env.SLACK_TOKEN,
  autoReconnect: true,
  autoMark: true
});

// respond to any message that comes through
bot.respondTo('', (message, channel, user) => {

  let tokenizedMessage = tokenizer.tokenize(message.text);

  bot.send(`Tokenized message: ${JSON.stringify(tokenizedMessage)}`, channel);
});

启动你的 Node 进程,并在 Slack 中输入一个测试短语:

分词器

返回的分词消息

通过使用分词,机器人将给定的短语拆分为短片段或标记,忽略标点符号和特殊字符。请注意,我们正在使用本地JSON对象的stringify方法在将其发送到频道之前将 JavaScript 数组转换为字符串。

这个特定的分词算法将通过删除标点符号并拆分单词来处理缩写词(例如,hasn't)。根据我们的用例,我们可能想要使用不同的算法。幸运的是,natural提供了三种不同的算法。每种算法对语料库返回的结果略有不同。要了解更多关于这些算法的信息,请访问natural的 GitHub 页面:github.com/NaturalNode/natural#tokenizers

大多数这些算法使用标点符号(空格、撇号等)来分词短语,而 Treebank 算法分析缩写词(例如,wannagimme),将它们拆分为常规单词(在wannagimme的情况下,want togive me)。让我们在下一个示例中使用 Treebank,并将初始化分词器的行替换为以下内容:

const tokenizer = new natural.TreebankWordTokenizer();

现在,回到 Slack 并尝试发送另一条测试消息:

分词器

Treebank 算法处理缩写词的方式不同

注意这里两个重要的事情:缩写词haven't被拆分为两部分,即词根动词(have)和缩写附加部分(not)。此外,cannot这个词也被拆分为两个单独的词,这使得命令更容易处理。这也使得某些俚语词如lemmegotta更容易处理。通过将缩写词拆分为两部分,我们可以更容易地推断出短语是积极的还是消极的。Can本身意味着积极;然而,如果它后面跟着not,它就会改变短语语境,使其变为消极。

词干提取器

有时候,找到词的词根或“词干”是有用的。在英语中,不规则动词的屈折形式并不罕见。通过推断动词的词根,我们可以显著减少查找短语动作所需的计算量。以动词searching为例;对于机器人来说,以其词根形式search处理这个动词会容易得多。在这里,词干提取器可以帮助我们确定这个词根。将index.js的内容替换为以下内容以演示词干提取器:

'use strict';

// import the natural library
const natural = require('natural');

const Bot = require('./Bot');

// initialize the stemmer
const stemmer = natural.PorterStemmer;

// attach the stemmer to the prototype of String, enabling
// us to use it as a native String function
stemmer.attach();

const bot = new Bot({
  token: process.env.SLACK_TOKEN,
  autoReconnect: true,
  autoMark: true
});

// respond to any message that comes through
bot.respondTo('', (message, channel, user) => {
  let stemmedMessage = stemmer.stem(message.text);

  bot.send(`Stemmed message: ${JSON.stringify(stemmedMessage)}`, channel);
});

现在,让我们看看对一个词进行词干提取会返回什么:

词干提取器

动词的屈折形式通常与其词根不同

如预期,searching 提取为 search,但(更有趣的是)标记 shining 提取为 shine。这表明词干提取的过程不仅仅是简单地从标记的末尾移除 -ing。现在,我们可以分析我们的分词和词干提取语料库,并挑选出某些动词或动作。例如,在提取词干后,短语 I went swimmingI swam 都包含动词 swim,这意味着我们只需要搜索一个术语(swim),而不是两个(swimmingswam)。

词干提取也可以用于从单词中去除复数。例如,searches 提取为 search,而 rains 提取为 rain

让我们将分词和词干提取的概念结合到一个程序中,看看其效果。再次,将 index.js 替换为以下内容:

'use strict';

// import the natural library
const natural = require('natural');

const Bot = require('./Bot');

// initialize the stemmer
const stemmer = natural.PorterStemmer;

// attach the stemmer to the prototype of String, enabling
// us to use it as a native String function
stemmer.attach();

const bot = new Bot({
  token: process.env.SLACK_TOKEN,
  autoReconnect: true,
  autoMark: true
});

// respond to any message that comes through
bot.respondTo('', (message, channel, user) => {
  let stemmedMessage = message.text.tokenizeAndStem();

  bot.send(`Tokenize and stemmed message: ${JSON.stringify(stemmedMessage)}`, channel);
});

注意,我们在 message.text 上调用了 tokenizeAndStem。这看起来可能有些奇怪,直到你意识到我们在之前的代码中已经将 tokenizeAndStem 方法附加到了 String 对象的原型上,这在前面的代码中已经突出显示。

切换到 Slack 客户端,你应该会看到:

词干提取器

分词和词干提取以产生有用的结果

分词和词干提取的组合自动排除了非常常见的词,例如 itin,从而将句子提炼成原始输入中最重要的标记。

仅使用分词和词干提取的结果,我们可以推断用户希望了解阿姆斯特丹的天气。此外,我们可以选择排除单词 is。这使我们只剩下 rain amsterdam,这对于我们进行天气 API 调用来说是足够的信息。

字符串距离

字符串距离测量算法是计算两个字符串之间相似度的计算。字符串 smellbell 可以定义为相似的,因为它们共享三个字符。字符串 bellfell 更接近,因为它们共享三个字符,并且彼此之间只有一个字符的差异。在计算字符串距离时,当测量 fellbell 之间的距离时,字符串 fell 将比 smell 获得更高的排名。

NPM 包 natural 提供了三种不同的字符串距离计算算法:Jaro-Winkler、Dice 系数和 Levenshtein 距离。它们的主要区别可以描述如下:

  • Dice 系数:这计算字符串之间的差异,并将差异表示为零到一之间的值。零表示完全不同,一表示完全相同。

  • Jaro-Winkler:这与 Dice 系数类似,但给字符串开头的相似性赋予更大的权重。

  • Levenshtein 距离:这计算将一个字符串转换为另一个字符串所需的编辑或步骤数量。零步意味着字符串是相同的。

让我们使用 Levenshtein 距离算法来演示其用法:

let distance = natural.LevenshteinDistance('weather', 'heater');

console.log('Distance:', distance); // distance of 10

let distance2 = natural.LevenshteinDistance('weather', 'weather');

console.log('Distance2:', distance2); // distance of 0

字符串距离的一个流行用途是执行模糊搜索,搜索结果返回与请求查询具有低字符串距离的值。字符串距离计算在处理包含错别字的命令时对机器人特别有用。例如,如果用户本想通过发送命令 weather amsterdam 请求阿姆斯特丹的天气预报,但错误地输入了 weater amsterdam。通过计算字符串之间的 Levenshtein 距离,我们可以对用户的意图做出合理的猜测。查看以下代码片段:

bot.respondTo('', (message, channel, user) => {
  // grab the command from the message's text
  let command = message.text.split(' ')[0];

  let distance = natural.LevenshteinDistance('weather', command);

  // our typo tolerance, a higher number means a larger 
  // string distance
  let tolerance = 2;

  // if the distance between the given command and 'weather' is
  // only 2 string distance, then that's considered close enough
  if (distance <= tolerance) {
    bot.send(`Looks like you were trying to get the weather, ${user.name}!`, channel);
  }}, true);

下面是 Slack 上的结果:

字符串距离

计算字符串距离可以使你的机器人更加用户友好

在这种情况下,我们将容差设置得相当低,允许有两个错误或 步骤 来指示命中。在生产代码中,将容差减少到只有一个步骤是有意义的。

注意

在选择使用哪种字符串相似度算法时要小心,因为每种算法可能都会以不同的方式确定距离。例如,当使用 Jaro-Winkler 和 Dice 系数算法时,得分为 1 表示两个字符串完全相同。使用 Levenshtein 差异时则相反,其中 0 表示相同,数字越高表示字符串距离越大。

变位

变位器可以用来在单数和复数形式之间转换名词。这在生成自然语言时很有用,因为名词的复数形式可能并不明显:

let inflector = new natural.NounInflector();

console.log(inflector.pluralize('virus'));
console.log(inflector.singularize('octopi'));

上述代码将分别输出 virioctopus

变位器还可以用来将数字转换为它们的序数形式;例如,1 变为 1st,2 变为 2nd,依此类推:

let inflector = natural.CountInflector;

console.log(inflector.nth(25));
console.log(inflector.nth(42));
console.log(inflector.nth(111)); 

这将分别输出 25th42nd111th

下面是一个简单机器人命令中使用的变位器的示例:

let inflector = natural.CountInflector;

bot.respondTo('what day is it', (message, channel) => {
  let date = new Date();

  // use the ECMAScript Internationalization API to convert 
  // month numbers into names
  let locale = 'en-us';
  let month = date.toLocaleString(locale, { month: 'long' });
  bot.send(`It is the ${inflector.nth(date.getDate())} of ${month}.`, channel);
}, true);

现在,当被问及今天是星期几时,我们的机器人可以回答得更加自然一些:

变位

变位可以让你的人工智能更加亲切

这引出了我们的下一个主题:如何以易于理解的方式显示数据。

以自然的方式显示数据

让我们构建机器人的天气功能。为此,我们将使用一个名为 Open Weather Map 的第三方 API。该 API 每分钟免费使用最多 60 次调用,并提供其他定价选项。要获取 API 密钥,您需要在此处注册:home.openweathermap.org/users/sign_up

注意

记住,你可以从命令行传递变量,例如 API 密钥到 Node。要运行天气机器人,你可以使用以下命令:

SLACK_TOKEN=[YOUR_SLACK_TOKEN] WEATHER_API_KEY=[YOUR_WEATHER_KEY] nodemon index.js

一旦您注册并获取了您的 API 密钥,将以下代码复制并粘贴到 index.js 中,用您新获得的 Open Weather Map 密钥替换 process.env.WEATHER_API_KEY

'use strict';

// import the natural library
const natural = require('natural');

const request = require('superagent');

const Bot = require('./Bot');

const weatherURL = `http://api.openweathermap.org/data/2.5/weather?&units=metric&appid=${process.env.WEATHER_API_KEY}&q=`;

// initialize the stemmer
const stemmer = natural.PorterStemmer;

// attach the stemmer to the prototype of String, enabling
// us to use it as a native String function
stemmer.attach();

const bot = new Bot({
  token: process.env.SLACK_TOKEN,
  autoReconnect: true,
  autoMark: true
});

bot.respondTo('weather', (message, channel, user) => {
  let args = getArgs(message.text);

  let city = args.join(' ');

  getWeather(city, (error, fullName, description, temperature) => {
    if (error) {
      bot.send(error.message, channel);
      return;
    }

    bot.send(`The weather for ${fullName} is ${description} with a temperature of ${Math.round(temperature)} celsius.`, channel);
  });
}, true);

function getWeather(location, callback) {
  // make an AJAX GET call to the Open Weather Map API
  request.get(weatherURL + location)
    .end((err, res) => {
      if (err) throw err;
      let data = JSON.parse(res.text);

      if (data.cod === '404') {     
        return callback(new Error('Sorry, I can\'t find that location!')); 
      }

      console.log(data);

      let weather = [];
      data.weather.forEach((feature) => {
        weather.push(feature.description);
      });

      let description = weather.join(' and ');

      callback(data.name, description, data.main.temp);
    });
}

// Take the message text and return the arguments
function getArgs(msg) {
  return msg.split(' ').slice(1);
}

使用熟悉的代码,我们的机器人执行以下任务:

  • 从自然包初始化词干提取器并将其附加到字符串原型

  • 等待weather命令并使用getWeather函数通过异步 JavaScript 和 XMLAJAX)调用检索 Open Weather Map 的天气数据

  • 向频道发送格式化的天气信息

这是机器人在行动:

以自然的方式显示数据

一个简单的天气机器人

在收到命令和地点名称后,机器人使用地点名称作为参数向 Open Weather Map 发送 AJAX 请求。作为回报,我们得到一个看起来像这样的 JSON 响应:

{ 
  coord: { lon: 4.89, lat: 52.37 },
  weather:
   [ { id: 310,
       main: 'Drizzle',
       description: 'light intensity drizzle rain',
       icon: '09n' } ],
  base: 'cmc stations',
  main: { temp: 7, pressure: 1021, humidity: 93, temp_min: 7, temp_max: 7 },
  wind: { speed: 5.1, deg: 340 },
  clouds: { all: 75 },
  dt: 1458500100,
  sys:
   { type: 1,
     id: 5204,
     message: 0.0103,
     country: 'NL',
     sunrise: 1458452421,
     sunset: 1458496543 },
  id: 2759794,
  name: 'Amsterdam',
  cod: 200 
}

注意,在返回的大量信息中,有地点的全称和有用的信息,如最低和最高温度。对于我们机器人的初始目的,我们将使用温度对象(main)、name属性和weather对象内的description

现在我们有一个简单的机器人,它会对weather命令做出响应,让我们看看我们是否可以使用 NLP 来获得更具体的答案。

注意到 Open Weather Map 的 AJAX 调用被抽象成getWeather函数。这意味着我们可以为命令调用和 NLP 调用使用相同的函数。

在继续之前,我们应该讨论 NLP 技术的正确用例。

何时使用 NLP?

可能会诱使天气机器人监听并处理频道中发送的所有消息。这立即提出了一些问题:

  • 我们如何知道发送的消息是关于天气的查询,还是完全不相关的?

  • 查询的是哪个地理位置?

  • 消息是问题还是陈述?例如,阿姆斯特丹冷吗阿姆斯特丹很冷之间的区别。

虽然可能找到解决前面问题的 NLP 解决方案,但我们必须面对现实:当监听通用消息时,我们的机器人可能会至少犯上述错误中的一个。这会导致机器人提供错误信息或提供不必要的信息,从而变得令人烦恼。我们无论如何都要避免的是,机器人频繁地发送太多错误信息。

这里有一个机器人使用 NLP 并完全忽略发送信息要点的例子:

何时使用 NLP?

一个明显被误解的信息

如果机器人经常将你的不相关消息误认为是实际命令,你可以想象用户在启用它后很快就会禁用它。

最佳解决方案可能是创建一个具有人类水平自然语言处理能力的机器人。如果这句话没有引起你的注意,那么考虑一下,人类水平的自然语言处理被认为是人工智能的完整问题。本质上,它等同于尝试解决使计算机像人类一样智能的问题。

相反,我们应该专注于如何让我们的机器人利用现有资源尽可能好地表现。我们可以从引入一条新规则开始:将 NLP 作为机器人增强功能,而不是主要功能。

一个例子是在机器人被直接提及时才使用 NLP 技术。在 Slack 频道中提及是在公共频道中用户直接向另一个用户发送消息时发生的。这是通过在用户名前加上@符号来完成的。机器人也可以被提及,这意味着我们应该能够以两种方式处理天气命令:

  • 用户在请求前加上命令weatherweather is it raining in Amsterdam

  • 用户使用提及@weatherbot is it raining in Amsterdam

提及

为了实现第二点,我们需要重新访问我们的Bot类并添加提及功能。在Bot类的构造函数中,用以下内容替换RTM_CONNECTION_OPENED事件监听器块:

this.slack.on(CLIENT_EVENTS.RTM.RTM_CONNECTION_OPENED, () => {
  let user = this.slack.dataStore.getUserById(this.slack.activeUserId)
  let team = this.slack.dataStore.getTeamById(this.slack.activeTeamId);

  this.name = user.name;
 this.id = user.id;

  console.log(`Connected to ${team.name} as ${user.name}`);
});

这里唯一的改变是将机器人的id添加到this对象中。这将有助于我们以后的操作。现在,用以下内容替换respondTo函数:

respondTo(opts, callback, start) {
  if (!this.id) {
    // if this.id doesn't exist, wait for slack to connect
    // before continuing
    this.slack.on(CLIENT_EVENTS.RTM.RTM_CONNECTION_OPENED, () => {
      createRegex(this.id, this.keywords);
    });  
  } else {
    createRegex(this.id, this.keywords);
  }

  function createRegex(id, keywords) {
    // if opts is an object, treat it as options
    // otherwise treat it as the keywords string
    if (opts === Object(opts)) {
      opts = {
        mention: opts.mention || false,
        keywords: opts.keywords || '',
        start: start || false
      };
    } else {
      opts = {
        mention: false,
        keywords: opts,
        start: start || false
      };
    }

    // mention takes priority over start variable
    if (opts.mention) {         
      // if 'mention' is truthy, make sure the bot only 
      // responds to mentions of the bot
      opts.keywords = `<@${id}>:* ${opts.keywords}`;
    } else {
      // If 'start' is truthy, prepend the '^' anchor to instruct
      // the expression to look for matches at the beginning of
      // the string
      opts.keywords = start ? '^' + opts.keywords : opts.keywords;
    }

    // Create a new regular expression, setting the case 
    // insensitive (i) flag
    // Note: avoid using the global (g) flag
    let regex = new RegExp(opts.keywords, 'i');

    // Set the regular expression to be the key, with the callback 
    // function as the value
    keywords.set(regex, callback);
  }
}

我们通过首先检查this.id是否存在来改进了respondTo函数。如果没有,这意味着我们尚未成功连接到 Slack。因此,我们等待 Slack 连接(记得我们在构造函数中连接后设置了this.id),然后继续。这是第二次监听RTM_CONNECTION_OPENED事件。幸运的是,第一次发生在Bot类的构造函数中,这意味着这个监听器总是作为第二个触发,因为它是在之后添加的。这确保了在事件触发时this.id已被定义。

函数现在接受一个字符串(我们正在寻找的关键词)或一个对象作为其第一个参数。在对象的情况下,我们检查提及属性是否为真;如果是,我们创建一个故意查找提及语法的正则表达式。当收到消息时,提及具有以下结构:

<@[USER_ID]>: [REST OF MESSAGE]

切换回index.js,让我们通过替换之前的weatherrespondTo块来尝试我们的新代码:

bot.respondTo({ mention: true }, (message, channel, user) => {
  let args = getArgs(message.text);

  let city = args.join(' ');

  getWeather(city, (error, fullName, description, temperature) => {
    if (error) {
      bot.send(error.message, channel);
      return;
    }

    bot.send(`The weather for ${fullName} is ${description} with a temperature of ${Math.round(temperature)} celsius.`, channel);
  });
});

现在我们提及我们的机器人并传递一个城市名,我们得到以下结果:

提及

提及可以用来识别特定的行为

注意

提及是确保发送的消息意图是命令你的机器人的有效方式。在实施自然语言解决方案时,强烈建议你使用提及。

现在有了提及,让我们看看我们将如何以自然语言处理(NLP)的方式回答与天气相关的问题。我们之前简要地讨论了分类和 NLP 系统的训练。让我们回顾一下这个话题,看看我们如何为我们的天气机器人使用它。

分类器

分类是将你的机器人训练成识别短语或单词模式并将其与标识符关联的过程。为此,我们使用natural内置的分类系统。让我们从一个简单的例子开始:

const classifier = new natural.BayesClassifier();

classifier.addDocument('is it hot', ['temperature', 'question','hot']);
classifier.addDocument('is it cold', ['temperature', 'question' 'cold']);
classifier.addDocument('will it rain today', ['conditions', 'question', 'rain']);
classifier.addDocument('is it drizzling', ['conditions', 'question', 'rain']);

classifier.train();

console.log(classifier.classify('will it drizzle today'));
console.log(classifier.classify('will it be cold out'));

第一个日志输出:

conditions,question,rain

第二个日志输出:

temperature,question,cold

分类器首先提取要分类的字符串,然后通过为每个可能性分配权重来计算它与训练短语中最相似的是哪一个。

你可以使用以下代码查看权重:

console.log(classifier.getClassifications('will it drizzle today'));

输出如下:

[ { label: 'conditions,question,rain',
    value: 0.17777777777777773 },
  { label: 'temperature,question,hot', value: 0.05 },
  { label: 'temperature,question,cold', value: 0.05 } ]

为了获得准确可靠的结果,你必须用可能成百上千的短语来训练你的机器人。幸运的是,你还可以将训练数据 JSON 文件导入分类器。

通过在你的目录中创建一个classifier.json文件来保存你的分类器训练数据:

classifier.save('classifier.json', (err, classifier) => {
  // the classifier is saved to the classifier.json file!
});

使用以下代码检索相同的文件:

natural.BayesClassifier.load('classifier.json', null, (err, classifier) => {
  if (err) {
    throw err;
  }

  console.log(classifier.classify('will it drizzle today'));
});

现在我们尝试使用分类器来为我们的 weatherbot 提供动力。

使用训练好的分类器

本书包含一个包含天气训练数据的classifier.json文件示例。在本章的其余部分,我们将假设该文件存在,并且我们通过前面的方法加载它。

将你的respondTo方法调用替换为以下代码片段:

let settings = {};

bot.respondTo({ mention: true }, (message, channel, user) => {
  let args = getArgs(message.text);

  if (args[0] === 'set') {
    let place = args.slice(1).join(' ');
    settings[user.name] = place

    bot.send(`Okay ${user.name}, I've set ${place} as your default location`, channel);
    return;
  }

  if (args.indexOf('in') < 0 && !settings[user.name]) {
    bot.send(`Looks like you didn\'t specify a place name, you can set a city by sending \`@weatherbot set [city name]\` or by sending \`@weatherbot ${args.join(' ')} in [city name]\``, channel);
    return;
  }

  // The city is usually preceded by the word 'in'  
  let city = args.indexOf('in') > 0 ? args.slice(args.indexOf('in') + 1) : settings[user.name];

  let option = classifier.classify(message.text).split(',');

  console.log(option);

  // Set the typing indicator as we're doing an asynchronous request
  bot.setTypingIndicator(channel);

  getWeather(city, (error, fullName, description, temperature) => {
    if (error) {
      bot.send(`Oops, an error occurred, please try again later!`, channel);
      return;
    }

    let response = '';

    switch(option[0]) {
      case 'weather':
        response = `It is currently ${description} with a temperature of ${Math.round(temperature)} celsius in ${fullName}.`;
        break;

      case 'conditions':
        response = `${fullName} is experiencing ${description} right now.`;
        break;

      case 'temperature':
        let temp = Math.round(temperature);
        let flavorText = temp > 25 ? 'hot!' : (temp < 10 ? 'cold!' : 'nice!');  

        response = `It's currently ${temp} degrees celsius in ${fullName}, that's ${flavorText}`;
    } 

    bot.send(response, channel);
  });
});

运行 Node 进程并向 weatherbot 提出一系列自然语言问题:

使用训练好的分类器

Weatherbot 现在可以理解会话语言

让我们来看看代码,看看发生了什么:

let settings = {};

bot.respondTo({ mention: true }, (message, channel, user) => {
  let args = getArgs(message.text);

  if (args[0] === 'set') {
    let place = args.slice(1).join(' ');
    settings[user.name] = place

    bot.send(`Okay ${user.name}, I've set ${place} as your default location`, channel);
    return;
  }

首先,我们检查关键字set是否紧跟在@weatherbot提及之后。如果是,则将这些参数设置为用户的默认城市。这里我们使用了一个简单的设置对象,但可以通过使用如 Redis 这样的数据存储来改进,如第四章使用数据中所述。

你可以在以下屏幕截图中看到set行为的示例:

使用训练好的分类器

设置城市可以节省用户在每次查询时输入地点名称的时间

接下来,我们尝试找到我们想要获取天气信息的地方:

if (args.indexOf('in') < 0 && !settings[user.name]) {
    bot.send(`Looks like you didn\'t specify a place name, you can set a city by sending \`@weatherbot set [city name]\` or by sending \`@weatherbot ${args.join(' ')} in [city name]\``, channel);
    return;
  }

  // The city is usually preceded by the word 'in'  
  let city = args.indexOf('in') > 0 ? args.slice(args.indexOf('in') + 1) : settings[user.name];

我们期望所有包含地点名称的天气查询都遵循[条件] in [地点名称]的模式。这意味着我们可以合理地假设所有在单词in之后的标记都是我们在 AJAX 调用中要使用的地点名称。

如果没有出现单词in并且没有设置地点名称,那么我们将发送一个包含最佳猜测示例的错误消息,说明如何使用 weatherbot。

当然,这不是检测地点名称的最佳方法——确定短语中哪一部分是地点名称是非常困难的,尤其是当涉及到的名称由多个单词组成,如New YorkDar es Salaam时。一个可能的解决方案是使用一系列城市名称分类器(本质上每个城市一个训练短语)来训练我们的机器人。其他解决方案包括 Query GeoParser www2009.eprints.org/239/和斯坦福命名实体识别器nlp.stanford.edu/software/CRF-NER.shtml

接下来,我们使用分类器来识别消息应关联哪些关键词:

let option = classifier.classify(message.text).split(',');

  console.log(option);

  // Set the typing indicator as we're doing an 
  // asynchronous request
  bot.setTypingIndicator(channel);

一些分类器的短语作为第二个参数添加了一个数组,例如:

classifier.addDocument('is it hot outside', ['temperature', 'question', 'hot']);

这意味着classifier.classify方法返回的值是一个以逗号分隔的字符串值。我们通过使用Array.split方法将其转换成 JavaScript 数组。

最后,我们设置打字指示器,这在进行异步调用时是一个好的实践:

getWeather(city, (error, fullName, description, temperature) => {
    if (error) {
      bot.send(`Oops, an error occurred, please try again later!`, channel);
      return;
    }

    let response = '';

    switch(option[0]) {
      case 'weather':
        response = `It is currently ${description} with a temperature of ${Math.round(temperature)} celsius in ${fullName}.`;
        break;

      case 'conditions':
        response = `${fullName} is experiencing ${description} right now.`;
        break;

      case 'temperature':
        let temp = Math.round(temperature);
        let flavorText = temp > 25 ? 'hot!' : (temp < 10 ? 'cold!' : 'nice!');  

        response = `It's currently ${temp} degrees celsius in ${fullName}, that's ${flavorText}`;
    } 

    bot.send(response, channel);
  });
});

选项对象的索引 0 处的值是问题的状态,在这种情况下是消息是否与温度、状况或通用天气相关。

我们有以下选项:

  • 温度: 将温度(摄氏度)发送到频道

  • 状况: 将天气状况(例如,下雨和刮风)发送到频道

  • 天气: 将状况和温度都发送到频道

理解分类和训练的底层概念对于构建更智能的机器人很重要。然而,通过使用第三方服务 wit.ai (wit.ai/)可以抽象出获取训练数据的问题。wit.ai 是由 Facebook 创建的免费服务,允许你训练短语(由 wit.ai 称为实体),并通过 AJAX 请求轻松快速地检索给定短语的分析。

或者,你也可以使用类似 api.ai (api.ai/)或微软的 LUIS (www.luis.ai/)的服务。然而,请记住,尽管这些服务免费且易于使用,但并不能保证它们将来仍然是免费的,甚至可能存在。除非你试图构建需要极其精确 NLP 服务的项目,否则几乎总是更好的选择是使用开源 NLP 库创建自己的实现。这还有一个额外的优点,即可以控制和拥有自己的数据,这是在使用第三方服务时无法保证的。

现在我们知道了如何处理语言,我们应该看看如何将我们的数据转换成人类可理解的自然语言。

自然语言生成

自然语言可以定义为机器人的回答中的对话语气。这里的目的是不是隐藏机器人不是人类的事实,而是使信息更容易消化。

之前代码片段中的flavorText变量是尝试让机器人的回答听起来更自然;此外,它是一种有用的技巧,可以避免执行更复杂的处理,以便在回答中达到对话的语气。

以下是一个例子:

自然语言生成

天气机器人的政治家式回答

注意第一个天气查询是询问是否寒冷。天气机器人通过针对每个问题做出关于温度的通用陈述来避免给出是或否的回答。

这可能看起来像是一种欺骗,但重要的是要记住 NLP 的一个非常重要的方面。生成的语言越复杂,出错的可能性就越大。通用的回答比完全错误的回答要好。

这个特定的问题可以通过向我们的分类器添加更多关键词和短语来解决。目前,我们的classifier.json文件包含与天气相关的 50 个短语;添加更多短语可以帮助我们更清楚地了解对 weatherbot 的提问。

这引出了我们在追求自然语言生成过程中一个非常重要的观点。

何时应该使用自然语言生成?

答案是偶尔。以 Slackbot 为例,这是 Slack 自己内部使用的机器人,用于设置新用户,以及其他一些事情。以下是 Slackbot 对新用户说的第一句话:

何时应该使用自然语言生成?

谦逊的机器人

立即,机器人的限制被概述出来,并且没有试图隐藏它不是人类的事实。当用于将数据密集型结构,如 JSON 对象,转换为易于理解的短语时,自然语言生成效果最佳。

图灵测试是由 Alan Turing 于 1950 年开发的一个著名测试,用于评估机器在纯文本交流中使自己与人类不可区分的能力。像 Slackbot 一样,你不应该努力使你的机器人通过图灵测试。相反,专注于你的机器人如何变得最有用,并使用自然语言生成使你的机器人尽可能易于使用。

诡异谷

诡异谷是一个用来描述那些行为和声音都像人类,但 somehow 稍微有些不对劲的系统的术语。这种轻微的差异实际上会导致机器人感觉更加不自然,这与我们通过自然语言生成所试图达成的目标正好相反。相反,我们应避免试图使机器人在自然语言响应中完美无缺;我们试图使机器人听起来越像人类,我们陷入诡异谷的可能性就越高。

相反,我们应该专注于使我们的机器人有用且易于使用,而不是使其响应自然。一个很好的原则是构建一个像小狗一样聪明的机器人,这是由 Matt Jones(berglondon.com/blog/2010/09/04/b-a-s-a-a-p/)所倡导的:

"制作出聪明但不过度聪明且失败的东西,并且确实,通过设计,在学习和改进的过程中展现出可爱的失败。就像小狗一样。"

让我们扩展我们的 weatherbot,使其生成的响应听起来更加自然(但不要太自然)。

首先,编辑getWeather函数,使其回调调用中包含data作为最后一个参数:

callback(null, data.name, condition, data.main.temp, data);

然后将data变量添加到我们在respondsTo中指定的回调函数中:

getWeather(city, (error, fullName, description, temperature, data) => {

getWeather调用中的switch语句中,将weather情况替换为以下内容:

case 'weather':
        // rain is an optional variable
        let rain = data.rain ? `Rainfall in the last 3 hours has been ${data.rain['3h']} mm.` : ''

        let expression = data.clouds.all > 80 ? 'overcast' : (data.clouds.all < 25 ? 'almost completely clear' : 'patchy');
        // in case of 0 cloud cover
        expression = data.clouds.all === 0 ? 'clear skies' : expression;

        let clouds = `It's ${expression} with a cloud cover of ${data.clouds.all}%.`;

        response = `It is currently ${description} with a temperature of ${Math.round(temperature)} celsius in ${fullName}. The predicted high for today is ${Math.round(data.main.temp_max)} with a low of ${Math.round(data.main.temp_min)} celsius and ${data.main.humidity}% humidity. ${clouds} ${rain}`;
        break;

询问一个城市的天气现在将指示我们的机器人发送以下内容:

奇异的谷

天气机器人现在可以对其报告更加具体

在这里,我们只是简单地从 AJAX 调用返回的 JSON 中提取数据,并将其格式化为对人类来说更易读的形式。包括降雨量,但仅当在过去的 3 小时内确实有降雨时(如果没有,则从返回的数据中省略rain属性)。云量以百分比表示,这对我们来说很完美,因为我们可以根据那个百分比分配预定的陈述(如patchyalmost completely clearclear skies)。

在生成自然语言时,考虑如何呈现你的数据。百分比是分配口头价值的一个极好方式。例如,80 到 100%之间的任何事物都可以使用副词如extremelyvery,而对于 0 到 20%的情况,我们可以使用barelyvery little

对于某些数据集,一段文字可能比列表或纯数据更容易消化。

结果是一个机器人,在对话语调中,可以对相关地区的天气进行天气预报员般的报告。

摘要

在本章中,我们讨论了自然语言处理(NLP)是什么以及如何利用它使机器人看起来比实际复杂得多。通过使用这些技术,自然语言可以被读取、处理并以同样自然的语调进行响应。我们还涵盖了 NLP 的限制,并了解了如何区分 NLP 的良好和不良使用。

在下一章中,我们将探讨基于 Web 的机器人的创建,这些机器人可以使用 webhooks 和 slash 命令与 Slack 进行交互。

第六章. Webhooks 和 Slash Commands

到目前为止,我们创建的每个机器人都有两个共同特点:它们依赖于用户发出的命令,并且需要 Slack API 令牌。这在我们的机器人中非常有用,但如果我们想让一个机器人向 Slack 频道发送消息而不需要 API 令牌呢?再或者,如果我们想创建一个不需要 API 令牌与用户交互的机器人呢?GitHub Slack 集成就是一个例子,这是一个将特定仓库的 GitHub 活动发布到您选择的 Slack 频道的服务。

在本章中,我们将讨论如何使用 Webhooks 将数据传入和传出 Slack,以及如何创建用户可以在 Slack 中与之交互的 slash 命令。

我们将涵盖以下主题:

  • Webhooks

  • Incoming webhooks

  • Outgoing webhooks

  • Slash commands

  • 频道内和临时响应

Webhooks

Webhook 是一种通过 HTTP 方法修改或增强 Web 应用程序的方式。以前,我们在机器人中使用第三方 API 将数据传入和传出 Slack。然而,这并非唯一的方法。Webhooks 允许我们使用带有 JSON 有效负载的常规 HTTP 请求将消息从 Slack 发送到其他地方。使 Webhook 成为机器人的特性是它能够将消息发布到 Slack,就像它们是机器人用户一样。

这些 Webhook 可以分为传入 Webhook 和传出 Webhook,每种都有自己的目的和用途。

Incoming webhooks

一个传入 Webhook 的例子是一个将信息从外部源转发到 Slack 频道的服务,而不需要明确请求。上述 GitHub Slack 集成就是一个例子:

Incoming webhooks

GitHub 集成发布关于我们感兴趣的仓库的消息

在前面的屏幕截图中,我们可以看到在团队监视的仓库上创建新分支后,消息是如何发送到 Slack 的。这些数据并非团队成员明确请求的,但它们是作为传入 Webhook 的结果自动发送到频道的。

其他流行的例子包括 Jenkins 集成,其中可以在 Slack 中监控基础设施更改(例如,如果 Jenkins 监视的服务器宕机,可以立即在相关的 Slack 频道发布警告消息)。

让我们从设置一个发送简单Hello world消息的传入 Webhook 开始:

  1. 首先,导航到自定义集成 Slack 团队页面(my.slack.com/apps/build/custom-integration)。Incoming webhooks

    不同的自定义集成版本

  2. 从列表中选择Incoming WebHooks,然后选择您希望 Webhook 应用发布消息到的频道:Incoming webhooks

    Webhook 应用将发布到您选择的频道

    自定义 webhook(即只为您的团队创建的 webhook)使用所选频道作为默认频道来发送消息。我们可以看到,可以使用相同的 webhook 向不同的频道发送消息。

  3. 一旦您点击了添加入站 WebHooks 集成按钮,您将看到一个选项页面,允许您进一步自定义您的集成。入站 webhook

    名称、描述和图标都可以从这个菜单中设置

  4. 为您的集成设置一个自定义图标(例如,本例中使用了wave表情符号)并记下 webhook URL,其格式如下:

    https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX

生成的 URL 对您的团队是唯一的,这意味着通过此 URL 发送的任何 JSON 有效负载都只会出现在您的团队 Slack 频道中。

现在,让我们在 Node 中快速测试我们的入站 webhook。启动一个新的 Node 项目(记住您可以使用npm init来创建package.json),然后在您的终端中运行以下命令来安装熟悉的superagent AJAX 库:

npm install superagent –save

创建一个名为index.js的文件,并将以下 JavaScript 代码粘贴到其中:

const WEBHOOK_URL = [YOUR_WEBHOOK_URL];

const request = require('superagent');

request
  .post(WEBHOOK_URL)
  .send({
    text: 'Hello! I am an incoming Webhook bot!'
  })
  .end((err, res) => {
    console.log(res);
  });

记住用您新生成的 URL 替换[YOUR_WEBHOOK_URL],然后通过执行以下命令来运行程序:

nodemon index.js

现在应该发生两件事:首先,在您的终端中记录一个长响应,其次,您应该在 Slack 客户端看到如下消息:

入站 webhook

入站 webhook 的“hello world”等效

我们在终端中记录的res对象是我们对 webhook URL 发出的 HTTP POST 请求的响应。它以大型 JavaScript 对象的形式出现,显示了我们对 webhook URL 发出的 HTTP POST 请求的信息。

观察在 Slack 客户端收到的消息,注意名称和图标是否与我们在团队管理员网站上设置的集成设置中的内容相同。请记住,如果没有提供,将使用默认图标、名称和频道,所以让我们看看当我们更改这些设置时会发生什么。将index.js中的request AJAX 调用替换为以下内容:

request
  .post(WEBHOOK_URL)
  .send({
    username: "Incoming bot",
    channel: "#general",
    icon_emoji: ":+1:",
    text: 'Hello! I am different from the previous bot!'
  })
  .end((err, res) => {
    console.log(res);
  });

保存文件后,nodemon将自动重启程序。切换到 Slack 客户端,您应该在#general频道中看到如下消息:

入站 webhook

新名称、图标和消息

注意

icon_emoji的位置,您也可以使用icon_url链接到您选择的特定图像。

如果您希望消息只发送给一个用户,可以将用户名作为channel属性的值:

channel: "@paul"

这将导致消息从 Slackbot 直接消息中发送。消息的图标和用户名将与您在设置中配置的或设置在 POST 请求正文中的内容相匹配。

最后,让我们看看在我们的集成中发送链接;将text属性替换为以下内容,并保存index.js

text: 'Hello! Here is a fun link: <http://www.github.com|Github is great!>'

Slack 会自动解析它找到的任何链接,无论是http://www.example.com还是www.example.com的格式。通过将 URL 放在尖括号中并使用|字符,我们可以指定我们希望 URL 显示的内容:

入站 webhooks

格式化的链接比长 URL 更容易阅读

有关消息格式的更多信息,请访问api.slack.com/docs/formatting

注意

注意,由于这是一个自定义 webhook 集成,我们可以更改集成的名称、图标和频道。如果我们将集成打包成 Slack 应用(其他团队可以安装的应用),则无法覆盖默认的频道、用户名和图标设置。

入站 webhook 由外部源触发——一个例子是当新用户注册到您的服务或产品被卖出时。入站 webhook 的目的是为您的团队提供易于访问和易于理解的信息。相反,如果您想从 Slack 中获取数据,这可以通过出站 webhook 的方式完成。

出站 webhooks

出站 webhook 与入站 webhook 不同,因为它们将数据从 Slack 发送到您选择的服务,然后该服务可以回复消息到 Slack 频道。

要设置出站 webhook,请再次访问您的 Slack 团队管理页面的自定义集成页面(my.slack.com/apps/build/custom-integration)。这次,请确保选择Outgoing WebHooks选项。

在下一屏幕上,请确保选择一个频道、一个名称和一个图标。注意有一个target URL 字段需要填写;我们很快就会填写它。

当 Slack 中的出站 webhook 被触发时,会向您提供的 URL(或 URLs,因为您可以指定多个)发送 HTTP POST 请求。因此,我们首先需要构建一个可以接受我们的 webhook 的服务器。

index.js中,粘贴以下代码:

'use strict';
const http = require('http');
// create a simple server with node's built in http module
http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});

  // get the data embedded in the POST request
  req.on('data', (chunk) => {
    // chunk is a buffer, so first convert it to 
    // a string and split it to make it more legible as an array
     console.log('Body:', chunk.toString().split('&'));
  });

  // create a response
  let response = JSON.stringify({
    text: 'Outgoing webhook received!'
  });

  // send the response to Slack as a message
  res.end(response);
}).listen(8080, '0.0.0.0');

console.log('Server running at http://0.0.0.0:8080/');

注意

注意我们要求使用http模块,尽管我们没有使用 NPM 安装它。这是因为http模块是 Node 的核心依赖项,并且会自动包含在您的 Node 安装中。

在此代码块中,我们在端口 8080 上启动一个简单的服务器,并监听传入的请求。

在这个例子中,我们将服务器设置为在0.0.0.0上运行,而不是localhost。这很重要,因为 Slack 正在向我们的服务器发送请求,因此它需要从互联网上可访问。将我们服务器的互联网协议IP)设置为0.0.0.0告诉 Node 使用您的计算机的网络分配的 IP 地址。因此,通过将我们服务器的 IP 设置为0.0.0.0,Slack 可以通过在端口 8080 上击中您的 IP 地址(例如,http://123.456.78.90:8080)来访问您的服务器。

注意

如果你遇到 Slack 无法连接到你的服务器的问题,这很可能是由于你位于路由器或防火墙后面。为了解决这个问题,你可以使用像ngrok(ngrok.com/)这样的服务。或者,检查你的路由器或防火墙的端口转发设置。

让我们相应地更新我们的外部钩子设置:

外部钩子

带有目标 URL 的外部钩子设置

保存你的设置并运行你的 Node 应用;通过在钩子设置中指定的频道中输入消息来测试外部钩子是否工作。你应该在 Slack 中看到类似以下内容:

外部钩子

我们构建了一个垃圾邮件机器人

好消息是,我们的服务器正在接收请求并向 Slack 发送消息。这里的问题是我们在外部钩子设置页面中跳过了触发词字段。没有触发词,发送到指定频道的任何消息都会触发外部钩子。这导致我们的钩子最初由发送外部钩子的消息触发,从而形成一个无限循环。

为了解决这个问题,我们可以做两件事之一:

  • 在监听所有频道消息时,请勿向频道返回消息

  • 指定一个或多个触发词,以确保我们不会向频道发送垃圾邮件

返回消息是可选的,但为了确保更好的用户体验,鼓励这样做。即使是一条确认消息,例如消息已接收,也比没有消息要好,因为它向用户确认他们的消息已被接收并正在处理。

假设我们更喜欢第二个选项并添加一个触发词:

外部钩子

触发词帮助我们组织外部钩子

现在,让我们再次尝试,这次在消息开头发送带有触发词的消息。重新启动你的 Node 应用并发送一条新消息:

外部钩子

我们的外部钩子应用现在功能上与之前我们的机器人非常相似

太好了,现在切换到你的终端,看看那条消息记录了什么:

Body: [ 'token=KJcfN8xakBegb5RReelRKJng',
  'team_id=T000001',
  'team_domain=buildingbots',
  'service_id=34210109492',
  'channel_id=C0J4E5SG6',
  'channel_name=bot-test',
  'timestamp=1460684994.000598',
  'user_id=U0HKKH1TR',
  'user_name=paul',
  'text=webhook+hi+bot%21',
  'trigger_word=webhook' ]

这个数组包含由 Slack 发送的 HTTP POST 请求的主体。其中包含一些有用的数据,例如用户的姓名、发送的消息和团队 ID。我们可以使用这些数据来自定义响应或执行一些验证,以确保用户有权使用此钩子。

在我们的响应中,我们只是发送了一个“消息已接收”字符串。然而,就像入站 webhooks 一样,我们可以设置自己的用户名和图标。频道不能与 webhook 设置中指定的频道不同。当 webhook 不是自定义集成时,同样适用这些限制。这意味着如果 webhook 作为另一个团队的 Slack 应用安装,则 webhook 只能以设置屏幕中指定的用户名和图标发布消息。我们将在第七章发布您的应用中详细讨论 Slack 应用。

需要注意的一个重要事项是,无论是入站还是出站的 webhooks,都只能在公共频道中设置。这主要是为了防止滥用并维护隐私,因为我们已经看到设置一个可以记录频道所有活动的 webhook 是极其简单的。

如果您想在私人组或 DM 中使用类似的功能,我们可以使用斜杠命令。

斜杠命令

以斜杠(/)开头的命令是可以在 Slack 客户端的任何地方使用的命令。你可能已经熟悉了 Slack 自己实现的更常见的那些命令。例如,使用topic命令:

/topic Sloths are great

这将设置频道的主题为“树懒很棒。”就像处理入站和出站 webhooks 一样,Slack 允许团队配置他们自己的自定义斜杠命令。为了演示其用法,我们将构建一个使用流行的计算知识引擎 Wolfram Alpha (www.wolframalpha.com/)的机器人。最终目标是创建一个机器人,它可以通过斜杠命令返回查询结果。

与 webhooks 不同,斜杠命令只能发送命令中包含的数据,因此你只能保证接收到的数据是故意发送的。正因为这个细微差别,我们使用斜杠命令还能获得额外的好处。它们可以在任何频道、DM 或私人组中使用。

首先,让我们设置斜杠命令集成并获取 Wolfram Alpha API 密钥。虽然我们不需要特定的 Slack 令牌,但我们确实需要它来访问 Wolfram Alpha 的服务。导航到您团队的集成设置(buildingbots.slack.com/apps/manage/custom-integrations),选择斜杠命令,然后选择添加配置。我们将使用wolfram字符串作为我们的斜杠命令,所以让我们填写它并继续。

斜杠命令

斜杠命令必须对您的团队是唯一的

现在,指定斜杠命令将要发送请求的 URL,这与我们之前处理 webhooks 时所做的类似。

斜杠命令

斜杠命令可以以不同于 webhooks 的方式定制

我们可以选择在请求提供的 URL 时使用哪种 HTTP 方法。如果您希望向服务器发送数据,请使用POST方法。如果您希望检索数据而不发送任何内容,请使用GET方法。对于我们的 Wolfram Alpha 机器人,我们将使用POST,因为我们正在向之前创建的服务器发送查询。

特别注意生成的令牌。这是一个唯一的标识符,您可以使用它来确保所有发送到您服务器的请求都来自这个特定的 Slack 斜杠命令,从而允许您拒绝任何不想要的请求。我们稍后会回到令牌。

接下来,我们将填写自动完成详情。尽管这是可选的,但强烈建议您无论如何都填写它们,因为它们为用户提供清晰的说明,告诉他们如何使用您的斜杠命令。

斜杠命令

对于从未使用过您的命令的用户来说,帮助文本非常有帮助

与我们在本书中使用的其他第三方 API 类似,Wolfram Alpha API 需要 API 令牌才能访问他们的计算服务。要获取一个,请导航到以下 URL 并遵循屏幕上的注册说明:developer.wolframalpha.com/portal/apisignup.html

注意

注意,Wolfram Alpha API 每月免费请求量只有 2000 次。如果您的斜杠命令超过这个数量,除非您支付更高级别的服务费用,否则您的请求将被拒绝。

Wolfram Alpha API 以 XML 格式发送响应,我们需要将其转换为 JSON 以便于使用。幸运的是,有一个 NPM 包可以为我们抽象这个问题:node-wolfram (www.npmjs.com/package/node-wolfram)。通过运行以下命令安装node-wolfram包:

npm install node-wolfram –save

一旦您有了密钥并且已经安装了node-wolfram,请将以下代码粘贴到index.js中:

'use strict';

const http = require('http');
const request = require('superagent');

const WOLFRAM_TOKEN = [YOUR_WOLFRAM_API_TOKEN];
const SLACK_TOKEN = [YOUR_SLACK_TOKEN];

const Client = require('node-wolfram');
const wolfram = new Client(WOLFRAM_TOKEN);

// create a simple server with node's built in http module
http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/plain'});

    // get the data embedded in the POST request
    req.on('data', (chunk) => {
      // chunk is a buffer, so first convert it 
      // to a string and split it to make it legible
      console.log('Body:', chunk.toString().split('&'));

      let bodyArray = chunk.toString().split('&');
      let bodyObject = {};

      // convert the data array to an object
      for (let i = 0; i < bodyArray.length; i++) {
        // convert the strings into key value pairs
        let arr = bodyArray[i].split('=');
        bodyObject[arr[0]] = arr[1];
      }

      // if the token doesn't match ours, abort
      if (bodyObject.token !== SLACK_TOKEN) {
        return res.end('Invalid token');
      }

      queryWolfram(bodyObject.text.split('+').join(' '), (err, result) => {
        if (err) {
          console.log(err);
          return;
        }

        // send back the result to Slack
        res.end(result);
      });
    });
}).listen(8080, '0.0.0.0');

console.log('Server running at http://0.0.0.0:8080/');

// make sure to unescape the value so we don't get Unicode
let query = unescape(bodyObject.text.split('+').join(' '));

queryWolfram(query, (err, result) => {  wolfram.query(message, (err, result) => {
    if (err) {
      return done(err);
    }

    // if the query didn't fail, but the message wasn't understood 
    // then send a generic error message
    if (result.queryresult.$.success === 'false') {
      return done(null, 'Sorry, something went wrong, please try again');
    }
    let msg = '';

    for (let i = 0; i < result.queryresult.pod.length; i++) {
      let pod = result.queryresult.pod[i];
      msg += pod.$.title + ': \n';

      for (let j = 0; j < pod.subpod.length; j++) {
        let subpod = pod.subpod[j];

        for (let k = 0; k <subpod.plaintext.length; k++) {
          let text = subpod.plaintext[k];
          msg += '\t' + text + '\n';
        }
      }
    }

    done(null, msg);
  });
}

简而言之,这段代码监听 8080 端口的传入请求。一旦接收到数据(通过 POST 请求),我们将数据转换为 JavaScript 对象以便于使用。如果请求中发送的令牌与程序中硬编码的令牌匹配,我们将发送一个包含斜杠命令内容的请求到 Wolfram Alpha。幸运的是,Wolfram Alpha 运行自己的自然语言处理NLP),因此我们可以直接发送用户的输入,让 Wolfram Alpha 完成繁重的工作。一旦我们从 Wolfram Alpha API 收到回调,我们将结果返回给 Slack,它会在 Slack 频道中发布。运行你的服务器,并在 Slack 中输入以下命令以查看其效果:

/wolfram 2 x 2

几分钟后,您应该会看到结果:

斜杠命令

Wolfram Alpha 计算一个简单的数学问题

成功!现在让我们尝试一个更具挑战性的查询:

/wolfram distance between earth and moon

那个请求应该会产生类似以下的结果:

斜杠命令

查询耗时过长

哎呀,看起来我们的查询超时了。如果我们给我们的应用程序添加一些日志记录,我们会看到虽然 Wolfram Alpha API 最终会返回结果,但它需要超过 Slack webhook 集成(3000 毫秒)的最大超时时间。这导致斜杠命令失败并显示前面的错误消息。

为了解决这个问题,让我们看看 Slack 初始接收到的数据;上一个斜杠命令的正文看起来像这样:

Body: [ 'token=86oxKgPrkxrvPHpmleaP8Rbs',
  'team_id=T00000000',
  'team_domain=buildingbots',
  'channel_id=C0J4E5SG6',
  'channel_name=bot-test',
  'user_id=U0HKKH1TR',
  'user_name=paul',
  'command=%2Fwolfram',
  'text=distance+between+earth+and+moon',   'response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT0HKKH1T9%2F35399194752%2Fm9mIVSHYjMdnwXWyCTYYTIZj' ]

我们感兴趣的是 Body 数组的最后一个索引——一个响应 URL。如果你的计算时间超过最大超时时间 3000 毫秒,Slack 会提供一个 URL,我们可以向其发送 POST HTTP 请求,就像我们发送 webhook 消息一样。

如果你的斜杠命令处理时间超过最大超时时间,并且你正在使用请求 URL,强烈建议你向 Slack 返回一条消息,告知用户他们的请求正在处理中。

将你的代码中的 http.createServer 块替换为以下内容,注意高亮区域:

// create a simple server with node's built in http module
http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/plain'});

    // get the data embedded in the POST request
    req.on('data', (chunk) => {
      // chunk is a buffer, so first convert it to a string 
      // and split it to make it legible
      console.log('Body:', chunk.toString().split('&'));

      let bodyArray = chunk.toString().split('&');
      let bodyObject = {};

      // convert the data array to an object
      for (let i = 0; i < bodyArray.length; i++) {
        // convert the strings into key value pairs
        let arr = bodyArray[i].split('=');
        bodyObject[arr[0]] = arr[1];
      }

      // if the token doesn't match ours, abort
      if (bodyObject.token !== SLACK_TOKEN) {
        return res.end('Invalid token');
      }

 // send a message immediately to confirm that 
 // the request was receive it's possible that the 
 // query will take longer than the time Slack waits
 // for a response (3000ms), so we'll send a 
 // preliminary response and then send the results later
 res.end('Calculating response, be with you shortly!');

      // make sure to unescape the value so we don't get Unicode
      let query = unescape(bodyObject.text.split('+').join(' '));

      queryWolfram(query, (err, result) => {  wolfram.query(message, (err, result) => {
        if (err) {
          console.log(err);
          return;
        }

 // send the result from the wolfram alpha request,
 // which probably took longer than 3000ms to calculate
 request
 .post(unescape(bodyObject.response_url))
 .send({
 text: result
 })
 .end((err, res) => {
 if (err) console.log(err);
 });
      });
    });
}).listen(8080, '0.0.0.0');

在确认斜杠命令请求来自我们的团队之后,但在我们开始 Wolfram Alpha API 请求之前,我们向 Slack 频道返回一个确认消息,告知用户他们的请求正在进行中。

一旦 Wolfram Alpha 返回我们的数据,我们就向斜杠命令初始请求体中提供的响应 URL 发送 HTTP POST 请求。让我们再次尝试那个最后的命令:

/wolfram distance between earth and moon

这应该返回一个确认消息:

斜杠命令

确认消息让用户知道事情正在进行

几秒钟后,我们应该看到斜杠命令查询的完整结果:

斜杠命令

我们的斜杠命令返回大量数据

在我们的斜杠命令按预期工作后,让我们看看返回输出的一个特性。

频道内和短暂响应

你可能已经注意到,当 Wolfram Alpha 机器人响应时,它的名字旁边有文本 只有你能看到这条消息。正如文本所暗示的,我们机器人的结果只对发起斜杠命令的用户可见。这是一个短暂响应的例子。请注意,原始斜杠命令的文本也只对执行它的用户可见。短暂响应的反面是频道内响应,它可以在频道中显示斜杠命令和结果,供所有人查看。

默认情况下,所有斜杠命令响应都由 Slack API 设置为短暂模式。让我们看看如何更改它,并发送频道内消息。再次,让我们替换 http.createServer 的内容。一步一步地查看更改:

// create a simple server with node's built in http module
http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'application/json'});

这里的主要区别在于,我们将响应的头部内容类型更改为 application/json。这通知 Slack 预期一个字符串形式的 JSON 包。

代码如下:

// get the data embedded in the POST request
req.on('data', (chunk) => {
     // chunk is a buffer, so first convert it to a string 
     // and split it to make it legible
  console.log('Body:', chunk.toString().split('&'));

  let bodyArray = chunk.toString().split('&');
  let bodyObject = {};

  // convert the data array to an object
  for (let i = 0; i < bodyArray.length; i++) {
    // convert the strings into key value pairs
    let arr = bodyArray[i].split('=');
    bodyObject[arr[0]] = arr[1];
  }

  // if the token doesn't match ours, abort
  if (bodyObject.token !== SLACK_TOKEN) {
 return res.end(JSON.stringify({
 response_type: 'ephemeral',
 text: 'Invalid token'
 }));
}

我们的错误响应现在需要以字符串化的 JSON 格式呈现。此外,我们添加了响应类型 ephemeral,这意味着错误消息只会对发起 slash 命令的用户可见:

// send a message immediately to confirm that
// the request was receive it's possible that the
// query will take longer than the time Slack waits
// for a response (3000ms), so we'll send a
// preliminary response and then send the results later
res.end(JSON.stringify({
 response_type: 'in_channel',
 text: 'Calculating response, be with you shortly!'
}));

现在,我们特别想要一个 in-channel 响应。在这个上下文中,这意味着 slash 命令和处理的响应将对频道中的所有人可见:

频道内和短暂响应

原始的 slash 命令和中间响应都是可见的

最后,我们查询 Wolfram|Alpha

// make sure to unescape the value so we don't get Unicode
let query = unescape(bodyObject.text.split('+').join(' '));

queryWolfram(query, (err, result) => {
  if (err) {
    console.log(err);
    return;
  }

   // send the result from the wolfram alpha request,
   // which probably took longer than 3000ms to calculate
   request
     .post(unescape(bodyObject.response_url))
 .send({
 response_type: 'in_channel',
 text: result
 })
     .end((err, res) => {
       if (err) console.log(err);
     });
    });
  });
}).listen(8080, '0.0.0.0');

在这里,我们再次确保 Wolfram Alpha 的结果对整个频道可见。最后,让我们对我们的 queryWolfram 函数中数据的显示做一些改进:

function queryWolfram(message, done) {
  wolfram.query(message, (err, result) => {
    if (err) {
      return done(err);
    }

    // if the query didn't fail, but the message wasn't understood
    // then send a generic error message
    if (result.queryresult.$.success === 'false') {
      return done(null, 'Sorry, something went wrong, please try again');
    }

 let msg = [];

    for (let i = 0; i < result.queryresult.pod.length; i++) {
      let pod = result.queryresult.pod[i];

      // print the title in bold
 msg.push(`*${pod.$.title}:*\n`);

      for (let j = 0; j < pod.subpod.length; j++) {
        let subpod = pod.subpod[j];

        for (let k = 0; k <subpod.plaintext.length; k++) {
          let text = subpod.plaintext[k];
 if (text) {
 // add a tab to the beginning
 msg.push('\t' + text + '\n');
 } else {
 // text is empty, so get rid of the title as well
 msg.pop();
 }
        }
      }
    }

    // join the msg array together into a string
    done(null, msg.join(''));
  });
}

这里的改进包括加粗章节标题和删除没有文本关联的章节。

现在我们已经把所有这些都整合在一起了,让我们来测试一下:

频道内和短暂响应

Wolfram Alpha 还可以用来获取流行算法的定义

请记住,slash 命令在您的 Slack 团队中是通用的。在我们的例子中,这意味着 Wolfram|Alpha 机器人可以从任何频道、DM 或私人组中触发。

使用 Webhooks 和 slash 命令

现在我们已经对 Webhooks 和 slash 命令有了明确的了解,我们应该确定何时使用它们。首先,我们应该考虑在什么情况下我们会使用 webhook 或 slash 命令而不是机器人用户,这是我们之前章节中学到的如何构建机器人用户。

机器人用户通常以一对一的方式进行操作;每个机器人都需要一个唯一的 Slack 令牌,这意味着机器人只能与该令牌关联的团队进行交互。这也允许机器人与 Slack 保持实时消息连接,并在连接失败的情况下重新连接。另一方面,Webhooks 和 slash 命令作为外部服务存在,可以被许多团队重复使用。通过消除对 Slack 令牌的需求,您可以让您的应用被许多其他团队使用。

使用此流程图来决定 webhook 或 slash 命令是否最适合您的需求:

使用 Webhooks 和 slash 命令

何时使用 Webhooks 或 slash 命令

在前面的图中,我们提到了 主动响应 的概念。我们在 第三章 中讨论了这些概念,增加复杂性,但基本要点是,主动应用和机器人无需输入即可发布消息,而响应式机器人会以用户输入的形式对刺激做出反应。

摘要

在本章中,我们介绍了 Webhooks 是什么以及如何设置它们以将数据从 Slack 发送到第三方服务器,并通过第三方服务器将数据传入 Slack。我们还讨论了 slash 命令以及如何实现它们。

在下一章中,我们将介绍如何发布您的应用,以便其他团队可以使用您的机器人、Webhooks 和 slash 命令。

第七章。发布您的应用

到目前为止,您已经拥有了构建一个能够提高您工作效率并改善团队间沟通的机器人的所有知识。希望到现在为止,您已经想到了一个机器人的想法,它不仅能让您的生活更轻松,也可能对他人有用。在本章中,您将学习如何使您的机器人对您自己的团队以外的用户和整个 Slack 社区开放。

我们将介绍将您的机器人添加到 Slack 应用目录并使其对他人可访问所需的步骤。我们将回顾以下步骤,以将您的机器人添加到 Slack 应用目录:

  • 注册您的机器人和获取令牌

  • 理解 OAuth 流程

  • 配置“添加到 Slack”按钮

  • 范围

  • 将您的应用或机器人提交到应用目录

  • 通过您的机器人盈利

Slack 应用目录

为了让用户更容易添加应用,Slack 创建了应用目录(slack.com/apps)。这是一个购买应用和机器人以添加到您的 Slack 团队的地方。与其他应用商店一样,提交到应用目录的每个应用都受到控制,并且必须由 Slack 本身批准,以防止垃圾邮件和滥用。

如前一章所示,其他团队可以通过 webhooks 使用您的机器人。然而,如果您试图触及广泛的受众并可能通过您的机器人盈利,应用目录是最有效的方式。

Slack 应用目录

应用目录使添加新应用变得简单

本章的最终目标是允许用户通过点击添加到 Slack按钮将机器人添加到他们的 Slack 团队,我们将在稍后详细说明。

让我们从注册一个应用开始。在这个例子中,我们将添加Wikibot机器人,这是我们第三章“增加复杂性”中构建的。

注意

请注意,我们注册 Wikibot(以及使用维基百科 API)仅用于演示目的。在使用第三方 API 为打算发布的机器人之前,请始终检查其条款和条件。例如,对于 Wikibot,我们可以使用维基百科 API,但不允许发布名为“维基百科机器人”的机器人,因为我们不拥有该商标。

注册您的应用并获取令牌

为了成功通过 Slack 的 OAuth 服务器进行身份验证,需要某些独特的令牌。这是必要的,以便 Slack 可以确定我们是否是我们所说的我们,以及我们的应用或机器人是否实际上与我们试图获取访问权限的团队集成。

我们首先导航到 Slack 新应用注册页面api.slack.com/applications/new。通过选择机器人的名称、来源团队、机器人的描述、帮助页面链接和重定向 URI 来填写表格:

注册您的应用和获取令牌

在填写此表格时,尽可能详细。

保存您的设置后,您可以选择设置机器人用户、webhook 或 slash 命令。对于 Wikibot,我们将设置一个机器人用户。

注册您的应用和获取令牌

如果您指定的用户名已被占用,Slack 会稍作修改以避免冲突。

保存您的更改后,您应该在下一屏看到 OAuth 信息。首先,在继续之前,请确保保存此页面的客户端 ID客户端密钥代码:

注册您的应用和获取令牌

永远不要与任何人分享您的客户端密钥。

注意

此过程不会使您的机器人对整个 Slack 用户群可见;它只是注册您开发应用的意图。您将通过 OAuth 过程测试您的应用。我们将在稍后的部分介绍如何将您的机器人提交到应用目录。

理解 OAuth 过程

为了在不是我们自己的团队中实现机器人用户,我们需要一个类似于我们之前为我们的团队创建的机器人令牌。我们可以请求此令牌,但首先我们必须使用 OAuth 过程证明我们是所说的那个人。OAuth开放认证)是一个由许多公司使用的开放标准认证,无论大小。

认证过程通过以下步骤进行:

  1. 用户点击添加到 Slack按钮。

  2. Slack 会将请求发送到我们在应用设置页面提供的重定向 URI。

  3. 一旦我们的服务器收到请求,我们将将其重定向到授权 API 端点(slack.com/oauth/authorize),并在查询字符串中包含以下参数:

    • client_id:这是我们首次创建应用时给出的唯一 ID。

    • scope:这包括我们应用所需的权限。我们将在本章稍后详细介绍权限。

    • redirect_uri:这是一个可选参数。这是 Slack 将发送授权结果的 URI。如果留空,则使用应用设置页面中指定的redirect_uri

    • State:这是我们创建的字符串;它可以包含我们希望保留的数据或作为我们自己的识别方法。例如,我们可以用只有我们知道的秘密短语填充此字段,我们可以在以后使用它来确保此请求来自受信任的来源。

    • Team:这是我们希望将我们的应用程序限制到的 Slack 团队 ID。这在调试我们的集成时很有用。

  4. Slack 会向我们在之前请求中提供的重定向 URI 发送一个 HTTP GET 请求。如果未提供,则默认使用我们在应用设置页面提供的 URI。请求包含以下参数:

    • code:这是 Slack 生成的临时代码,用于确认我们的身份。

    • state:这是我们之前创建的字符串,可以用来确保此请求是合法的。

  5. 凭借我们需要的所有工具和代码,我们通过另一个 HTTP GET 请求向 Slack 请求机器人用户令牌,传递以下参数:

    • client_id: 这是在应用设置页面给我们的唯一客户端 ID

    • client_secret: 这是在应用设置页面给我们的唯一且秘密的 ID

    • code: 这是第 4 步请求给我们的代码

    • redirect_uri: 如果之前发送了redirect_uri,则此必须匹配;否则,它是可选的

  6. 最后,如果一切顺利,我们将从 Slack 收到包含我们所需所有数据的响应。它看起来可能像这样:

    { 
      ok: true,
      access_token: 'xoxp-xxxxxxxxxxx-xxxxxxxxxxx-xxxxxxxxxxx-xxxxxxxxxx',
      scope: 'identify,bot',
      user_id: 'Uxxxxxxxx',
      team_name: 'Building Bots',
      team_id: 'Txxxxxxxx',
      bot: { 
        bot_user_id: 'U136YALCW',
        bot_access_token: 'xoxb-xxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx' 
      }
    }
    

为了让这个过程更容易理解,让我们看看这些交易的图表:

理解 OAuth 流程

Slack 的 OAuth 授权流程

现在,让我们看看前面的代码示例。为了使我们的生活更加轻松,我们将使用 Express 网络框架(expressjs.com/)和熟悉的superagent AJAX 库。请确保使用以下命令安装它们:

npm install –save express superagent

接下来,让我们构建我们的服务器;创建或重用index.js文件,并粘贴以下代码:

const request = require('superagent');
const express = require('express');

const app = express();

const CLIENT_ID = 'YOUR_CLIENT_ID';
const CLIENT_SECRET = 'YOUR_CLIENT_SECRET';

app.get('/', (req, res) => {
 res.redirect(`https://slack.com/oauth/authorize?client_id=${CLIENT_ID}&scope=bot&redirect_uri=${escape('http://YOUR_REDIRECT_URI/bot')}`);
});

app.get('/bot', (req, res) => {
  let code = req.query.code;

  request
 .get(`https://slack.com/api/oauth.access?client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&code=${code}&redirect_uri=${escape('http://YOUR_REDIRECT_URI/bot')}`)
    .end((err, res) => {
      if (err) throw err;
      let botToken = res.body.bot.bot_access_token;
      console.log('Got the token:', botToken);
    });

  res.send('received');
});

app.listen(8080, () => {
  console.log('listening');
});

突出的区域表示您应该填写自己的令牌和 URI。

注意

强烈建议使用ngrok等服务,以便您的本地服务器可以从互联网上访问。访问ngrok.com/获取更多详情和设置说明。您应仅将ngrok用于开发目的。在生产环境中,您应使用专用服务器。

导航到 Slack 按钮文档页面(api.slack.com/docs/slack-button#button-widget)并向下滚动,直到您看到以下测试界面:

理解 OAuth 流程

您可以使用这个区域来测试您的集成是否正确认证

点击添加到 Slack按钮,您应该会看到一个屏幕,要求您确认是否希望授权您的机器人在您的频道中使用。点击授权按钮,然后切换到您的终端。所需的机器人令牌将显示在日志中:

listening
Got the token: xoxb-37236360438-xxxxxxxxxxxxxxxxxxxxxxxx

我们可以使用我们的令牌启动我们的机器人用户,并使其能够响应和与其他团队的用户互动。现在让我们用 Wikibot 来做这件事。我们将使用本书前面介绍过的 Wikibot 代码,并修改它以与之前概述的 OAuth 流程一起工作。将index.js的内容替换为以下内容:

'use strict';

const Bot = require('./Bot');

const wikiAPI = 'https://en.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exintro=&explaintext=&titles=';
const wikiURL = 'https://en.wikipedia.org/wiki/';

const request = require('superagent');
const express = require('express');

const app = express();

const CLIENT_ID = 'YOUR_CLIENT_ID';
const CLIENT_SECRET = 'YOUR_CLIENT_SECRET';

app.get('/', (req, res) => {
 res.redirect(`https://slack.com/oauth/authorize?client_id=${CLIENT_ID}&scope=bot&redirect_uri=${escape('http://[YOUR_REDIRECT_URI]/bot')}`);
});

app.get('/bot', (req, res) => {
  let code = req.query.code;

  request
 .get(`https://slack.com/api/oauth.access?client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&code=${code}&redirect_uri=${escape('http://[YOUR_REDIRECT_URI]bot')}`)
    .end((err, result) => {
      if (err) {
        console.log(err);
        return res.send('An error occured! Please try again later');
      }
      console.log(res.body);

      let botToken = result.body.bot.bot_access_token;
      console.log('Got the token:', botToken);

      startWikibot(result.body.bot.bot_access_token);

      res.send('You have successfully installed Wikibot! You can now start using it in your Slack team, but make sure to invite the bot to your channel first with the /invite command!');
    });
});

app.listen(8080, () => {
  console.log('listening');
});

function startWikibot(token) {
  const bot = new Bot({
    token: token,
    autoReconnect: true,
    autoMark: true
  });

  // The rest of the familiar Wikibot code follows.
  // Visit https://github.com/PaulAsjes/BuildingBots for the 
  // complete source code
}

让我们试试这个。在确保您的 client_idclient_secretredirect_uri 已插入先前代码中突出显示的部分后,运行 Node 应用程序。为了测试集成,导航到此处关于“添加到 Slack”按钮的文档:api.slack.com/docs/slack-button#button-widget。和之前一样,向下滚动直到您看到测试小部件,勾选“机器人”框,然后点击“添加到 Slack”按钮。

小贴士

在此测试小部件下方是您在网站上放置“添加到 Slack”按钮时应使用的嵌入代码。

理解 OAuth 流程

注意 Slack 如何自动将我们的机器人重命名为 @wikibot2 以避免冲突

一旦授权,你应该会看到以下信息:

您已成功安装 Wikibot!现在您可以在您的 Slack 团队中使用它,但请确保首先使用 /invite 命令将机器人邀请到您的频道!

在这个例子中,我们返回了一个简单的字符串。根据最佳实践,我们需要重定向到一个网页,上面有一些关于如何操作 Wikibot 的说明。

切换到 Slack 客户端和您想集成 Wikibot 的频道。正如我们在 第二章 中讨论的,“您的第一个机器人”,机器人用户必须手动邀请到频道,所以让我们这样做并测试我们的机器人:

理解 OAuth 流程

我们的机器人已成功集成并正在运行!

Wikibot 将在 Node 服务运行期间继续正常工作。

接下来,我们将查看我们可用的其他作用域。

作用域

OAuth 作用域允许您指定您的应用程序执行其功能所需的确切访问权限。在先前的例子中,我们请求了 bot 作用域,这使我们的机器人能够访问机器人用户可以执行的所有操作。例如,channels:history 作用域使我们能够访问频道的聊天历史,而 users:read 允许我们访问团队中用户的完整列表。作用域有很多(您可以在 api.slack.com/docs/oauth-scopes 上查看),但我们将重点关注我们应用程序中最可能使用的三个作用域:

  • bot:这提供了一个机器人令牌,允许我们以机器人用户身份连接到团队

  • incoming-webhook:这提供了一个传入 Webhook 令牌

  • commands:这提供了一个 Slack 令牌,我们可以使用它来确保传入的斜杠命令请求是有效的

注意

机器人类型的作用域自动包括机器人执行所需的其他子集作用域。更多信息,请访问 api.slack.com/bot-users#bot-methods

可以无问题地请求多个作用域。以下是我们初始重定向中请求的机器人、传入 Webhook 和命令作用域的示例:

app.get('/', (req, res) => {
  res.redirect(`https://slack.com/oauth/authorize?client_id=${CLIENT_ID}&scope=bot+incoming-webhook+commands&redirect_uri=${escape('http://YOUR_REDIRECT_URI/bot')}`);
});

注意请求的作用域是如何用+符号分隔的。认证后,这将返回以下对象:

{ 
  ok: true,
  access_token: 'xoxp-xxxxxxxxxxx-xxxxxxxxxxx-xxxxxxxxxxx-xxxxxxxxxx',
  scope: 'identify,bot,commands,incoming-webhook',
  user_id: 'Uxxxxxxxx',
  team_name: 'Building Bots',
  team_id: 'Txxxxxxxx',
  incoming_webhook:
   { channel: '#bot-test',
     channel_id: 'Cxxxxxxxx',
     configuration_url: 'https://buildingbots.slack.com/services/xxxxxxxxx',
     url: 'https://hooks.slack.com/services/Txxxxxxxx/Bxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxxx' },
  bot:
   { 
     bot_user_id: 'Uxxxxxxxx',
     bot_access_token: 'xoxb-xxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'        
   }
}

注意

除了使用+符号外,作用域也可以用逗号分隔。

我们现在已经拥有了创建机器人所需的所有组件(bot_access令牌)、一个入站 webhook(incoming_webhook对象中的url参数)以及用于斜杠命令的access_token

将您的应用提交到应用目录

一旦您在团队频道内测试了您的集成,并且对您的机器人感到满意,就是时候将其提交到应用目录了。为此,首先确保您的应用程序符合 Slack 部署应用清单的要求(api.slack.com/docs/slack-apps-checklist)。简而言之,您的应用必须:

  • 只请求实际使用的那些作用域。

  • 在网页上显示添加到 Slack按钮。您需要拥有自己的网站,为新用户提供说明和帮助。

  • 有一个合适的名称(例如,没有商标或版权侵权)。

  • 有一个清晰且独特的应用或机器人图标。

  • 拥有一个至少 512 x 512 像素大小的优质图标。

  • 包含您机器人操作的简短和详细描述。

  • 包含一个安装链接(这可以是一个显示添加到 Slack按钮和使用指南的网页)。

  • 提供客户支持链接和电子邮件,以防用户在安装您的机器人时遇到问题。

  • 包含一个隐私政策的链接。您的机器人可能会监听私密对话,因此您需要明确说明您的机器人将收集哪些数据(如果有的话)。

  • 确保格式和拼写正确。您的机器人应该使用清晰的语言,并且不包含任何错误。

注意,我们的示例,Wikibot,未能通过合适的名称条款,因为维基百科显然是一个注册商标,我们并不拥有其权利。仅基于这一点,Wikibot 将被拒绝。

一旦您确认您的应用或机器人符合前面的要求,您就可以在api.slack.com/submit提交您的应用程序进行审查。

就像其他应用商店一样,所有新提交都必须经过审查流程。审查期的长度高度取决于您应用的复杂性以及 Slack 录取团队需要处理的提交数量。

注意

当您准备好将您的应用发布到 Slack 应用目录时,您需要托管。快速让您的机器人上线的一个好方法是使用 Beep Boop beepboophq.com/。这是一个付费服务,Beep Boop 会为您托管 Slack 机器人,这样您就可以专注于开发而不是基础设施。

为了确保你的机器人达到目标受众,考虑将其提交到有用的网站,如 Botwiki (botwiki.org)、botlist (botlist.co) 和 Product Hunt (www.producthunt.com),以获得最大曝光。

机器人盈利

当然,机器人盈利是可选的,你如何盈利取决于你的机器人功能以及是否存在市场。请注意,如果你的目标是按一次性价格出售你的机器人,Slack 应用目录不支持货币转账。

应用指令中的所有应用都可以免费安装,但如何将你的用户群转换为付费客户则取决于你。

有多种方法可以实现这一点,没有单一的正确方式或 Slack-认可的方法。Zoho Expense 等公司采用的一种流行方法是基于用户的支付计划(www.zoho.com/us/expense/slack-integration/)。对于小型团队,这项服务是免费的,但一旦需要超过三个用户访问,就必须迁移到付费层。

这里的想法与我们所遇到的 API 类似,例如 Wolfram Alpha。这意味着采用分层方法,其中存在免费层(与调用次数或到期日期相关联),但如果需要更多请求,则付费层是可选的。

记住,在尝试为你的机器人盈利时,“先试后买”的销售策略在这里至关重要。如果用户不知道你的机器人如何工作以及它是否真正对他们有益,他们不太可能成为付费客户。考虑提供一个免费试用期或带有有限功能的免费层。

最重要的是,确保你有一个真正值得付费的产品。尽管我们第四章中的待办事项机器人(Chapter 4)和使用数据很有用,但不太可能有人会为这样一个简单的机器人付费,因为免费替代品很容易获得或轻易复制。

因此,你的机器人应该首先关注解决特定问题,其次才是盈利。

摘要

在本章中,你了解了如何通过 Slack 应用目录使你的应用可供其他团队使用。你了解了如何从 Slack 请求作用域,以确保你的应用具有执行操作的正确权限。最后,你学习了如何正确地使用 Slack 验证你的应用并获得使你的机器人、webhooks 和 slash 命令工作所需的令牌。

通过遵循本书中的课程,你已经获得了创建世界级 Slack 机器人所需的所有知识和工具。现在,取决于你如何创造机器人技术的下一个飞跃,并推动我们与机器人互动以解决问题和实现最佳效率的界限。

为了进一步激发你的灵感,你应该知道,聊天机器人和特别地 Slack 机器人正享受着前所未有的流行和认可爆炸。

在 2016 年微软 Build 开发者大会的开幕式上,微软 CEO 萨蒂亚·纳德拉预言了机器人的未来:

"机器人是新的应用程序。人与人之间的对话,人与数字助手之间的对话,人与机器人之间的对话,甚至数字助手与机器人之间的对话。这就是你将在未来几年看到的这个世界。"

他的论点非常吸引人:机器人可能会取代应用程序,成为公司与客户之间沟通的主要来源。

Facebook 也看到了机器人的潜力。2016 年 4 月,他们宣布了为他们的 Messenger 平台开发的机器人,预计在未来几个月和几年内将看到巨大的活动。

虽然这本书专注于为 Slack 平台构建机器人,但技术、最佳实践和理论对所有机器人平台都是有效的。有了这些知识,你将拥有成为这个新机器人革命中合格开发者所需的一切。

开心编码!

进一步阅读

在这本书中,我们直接使用了 Node Slack 客户端来构建我们的机器人。通过 GitHub 跟踪这个包是保持最新功能和对 Slack 生态系统变化了解的最佳方式。然而,使用官方 Node Slack 客户端有其他替代方案。Botkit(github.com/howdyai/botkit)是一个出色的包,旨在抽象出许多底层概念并简化机器人创建过程。Botkit 还支持为 Facebook Messenger 创建机器人,便于跨平台机器人开发。如果你希望尽快启动并运行你的机器人,考虑使用 Botkit。

posted @ 2025-10-24 10:00  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报