Flue Notion Channel 实战:为 Agent 接入带签名验证的 Notion Webhook 事件流 📅 发布时间:2026/9/16 14:17:42 👁 浏览次数: Flue Notion Channel 实战为 Agent 接入带签名验证的 Notion Webhook 事件流【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue本篇技术指南聚焦 Flue 生态中的 Notion 通道channel通过一条flue add channel notion命令为已有 Flue 项目接入经过 HMAC-SHA256 签名验证的 Notion Webhook 入口同时利用官方notionhq/client构建由应用持有的只读页面检索工具。读完本文你将掌握 Notion 通道模块的完整结构、环境变量配置、初始验证回调的临时流程、事件到 Agent 实例的映射约定以及 Cloudflare Workers 下的部署与离线测试方法。快速开始用 blueprint 生成通道Flue 的通道channel以 blueprint 形式交付——它不是简单的包安装器而是一份由编码 Agent 执行的 Markdown 实施指南。在终端或编码 Agent 中运行flue add channel notion该命令会基于 blueprint 文档 完成以下工作安装flue/notion入口包请求验证与通道 HTTP 路由和官方notionhq/client及其必需的 TypeScript peer 依赖创建source-root/channels/notion.ts导出命名channel、项目持有的client、本地页面身份辅助函数以及一个绑定到页面的检索工具将该工具接入目标 Agent在受限的compilerOptions.types列表中加入node。通道遵循 Flue 的通用分工Flue 拥有已验证的入口ingress出站outbound行为由应用通过官方 SDK 自行实现。详细设计见 Channels 指南。通道模块验证入口与业务逻辑的边界blueprint 生成的src/channels/notion.ts是整条通道的核心abridged 形态如下import { Client } from notionhq/client; import { createNotionChannel } from flue/notion; import { dispatch, useModel } from flue/runtime; import { Assistant } from ../agents/assistant.ts; export const client new Client({ auth: process.env.NOTION_TOKEN! }); export const channel createNotionChannel({ verificationToken: process.env.NOTION_WEBHOOK_VERIFICATION_TOKEN!, async webhook({ event }) { if (event.type ! page.content_updated) return; await dispatch(Assistant, { id: notion-page:${encodeURIComponent(event.entity.id)}, message: { kind: signal, type: notion.${event.type}, // data is Notions event-specific detail object; page events // carry no natural message text. body: JSON.stringify(event.data ?? {}), attributes: { eventId: event.id, pageId: event.entity.id, attemptNumber: String(event.attempt_number), authorIds: event.authors.map((author) author.id).join(,), }, }, }); }, });这里清晰划出了两条边界验证与解析由flue/notion负责createNotionChannel()在 index.ts 中只声明一条固定路由POST /webhook对应的 handler 由 webhook.ts 实现——校验 content-type、读取请求体、比对签名全部完成之后才调用应用提供的webhook回调业务路由由应用负责webhook回调拿到的是已经过验证的官方 SDK 原生负载应用自行决定把哪些事件派发dispatch给哪个 Agent、以什么 id 进入会话。事件类型与agent作者类型的拓宽event的类型是官方 SDK 导出的*WebhookPayload联合体见 index.ts因此switch (event.type)可以把每个变体收窄到其 snake_case 负载形状。通道对类型只做了一处调整把authors/accessible_by拓宽为包含 Notion 文档中记录的agent作者类型——当前 SDK 类型遗漏了它对应NotionWebhookAuthorType定义见 index.ts而accessible_by仍保持person | bot。值得注意的前向兼容设计一个已通过签名验证、但其type比已安装 SDK 更新的事件依然会被转发——运行时按当前联合体类型传入字段保持原生由default分支处理。这里不存在合成的type: unknown变体、eventType或raw镜像。本地实例 id 约定notion-page:前缀是本地应用约定因为flue/notion不会为互不相关的 Notion 资源发明一个通用实例 id。本示例直接使用页面 id前提是一个项目持有的client即可选定安装。当单个 Agent 需要跨凭证域工作时应在本地 id 中加入 workspace 或 installation 身份信息。挂载通道route() 是纯路由工厂通道只在app.ts挂载它的地方提供 HTTP 路由。挂载模块的命名channel导出import { channel as notion } from ./channels/notion.ts; app.route(/channels/notion, notion.route());channel.route()是一个纯路由工厂负责在挂载路径下提供服务声明的路由。通道内部通过createChannelRouter(routes)构建可挂载的 Hono 子应用见 index.ts。本文中的 Webhook 路径均假设采用惯例挂载点/channels/notion不同的挂载路径会使所有提供方 URL 相应平移。完整的可运行示例见 examples/notion-channel/src/app.ts其中还额外挂载了createAgentRouter(Assistant)以便 Agent 可直接通过 HTTP 访问——但派发目标 Agent 模块本身带use agent指令该指令完成注册因此纯派发型 Agent 不需要自己的 HTTP 挂载。环境变量配置变量用途NOTION_WEBHOOK_VERIFICATION_TOKEN初始验证之后必填—— 验证周期性 Webhook 事件。NOTION_TOKEN必填—— 对出站 API 调用进行认证。Webhook URL 配置为https://example.com/channels/notion/webhook两个凭据相互独立Webhook 验证令牌用于入站签名校验出站 API 令牌用于notionhq/client认证。初始配置阶段应使用下文「初始验证」中的verification回调接收并安全持久化 Webhook 验证令牌。types/node为什么是必需 peerflue/notion将types/node声明为必需 peer见 package.json因为官方notionhq/client的声明文件导入了node:http。当包管理器不会自动安装必需 peer 时把它作为开发依赖添加pnpm add -D types/node这只是类型依赖不会向 Worker bundle 引入任何 Node 运行时代码。如果项目存在compilerOptions.types务必把node加进该列表。示例项目 examples/notion-channel/tsconfig.json 即按此配置。完整通道模块与检索工具blueprint 生成并完善的完整src/channels/notion.ts如下import { Client } from notionhq/client; import { createNotionChannel } from flue/notion; import { defineTool, dispatch, useModel } from flue/runtime; import { Assistant } from ../agents/assistant.ts; const PAGE_INSTANCE_PREFIX notion-page:; const notionFetch: NonNullableNonNullableConstructorParameterstypeof Client[0][fetch] ( url, init, ) globalThis.fetch(url, { method: init?.method, headers: init?.headers, body: init?.body, }); const verificationToken process.env.NOTION_WEBHOOK_VERIFICATION_TOKEN || undefined; export const client new Client({ auth: process.env.NOTION_TOKEN!, fetch: notionFetch, }); export const channel createNotionChannel({ ...(verificationToken ? { verificationToken } : {}), // Initial setup only: temporarily use this instead of verificationToken and // persist the received value through the projects secure secret workflow. // async verification({ verificationToken }) { // await saveNotionWebhookVerificationToken(verificationToken); // }, // Path: /channels/notion/webhook async webhook({ event }) { switch (event.type) { case page.created: case page.content_updated: case page.properties_updated: case page.moved: case page.undeleted: case page.locked: case page.unlocked: { await dispatch(Assistant, { id: pageInstanceId(event.entity.id), message: { kind: signal, type: notion.${event.type}, // data is Notions event-specific detail object; page events // carry no natural message text. body: JSON.stringify(event.data ?? {}), attributes: { eventId: event.id, pageId: event.entity.id, attemptNumber: String(event.attempt_number), authorIds: event.authors.map((author) author.id).join(,), }, }, }); return; } default: return; } }, }); export function retrievePage(pageId: string) { return defineTool({ name: retrieve_notion_page, description: Retrieve the Notion page bound to this agent., async run() { const page await client.pages.retrieve({ page_id: pageId }); return { output: { id: page.id, object: page.object, archived: archived in page ? page.archived : null, inTrash: in_trash in page ? page.in_trash : null, }, }; }, }); } export function pageInstanceId(pageId: string): string { if (!pageId) throw new TypeError(Notion page id must be non-empty.); return ${PAGE_INSTANCE_PREFIX}${encodeURIComponent(pageId)}; } export function pageIdFromInstanceId(id: string): string { if (!id.startsWith(PAGE_INSTANCE_PREFIX)) { throw new TypeError(Expected a local Notion page instance id.); } const pageId decodeURIComponent(id.slice(PAGE_INSTANCE_PREFIX.length)); if (!pageId) throw new TypeError(Expected a local Notion page instance id.); return pageId; }几个需要吃透的细节注入 Fetch 传输notionFetch把官方客户端内部请求显式路由到globalThis.fetch保证同一份代码在 Node 与 Cloudflare workerd 中都可执行notionhq/client的默认传输依赖 Node 环境。示例项目中该逻辑被抽到 examples/notion-channel/src/notion-client.ts页面事件集合page.created、page.content_updated、page.properties_updated、page.moved、page.undeleted、page.locked、page.unlocked归入同一分支刻意省略page.deleted——被删除页面上的绑定检索工具可能已无法读取删除事件应按应用持久化路径另行处理。评论事件暴露event.data.page_id当与应用的 Agent 策略一致时可复用同一本地页面身份事件体语义Notion Webhook 负载描述的是「变更」而非资源的完整当前状态页面事件没有天然消息文本因此body用JSON.stringify(event.data ?? {})序列化事件特定细节对象而eventId、pageId、attemptNumber、authorIds等事实放入attributes。页面检索工具的安全边界retrievePage(pageId)返回的defineTool闭包捕获了应用代码传入的页面 id。模型可以请求当前页面摘要但无法选择其他 workspace、页面、令牌或 API 路由——绑定的页面来自已验证事件。这正符合 blueprint 的告诫不要把 workspace、subscription、integration、页面或递送 id 当作出站 API 调用的授权依据。将工具绑定到 Agentuse agent; import { type AgentProps, useModel, useTool } from flue/runtime; import { pageIdFromInstanceId, retrievePage } from ../channels/notion.ts; export function Assistant({ id }: AgentProps) { useModel(anthropic/claude-haiku-4-5); const pageId pageIdFromInstanceId(id); useTool(retrievePage(pageId)); return Review the Notion page change. Retrieve the current page when its properties are needed.; }示例项目采用useInitialData的变体见 examples/notion-channel/src/agents/assistant.ts通道在dispatch时通过initialData: { pageId }携带页面 id实例创建时记录一次、之后忽略Agent 用useInitialData()读取并以Assistant.initialData静态字段 valibot schema 做校验。两种方式都遵循同一原则——工具不接受模型提供的页面 id受信代码从已验证事件绑定页面。use agent指令模块第一行完成 Agent 注册因此通道回调中的dispatch(...)不需要在app.ts挂载。通道与 Agent 的导入环是受支持的导入的绑定只在延迟回调与 Agent 函数体内读取。注意只有 Agent 需要直接经 HTTP 访问时才添加app.route(/agents/name, createAgentRouter(Assistant))。初始验证Notion 的第一次请求与众不同Notion 的第一次请求与后续周期递送完全不同它是一个未签名的 JSON 对象只包含verification_token在签名密钥存在之前发送。处理方式临时把示例中的verificationToken替换为被注释的verification({ verificationToken })回调。通过项目的安全密钥工作流持久化收到的令牌然后设置NOTION_WEBHOOK_VERIFICATION_TOKEN启用verificationToken重新部署移除临时设置回调。不要把验证令牌记入日志或派发给 Agent。该回调是设置代码不是经过认证的应用入口。当未配置verificationToken时已签名的周期事件会收到503且webhook回调不会执行——这一点在源码中有明确对应没有验证密钥时 handler 直接短路见 webhook.ts。对周期事件Notion 发送X-Notion-Signature: sha256hex-hmac。包在解析之前用存储的验证令牌对精确请求字节做 HMAC-SHA256 验证parseSignature只接受sha256后跟 64 位十六进制见 webhook.ts。每个订阅的签名令牌已经通过签名验证确立了发送方身份因此通道不暴露额外的 workspace、subscription 或 integration 约束选项。从 webhook.ts 的完整请求处理链可以看到一组明确的状态码契约场景状态码非application/jsoncontent-type415content-length 非法 / JSON 无法解析 / 缺少type字段400请求体超过限制默认 1 MiBbodyLimit可调413缺少签名且无verification_token401签名格式非法或 HMAC 校验失败401无验证密钥却收到签名事件503令牌轮换后旧令牌无法应答403或转交verification回调其中verification回调还有一个隐藏职责当递送令牌与已配置的verificationToken不匹配即 Notion 轮换了令牌时回调仍会运行——它是把新令牌呈现给运维的唯一路径对应注释见 index.ts。递送行为重试、顺序与幂等Notion 对失败递送最多重试8 次采用指数退避且不保证事件顺序event.id是递送 idevent.attempt_number标识重试次数当重复准入不可接受时应在派发前把递送 id 记入应用持有的持久化存储回调返回undefined产生空200返回 JSON 兼容值会成为响应体普通 Hono 或 FetchResponse原样透传对应serializeHandlerResult见 webhook.ts包不会强加自造的 handler 截止时间。应用始终拥有Webhook 订阅创建、事件选择、OAuth、安装与令牌存储、去重、顺序恢复、资源拉取与全部出站工具。flue/notion是无状态的不做去重或重排。Cloudflare Workers 部署与离线测试普通 API 调用通过注入的 Fetch 执行在 Flue 要求的nodejs_compat配置下的 workerd 中运行。按项目凭证约定使用process.env或类型化 Worker bindings并验证完整的 Worker 构建。OAuth 不在本通道示例范围内——应用额外选择上线的 SDK 操作需要自行验证。推荐离线测试策略详见 examples/notion-channel/README.md 与 blueprint 的测试清单使用原创的合成验证与事件负载测试而非直接抓包用 Web Crypto 生成本地 HMAC 签名覆盖合法、被篡改、缺失或畸形签名分别测试未签名的单字段验证请求与安全捕获路径在 Node 与 workerd 中通过注入的 fake Fetch 执行真实的Client.pages.retrieve()调用——fake 传输必须对意外 URL 抛错确保测试不会触达 Notion覆盖畸形 JSON、content-type、body 限制与 handler 响应行为以及确切的/channels/notion/webhook路由。pnpm run check:types pnpm run test pnpm run build pnpm run build:cloudflare参考资源通道生态文档Notion 官方文档页、Channels 总览包实现packages/notion/src/index.ts、packages/notion/src/webhook.ts、packages/notion/package.json、packages/notion/README.md实施蓝图blueprints/channel--notion.md可运行示例examples/notion-channel含通道模块、Agent、app.ts与双运行时Node Cloudflare构建配置【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考