Skip to content

第 10 章 · Channels 事件通道

本章目标:理解 Flue 的 Channels 入站事件模型,学会接收 Slack/GitHub 等外部平台的验证事件,并用 dispatch(...) 把事件路由进 agent 会话。

10.1 Channel 是什么

一个 channel 把外部服务(Slack、GitHub、Stripe……)连接到你的 agents。它本质上是一段经过验证的 HTTP 入口:每个到达的投递(delivery)先被验证签名与身份,然后把平台原生 payload 交给你的代码,由你决定路由给哪个 agent 会话。

关键设计:入站专用

Channel 只负责"进来"。对外调用(发 Slack 消息、建 GitHub issue)不属于 channel——它们留在你的应用里,直接使用对应平台自己的 SDK 编写。这个边界让 Flue 不必封装所有第三方 API。

typescript
// src/channels/github.ts
// GitHub channel:接收 webhook 投递并验证来源
import { defineChannel } from '@flue/runtime';

export const githubChannel = defineChannel({
  name: 'github',
  // 平台标识:Flue 据此选择对应的签名验证逻辑
  provider: 'github',
});

10.2 验证与信任

"Verified events" 是 channels 的核心承诺:不是任何人 POST 一个 JSON 就能触发你的 agent。以 GitHub 为例,标准做法是校验 X-Hub-Signature-256

typescript
// 手动验证 webhook 签名(理解 channel 内部做了什么)
import { createHmac } from 'node:crypto';

export function verifyGithubSignature(
  rawBody: string,
  signature: string | undefined,
  secret: string,
): boolean {
  if (!signature) return false;
  // HMAC-SHA256 计算摘要
  const expected =
    'sha256=' +
    createHmac('sha256', secret).update(rawBody).digest('hex');
  // 常量时间比较,避免时序攻击
  return signature === expected;
}

Flue 的 channel 层把这类验证标准化了:你配置好密钥,投递未通过验证时根本不会进入你的处理函数。

10.3 dispatch(...):把事件路由给 agent

拿到已验证的原生 payload 后,用 dispatch(...) 把它送进某个 agent 的会话:

typescript
// src/agents/triage.ts(节选)
'use agent';
import { useModel, useTool } from '@flue/runtime';
import { searchCode } from '../tools/github.ts';

export function Triage() {
  useModel('anthropic/claude-sonnet-4-6');
  useTool(searchCode);
  return '你是 issue 分诊助手,收到新 issue 后判断优先级并给出结论。';
}
typescript
// src/channels/route-github.ts
// 把 GitHub webhook payload 路由给 Triage agent
import { dispatch } from '@flue/runtime';
import type { AgentHandle } from '@flue/runtime';

export async function onIssueOpened(
  triage: AgentHandle,
  event: { action: string; issue: { number: number; title: string; body?: string } },
) {
  // 只关心"打开 issue"这一种动作
  if (event.action !== 'opened') return;

  await dispatch(triage, [
    {
      role: 'user',
      content: `新 GitHub issue #${event.issue.number}:
标题:${event.issue.title}
内容:${event.issue.body ?? '(无)'}

请分诊:判断类型、严重程度、是否需要立即修复。`,
    },
  ]);
}

dispatch 的返回是"受理"而非"完成结果"——真正的执行由 runtime 接管,这也是第 12 章 Durability 合约的基础。

10.4 事件驱动的完整链路

一条 Slack 消息从用户发出到 agent 回应,经过四个阶段:

text
① Slack 发送事件 → ② Flue HTTP 入口验签 → ③ 你的路由代码 dispatch() → ④ agent 会话执行
typescript
// src/server.ts
// 把多个 channel 注册到同一个 HTTP 服务上
import { createApp } from '@flue/runtime/node';
import { githubChannel } from './channels/github.ts';

const app = createApp();

// 注册 channel:Flue 自动挂载验证过的入口路由
app.channel(githubChannel, {
  secret: process.env.GITHUB_WEBHOOK_SECRET!, // 从环境变量读密钥
});

app.listen(3000, () => console.log('channels ready on :3000'));

密钥管理

Webhook 密钥永远不要硬编码。本地开发放 .env,Cloudflare Workers 用 Secrets,CI 用仓库 Secrets——三种环境统一通过 process.env / env 读取。

10.5 常见模式:去重与过滤

外部平台会重试投递(超时即重发),你的路由层应当幂等。两个实用技巧:

typescript
// 幂等去重:用 delivery id 做短期缓存
const seen = new Set<string>();

export async function handleDelivery(id: string, fn: () => Promise<void>) {
  if (seen.has(id)) return;      // 重复投递直接忽略
  seen.add(id);
  try {
    await fn();
  } finally {
    // 生产环境建议换成 Redis/数据库 + TTL,这里仅演示
    setTimeout(() => seen.delete(id), 10 * 60 * 1000);
  }
}
typescript
// 事件过滤:只让值得处理的负载进入 agent(省钱省时间)
export function isWorthTriage(issue: { labels: string[]; author_association: string }) {
  // 已有标签或机器人创建的 issue 不再分诊
  if (issue.labels.length > 0) return false;
  if (issue.author_association === 'BOT') return false;
  return true;
}

10.6 本章小结

  • Channel = 经过验证的 HTTP 入口 + 平台原生 payload + dispatch(...) 路由;
  • Channel 只做入站;出站调用留在应用层,用各平台自己的 SDK;
  • 每个 delivery 先验签后处理,未通过验证不会触发 agent;
  • 生产必备两件事:幂等去重(应对重试)+ 密钥走环境变量。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. Flue 中 Channel 的职责边界是什么?

2. 把一个已验证的外部事件送进 agent 会话,应该使用哪个 API?

3. GitHub 对同一次 webhook 因超时会重复投递,你的路由代码应该如何应对?

4. 关于 webhook 密钥的管理,下列做法正确的是?

🛠️ 动手实践

  1. 为 GitHub channel 写一个只响应 issues.openedpull_request.synchronize 两种事件的过滤器,其余事件打印日志后忽略。
  2. 给 10.5 的去重函数换上持久化实现(SQLite 或 Redis),并在 README 里说明 TTL 选择依据。
  3. curl -X POST 模拟一次不带签名的投递,确认 Flue 入口拒绝它;再带上正确签名重试。