首页 / 文章 / 如何构建具有每用户OAuth访问权限的AI智能体 [完整手册]
← 返回
IT技术

如何构建具有每用户OAuth访问权限的AI智能体 [完整手册]

✍️ zhirenhun 📅 2026/8/14 👁 209 阅读 ⏱ 104 分钟
如何构建具有每用户OAuth访问权限的AI智能体 [完整手册]

当你的AI智能体服务不止一个人时,每次工具调用都必须回答:这个智能体在代表谁行事?让我们通过构建一个连接Slack和GitHub的AI智能体来学习如何解决这个问题。

一次Slack读取使用该用户的工作区。GitHub issue以该用户的身份在其可访问的仓库中创建。智能体可能做出错误的判断,但绝不能用错误用户的权限行事。

解决方案有两个部分,两者都出现在本教程的前半部分:

  1. 每个用户分别授予访问权限。Alice为自己授权Slack。Bob为自己授权Slack。

  2. 你的智能体传递一个标识符,而不是令牌。alice@example.com这样的字符串选择使用谁的授权。一个函数在调用时将其转换为令牌,该令牌永远不会到达你的模型输入、工具模式或日志。

大多数智能体教程在这两点之前就止步了。它们给你一个API密钥,接好一个函数,然后模型调用它。这种设计在第二个人出现之前都能正常工作。

为了让这个模式更加具体,你将构建一个命令行智能体,它监视一个Slack频道,自行判断哪些消息描述了实际工作,为这些消息创建GitHub issue,并在Slack线程中回复issue链接。每次调用都以用户自己的OAuth授权运行。

你将自己编写OAuth流程:同意重定向、state检查、令牌交换、加密存储和刷新路径。这些都不长,而且看到完整流程才能让身份论证可被检验,而不是你凭信念接受的主张。

这里有两个主题不在讨论范围内:我们不会涉及Model Context Protocol服务器或语音/实时主机。身份模式在两种环境中都适用,但周围的配套机制值得单独写一篇文章。

目录

你将构建什么

这个智能体叫做channel-watcher-agent。每次运行做四件事:

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

  2. 逐条消息询问模型,文本描述的是bug还是具体的行动项。

  3. 为符合条件的消息创建GitHub issue。

  4. 在原始Slack线程中回复新issue的链接。

没有人点击按钮来启动这些操作。 Slack已经自带了一个“从这条消息创建issue”的功能,那是另一个产品。在这里,智能体读取频道,形成自己的判断,并且只对它认为值得处理的内容采取行动。

工具运行示例

技术栈刻意保持精简:

组件 作用
Node.js,纯ES模块 无Web框架,无队列
node:http OAuth回调服务器
node:crypto 令牌加密
node:sqlite 令牌存储,无需安装依赖
Vercel AI SDK 模型调用和工具循环

这五项中有三项随Node内置。你只需要安装AI SDK及其相关包。

到最后你将拥有:

完整的代码位于github.com/saif-shines/channel-watcher-agent

先决条件

账户和工具:

有用的背景知识,虽然不是硬性要求:

开始前的一个警告: 这个智能体写入真实系统。它会打开真实的GitHub issue并发布真实的Slack消息。在你确认它只处理你预期的消息之前,请使用测试Slack频道和临时GitHub仓库。

什么是AI智能体工具?

工具是你连同输入一起交给模型的函数。 模型不能自己运行该函数。它只能请求:用这个标题和正文调用fileGithubIssue。你的代码执行调用、返回结果,模型使用该结果来选择下一步。

请求、执行、返回。这种交换就是整个机制,所有被称为“智能体”的东西都是围绕它的循环。

工具与API有何不同

工具和API封装的是同一个调用,但面向不同的读者。

API是为你编写的。它假设你读过文档,并且知道thread_ts是将Slack消息变成线程回复的字段。

工具是为一个什么都没读过的模型编写的。所以工具自带解释:

下面是项目中的一个工具。大部分代码是解释而非逻辑:

const fileGithubIssue = tool({
  description: 'File a GitHub issue for an actionable Slack message',
  inputSchema: z.object({
    title: z.string(),
    body: z.string(),
  }),
  execute: async ({ title, body }) => {
    // ... the actual API call goes here
  },
});

description(描述)和 inputSchema(输入模式)是模型可见的部分。execute 函数则完全属于你自己。身份信息在 execute 内部得到确定,因此模型永远不会知道该调用是针对哪个账户执行的。

为什么模型处理工具优于直接调用原始API

把 curl 命令粘贴到输入中并让模型填空是可行的。但这种方法会以可预见的方式失败。

工具效果更好的原因有三个:

  1. 在你的代码运行之前,模式就已经得到强制执行。格式错误的工具调用会被 SDK 拒绝并重试。而格式错误的 URL 则会在运行时失败。

  2. 结果会返回给模型。在 fileGithubIssue(提交GitHub问题)返回后,模型可以读取新的问题 URL,并在 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,代理以触发运行的用户的身份行事。当这些用户是你产品的客户时,同样的设计依然成立:每个人仍然拥有自己的授权,而错误的映射意味着某人的运行使用了另一个人的访问权限。变化的只是标识符的来源。对团队成员来说是会话,对客户来说是租户记录。

架构概览

有两个流程很重要,它们发生在不同的时间。将它们分开是大部分工作的关键。

连接时间发生在每个用户、每个应用各一次。用户授权后,令牌落入你的存储中。此时代理没有运行。

运行时发生在每次执行时。代理将当前用户解析为标识符并执行工作。没有授权页面,也没有浏览器。

CONNECTION TIME (once per user, per app)

  Your user              connect.js              Slack / GitHub
     |                       |                         |
     |-- "connect Slack" --->|                         |
     |<--- consent link -----|                         |
     |----------------------- OAuth consent ---------->|
     |                       |<--- redirect + code ----|
     |                       |---- exchange code ----->|
     |                       |<---- tokens ------------|
     |                       |                         |
     |                  [encrypt, store                |
     |                   under (identifier,            |
     |                   provider)]                    |
     |                       |                         |


RUNTIME (every agent run)

  Your agent             Token store             Slack / GitHub
     |                       |                         |
  [resolve identifier        |                         |
   from your own session]    |                         |
     |                       |                         |
     |-- getAccessToken( --->|                         |
     |     identifier,       |                         |
     |     provider )        |                         |
     |<---- token -----------|                         |
     |                       |                         |
     |------------------ API call as user ------------>|
     |<----------------- result -----------------------|
     |                       |                         |
  [model sees result,        |                         |
   never a token]            |                         |

这种形状引出了三个性质。

标识符取代了代理代码中的令牌。令牌存储之上的所有内容都处理像alice@example.comuser_8f21c这样的字符串。字符串本身毫无价值:没有存储及其加密密钥,它什么也打不开。

一个身份横跨多个应用。单个标识符下面有Slack行和GitHub行。第三个应用不会创建需要协调的第三个身份。

授权保留在你的代码中。存储回答哪些令牌属于某个标识符。存储无法知道请求是否应得到回答。决定调用者可以以该标识符身份行事是在任何调用之前发生的。

有一条规则,违反它会破坏整个设计:从经过身份验证的会话中在服务端解析标识符。绝不接受来自请求体、查询参数或浏览器的标识符。从客户端接受的标识符就是一个“以任意用户身份操作”的端点。

如何注册Slack和GitHub OAuth应用

本教程从头到尾使用Slack和GitHub作为两个提供商。两者都需要相同的三样东西:已注册的应用、重定向URI和一组作用域。细节差异足够大,值得分别进行。

重定向URI必须使用HTTPS

大多数涉及OAuth的教程都会给你http://localhost:3000/callback然后继续。Slack拒绝它。Slack的文档直截了当地说“重定向URL也必须使用HTTPS”,并且对localhost没有例外。GitHub更宽松,接受任一种,所以一个HTTPS回调就同时满足两者。

这条规则看起来有些迂腐,因为在localhost上,请求永远不会离开你的机器,线路上也没有任何东西可拦截。但Slack还是一视同仁地应用它,对于发放凭证的提供商来说,一条没有例外的统一规则是站得住脚的选择:每个例外都是一个分支,必须有人正确处理,而“这真的是localhost吗”这个问题以前曾被错误回答过。

mkcert会签发由本地机构签名的证书,该机构会添加到你的系统信任存储中,因此浏览器会毫无警告地接受它:

mkcert -install
mkcert localhost

这会将 localhost.pemlocalhost-key.pem 写入当前目录。诸如 ngrok 之类的隧道服务也可以,但其免费 URL 会轮换,这意味着每次会话都需要重新编辑两个应用注册。

Slack 应用,以及那个唯一重要的设置

api.slack.com/apps 处,在你的工作区创建一个应用。然后打开 OAuth 与权限 并设置两件事。

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

然后找到作用域。页面有两个部分,选择错误的部分会静默地重建共享机器人设计:

部分 授予的权限 在这里使用?
机器人令牌范围 一个充当应用的 xoxb- 令牌
用户令牌范围 一个代表用户的 xoxp- 令牌

用户令牌范围 下,添加:

将机器人令牌范围留空。从 基本信息 复制客户端 ID 和客户端密钥。

GitHub OAuth 应用

在 设置 → 开发者设置 → OAuth 应用 → 新建 OAuth 应用 下,将授权回调 URL 设置为相同的 https://localhost:3000/callback,然后生成客户端密钥。GitHub 完整记录了 Web 应用流程,如果你想要更详细的背景信息。

GitHub 用于创建议题的 作用域 取决于仓库:

在可行时选择更窄的那个。不需要的作用域,日后还得解释。

两个应用都生成客户端 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',

    // These go in `user_scope`, not `scope`. Scopes listed under `scope` grant
    // a bot token, and a bot token is what this project exists to avoid.
    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 and refresh follow below
  },
};

这个 user_scope 参数是这一行中的完整参数。 Slack 为机器人权限读取 scope,为用户权限读取 user_scope。此项目只设置了后者,因此响应返回时根本不含任何机器人令牌。

state 参数不是可选的。它是一个你生成的随机字符串,发送给提供者,并在返回时进行核对。没有它,互联网上的任何页面都可以通过附加攻击者的 code 将浏览器指向你的回调 URL,而你的服务器会很乐意进行交换,并将攻击者的令牌存储在你的用户标识下。

第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 answers HTTP 200 even when the exchange failed. The `ok` field
  // is the real status.
  if (!json.ok) {
    throw new Error(`Slack token exchange failed: ${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user);
}

该函数中有两个细节一旦遗漏,就会耗费大量调试时间。

Slack 对失败也返回 HTTP 200。检查 response.ok 会告诉你 HTTP 请求是否成功,而它确实成功了。json.ok 字段才是报告 OAuth 交换是否成功的字段。

json.authed_user,而不是 json这就是上文所述分叉的体现,表现为一次属性访问。此处读取 json.access_token 会编译、运行、存储令牌,并悄悄让你代理的每个用户都拥有同一个机器人身份。

对结果进行规范化处理,可使代码库的其余部分与提供商无关:

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

GitHub对同一函数的实现有两个值得注意的不同之处:

async exchangeCode(code) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    // Without this header GitHub answers with a form-encoded body.
    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 token exchange failed: ${json.error_description ?? json.error}`
    );
  }

  // OAuth App tokens carry no expiry, so there is nothing to refresh.
  return {
    accessToken: json.access_token,
    refreshToken: null,
    expiresAt: null,
    scope: json.scope,
  };
}

这个 Accept: application/json 请求头很容易被忽略,并导致令人困惑的失败:response.json() 在返回体为 access_token=gho_...&scope=repo 时会抛出异常。

第 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(
      `Could not read the TLS certificate (${err.code ?? err.message}).\n` +
        'Generate a locally-trusted one with mkcert:\n' +
        '  mkcert -install\n' +
        '  mkcert localhost\n' +
        'then point TLS_CERT_PATH and TLS_KEY_PATH at the two files it writes.'
    );
  }
}

缺少证书的情况总会发生在某个人身上,而 ENOENT 本身无法解释任何关于 OAuth 的信息。catch 块用了四行来说明应该运行什么。

handler 本身正是在那里对 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('Not found');
    return;
  }

  const state = url.searchParams.get('state');
  const entry = pending.get(state);

  if (!entry) {
    response.writeHead(400).end('State mismatch. Start the flow again.');
    return;
  }

  pending.delete(state);

  const error = url.searchParams.get('error');
  if (error) {
    response.writeHead(400).end(`Authorization denied: ${error}`);
    entry.reject(new Error(`[${entry.provider}] authorization denied: ${error}`));
    return;
  }

  entry.finish(url.searchParams.get('code'), response);
}

这个 pending 映射就是 state 检查。 状态值只有在此进程生成它时才会进入该映射,并且一旦使用就会被删除。无法识别的状态意味着回调并非来自你启动的流程,而状态出现两次则意味着重放。这两种情况都源自一次 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} connected. You can close this tab.

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

randomBytes(16) 而不是 Math.random()。可预测的 state 参数等同于没有 state 参数。

运行它将依次遍历每个未连接的提供者:

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

请注意这个URL不包含任何scope参数。Slack没有指示要生成bot令牌,所以它不会这样做。

如何以用户为键存储加密令牌

存储回答一个问题:对于这个提供商,哪个令牌属于这个用户?其他方面都源于确保这个答案的安全。

node:sqlite自Node v22.5起随附发布,并在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 must be 32 bytes, base64-encoded. ' +
      `Got ${KEY.length} bytes.`
  );
}

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

// One row per user, per provider. expires_at stays outside the ciphertext so
// a token's freshness can be checked without decrypting it.
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. 每次加密使用全新的IV: 对GCM重用初始化向量是灾难性的失败,而非轻微问题。每次调用都使用randomBytes(12),并将其存储在密文旁边。

  2. 保留认证标签: GCM生成的标签用于证明密文未被篡改。如果在解密时没有调用setAuthTag,你就只有加密而没有完整性校验,而且decipher.final()也不会报错。

  3. 加密整个令牌对象,而不是每个字段。{ accessToken, refreshToken, scope }使用一份密文,意味着只需管理一个IV和一个标签,而不是分别管理三个。

写入和读取操作因此变得平淡无奇:

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 = excluded.expires_at`
  ).run(identifier, provider, ciphertext, iv, authTag, tokens.expiresAt ?? null);
}

ON CONFLICT 子句的重要性远超其表象。重新授权必须替换已有授权,而不是失败或产生重复,而重新授权正是用户在撤销授权或更改权限范围后所做的操作。

加密密钥本身存放在此处的 .env 中,这对教程来说是正确的,但对生产环境则是错误的——在生产环境中,它应放在密钥管理器或 KMS 中。丢失密钥会使所有已存储的授权无法读取,并迫使每个用户重新授权。这确实是一次真实的中断,但总比另一种情况要好:数据库文件被盗,导致你智能体每个用户的有效令牌被泄露。

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

运行时包含三个步骤:解析标识符,用它获取令牌,并将整个过程封装为工具。

步骤 1:先解析标识符,再授权

标识符是任意稳定的字符串,代表一个用户、一个电子邮件地址、一个用户 ID 或一个租户范围内的密钥。

// In a real app this comes from your authenticated session, resolved
// server-side. Never accept it from client input.
const IDENTIFIER = process.argv[2] ?? 'channel-watcher-agent';

argv中读取标识符使演示无需登录即可运行,并且使本教程后面的隔离测试成为一条单一命令。真实应用程序会替换这一行:

// Real app: resolve from your authenticated session, server-side.
const session = await getSession(request);                  // your auth
const identifier = await lookupIdentifier(session.userId);  // your database

这两行代码的顺序很重要。 先验证调用者身份,再查找该调用者可以代表哪个标识符进行操作。来自客户端的标识符会将端点变成任何用户 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}] no grant for "${identifier}".\n` +
        `Connect it first: node src/connect.js ${identifier}`
    );
  }

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

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

  if (!grant.refreshToken) {
    throw new Error(
      `[${providerName}] token for "${identifier}" expired and no refresh ` +
        'token is stored. The user has to consent again.'
    );
  }

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

在API调用之前立即调用此方法,而不是在启动时调用一次。长时间运行的agent可能会超过十二小时令牌的有效期,提前解析令牌意味着在最不方便的时刻才发现问题。延迟获取只需一次廉价的数据库读取,却消除了整类问题。

此外,六十秒的窗口期并非为了凑数而存在。一个只剩四秒的令牌能通过简单的过期检查,然后在运行中过期。在窗口期内刷新任何内容,意味着返回的令牌至少可以保证一分钟的工作。

最后,缺失授权时应抛出错误而不是回退。没有合理的回退方案。对于未连接的用户,正确的结果是停止运行,并显示提示信息告知如何连接。

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

身份信息在此处注入,位于模型可以影响的一切之下的一层:

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

  const fileGithubIssue = tool({
    description: 'File a GitHub issue for an actionable Slack message',
    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:
      'Reply in the original Slack thread (e.g. with the created issue link)',
    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返回一个时间戳。两者都不会返回令牌,也都不会返回原始提供者响应——如果有令牌要隐藏的话,它就会藏在那里。

提供者调用本身是普通的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 here usually means the grant is missing the `repo` scope.
    throw new Error(
      `GitHub issue creation failed (${response.status}): ${json.message}`
    );
  }

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

第4步:读取频道

Slack 的 conversations.history 返回干净的 JSON,但有一个缺口:消息携带的是用户 ID,而不是显示名称。将这些 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(); // oldest first
}

该函数中的三个小决定:

  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.env.MODEL),
  tools,
  stopWhen: stepCountIs(5),
  prompt: `You triage messages from a dev team's Slack channel.

Message from ${message.author}: "${message.text}"
Message timestamp (thread_ts): ${message.ts}

Decide if this message is actionable (a bug report or concrete action item) or just noise (chit-chat, join notices, already-resolved chatter).

If actionable: file a GitHub issue with a clear title and body drafted from the message, then reply in the original Slack thread (use the exact thread_ts above) with a short note and the created issue's URL.

If not actionable: do nothing and briefly say why.`,
});

循环是使第二步成为可能的机制。模型读取消息,并可能调用fileGithubIssue。AI SDK运行该工具,将结果连同新的issue URL一起反馈到上下文中,并再次调用模型。现在模型可以在线程中回复一个它在第一轮时不可能知道的URL。然后停止。

stopWhen: stepCountIs(5)限制了轮数。如果没有边界,困惑的模型可能会无限重试失败的工具。对于两个工具来说,五轮已经很宽容了。

确定性版本也是合理的:使用结构化输出调用进行分类,然后在消息符合条件时按固定顺序自行调用这两个工具。

固定序列更容易测试,但放弃了真正的灵活性。循环允许模型跳过回复,或不回复就提交issue,而且添加第三个工具不需要新的分支。当每类输入对应的操作集合各不相同,且永远不会固定时,请选择循环;而在它们永不变化时,选择固定序列。

关于provider这一行,为了准确说明实际运行的内容。上面的代码片段使用了@ai-sdk/anthropic,这适用于直接使用Anthropic API密钥的情况。我自己的测试是通过一个兼容OpenAI的网关进行的,这仅改变了provider的构造方式:

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,
});
// then: model: gateway(process.env.MODEL)

无论哪种方式,工具、循环和令牌处理都是相同的。只有model参数会发生改变。

如何处理刷新和撤销

令牌以两种不同的方式结束,其中只有一种是你的代码的问题**。** 过期是常规且可恢复的。撤销是某人做出的决定,正确的响应是再次请求同意。

本教程中的两个提供方位于该范围的两端,因此它们是一对有用的组合。

GitHub:令牌不会过期,直到它们过期

OAuth 应用用户令牌没有过期时间戳。没有需要存储的刷新令牌,也没有需要发出的刷新调用,这就是为什么本项目中的github.refresh()除了自我说明之外什么都不做:

async refresh() {
  throw new Error(
    'GitHub OAuth App tokens do not expire. A failure here means the ' +
      'grant was revoked — send the user through consent again.'
  );
}

"不会过期"不等于"永久有效",GitHub出于几个值得了解的原因会撤销令牌

第三种情况值得特别关注。GitHub会扫描公共推送中是否包含其自有令牌格式,一旦发现就会将其删除。这是一个安全网,而非一种策略,但它无法保护私有仓库或日志文件中的令牌。

GitHub Apps与OAuth Apps的行为有所不同,这在阅读GitHub文档时常常造成困惑。GitHub App的用户访问令牌在八小时后过期,并附带一个有效期为六个月的刷新令牌。如果你改用GitHub Apps进行构建,下面提到的Slack风格刷新路径正是你需要的。

Slack:轮换可选择开启且永久生效

默认情况下,Slack用户令牌同样不会过期。令牌轮换改变了这一状况,并附带一个值得重申的警告:轮换一旦开启,便无法关闭。请先在测试应用上启用它。

开启轮换后,令牌有效期为十二小时,并随附一个刷新令牌。刷新调用复用与初始交换相同的端点,但使用不同的授权类型:

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 token refresh failed: ${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user ?? json);
}

存储新的刷新令牌,而不只是新的访问令牌。刷新令牌同样会轮换。如果只写回访问令牌,你就会持有一个已用过的刷新令牌,而失败会在十二小时后才出现,等待这么久才知道结果,实在漫长。

正是出于这一原因,getAccessToken在刷新后会调用saveGrant进行回写,而不是返回令牌后就此了事。

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

被撤销的授权并不是异常意义上的异常。用户会离开,管理员会收紧作用域,人们也会改变对代理可以做什么的想法。

可行的做法正是getAccessToken已经采用的:捕获失败,并呈现一个新的授权链接,而不是堆栈跟踪。connect.js使用相同的标识符让用户重新同意,ON CONFLICT覆盖失效的行,而用户记录中的其他内容都不会改变。

如何添加第二个提供商

第二个提供商只需要一个OAuth应用、providers对象中的一个条目,以及一个工具。将身份保存在单个字符串中,正是获得这种折扣的关键。

代理一直使用两个提供商。值得注意的第二个提供商不需要的是:不需要第二个身份、不需要第二个同意服务器,也不需要第二个令牌表。

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

将 Google Calendar 作为第三种,意味着第三个条目,带有自己的 authorizeUrltokenUrl、作用域和 exchangeCode。同意服务器会遍历 Object.keys(providers),因此无需修改即可获取新条目。存储已经以 (identifier, provider) 为键,因此无需迁移。然后再加一个工具:

const createCalendarEvent = tool({
  description: 'Create a calendar event',
  inputSchema: z.object({ summary: z.string(), start: z.string() }),
  execute: async ({ summary, start }) => {
    const token = await getAccessToken(identifier, 'google-calendar');
    // ...one more provider call
  },
});

标识符不会改变,你的用户表不会改变,而模型对世界的认知恰好增加了一个工具。

无法随规模扩展而降低的成本是供应商特定的知识。每个新供应商都会带来自己的作用域词汇、自己的错误格式,以及自己对令牌是否会过期的回答。Slack 和 GitHub 在这三方面都意见不一,而第三个供应商也会以不同的方式存在分歧。注册表模式将这些知识封装在每家供应商一个对象中,而不是将其分散在你的代理中,但这并不会让这些知识变得多余。

关于同意的一个注意事项:授权是基于每个用户、每个供应商的。Alice 连接了 Slack 但未连接 Calendar,这意味着她的日历工具调用会失败,而失败在那里是正确的结果,因为她从未同意。应将其视为连接提示而非错误,这一点在故障模式部分会再次提及。

完整演练

克隆仓库,安装,并填写.env

git clone https://github.com/saif-shines/channel-watcher-agent.git
cd channel-watcher-agent
npm install
cp .env.example .env
# fill in both client IDs and secrets, the encryption key, channel ID, repo

然后进行连接。该命令会启动回调服务器,并为每个未连接的提供商打印一个链接:

npm run connect
[slack] authorize as "channel-watcher-agent":
https://slack.com/oauth/v2/authorize?client_id=123.456&user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&state=1159699dbf1a808fd33ba31c7b643505
[slack] connected.

[github] authorize as "channel-watcher-agent":
https://github.com/login/oauth/authorize?client_id=Iv1.abc&scope=repo&redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&state=e6d13461099c391367266235f8313630
[github] connected.

All providers connected. Run: node src/index.js channel-watcher-agent

打开每个链接,同意授权,标签页会确认。这些URL中的state参数会在返回时被检查。携带其他任何内容的回调都会收到400错误,且永远不会到达令牌交换环节。

然后针对一个包含普通聊天的频道运行代理:

node src/index.js

针对一个仅有三条普通消息的频道,该次运行的输出如下:

[channel-watcher-agent] 3 messages fetched, 3 new.

--- Alex: "Sending draft message" ---
The message "Sending draft message" is noise — it appears to be a test or
accidental send, not a bug report or concrete action item.

...

没有调用工具,也没有提交问题。 负面情况比看起来更重要。一个拥有写入权限却不会拒绝的智能体是一种负担,而对普通闲聊运行一次是最便宜的克制测试。

现在在频道中发布一个实际的错误报告:

嘿,/export 端点对于任何超过50MB的文件都会超时,从昨天的部署开始就出现了。

再次运行智能体,状态文件可以防止早先的消息被重复分类处理。

作者身份才是关键。 标识符背后的GitHub账户会打开问题,使用的是该用户自己的OAuth授权,而不是共享机器人。Slack回复也来自该人。撤销他们的访问权限后,下一次运行将在 getAccessToken 处失败,这是正确的结果。

第二个用户会发生什么变化

标识符来自命令行,因此无需先构建登录即可测试隔离性:

node src/index.js                     # the identifier you already authorized
node src/index.js alice@example.com   # a different user entirely

第二个命令从不读取通道。它会停止:

[slack] no grant for "alice@example.com".
Connect it first: node src/connect.js alice@example.com

拒绝才是关键所在。两条命令之间,agent 本身没有任何变化。提供方、工具和代码完全相同。只有标识符不同,而 Alice 尚未同意,因此不存在可解密的记录行,运行在访问 Slack 之前就停止了。

共享 bot 版本的行为则不同。第二条命令会以 bot 的身份读取频道并提交 issue,因为这其中从未涉及按用户授权。

一旦 Alice 同意,后续的一切都遵循她的授权。readGrant 返回她的记录行。getAccessToken 解密她的令牌。Slack 读取返回她能看到的频道,而她的 GitHub 账户则作为 issue 的作者。

生产环境中,argv 会被替换为会话查找:

const identifier = await lookupIdentifier(session.userId);

测试无凭据隔离

该仓库包含一个测试套件,用模拟的 Slack 和 GitHub 端点替换 fetch,因此请求构建、响应解析、存储和刷新逻辑全部在未注册任何 OAuth 应用的情况下运行:

npm test

其中三个测试值得点名,因为它们检验的是本教程的论断,而非代码内部实现:

首次运行就通过的测试值得怀疑,所以我通过故意破坏代码来检查这些测试。用机器人令牌取代用户令牌、在buildTools中忽略标识符、以及在模型可见的模式中暴露identifier,每种情况都至少使一个测试失败。

如何将该模式应用于其他用例

该模式中没有任何内容是特定于Slack分类的。其形态是:从一个应用读取,用模型决策,写入另一个应用,全程以同一个用户身份进行。

更换提供商会产生不同的产品:

读取自 写入到 结果
Slack GitHub 将频道闲聊分类为issue,如本教程所示
Gmail Linear 将支持邮件转化为可跟踪的工作
Google Calendar Notion 会议准备笔记,在会议前起草
Zendesk Salesforce 针对正确的账户记录支持信号
GitHub Slack 在关心的频道中生成变更摘要

每一行都使用相同的三个组成部分:一个提供商入口、按标识符查找令牌、以及一个工具。只有三个东西会变:OAuth应用注册、execute内部的API调用,以及你为模型编写的输入。

输入才是你的产品所在。OAuth是管道工程。决定哪些消息值得一个issue,以及issue应该说什么,这是判断力,而判断力才是值得你花数周的部分。

相同的代码支持两种部署形态:

代码保持不变,但错误标识符的后果却不同。

构建过程中遇到的问题

这些是我在构建项目时遇到的问题,大致按它们出现的顺序排列。如果你遇到同样的问题,修复通常很小。

Slack无法保存重定向URL

症状在任何代码运行之前就出现了:Slack应用配置页面拒绝接受http://localhost:3000/callback

Slack要求重定向URL使用HTTPS,对localhost也不例外。使用mkcert签发本地证书,注册https://形式,并将TLS_CERT_PATHTLS_KEY_PATH指向它生成的文件。GitHub接受两种协议,因此同一个HTTPS URL可以同时用于两个应用。

浏览器警告证书不受信任

mkcert -install是将mkcert的本地证书颁发机构添加到系统信任库的步骤,跳过它会导致生成的证书不被任何浏览器识别。

运行一次即可修复之后mkcert签发的所有证书。用openssl生成的自签名证书总是会警告,因为没有任何信任它。

重定向URI不匹配

两个提供商都会将你发送的redirect_uri与应用中注册的进行精确比较。末尾斜杠、用127.0.0.1代替localhost、在注册https的地方使用http,或不同的端口,都会失败。

错误在授权同意之前出现在提供商自己的页面上,至少这使它容易发现。保持OAUTH_REDIRECT_URI作为单一来源,并在授权URL和令牌交换中都传入它,就像提供商注册表所做的那样。

回调端口已被占用

connect.js绑定来自OAUTH_REDIRECT_URI的端口,而3000端口很常用。未处理的EADDRINUSE会产生一个与OAuth无关的堆栈跟踪,因此项目捕获它并说明应该怎么做。

更改端口意味着要在三个地方修改:.env、Slack应用的重定向URL以及GitHub应用的回调URL。遗漏一个就会导致之前的失败。

状态检查拒绝合法的回调

状态值存在于内存中,使用后即被删除。打开链接后重启connect.js,或刷新回调标签页,都会产生一个不再在映射中的状态。

两种都是正确的拒绝。生成一个新的链接并重新开始。

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

缺少权限范围或被撤销的授权会导致两种情况。

当授权缺少repo时,GitHub返回403并提示“Resource not accessible”。Slack返回200,带有ok: false和类似missing_scope的错误。修复权限范围列表,然后让用户再次通过授权同意,因为已有的授权不会追溯获得新的权限范围。

权限范围不完整的授权在使用时失败,而非在连接时失败,这使症状看起来神秘。同意屏幕成功,令牌存储正常,而失败却在数小时后的工具调用中出现。

代理读取了不该读取的频道

最可能的原因是在Slack交换期间存储了json.access_token而不是json.authed_user.access_token。两者都是字符串,都是真值,都能工作(其中一个是以应用身份而非个人身份工作)。

破绽在于返回的内容范围。用户令牌只返回那个人的频道。如果conversations.history返回了当前用户从未加入的频道,那么存储中就是机器人令牌。

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

使用属于别人的令牌或标识符,会正确地做错误的事情。

两个习惯可以防止这一点。在验证调用者后,在服务端解析标识符,绝不要从客户端输入获取。然后,在buildTools中将标识符作为闭包参数,让每个execute获取自己的令牌,这样任何代码路径都无法传递游离的凭证。

刷新只工作一次然后停止

刷新令牌会轮换。如果刷新时写回了新的访问令牌但保留了旧的刷新令牌,那么它会立即成功,但在下一个周期失败,这使得bug和症状之间间隔十二小时。

saveGrant正是因为这个原因而接收整个规范化后的令牌对象。将刷新返回的所有内容都写回。

代理提交了重复的issue

原因有两个。状态文件缺失或未写入会导致每次运行都重新处理所有内容。或者stopWhen允许足够多的轮次,使困惑的模型重试已经成功的工具。

首先检查状态文件。然后检查工具的返回值是否明确表明成功,因为模糊的结果会招致重试。

结论

你已经构建了一个智能体,它能读取 Slack 频道,判断哪些消息描述的是真实工作,为这些消息提交 GitHub issue,并通过线程回复完成闭环。每次调用都以某个特定用户自己的 OAuth 授权身份运行,整个过程依靠你自己编写的 OAuth 流程和令牌存储。

有五个理念可以沿用到任何提供商:

分量最重的细节同时也是最小的:authed_user.access_token而不是access_token。一次属性访问就决定了你的智能体是尊重工作区已有的权限,还是悄然绕过它们。

从这里开始,保持整体形态,更换提供商。把读取端指向 Gmail,写入端指向 Linear,然后为模型重写输入。身份管道不需要改变。

完整源码位于 github.com/saif-shines/channel-watcher-agent

这篇文章回顾了我们在构建 Scalekit(你刚刚构建的令牌库的托管版本)时学到的经验。

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

← 上一篇
构建你的智能体软件工厂!
下一篇 →
2026年LLM推理成本对比:DigitalOcean与Together AI、Fireworks AI、Modal、Nebius、Baseten和OpenRouter

📌 相关推荐

GraphRAG 是推理问题,而非数据库问题
2026/8/30
构建市场时光机:使用 Python 和 WebSocket 重放交易会话
2026/8/30
如何自行基准测试LLM推理:值得信赖的数字设计标准
2026/8/30
← 返回文章列表