← 返回蜂巢洞察

如何使用针对用户的OAuth访问机制来构建人工智能代理程序【完整手册】

当你的AI代理同时为多个人提供服务时,每一次工具调用都必须明确:该代理究竟是在代表哪位用户行事。让我们通过构建一个能够与Slack和GitHub连接的AI代理来学习如何解决这个问题。 当使用Slack时,系统会使用 해당用户的 workspace;而在GitHub上创建问题时,也会以该用户的身份在其有权访问的仓库中操作。虽然代理可能会犯错,但它绝对不能使用错误用户的权限来进行操作。 解决这个问题的方法分为两个部分,而这两个部分都在本教程的前半部分进行了讲解: 每位用户都需要单独授权。 Alice为自己授权Slack,Bob也为自己授权Slack。 代理传递的是标识符,而不是令牌。 像 alic

当你的AI代理同时为多个人提供服务时,每一次工具调用都必须明确:该代理究竟是在代表哪位用户行事。让我们通过构建一个能够与Slack和GitHub连接的AI代理来学习如何解决这个问题。

当使用Slack时,系统会使用 해당用户的 workspace;而在GitHub上创建问题时,也会以该用户的身份在其有权访问的仓库中操作。虽然代理可能会犯错,但它绝对不能使用错误用户的权限来进行操作。

解决这个问题的方法分为两个部分,而这两个部分都在本教程的前半部分进行了讲解:

  1. 每位用户都需要单独授权。Alice为自己授权Slack,Bob也为自己授权Slack。

  2. 代理传递的是标识符,而不是令牌。alice@example.com这样的字符串用于指定使用哪位用户的权限。在调用时,会有一个函数将这个标识符转换成令牌,而这个令牌永远不会被传送到模型的输入数据、工具架构或日志中。

大多数关于AI代理的教程在这里就会停止讲解。它们只会提供API密钥,让你配置好某个功能,然后让模型使用它来执行操作。但这种设计在遇到第二位用户时就会出现问题。

为了使这个方案更加具体化,你将构建一个命令行代理:它会监控Slack频道,自行判断哪些消息属于真正的工作内容,为这些消息在GitHub上创建问题,并在Slack对话中回复问题的链接。每一次调用都是使用当前用户的OAuth权限来进行的。

你需要自己编写整个OAuth授权流程:包括同意页面的跳转、state状态的检查、令牌的交换过程、加密存储机制以及令牌的刷新路径等。这些步骤并不复杂,但只有将它们全部看清楚,才能确保身份验证过程的可靠性,而不会只是盲目地相信某些设定。

这里还有两个主题不在讨论范围内:我们不会介绍模型上下文协议服务器,也不会涉及语音或实时通信相关的功能。虽然这种身份验证模式在这两种环境中都是可行的,但相关的实现细节确实值得单独撰写一篇文章来详细说明。

目录

你将构建什么

这个代理程序被称为channel-watcher-agent。每次运行时,它会执行以下四项操作:

  1. 从Slack频道中读取最新的消息。

  2. 逐条向模型询问这些消息是描述了一个错误,还是具体的行动事项。

  3. 对于符合条件的消息,会在GitHub上创建一个问题。

  4. 在原始的Slack对话中回复,并附上新问题的链接。

没有人需要点击任何按钮来启动这些操作。Slack本身就提供了“根据这条消息创建问题”的功能,但这是一个不同的产品。而在这里,代理程序会自行读取频道中的信息,然后自行判断是否需要采取行动,只有当它认为有必要时才会执行相应的操作。

该工具实际运行时的示例

我们有意保持这个技术栈的规模较小:

组件 作用
Node.js及普通的ES模块 不使用任何Web框架,也不使用队列机制
node:http OAuth回调服务器
node:crypto 用于对令牌进行加密处理
node:sqlite 用于存储令牌,且无需安装任何额外的依赖库
Vercel AI SDK 用于调用模型以及控制工具的运行流程

其中有三项组件是随Node.js一起提供的。你只需要安装AI SDK及其相关的依赖包即可。

最终,你会得到以下成果:

  • 两个OAuth应用:Slack和GitHub,用户只需同意一次这些应用的授权请求即可。

  • 一个经过加密的令牌存储系统,该系统的密钥由用户和提供API服务的方共同生成。

  • 一个代理程序,它能够将当前用户的身份信息转换成特定的标识符,并确保令牌永远不会直接传递给模型。

  • 一个工具循环机制,通过这个机制,模型会决定是否真的需要创建问题。

  • 一个演示功能:当第二个用户启动程序时,系统会停止读取第一个用户的数据。

完整的代码托管在github.com/saif-shines/channel-watcher-agent地址上。

先决条件

所需账户和工具:

  • Node.js 22.13或更高版本,以及npm。令牌存储系统使用了node:sqlite模块,从该版本开始,这个模块就已经非常稳定了。

  • 一个Slack工作空间,在其中你可以安装各种应用,并选择一个频道进行监控。使用临时创建的频道效果最佳。

  • 一个GitHub账户,以及一个可以用来存放测试问题的仓库。

  • 你需要拥有某个模型提供者提供的API密钥,AI SDK支持使用这些密钥。示例中使用的就是Anthropic提供的API密钥。

  • mkcert工具,用于生成本地的HTTPS证书。如何注册Slack和GitHub的OAuth应用会解释为什么普通的http://localhost回调方式是不可行的。

这些背景知识虽然很有用,但都不是必须掌握的内容:

  • asyncawait,以及如何阅读简单的Node脚本。

  • OAuth 2.0的基本原理:应用程序会将用户重定向到服务提供商处,用户确认同意后,应用程序会收到相应的令牌。

  • 函数调用。下一节将详细介绍本教程所需掌握的内容。

在开始之前需要提醒一点:该工具会直接操作真实的系统环境——它会在实际的GitHub平台上创建问题,也会在Slack中发送消息。因此在测试阶段,请使用专门的测试Slack频道和临时创建的GitHub仓库,以确保该工具仅会对您预期的消息进行操作。

什么是AI代理工具?

工具其实就是一种函数——您可以将这种函数与输入数据一起传递给模型。模型本身无法直接执行这个函数,它只能发出指令,比如“使用标题和内容调用fileGithubIssue函数”。然后由您的代码来执行这个函数并返回结果,模型再根据这些结果决定下一步该做什么。

请求、执行、返回——这种交互机制就是整个工具的核心工作流程,而所有被称为“代理”的组件,其实都是围绕这一机制运行的。

工具与API有何不同?

工具和API虽然都实现了相同的功能,但它们的设计目标受众是不同的。

API是为人类用户设计的。它假设用户会阅读相关文档,并且知道thread.ts这个字段的作用是什么。

而工具则是为那些什么都不了解的模型设计的。因此,工具本身就需要包含详细的说明:

  • 一个模型能够理解的名称,比如fileGithubIssue

  • 用通俗语言编写的描述,其中也会说明在什么情况下不应该使用该工具。

  • 输入数据的格式规范,这样模型就能知道title字段是必填项。

下面是该项目中的一款工具示例。其中大部分代码其实都是用于解释功能的工作原理,而非具体的逻辑实现:

const fileGithubIssue = tool({
  description: '为Slack消息创建GitHub问题',
  inputSchema: z.object({
    title: z.string(),
    body: z.string(),
  }),
  execute: async ({ title, body }) => {
    // ... 实际的API调用代码放在这里
  },
});

descriptioninputSchema是模型能够看到的部分,而execute函数则完全由您来编写。在execute函数内部会处理身份验证相关逻辑,因此模型永远不会知道这个调用是用哪个账户执行的。

为什么模型使用工具比直接使用API效果更好?

虽然可以将curl命令粘贴到输入框中,然后让模型完成剩余的填写工作,但这种做法总会出现可预测的问题。

工具之所以更有效,原因有三点:

  1. 在您的代码运行之前,该规范就会被严格执行。如果工具调用格式不正确,SDK会拒绝该请求并重新尝试发送;而如果URL格式有误,那么程序在运行时就会出现错误。

  2. 处理结果会被返回给模型。当fileGithubIssue完成执行后,模型就可以获取到新的问题链接,并将其用于在Slack中回复用户。正是这种流程上的衔接机制,使得第二步操作成为可能。

  3. 用户的凭证信息不会被纳入通信过程。模型只会通过名称来请求用户执行相应的操作,而永远不会看到任何令牌信息。由于模型根本看不到令牌,因此这些令牌也不可能泄露到回复内容、日志记录或提示信息中。

第三个原因正是本教程后续内容所要阐述的重点。你会故意不让模型使用令牌:代理程序会持有一个标识符,而令牌只有在调用相关服务时才会被生成。

大多数代理程序需要多个应用程序

很少有实用的代理程序只与一个应用程序进行交互。例如,支持团队会使用Zendesk处理客户咨询,并同时更新Salesforce数据;任务协调人员会查看GitHub上的信息并发布到Slack上;日程安排人员则会读取Gmail邮件并记录到Google Calendar中。

每个应用程序都有自己的OAuth注册信息、权限范围、令牌的有效期限以及刷新机制。如果每名代理程序的使用者都需要使用这些不同的应用程序,那么问题就变得非常严重了。

为什么共享令牌会引发问题

在演示环境中,为所有人使用同一个共享凭证确实是可以正常工作的,但一旦有第二个人开始使用这个系统,问题就会立刻出现。以Slack为例来说,创建一个Slack应用程序,将其安装到位,将机器人的令牌复制到.env文件中,然后让所有工具都使用这个令牌——这样看起来很简单,对吧?

但很快就会出现三个问题。

首先,每次运行该程序时都会使用相同的权限。无论是谁触发了程序的运行,机器人都会看到自己被邀请加入的所有频道。如果你询问某个你从未访问过的频道,机器人依然会读取相关信息。这样一来,代理程序就变成了绕过你自己工作空间权限设置的一种工具。

其次,审计追踪也会出现错误。每个GitHub问题记录都会显示是机器人创建了这个问题,而每条Slack回复也都似乎是由机器人发出的。当被问及为什么会有这个问题时,诚实的回答往往是“是某个代理程序为别人提交了这个问题,但我们无法确定具体是谁”。

第三,令牌的撤销机制也会失效。如果某位用户离开了公司,他们的Slack账户会被停用,但代理程序仍然会继续运行,因为它根本就没有使用过那个人的凭证信息。

解决办法就是为每个用户分别授权相应的应用程序。不过,这又会带来一个新的问题:需要为这些用户的权限设置找到一个存储和管理的地方。

区别在于同一个响应中包含的不同字段

Slack让这种区别变得非常容易理解。当用户完成授权流程后,令牌交换过程会在同一JSON对象中同时返回两种类型的令牌:

{
  "ok": true,
  "access_token": "xoxb-REDACTED-BOT-TOKEN",
  "token_type": "bot",
  "authed_user": {
    "id": "U0A1B2C3D",
    "scope": "channels:history,chat:write,users:read",
    "access_token": "xoxp-REDACTED-USER-TOKEN",
    "token_type": "user"
  }
}

顶层的access_token是机器人的令牌,而嵌套在其中的authed_user.access_token则是刚刚完成授权操作的用户的令牌。使用第一个令牌来读取conversations.history时,系统会返回该机器人被邀请加入的所有频道;而使用第二个令牌时,系统只会返回用户本身能够看到的频道。在写入操作方面也是如此:chat.postMessage方法使用用户的令牌进行发送时,消息会显示在该用户的名下。

有两个字段,它们的前缀仅相差一个字母,而你的代理程序的整个权限模型实际上取决于你选择存储哪一个字段。本教程仅涉及用户权限相关的设置,因此Slack根本不会发放任何机器人令牌。

令牌必须远离模型和日志文件

针对用户的令牌堪称系统中最为敏感的数据。有两大地方绝对不能将这些令牌存放其中:

  • 模型:请不要将令牌包含在输入数据、工具描述或工具返回结果中。一旦模型接触到了令牌,就可能会重复使用该令牌;而通过提示注入攻击,任何工具返回的结果都可能被恶意利用,从而变成不可信任的输入数据。

  • 日志文件:在调试代理程序时,你肯定希望记录下工具的输入输出信息。但如果这些日志中包含了令牌,那么这些令牌就会永久性地保存在你的日志存储系统中。

本教程规定令牌必须沿着一条非常明确的路径来使用:你的代码会传递一个标识符——这个标识符是对应某个用户的唯一参考信息。有一个辅助函数会将这个标识符转换成令牌,然后该令牌只会被直接用于调用相应的服务提供商接口,而不会被用于其他任何地方。在工具的配置方案中,永远不会出现令牌的相关名称;也不会有任何数据会被存储到模型能够读取的地方;同时,令牌也不会从任何工具中返回出来。

为什么你需要自己拥有OAuth应用程序和相应的存储系统

之所以要自己编写这些处理流程,目的并不在于实现具体的技术功能,而在于能够控制谁可以使用谁的授权信息。

在本教程中,这些用户都是你的团队成员。每个人都会将自己的Slack账户和GitHub账户关联起来,而代理程序则会被视为是触发任务执行的一方。即使这些用户是你产品的客户,这种设计依然适用:每个人仍然拥有自己的授权信息,但如果映射关系出错,就会导致某人使用他人的权限来执行任务。唯一会发生变化的,只是标识符的来源罢了——对于团队成员来说,标识符来源于他们的Slack账户;而对于客户来说,则来源于他们的GitHub账户。

架构概述

有两个处理流程非常重要,而且它们发生在不同的时间点。将这两个流程分开处理,才是整个系统设计的关键所在。

“连接流程”只会为每个用户、每个应用程序执行一次。用户表示同意后,令牌就会被保存到你的存储系统中,此时代理程序尚未开始运行。

“运行时流程”会在每次代理程序被执行时发生。代理程序会根据当前用户的身份信息来获取相应的令牌,然后继续执行其任务。在这个过程中,不会出现任何需要用户再次确认同意的界面,也不会使用浏览器。

连接流程(每个用户、每个应用程序仅执行一次)

  你的用户              connect.js              Slack / GitHub
     |                       |                         |
     |-- “连接Slack” --->|                         |
     |<--- 同意链接 -----|                         |
     |----------------------- OAuth授权 ---------->|
     |                       |<--- 重定向页面 + 代码 ----|
     |                       |---- 交换代码 ----->|
     |                       |<---- 令牌 ------------|
     |                       |                         |
     |                  [将令牌加密并存储                |
     |                   在 (用户标识符,            |
     |                   提供商) 的关联结构中                    |
     |                       |                         |


运行时流程(每次代理程序执行时)

  你的代理程序             令牌存储系统             Slack / GitHub
     |                       |                         |
  [根据用户的会话信息获取标识符]    |                         |
     |                       |                         |
     |-- 调用getAccessToken()函数( --->|                         |
     |     使用用户标识符及            |                         |
     |     相应的提供商信息 )        |                         |
     |<---- 获取到令牌 -----------|                         |
     |                       |                         |
     |------------------ 以用户的身份通过API调用 ------------>|
     |<----------------- 接收到处理结果 -----------------------|
     |                       |                         |
  [模型会看到处理结果,        |                         |
   但永远不会直接接触到令牌本身]            |                         |

这种结构决定了三个特定的属性。

这个标识符会替换你代理代码中的相应占位符。占位符之后的所有内容都会用来处理像 `alice@example.com` 或 `user_8f21c` 这样的字符串。然而,单独来看,这些字符串本身并没有任何意义:如果没有相应的存储系统及其加密密钥,它们根本无法发挥作用。

一个身份标识可以同时应用于多个应用程序。同一个标识符会在 Slack 和 GitHub 上分别对应相应的记录。而第三个应用程序并不会为这个标识符创建额外的记录来进行匹配处理。

授权相关的逻辑仍然存在于你的代码中。存储系统的作用仅仅是确定哪些占位符属于某个特定的标识符;但它本身无法判断某个请求是否真的需要得到响应。在任何请求被发送之前,系统就会先判断调用者是否有权使用该标识符。

有一条规则必须严格遵守:标识符必须通过服务器端、在经过身份验证的会话环境中才能被确定下来。绝对不能从请求体、查询参数或浏览器中获取标识符。如果客户端提供了标识符,那么这就相当于允许用户“以任何身份进行操作”了。

如何注册 Slack 和 GitHub 的 OAuth 应用程序

本教程以 Slack 和 GitHub 作为示例,详细说明了整个注册流程。这两种平台都需要满足相同的三个条件:已注册的应用程序、重定向 URI 以及一系列权限设置。不过它们的具体要求有所不同,因此有必要分别进行说明。

重定向 URI 必须使用 HTTPS

大多数关于 OAuth 的教程都会直接给出 `http://localhost:3000/callback` 这个地址,然后就不再进一步解释了。但实际上,Slack 是不会接受这个地址的。Slack 的官方文档明确指出:“重定向 URI 必须使用 HTTPS”,并且对于 `localhost` 也不例外。而 GitHub 的规定则相对宽松一些,它允许使用任何协议,因此一个基于 HTTPS 的重定向地址就可以同时满足 Slack 和 GitHub 的要求。

这条规则看起来可能有些苛刻,因为在使用 `localhost` 时,请求数据根本不会离开用户的计算机设备,也就不存在被拦截的风险。不过 Slack 仍然严格执行这一规定。对于负责颁发认证信息的平台来说,统一且没有例外的规则才是合理的选择——因为任何例外情况都意味着需要有人去处理这些特殊情况,而“这个地址真的是 localhost 吗?”这样的问题也确实曾经被错误地判断过。 mkcert 工具可以生成由本地认证机构签发的证书,这些证书会被添加到用户的系统信任列表中,因此浏览器在接收到这些证书时不会发出任何警告:
mkcert -install
mkcert localhost
执行这条命令后,`localhost.pem` 和 `localhost-key.pem` 这两个文件会被生成并保存在当前目录中。像 ngrok 这样的隧道服务也可以用来实现类似的功能,但它的免费 URL 会定期更换,这意味着每次会话开始时都需要重新修改相关配置信息。

Slack 应用程序,以及那个至关重要的设置

api.slack.com/apps 这个页面上,你可以为你的工作空间创建一个新的应用程序。然后打开 OAuth 和权限设置 部分,并进行两项必要的配置。

重定向URL选项下,添加https://localhost:3000/callback

接下来,请找到相关的权限范围。该页面分为两个部分,如果选错了部分,共享机器人的配置将会被自动重新设置:

>
部分名称 所授予的权限此处需要使用这些权限吗?
机器人令牌权限范围 一种xoxb-格式的令牌,用于代表应用程序 不需要
用户令牌权限范围 一种xoxp-格式的令牌,用于代表用户本人 需要

用户令牌权限范围选项下,添加以下内容:

  • channels:history:允许读取用户所属公共频道中的消息

  • chat:write:允许以用户的身份发布内容

  • users:read:能够将用户ID转换为对应的用户名

“机器人令牌权限范围”选项保持空白即可。请从基本信息中复制客户端ID和客户端密钥。

GitHub OAuth应用配置

在“设置” → “开发者设置” → “OAuth应用” → “新建OAuth应用”中,将授权回调URL设置为相同的https://localhost:3000/callback,然后生成客户端密钥。如果您需要了解更多详细信息,GitHub的文档中有完整的说明。

对于问题创建功能而言,GitHub规定的权限范围会根据仓库类型而有所不同:

  • repo:适用于私有仓库,可授予访问代码的读写权限

  • public_repo:这个选项的范围更有限,但当您的测试仓库是公共仓库时,使用这个权限范围就足够了

只要有可能,就应该选择范围较窄的选项。那些不必要的权限范围日后反而会带来解释上的麻烦。

环境文件配置

这两个应用都会生成客户端ID和客户端密钥,而系统还需要一个加密密钥。请先生成这个密钥:

node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"

接下来,请填写.env文件的内容:

OAUTH_REDIRECT_URI=https://localhost:3000/callback
TLS_CERT_PATH=./localhost.pem
TLS_KEY_PATH=./localhost-key.pem

SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

TOKEN_ENCRYPTION_KEY=

SLACK_CHANNEL_ID=C0XXXXXXXXX
GITHUB_REPO=your-name/your-test-repo

这些客户端密钥用于让您的应用程序能够向各服务提供商进行身份验证。它们并不是用户的登录凭证,也绝不应该出现在浏览器中。

所有与特定服务提供商相关的配置都应集中在一个地方,这样以后如果需要添加第三家服务提供商,只需添加相应的配置项即可,而无需进行复杂的修改。

步骤1:逐一描述各服务提供商的配置要求

const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI;

export const providers = {
  slack: {
    label: 'Slack',
    authorizeUrl: 'https://slack.com/oauth/v2/authorize',
    tokenUrl: 'https://slack.com/api/oauth.v2.access',

    // 这些参数应该被设置在 `user_scope` 中,而不是 `scope` 中。`scope` 下列出的权限是用于获取机器人令牌的,
    // 而这个项目正是为了避免使用机器人令牌而存在的。
    userScopes: ['channels:history', 'chat:write', 'users:read'],
    buildAuthorizeUrl(state) {
      const url = new URL(this.authorizeUrl);
      url.searchParams.set('client_id', process.env.SLACK_CLIENT_ID);
      url.searchParams.set('user_scope', this.userScopes.join(','));
      url.searchParams.set('redirect_uri', REDIRECT_URI);
      url.searchParams.set('state', state);
      return url.toString();
    },
    // exchangeCode 和 refresh 的相关代码如下
  },
};

`user_scope` 这个参数需要将所有所需的信息放在同一行中。Slack会用 scope 来获取机器人权限,而用 user_scope 来获取用户权限。这个项目只设置了后者,因此响应中根本不会包含机器人令牌。

state 这个参数是必填的。你需要生成一个随机字符串,将其发送给提供者,在接收到回复时再检查这个字符串是否正确。如果没有这个参数,任何网页都可以将用户的浏览器指向你的回调URL,并且攻击者还可以附上自己的 code;这样一来,你的服务器就会错误地交换令牌,并将攻击者的令牌存储在用户的账户下。

步骤2:交换代码并获取正确的令牌

async exchangeCode(code) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code,
      client_id: process.env.SLACK_CLIENT_ID,
      client_secret: process.env.SLACK_CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
    }),
  });

  const json = await response.json();

  // 即使交换失败,Slack也会返回HTTP 200状态码。实际上,真正的状态信息在 `ok` 字段中。
  if (!json.ok) {
    throw new Error(`Slack令牌交换失败:${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user);
}

如果忽略了该函数中的两个细节,就会花费大量的时间进行调试。

即使交换失败,Slack也会返回HTTP 200状态码。检查 response.ok 只能告诉你HTTP请求是否成功完成了,而真正判断OAuth交换是否成功的应该是 json.ok 这个字段。

需要使用的是 json.authed_user,而不是 json。注意这里的区别——如果直接读取 json.access_token,就会错误地获取到令牌,并导致所有使用该代理的用户都获得相同的机器人身份。

对结果进行规范化处理,可以使代码库的其他部分保持与具体提供者无关的状态:

function normalizeSlackTokens(authedUser) { return { accessToken: authedUser.access_token, refreshToken: authedUser.refresh_token ?? null, expiresAt: authedUserexpires_in ? Date.now() + authedUserexpires_in * 1000 : null, scope: authedUser.scope, }; }

GitHub实现的相同功能在两个方面存在差异,这些差异值得注意:

async exchangeCode(code) { const response = await fetch(this.tokenUrl, { method: 'POST', // 如果不添加这个头部,GitHub会返回一个经过表单编码的响应体。 headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ code, client_id: process.env.GITHUB_CLIENT_ID, client_secret: process.env.GITHUB_CLIENT_SECRET, redirect_uri: REDIRECT_URI, }), }); const json = await response.json(); if (json.error) { throw new Error( `GitHub令牌交换失败:${json.error_description ?? json.error}` ); } // OAuth应用令牌没有过期时间,因此不需要进行刷新操作。 return { accessToken: json.access_token, refreshToken: null, expiresAt: null, scope: json.scope, }; }

如果省略`Accept: application/json`这个头部,就会导致程序出现错误:当响应体的内容为`access_token=gho_...&scope=repo`时,调用`response.json()`会抛出异常。

步骤3:捕获重定向请求

OAuth流程需要一个接收响应的服务器。对于命令行工具来说,只需启动一个服务器,处理每个提供者的回调请求,然后退出即可。但由于Slack要求使用HTTPS协议,因此`OAUTH_REDIRECT_URI`这个参数决定了应该启动哪种类型的服务器:

function createCallbackServer(handler) { if (redirect.protocol !== 'https:') { return createHttpServer(handler); } try { return createHttpsServer( { cert: readFileSync(process.env.TLS_CERT_PATH), key: readFileSync(process.env.TLS_KEY_PATH), }, handler ); } catch (err) { throw new Error( `无法读取TLS证书 (${err.code ?? err.message}).\n` + `可以使用mkcert生成本地可信的证书:\n` + ` mkcert -install\n` + ` mkcert localhost\n` + `然后将TLS_CERT_PATH和TLS_KEY_PATH设置为这些命令生成的文件路径。` ); } }

如果缺少TLS证书,肯定会有用户遇到问题,而单独出现`ENOENT`错误并不能说明与OAuth流程有关的问题。因此,异常处理代码用四行文字说明了在这种情况下应该执行什么操作。

在`handleCallback`函数中,才会检查`state`变量的值:

const pending = new Map(); function handleCallback(request, response) { const url = new URL(request.url, redirect.origin); if (url.pathname !== redirect.pathname) { response.writeHead(404).end('未找到相应资源'); return; } const state = url.searchParams.get('state'); const entry = pending.get(state); if (!entry) { response.writeHead(400).end('状态信息不匹配,请重新开始流程。'); return; } pending.delete(state); const error = url.searchParams.get('error'); if (error) { response.writeHead(400).end(`授权失败:${error}`); entry.reject(new Error(`[${entry.provider}]授权失败:${error}`)); return; } entry.finish(url.searchParams.get('code'), response); }

“pending”映射实际上就是用来检查状态的变化的。只有当某个进程生成了某个状态值时,该状态值才会被添加到这个映射中;而一旦该状态值被使用完毕,它就会立即从映射中删除。如果某个状态值没有被识别出来,那就意味着相应的回调请求并不是来自你启动的那个流程;而如果某个状态值出现了两次,那就说明发生了数据重放的情况。这两种情况都可以通过查询同一个Map来得到判断结果。

生成状态值并等待其对应的回调请求的到来:

function connect(providerName) {
  const provider = providers[providerName];
  const state = randomBytes(16).toString('hex');

  console.log(`\n[${providerName}] authorize as "${IDENTIFIER}":`);
  console.log/provider.buildAuthorizeUrl(state));

  return new Promise((resolve, reject) => {
    pending.set(state, {
      provider: providerName,
      reject,
      async finish(code, response) {
        const tokens = await provider.exchangeCode(code);
        saveGrant(IDENTIFIER, providerName, tokens);
        response
          .writeHead(200, { 'Content-Type': 'text/html' })
          .end(`

${provider.label}已经连接成功。您可以关闭此标签页。

`); resolve(); }, }); }); }

这里应该使用randomBytes(16)而不是Math.random(),因为如果状态参数是可以预测的,那么实际上就相当于没有设置任何状态参数一样。

运行这个程序时,会依次访问每一个尚未被连接的提供者:

[slack] authorize as "alice@example.com":
https://slack.com/oauth/v2/authorize?client_id=123.456&user_scope=channels%3Ahistory%2Cchat%3:write%2Cusers%3:read&redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&state=1159699dbf1a808fd33ba31c7b643505

注意看,这个URL中并没有包含任何scope参数。由于Slack并没有要求生成机器人令牌的指令,因此它也不会自动生成这样的令牌。

如何将令牌加密并使用用户自定义的密钥进行存储

存储令牌的关键在于回答这样一个问题:对于这个用户来说,哪个令牌是属于哪个提供者的?而关于令牌的其他所有信息,其实都是基于这个答案来确定的。

从Node v22.5开始,node:sqlite模块就已经被内置到了Node.js中,并且从v22.13版本起,不再需要使用任何额外的配置标志了。这样一来,人们就可以直接使用这个模块来创建一个真正的数据库,而无需再进行任何额外的安装操作:

import { DatabaseSync } from 'node:sqlite';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

const KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY ?? '', 'base64');

if (KEY.length !== 32) {
  throw new Error(
    'TOKEN_ENCRYPTION_KEY必须是由32个字节组成的、经过base64编码的字符串。'
    + `当前得到的键的长度为${KEY.length}字节。`
  );
}

const db = new DatabaseSync(
  process.env.TOKEN_DB_PATH ?? new URL('../tokens.db', import.meta.url).pathname
);

// 每个用户对应一个记录,每个提供者也对应一条记录。由于“expires_at”字段被存储在密文之中,因此无需解密就可以检查令牌的有效期。
db.exec(`
  CREATE TABLE IF NOT EXISTS grants (
    identifier TEXT    NOT NULL,
    provider   TEXT    NOT NULL,
    ciphertext BLOB    NOT NULL,
    iv         BLOB    NOT NULL,
    auth_tag   BLOB    NOT NULL,
    expires_at INTEGER,
    PRIMARY KEY (identifier, provider)
  )
`);
复合主键正是实现数据隔离性的关键所在。 (identifier, provider) 这一结构确保了Alice的Slack信息与Bob的Slack信息不会发生冲突;任何同时包含这两部分信息的查询都不会返回他人的授权信息。

expires_at 这一字段被刻意设置在了加密密文之外。在每次调用相关函数之前,都需要检查该令牌是否需要更新;如果尝试通过解密来获取这一信息,就会导致频繁的解密操作,而唯一一个未被加密的字段使得这一信息依然可以被正常读取。

所使用的加密算法是AES-256-GCM,这种算法既能实现数据加密,还能进行身份验证:

function encrypt(payload) {
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', KEY, iv);
  const ciphertext = Buffer.concat([
    cipher.update(JSON.stringify(payload), 'utf8'),
    cipher.final(),
  ]);
  return { ciphertext, iv, authTag: cipher.getAuthTag() };
}

function decrypt({ ciphertext, iv, authTag }) {
  const decipher = createDecipheriv('aes-256-gcm', KEY, iv);
  decipher.setAuthTag(authTag);
  const plaintext = Buffer.concat([
    decipher.update(ciphertext),
    decipher.final(),
  ]);
  return JSON.parse(plaintext.toString('utf8'));
}
  1. 每次加密时都必须使用新的初始化向量: 在GCM加密算法中,重复使用相同的初始化向量会导致严重的安全问题,绝不能这样做。因此,每次调用加密函数时都会生成一个新的randomBytes(12)值,并将其与加密密文一起保存。

  2. 必须保留认证标签: GCM算法会生成一个认证标签,用于证明加密后的密文没有被篡改。如果在解密过程中不使用setAuthTag方法来设置这个标签,那么解密出来的数据就失去了完整性,不过decipher.final()方法本身并不会发出错误提示。

  3. 需要加密整个令牌对象,而不仅仅是其中的各个字段: 对于{accessToken, refreshToken, scope }这样的令牌对象来说,使用一个加密密文、一个初始化向量和一个认证标签就足够了,这样就可以避免重复存储大量数据。

saveGrant函数的编写和调用过程也非常简单:
export function saveGrant(identifier, provider, tokens) {
  const { ciphertext, iv, authTag } = encrypt(tokens);
  db.prepare(
    `INSERT INTO grants (identifier, provider, ciphertext, iv, auth_tag, expires_at)
     VALUES (?, ?, ?, ?, ?, ?)
     ON CONFLICT (identifier, provider) DO UPDATE SET
       ciphertext = excluded.ciphertext,
       iv         = excluded.iv,
       auth_tag   = excluded.auth_tag,
       expires_at = excludedexpires_at`
  ).run(identifier, provider, ciphertext, iv, authTag, tokens.expiresAt ?? null);
}
ON CONFLICT子句的作用其实比看起来要重要得多。当发生数据冲突时,系统需要更新原有的授权信息,而不是让操作失败或产生重复的授权记录。而用户在权限被撤销或权限范围发生变化后,也正是需要重新进行授权操作的。
加密密钥本身存储在 `.env` 文件中。这种存储方式适合用于教程教学,但不适合生产环境——在生产环境中,加密密钥应该被保存在秘密管理工具或 KMS 中。如果丢失了这些密钥,所有存储的授权信息都会变得无法读取,从而导致所有用户都必须重新进行授权操作。虽然这确实会引发系统故障,但总比另一种情况要好:如果数据库文件被盗,那么你的代理程序中所有用户的有效令牌都会被泄露出去。

如何以当前用户身份运行工具调用

在运行时,需要执行三个步骤:解析标识符、使用该标识符获取令牌,然后将整个流程封装成一个工具。

步骤1:解析标识符,然后进行授权

标识符是指任何能够代表某个用户的稳定字符串,它可以是一个电子邮件地址、用户ID,或者是租户级别的密钥。

// 在实际应用中,这个标识符通常是从服务器端获取的,因为它是通过认证会话得到的。
// 绝不要从客户端输入中直接获取这个标识符。
const IDENTIFIER = process.argv[2] ?? 'channel-watcher-agent';

argv中读取标识符可以让这个演示程序在无需登录的情况下正常运行,这也使得本教程后面提到的隔离测试可以通过一个简单的命令来完成。在实际应用中,这段代码会替换为如下形式:

// 在实际应用中:从服务器端的认证会话中获取标识符。
const session = await getSession(request);                  // 这是你的认证会话对象
const identifier = await lookupIdentifier(session.userId);  // 从数据库中查询标识符

在这两行代码中,顺序非常重要。必须先进行身份验证,然后再确定调用者可以使用哪个标识符来进行操作。如果标识符是从客户端获取的,那么这个接口就会变成一个可以读取任何用户Slack信息的工具。

步骤2:将标识符转换为令牌

在标识符和实际的调用操作之间,有一个函数起着关键作用:

const REFRESH_WINDOW_MS = 60_000;

export async function getAccessToken(identifier, providerName) {
  const grant = readGrant(identifier, providerName);

  if (!grant) {
    throw new Error(
      `[${providerName}] 没有为 "${identifier}"获取到令牌。\n`
      `请先进行授权操作:node src/connect.js ${identifier}`
    );
  }

  const expiringSoon =
    grantexpiresAt !== null && grantexpiresAt !== undefined &&
    grantexpiresAt - Date.now() < REFRESH_WINDOW_MS;

  if (!expiringSoon) {
    return grant.accessToken;
  }

  if (!grant.refreshToken) {
    throw new Error(
      `[${providerName}] 为 "${identifier}"生成的令牌已经过期,且没有可更新的令牌。
      用户需要再次进行授权操作。`
    );
  }

  const refreshed = await providers[providerName].refresh(grant.refreshToken);
  saveGrant(identifier, providerName, refreshed);
  return refreshed.accessToken;
}

必须在调用API之前立即执行这个函数,而不能在程序启动时才执行一次。因为一个长时间的代理进程可能会持续运行超过12小时,而如果提前获取令牌,就可以在最方便的时候进行检查。如果在需要使用时才去获取令牌,虽然只需要进行一次简单的数据库查询操作,但这样就会避免很多不必要的麻烦。

另外,这个60秒的时间窗口并不是为了增加额外的延迟而设置的。如果一个令牌还剩4秒钟就快要过期了,在进行检查时它可能仍然被认为是有效的;而在这个时间窗口内重新获取令牌,就可以确保返回的令牌至少还能使用1分钟。

最后,如果缺少必要的授权信息,系统会抛出错误,而不会采取任何回退措施。因为在这种情况下,根本没有任何合理的回退方案可供选择。对于未获得授权的用户来说,正确的处理方式应该是停止当前操作,并显示一条提示信息,告知他们如何获取授权。

步骤3:将提供商调用封装为工具

在这里,身份识别信息会被插入到模型能够影响的那些数据之前:

export function buildTools(identifier) {
  const [owner, repo] = process.env.GITHUB_REPO.split('/');

  const fileGithubIssue = tool({
    description: '为生成可操作的Slack消息,需要在GitHub上提交一个问题',
    inputSchema: z.object({
      title: z.string(),
      body: z.string(),
    }),
    execute: async ({ title, body }) => {
      const token = await getAccessToken(identifier, 'github');
      return createIssue(token, owner, repo, { title, body });
    },
  });

  const replyInSlackThread = tool({
    description:
      '在原始的Slack对话中回复(例如,可以附上刚刚创建的问题链接)',
    inputSchema: z.object({
      text: z.string(),
      thread_ts: z.string(),
    }),
    execute: async ({ text, thread_ts }) => {
      const token = await getAccessToken(identifier, 'slack');
      return postThreadReply(
        token,
        process.env.SLACK_CHANNEL_ID,
        text,
        thread_ts
      );
    },
  });

  return { fileGithubIssue, replyInSlackThread };
}

需要对比模型能够控制哪些信息,以及它无法控制哪些信息。模型可以设置titlebodytext这些内容,但模型无法选择用户身份identifier是一个在模型运行之前就已经确定的参数,而且它并没有出现在任何inputSchema中。由于账户信息并不属于模型的输入参数,因此模型也无法以其他用户的身份来提交问题。

每个返回值都值得单独进行审查。createIssue函数会返回问题的编号、URL和标题,而postThreadReply函数则会返回一个时间戳。这两种函数都不会返回token,也不会返回提供商的实际响应数据——如果真的需要隐藏token的话,也应该把这些信息放在响应数据中。

这些由提供商自己发起的HTTP请求其实都是普通的HTTP请求而已:

export async function createIssue(token, owner, repo, { title, body }) {
  const response = await fetch(
    `https://api.github.com/repos/${owner}/${repo}/issues`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title, body }),
    }
  );

  const json = await response.json();

  if (!response.ok) {
    // 在这里,403错误通常表示授权信息中缺少`repo`权限。
    throw new Error(
      `在GitHub上创建问题失败(状态码:${response.status}):${json.message}`
    );
  }

  return { number: json.number, url: json.html_url, title: json.title };
}

步骤4:阅读聊天记录

Slack的conversations.history方法返回结构清晰的JSON数据,但其中存在一个问题:这些消息中包含的是用户ID,而非显示名称。因此,若要将这些信息转换为显示名称,就需要对每条消息分别调用users.info接口;而这正是users:read方法被用于实现这一功能的原因。

export async function readChannel(token, channelId, limit = 20) {
  const { messages } = await slackCall(token, 'conversations.history', {
    channel: channelId,
    limit: String(limit),
  });

  const authors = await resolveAuthors(
    token,
    messages.filter((m) => m.user).map((m) => m.user)
  );

  return messages
    .filter((message) => message.text)
    .map((message) => ({
      author: authors.get(message.user) ?? 'unknown',
      userId: message.user,
      text: message.text,
      ts: message.ts,
    }))
    .reverse(); // 按时间顺序倒序排列
}

在这个函数中,有三个关键的决策需要考虑:

  1. 为每个唯一的作者,都需要调用一次users.info接口来获取其显示名称。通过将结果缓存起来,可以避免同一频道内的消息反复触发相同的查询操作。如果查询失败,系统会自动回退到使用用户ID,而不会因此终止整个流程——因为无法解析显示名称并不会导致程序出错。

  2. 那些没有text字段的消息会被直接忽略。当有人加入频道或聊天的目的发生变化时,系统会收到一些没有内容字段的消息对象,而模型也无法对这些消息进行任何处理。

  3. .reverse()方法的用途并非仅仅是为了美观。Slack默认返回的是最新的消息顺序,如果按逆序读取聊天记录,模型就会无法正确判断哪些消息是针对哪些问题发出的回复。

ts字段具有双重作用:它既用于标识每条消息,使系统能够确定某条消息是对哪个问题的回复;同时也帮助系统记住哪些问题已经得到了处理:

const state = await loadState();
const processed = new Set(state[IDENTIFIER]?.processedTs ?? []);
const newMessages = messages.filter((m) => !processed.has(m.ts));

正如代码片段中所展示的那样,状态是按照标识符来管理的。如果将所有已处理的消息放在同一个列表中,那么某个用户的处理结果就会覆盖其他用户的消息,而这恰恰违背了整个设计初衷——即避免不同用户之间的信息干扰。

步骤5:运行工具循环

将这两种工具提供给模型,让模型自己来决定如何处理这些消息:

const { text } = await generateText({
  model: anthropic(process.envMODEL),
  tools,
  stopWhen: stepCountIs(5),
  prompt: `你正在处理一个开发团队Slack频道中的消息。

来自${message.author}的消息:${message.text}
消息的时间戳:${message.ts}

请判断这条消息是否需要采取行动(例如报告错误或安排具体任务),还是只是无关紧要的聊天内容。

如果需要采取行动,请在GitHub上创建一个问题,标题和内容请参考这条消息的内容;然后在原来的Slack对话中回复,附上简短的说明以及问题的链接。

如果不需要采取行动,就什么都不做,并简要说明原因。`,
});

循环机制使得第二步成为可能。模型会读取消息,并可能会调用fileGithubIssue函数。AI SDK会运行该工具,将结果以及新的问题链接一并反馈给模型,然后模型会再次被调用。这样一来,模型现在就可以在讨论线程中回复了——因为这些信息是在第一次处理时还无法获得的。之后,循环就会停止。

stopWhen: stepCountIs(5)这一设置限制了循环的次数。如果没有这个限制,一个混乱的模型可能会无限次地重试那些失败的操作。对于两个工具来说,进行五轮循环已经算是相当宽容的了。

另一种更为确定性的处理方式也是可行的:首先使用结构化输出函数进行分类处理,然后当消息符合条件时,按照固定的顺序手动调用这两个工具。

固定顺序的方式更便于测试,但灵活性较差。而循环机制则允许模型选择不回复或直接提交处理结果;如果需要添加第三个工具,也不需要进行任何新的逻辑分支处理。因此,当根据不同的输入需要执行不同的操作时,应选择循环机制;而当所有操作都相同的情况下,则可以使用固定顺序。

关于提供者的设置,有一点需要注意,以确保能够准确了解实际执行了哪些操作。上面的示例中使用了@ai-sdk/anthropic,这种配置适用于直接使用Anthropic API密钥的情况。而我自己的测试则是通过一个与OpenAI兼容的网关来进行的,这种情况下只需要修改提供者的配置即可:

import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

const gateway = createOpenAICompatible({
  name: 'gateway',
  baseURL: `${process.env.GATEWAY_BASE_URL}/v1`,
  apiKey: process.env.GATEWAY_API_KEY,
});
// 然后就可以使用:model: gateway(process.envMODEL)

无论采用哪种方式,所使用的工具、循环机制以及令牌处理流程都是相同的,唯一会变化的是model参数的值。

如何处理令牌的更新与撤销

令牌有两种不同的失效方式,而其中只有一种情况是你的代码本身导致的问题。令牌过期是一种常规现象,也是可以恢复的;而令牌被撤销则是他人做出的决定,面对这种情况,正确的应对措施就是再次征求用户的同意。

本教程中介绍的这两种提供者分别代表了令牌失效机制的两个极端情况,因此将它们结合使用会非常有用。

GitHub:那些看似永远有效但实际上会过期的令牌

OAuth应用用户的令牌并没有设置过期时间。也没有需要保存的刷新令牌,更不需要执行任何刷新操作。因此,在这个项目中,github.refresh()函数实际上只是起到了说明作用而已:

async refresh() {
  throw new Error(
    'GitHub OAuth应用用户的令牌是不会过期的。如果这里出现错误,那就意味着令牌已经被撤销了——需要再次征求用户的同意。’
  );
}

“不会过期”并不等同于“永远有效”。事实上,GitHub会出于一些合理的理由撤销令牌,了解这些原因是很重要的。

  • 用户可以通过账户设置撤销对该令牌的授权。

  • 如果该令牌在一年内未被使用,它将会被弃用。

  • 如果该令牌被保存到公共仓库或Gist中,GitHub会自动撤销其有效性。

  • 如果某个应用程序为同一用户和相同的权限范围积累了超过10个令牌,那么最旧的那些令牌将会被撤销。

第三种情况值得我们特别关注。GitHub会扫描公共仓库中使用的令牌格式,并删除那些不符合其规定的令牌。这种机制其实是一种安全措施,而非一种策略;而且它无法保护私密仓库中的令牌或日志文件。 GitHub应用程序与OAuth应用程序的行为有所不同,这一点在阅读GitHub的文档时经常会引起混淆。GitHub应用程序的用户访问令牌会在8小时后失效,但会附带一个有效期为6个月的刷新令牌。如果你选择使用GitHub应用程序,那么下面的Slack相关的刷新流程就是你需要遵循的。

Slack:令牌轮换是可选且永久性的

默认情况下,Slack用户的令牌也不会过期。令牌轮换功能可以改变这一设置。需要特别提醒的是:一旦启用了令牌轮换,就无法再将其关闭。建议先在测试应用程序上启用该功能。 当令牌轮换功能被启用后,令牌的有效期为12小时,并且会附带一个刷新令牌。使用刷新令牌进行请求时,会使用与初次获取令牌时相同的接口,但授权类型会有所不同:
async refresh(refreshToken) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.SLACK_CLIENT_ID,
      client_secret: process.env.SLACK_CLIENT_SECRET,
    }),
  });

  const json = await response.json();
  if (!json.ok) {
    throw new Error(`Slack令牌刷新失败:${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user ?? json);
}
请务必保存新的刷新令牌,而不仅仅是新的访问令牌。因为刷新令牌也会过期。如果只保存访问令牌,那么你会继续使用已经过期的刷新令牌,从而导致12小时后才出现错误,这时再发现问题就已经太晚了。
正是因为这个原因,getAccessToken方法在完成令牌更新后会调用saveGrant方法,而不会直接返回更新后的令牌后就结束操作。

将失效的授权信息视为正常状态

被撤销的授权信息其实并不属于异常情况。用户可能会离开平台,管理员也可能会调整权限设置,而且人们对于代理程序可以执行哪些操作也会改变看法。 正确的处理方式就是getAccessToken方法目前所采用的方法:当检测到授权失败时,应立即提供一个新的授权链接,而不是显示错误堆栈信息。使用connect.js库,并为相关标识符设置ON CONFLICT处理逻辑,就可以让用户重新进行授权确认;这样一来,用户记录中的其他信息就不会发生任何变化。

如何添加第二个认证提供者

添加第二个认证提供者需要消耗一个OAuth应用程序名额、在“提供者”对象中添加一条记录,还需要使用相应的工具。将身份验证相关信息存储在一个字符串中,正是这种处理方式使得添加第二个提供者能够享受折扣优惠。

该代理一直在使用两种服务提供商。值得注意的是,第二种服务提供商并不需要额外的组件:不需要第二个身份认证机制,也不需要第二个同意服务器,更不需要第二个令牌表。

export const providers = {
  slack: { /* ... */ },
  github: { /* ... */ },
};

Google Calendar作为第三种服务提供商,意味着系统中会增加一个新的条目,这个新条目拥有自己独立的authorizeUrltokenUrl、权限范围以及exchangeCodeObject.keys(providers),因此它会直接使用新的服务提供商设置而无需进行任何修改。系统存储结构已经按照(identifier, provider)的格式进行了设计,因此也不需要进行任何数据迁移。接下来还有另一种工具:

const createCalendarEvent = tool({
  description: '创建日历事件',
  inputSchema: z.object({ summary: z.string(), start: z.string() }),
  execute: async ({ summary, start }) => {
    const token = await getAccessToken(identifier, 'google-calendar');
    // ...还需要调用另一个服务提供商的接口
  },
});

标识符不会发生变化,用户的账户信息也不会改变,而系统所使用的工具数量仅仅增加了一个而已。

唯一不会随使用数量增加而减少的成本,就是针对特定服务提供商所需掌握的知识。每一种新的服务提供商都会带来自己独特的权限范围、错误处理格式,以及关于令牌有效期的规定。Slack和GitHub在这些问题上存在差异,而第三种服务提供商也会表现出不同的特点。注册中心模式将这些知识存储在每个服务提供商对应的对象中,而不是分散在代理程序中,但这也并不意味着这些知识就变得不必要了。

关于同意流程需要特别注意的一点是:每种服务提供商的授权都是针对特定用户的。如果用户只连接了Slack而没有连接Google Calendar,那么她使用Google Calendar相关的功能时就会遇到错误,因为这个错误是正常的——毕竟她并没有给予相应的授权。应该将这种情况视为一种提示,而不是一个错误信号;这一点在“故障处理方式”部分也有详细说明。

完整操作流程

首先克隆仓库,然后进行安装,并填写.env文件:

git clone https://github.com/saif-shines/channel-watcher-agent.git
cd channel-watcher-agent
npm install
cp .env.example .env
# 请填写客户端ID、密钥、频道ID以及仓库地址

接下来进行连接操作。这个命令会启动回调服务器,并为每个未连接的 service provider 显示一个链接:

npm run connect
[slack] 以 "channel-watcher-agent" 的身份进行授权:
https://slack.com/oauth/v2/authorize?client_id=123.456&user_scope=channels%3Ahistory%2Cchat%3:write%2Cusers%3:read&redirect_uri=https%3:A%2F%2Flocalhost%3A3000%2Fcallback&state=1159699dbf1a808fd33ba31c7b643505
[slack] 连接成功。

[github] 以 "channel-watcher-agent" 的身份进行授权:
https://github.com/login/oauth/authorize?client_id=Iv1.abc&scope=repo&redirect_uri=https%3:A%2F%2Flocalhost%3A3000%2Fcallback&state=e6d13461099c391367266235f8313630
[github] 连接成功。

所有服务提供商都已连接成功。现在可以运行:node src/index.js channel-watcher-agent

点击每个链接,同意相关条款后,标签页就会确认操作完成。在返回过程中,会检查这些URL中的状态参数;如果回调函数携带了其他信息,系统会返回400错误码,导致该信息无法到达令牌交换环节。

接下来,使用该代理程序针对一个包含普通聊天内容的频道进行测试:

node src/index.js

当针对一个只包含三条普通消息的频道运行该代理程序时,输出结果如下:

[channel-watcher-agent] 获取到3条消息,其中3条都是新消息。

--- Alex: "正在发送草稿消息" ---
“正在发送草稿消息”这条消息属于无关信息——它似乎只是测试内容或意外发送的,并非错误报告或需要处理的操作事项。

...

没有调用任何工具,也没有生成任何问题报告。这种负面情况其实比看起来更为重要。一个拥有写入权限却无法拒绝用户请求的代理程序确实存在安全隐患,而通过测试它对普通聊天内容的处理方式,就可以最简单地验证它的约束机制是否有效。

现在,在该频道中发布一条真正的错误报告:

嘿,/export接口在处理大小超过50MB的文件时会出现超时现象,这个问题从昨天部署之后就存在了。

再次运行代理程序后,状态文件会确保之前的消息不会被重复处理。

身份验证信息才是关键所在。使用该标识符登录GitHub账户后,问题报告是由该用户本人使用自己的OAuth权限提交的,而不是由共享机器人完成的。Slack上的回复也来自同一位用户。如果撤销该用户的访问权限,下次运行代理程序时,在执行getAccessToken操作时会失败,而这正是预期的结果。

第二个用户会遇到哪些变化?

标识符是通过命令行提供的,因此可以在不先创建登录账户的情况下测试隔离机制是否有效:

node src/index.js                     # 使用你已经授权的标识符
node src/index.js alice@example.com   # 使用另一个完全不同的用户标识符

第二个命令根本不会读取该频道的内容,执行后会立即停止:

[slack] "alice@example.com"没有相应的访问权限。请先连接账户:node src/connect.js alice@example.com

关键就在于系统会拒绝这个请求。

在这两个命令中,代理程序的配置没有任何变化——使用的提供者、工具和代码都是一样的,唯一不同的就是标识符而已。由于Alice没有同意授权,因此系统中不存在需要解密的记录,执行过程也会在触及Slack之前就终止。

如果使用共享机器人的版本,第二个命令会读取频道内容并以机器人的身份提交问题报告,因为这个过程中根本不需要考虑用户的权限问题。

一旦Alice同意授权,后续的所有操作都会依据她的权限来进行。readGrant会返回与她相关的信息:getAccessToken会解密她的令牌,Slack也会显示她能够看到的频道列表,而问题报告则由她的GitHub账户来创建。

在生产环境中,argv会被替换为会话查询机制:

const identifier = await lookupIdentifier(session.userId);

测试无需凭证时的隔离机制

该代码库包含一套测试用例,这些用例将 `fetch` 替换为 Slack 和 GitHub 的模拟接口,因此,在没有注册任何 OAuth 应用的情况下,请求构建、响应解析、数据存储以及更新逻辑依然可以正常运行:

npm test

其中有三项测试特别值得关注,因为它们检验的是本教程所阐述的内容,而非代码的内部实现细节:

  • Slack 会保留用户的访问令牌,而忽略机器人的访问令牌。测试用例会同时返回这两种令牌,并验证存储的令牌确实是 `xoxp-` 格式的。

  • 对于相同的输入信息,两个用户会获得不同的访问令牌。虽然他们的 `text` 和 `thread_ts` 值相同,但他们的标识符不同,因此发送给服务提供商的 `Authorization` 头部信息也各不相同。

  • 为未登录用户设计的工具在遇到问题时会直接失败,而不会尝试其他补救措施。此外,测试还会确认实际上并没有向服务提供商发起任何请求,因为当请求数据泄露后立即导致程序失败,其实并不能算真正的“失败”。

那些在首次运行时就通过测试的用例其实并不可信,因此我特意对代码进行了修改进行验证。将机器人的访问令牌替换为用户的访问令牌,忽略 `buildTools` 中的标识符信息,或者让 `identifier` 在模型可见的数据结构中被公开显示,这些修改都会导致至少有一项测试失败。

如何将这一模式应用到其他场景中

这种模式中的任何内容都并非专门为 Slack 的问题分类系统设计的。其核心思路就是:从一个应用程序中读取数据,通过模型进行处理,然后将结果写入另一个应用程序,整个过程都是以同一个用户的身份来完成的。

如果更换服务提供商,就会得到不同的应用效果:

读取数据来自 写入数据到 最终结果
Slack GitHub 将聊天内容转化为问题,如本教程中所示
Gmail Linear 将支持邮件转化为可追踪的工作项
Google Calendar Notion 在会议前准备会议笔记
Zendesk Salesforce 将支持请求记录到相应的账户中
GitHub Slack 在相关频道中汇总更新内容

每一行都包含了相同的三个要素:服务提供商、通过标识符获取的访问令牌,以及用于处理数据的工具。唯一会发生变化的是 OAuth 应用的注册信息、`execute` 方法中的 API 调用内容,以及输入到模型中的数据。

输入数据才是决定最终应用效果的关键所在。OAuth 只是一种底层技术而已。判断哪些消息应该被转化为问题,以及这些问题应该如何表述,这些都需要人为进行决策,而这种决策过程才真正值得花费时间和精力去完成。

同样的代码可以用于两种不同的部署场景:

  • 内部团队代理: 这种代理的标识符是由触发相应操作的同僚指定的;这类操作通常是按照预定的时间表或特定的命令来执行的。

  • 面向客户的代理: 其标识符来源于您的租户信息及用户记录;这类操作是在客户账户内部、利用客户数据来执行的。

代码本身并没有发生变化,但使用错误的标识符所带来的后果却截然不同。

在构建这个项目时出现了哪些问题

这些都是在构建项目过程中遇到的问题,它们出现的顺序大致与下面列出的顺序一致。如果你也遇到了同样的问题,那么解决方法通常都很简单。

Slack不会保存重定向URL

这个问题会在任何代码开始运行之前就出现:Slack应用程序的配置页面会拒绝接受http://localhost:3000/callback这个地址。

对于所有使用重定向URL的情况,Slack都要求必须使用HTTPS协议,即使是对localhost也是如此。你可以使用mkcert命令生成一个本地证书,然后将其注册到Slack系统中,并将TLS_CERT_PATHTLS_KEY_PATH设置为该证书文件的位置。GitHub两种协议都是可以接受的,因此同一个HTTPS地址在两个平台上都可以正常使用。

浏览器会提示该证书不可信任

执行mkcert -install命令可以将mkcert生成的本地证书添加到系统的信任列表中,如果不执行这个步骤,那么生成的证书将不会被任何浏览器识别。

只需运行一次这个命令,之后使用mkcert生成的所有证书问题就能得到解决。不过,用openssl工具生成的自我签名证书总是会引发警告,因为没有任何系统会信任这种证书。

重定向URL不一致

所有的服务提供商都会将你发送的redirect_uri与他们在系统中注册的地址进行比较,而且这个比较必须是完全匹配的。如果在地址末尾添加斜杠、使用127.0.0.1代替localhost、或者将https改为http,都会导致匹配失败。

这个问题会在用户点击链接之前,在服务提供商自己的页面上就显示出来,因此很容易被发现。请确保OAUTH_REDIRECT_URI这个地址是唯一的,并且要在授权请求的URL以及令牌交换的过程中都使用这个地址,就像服务提供商注册表所要求的那样。

回调端口已经被其他人使用了

connect.js会根据OAUTH_REDIRECT_URI中的信息来绑定相应的端口,而端口3000是一个非常常用的端口。如果遇到未处理的EADDRINUSE错误,系统会生成一堆堆栈跟踪信息,但这些信息与OAuth协议无关;因此项目能够捕获到这个错误,并提示应该采取什么措施来解决它。

如果要更改回调端口,就需要在三个地方进行修改:.env文件、Slack应用程序的配置设置,以及GitHub应用程序的回调URL设置。如果其中任何一个地方没有进行相应的修改,就会导致之前提到的各种问题。

状态检查会拒绝合法的回调请求

状态信息是存储在内存中的,一旦被使用过后就会被删除。因此,在点击链接后重新启动connect.js程序,或者刷新回调页面,都会导致系统中的状态信息发生变化,从而使得回调请求失败。

这两种情况都属于正常的拒绝响应。你需要生成一个新的链接,然后重新开始操作。

工具调用会返回权限错误或空结果

范围信息缺失或授权被撤销都会导致这种问题。

当授权信息中缺少`repo`字段时,GitHub会返回`403`错误码,并显示“资源无法访问”;而Slack则会返回`200`状态码,同时显示`ok: false`以及类似`missing_scope`的错误信息。此时需要修复权限范围列表,然后再次让用户进行授权操作,因为现有的授权信息不会自动更新权限范围。

部分权限范围的授权会在使用环节出现故障,而不是在连接时出现问题,正是这一点使得这一现象看起来很神秘。授权流程本身是成功的,令牌也存储正确了,但几小时后工具调用时却会出现故障。

代理会读取本不应读取的频道

最有可能的原因是在与Slack进行交互时,存储的是`json.access_token`而不是`json.authed_user.access_token`。这两者都是字符串类型,都具有有效性,而且都能正常使用(只不过前者是代表应用程序而非具体用户)。

区分它们的关键在于返回的数据内容:如果返回的是用户的频道信息,那么使用的就是用户的令牌;而如果返回的频道是当前用户从未访问过的,那就说明存储的是机器人的令牌。

工具调用会以错误用户的身份运行

使用属于其他用户的令牌或标识符,虽然会“正确”地执行操作,但结果却是错误的。

有两种方法可以避免这种情况:首先,在验证用户身份后,要在服务器端确定用户的标识符,而不能根据客户端的输入来决定;其次,在调用`buildTools`函数时,要将用户的标识符作为参数传递,这样每个`execute`方法都会获取自己的令牌,从而确保没有代码路径会使用错误的凭证。

刷新功能一开始能正常使用,但后来就会失效

令牌是需要定期更新的。如果更新操作成功地将新的访问令牌写回系统,但却保留了旧的令牌,那么第一次更新会成功,但在下一次更新时就会失败。这种机制导致问题出现的时间与真正显示故障的时间之间会有12个小时的间隔。

saveGrant函数之所以要接收整个规范化后的令牌对象,原因就在于此——它需要将刷新操作返回的所有信息都保存下来。

代理会提交重复的问题

造成这种问题的原因有两个:要么是状态文件缺失或未正确生成,导致每次运行时系统都会重新对所有问题进行分类处理;要么是`stopWhen`参数设置的周期过长,使得模型会反复尝试已经成功完成的任务。

首先需要检查状态文件是否正常;其次要确认工具返回的结果是否明确表示操作成功,因为如果结果模棱两可,系统就可能会自动尝试再次执行操作。

结论

你成功地构建了一个代理程序,它能够读取Slack频道中的消息,判断哪些消息属于工作相关内容,并在GitHub上为这些消息创建问题报告,最后还会通过回复来完成整个处理流程。每次调用这个代理程序时,都会使用该用户自己的OAuth授权信息,通过你自己编写的 OAuth流程和令牌存储机制来执行操作。

这五个经验对于使用任何提供 OAuth 授权服务的平台来说都是通用的。

  • 工具其实就是一次 API 调用,再加上对该模型的相关说明而已,而其中解释这部分内容才是真正关键的工作。

  • 这个标识符会取代你代理代码中的令牌。所有那些用于处理用户信息的功能,实际上都是在引用该标识符,而非直接使用凭证信息;因此,令牌根本不会进入你的模型输入环节或日志系统中。

  • 连接时间和运行时间属于不同的流程。对于每个用户而言,每次使用某个应用时都需要进行授权操作;而运行时阶段才会根据该标识符去获取相应的令牌。

  • 授权权限仍然由你掌控。令牌存储系统只会告诉你哪些令牌属于某个特定的标识符,但是否允许某个调用方使用这个标识符,只有你的代码才能做出判断。

  • 如果身份信息能够被表示为一个字符串,那么支持多供应商就是个注册表管理的问题,而非架构设计上的问题。

authed_user.access_token这一细节虽然最为关键,但其体积却是最小的;正是这个属性决定了你的代理程序是会遵守你的工作空间已有的权限设置,还是会默默地绕过这些限制来执行操作。

接下来,只需调整各功能提供者的配置即可:将读取功能的提供者设置为Gmail,将写入功能的提供者设置为Linear,然后重新编写模型的输入数据。身份验证相关的逻辑并不会发生任何变化。 完整的源代码可以在这里找到:github.com/saif-shines/channel-watcher-agent

这篇文档详细介绍了我们在开发Scalekit这一工具过程中所学到的经验。实际上,Scalekit就是你刚刚构建的那个令牌管理系统的托管版本。

相关文章

技术实践

如何利用提示工程与上下文工程来开发人工智能代理

在这个教程中,我将向您展示提示工程和上下文工程如何提升人工智能模型的性能。 我们将构建一个简单的本地模型,从基础输入开始,然后通过使用更合适的提示语和更丰富的上下文信息来改进它,这样您就能看到每一项改变对最终输出结果的影响。 我们将会使用LangChain v1、Ollama、Qwen以及Python。所有操作都在您的个人电脑上完成,因此您无需支付任何API费用。 目录 背景知识 什么是提示工程? 什么是上下文工程? 为什么提示工程和上下文工程对人工智能模型如此重要 动机与架构 步骤1:安装Ollama并下载模型 步骤2:安装Python相关依赖库 步骤3:编写代理代码 示例输出结果 提示语优

阅读全文
技术实践

如何利用Gemini构建人工智能功能:面向开发者的提示工程实用指南

大多数关于提示工程的教学教程都遵循相同的流程:安装SDK,输入API密钥,调用 generateContent 函数,然后打印输出结果。模型会生成一些看似合理的内容,之后教学教程也就结束了。 但当你真正尝试将这个系统投入实际使用时,才会发现其实真正的准备工作根本还没有开始。 “API返回的文本”与“让用户感到可信的实际功能”之间的差距,正是需要耗费大量精力去解决的地方。 这个差距中充满了各种棘手的问题:模型生成的内容听起来和其他聊天机器人没什么两样;它会编造用户从未说过的话;它返回的数据会被用Markdown格式包裹起来;系统会在凌晨2点出现故障;而对于那些只是想得到答案的用户来说,系统展示的

阅读全文
技术实践

如何让你的副业项目被人们注意到,并吸引到愿意付费使用的用户

2022年,我在业余时间开发了一个小型微服务产品,最终以几千美元的价格将其卖了出去。如今,有了人工智能工具的帮助,开发这样的产品可能会更加容易。 但真正发生巨大变化的是获取关注的成本,而不是开发软件本身的成本。 我认为,在2026年,产品的分发渠道将比开发本身更为重要。在这篇文章中,我会与大家分享我在产品开发过程中所学到的经验,并试图劝阻大家在开始下一个项目之前,先不要急着直接投入编码工作。 读完这份指南后,你应该能够掌握一些实用的方法和思路,这些方法可以帮助你将自己那些充满热情的项目推向市场。 需要明确的是,这篇文章主要是针对那些正在开发数字产品的人,尤其是软件产品。不过,这些概念同样适用于

阅读全文
技术实践

在大型语言模型应用中,当你的两个模型都出现错误时,如何通过双重鲁棒性估计方法来进行产品测试与优化?

六个月前,你们推出的这款人工智能产品开始采用“用户主动选择参与”的模式。你们进行了倾向性分析,并考虑了用户的参与程度以及查询的准确性等因素,最终发现任务完成率确实提高了8个百分点。这一数据被纳入了季度业务报告中,大家都对此感到满意。 然而,严谨的数据科学家难免会提出一些棘手的问题:你们有多确定这个倾向性模型已经考虑到了所有可能影响结果的因素?如果遗漏了某些因素,那么逻辑回归模型得出的选择概率就会不准确;又或者,如果结果回归模型的设定本身就有误——因为任务完成率与查询准确性之间的关系并非线性的,线性模型根本无法准确反映这一关系——那又会怎样呢? 你们有两个模型,但并不确定哪个是正确的,而这两个模

阅读全文