基于 Flue 的 Linear 渠道集成:验证 Webhook 接入、Agent 分发与双向会话工具实战
2026/9/17 3:28:45 网站建设 项目流程

基于 Flue 的 Linear 渠道集成:验证 Webhook 接入、Agent 分发与双向会话工具实战

【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue

本文以 Flue 仓库中的linear-channel示例为核心,讲解如何在 Flue 应用中接入 Linear:通过官方@flue/linear包实现经过 HMAC-SHA256 签名验证的 webhook 入口,将 Issue 评论与 Agent 会话事件分发给专属 Agent 实例,并借助项目自持的官方LinearClient暴露post_linear_message工具让 Agent 回帖到 Issue 评论线程或 Agent 会话。读完本文,你将掌握一套完整的"Linear 事件入站 → Flue Agent 处理 → Linear 出站回复"双向闭环的落地姿势。

示例概览:一条完整的事件驱动闭环

examples/linear-channel演示了 Flue 与 Linear 集成的三个核心要素,缺一不可:

  1. 已验证的 Linear webhook 入站:由@flue/linear提供的createLinearChannel完成签名校验、时间戳防重放、固定组织/Webhook 身份校验,只把可信的负载交给应用。
  2. 项目自持的官方LinearClient:直接使用 Linear 官方 SDK(@linear/sdk)而非抽象封装,出站能力完整。
  3. 应用自有的工具函数:通过defineTool定义post_linear_message,让 Agent 在运行时把消息写到当前绑定的 Issue 评论线程或 Agent 会话中。

示例的入口文件 app.ts 清晰地展示了整个应用的挂载结构:

import { createAgentRouter } from '@flue/runtime/routing'; import { Hono } from 'hono'; import { Assistant } from './agents/assistant.ts'; import { channel } from './channels/linear.ts'; const app = new Hono(); app.route('/agents/assistant', createAgentRouter(Assistant)); app.route('/channels/linear', channel.route()); export default app;
  • POST /agents/assistant/:idcreateAgentRouter(Assistant)提供,用于按实例 id 触发 Agent(详见 createAgentRouter 的实现);
  • POST /channels/linear/webhookchannel.route()挂载,是 Linear 回调的入口,路由路径由@flue/linear的 channel 定义生成(见 createLinearChannel 中的路由定义)。

必需环境变量与可选加固项

示例 README 声明了两组必需变量:

LINEAR_WEBHOOK_SECRET=... LINEAR_API_KEY=lin_api_...
  • LINEAR_WEBHOOK_SECRET:与 Linear 后台配置的签名密钥一致,用于验证请求字节;
  • LINEAR_API_KEYlin_api_前缀的 API Key,用于构造官方LinearClient出站调用。

另有三个可选变量用于加固或适配不同认证方式:

  • LINEAR_ACCESS_TOKEN当使用已安装的 OAuth 应用时,用它替代LINEAR_API_KEY。二者不能同时设置——linear.ts 中的linearCredentials()会明确抛出Set LINEAR_API_KEY or LINEAR_ACCESS_TOKEN, not both.,两者都缺失时抛出LINEAR_API_KEY or LINEAR_ACCESS_TOKEN is required.
  • LINEAR_ORGANIZATION_ID:可选,把签名端点固定到某一个组织,签名负载中的organizationId不匹配时返回403
  • LINEAR_WEBHOOK_ID:可选,把签名端点固定到某一个 webhook,负载中的webhookId不匹配时同样返回403

对应的取值逻辑在 linear.ts 顶部通过optionalEnv读取,再以展开语法条件性地传入 channel 配置。

创建已验证的 Linear Webhook 通道

示例在 channels/linear.ts 中调用createLinearChannel创建通道:

export const channel = createLinearChannel({ webhookSecret: requiredEnv('LINEAR_WEBHOOK_SECRET'), ...(organizationId === undefined ? {} : { organizationId }), ...(webhookId === undefined ? {} : { webhookId }), // Path: /channels/linear/webhook async webhook({ payload, deliveryId }) { // ... }, });

createLinearChannel的完整选项定义见 @flue/linear 包:

选项类型说明默认值
webhookSecretstring验证 Linear 请求原始字节的签名密钥,必填且非空
organizationIdstring可选的固定组织 id,签名负载不匹配返回403
webhookIdstring可选的固定 webhook id,签名负载不匹配返回403
bodyLimitnumber请求体大小上限(字节)1 MiB1024 * 1024
webhook函数接收每个通过验证的 Linear 投递,参数为{ c, payload, deliveryId }必填

validateOptions(packages/linear/src/index.ts#L177-L193)会在创建时对空密钥、空字符串的可选身份字段以及缺失的 webhook 回调直接抛出TypeError,把配置错误提前到启动阶段。

签名验证与防重放:webhook 处理器底层逻辑

@flue/linear的验证管线实现在 webhook.ts:

  1. 内容类型检查Content-Type必须为application/json,否则返回415Content-Length非法时返回400
  2. 体积限制:超过bodyLimit(默认 1 MiB)的请求在流式读取中途即被取消并返回413,避免恶意大包拖垮运行时。
  3. HMAC-SHA256 签名验证:读取Linear-Signature头(要求 64 位十六进制),用webhookSecret作为密钥对原始请求字节crypto.subtle.verify('HMAC', …),失败返回401
  4. 时间戳防重放:Linear 签名负载中携带webhookTimestamp,与服务器时钟差超过 60 秒(TIMESTAMP_TOLERANCE_MS = 60_000)即返回401
  5. 固定身份校验:配置了organizationId/webhookId时,与负载中的字段逐一比对,不匹配返回403
  6. 投递标识校验Linear-Delivery头必须是 UUID-v4 格式,否则返回400

通过全部校验后,回调收到的是 Linear官方原生负载payload(类型为从@linear/sdk/webhooks再导出的LinearWebhookPayload联合类型),以及deliveryIdLinear-Delivery头值)。@flue/linear不做去重——它把去重责任交给应用,官方推荐在dispatch时用idempotencyKey: deliveryId命名投递,使重投递收敛到原始提交上(见 @flue/linear 的 README)。

回调的返回值同样灵活:返回undefined得到空200,返回 JSON 兼容值得到 JSON 响应,返回 Hono/FetchResponse则原样透传(serializeHandlerResult)。

事件分发:把 Linear 负载路由到 Agent 实例

示例的webhook回调只处理两类事件,其余静默返回:

  1. 评论事件(Comment):当payload.type === 'Comment''body' in payload.data时命中(isCommentEvent,见 linear.ts)。由于 Linear 官方联合类型中 catch-all 成员会把type拓宽,仅靠字面量判断无法收窄类型,因此需要结合判别字段'body' in payload.data才能收窄到EntityWebhookPayloadWithCommentData
  2. Agent 会话事件(AgentSessionEvent):当payload.type === 'AgentSessionEvent''agentSession' in payload时命中(isAgentSessionEvent,linear.ts),对应@linear/sdk/webhooks导出的AgentSessionEventWebhookPayload

两类事件都通过 Flue 的dispatch(Agent, { id, initialData, message })(实现见 flue-app.ts 的 dispatch)创建或投递给 Agent 实例。以评论事件为例:

await dispatch(Assistant, { id: channel.instanceId({ type: 'issue', organizationId: payload.organizationId, issueId: comment.issueId, ...(comment.parentId ? { threadCommentId: comment.parentId } : {}), }), // Recorded once when this event creates the instance; ignored after. initialData: { type: 'issue', issueId: comment.issueId, ...(comment.parentId ? { threadCommentId: comment.parentId } : {}), ...(comment.issue?.title ? { issueTitle: comment.issue.title } : {}), }, message: { kind: 'signal', type: 'linear.comment.created', body: comment.body, attributes: { deliveryId, ...(payload.actor ? { actorId: payload.actor.id } : {}), ...(payload.actor && 'name' in payload.actor ? { actorName: payload.actor.name } : {}), }, }, });

要点拆解:

  • 实例 id 由 channel 生成channel.instanceId(ref)把 Issue/线程/Agent 会话规范化为带命名空间的 id。从源码看(packages/linear/src/index.ts#L113-L135),Issue 会话的格式为linear:v1:organization:<orgId>:issue:<issueId>:thread:<threadCommentId>,Agent 会话为linear:v1:organization:<orgId>:agent-session:<agentSessionId>,各段均经encodeURIComponent编码;parseInstanceId可逆向解析,且会反查instanceId做双向校验,非法输入抛出InvalidLinearInstanceIdError。实例 id 不是授权凭据。
  • initialData仅在实例创建时记录一次,后续投递忽略:携带了typeissueId、可选的threadCommentIdissueTitle,Agent 无需解析实例 id 即可拿到结构化上下文。
  • messagekind: 'signal'信号形式入站type: 'linear.comment.created'标记事件来源,body是评论正文,attributes携带deliveryId(可用于幂等去重)以及可选的actorId/actorName

Agent 会话事件的处理模式相同,只是initialData换为agentSessionId,信号类型变为linear.agent_session.${payload.action},并把promptContextagentActivitysession序列化进body(linear.ts)。

Agent 端:消费初始数据并绑定回复工具

Agent 定义在 assistant.ts,使用'use agent'指令标记:

'use agent'; import { useInitialData, useModel, useTool } from '@flue/runtime'; import * as v from 'valibot'; import { postMessage } from '../channels/linear.ts'; const initialDataSchema = v.variant('type', [ v.object({ type: v.literal('agent-session'), agentSessionId: v.string(), issueTitle: v.optional(v.string()), }), v.object({ type: v.literal('issue'), issueId: v.string(), threadCommentId: v.optional(v.string()), issueTitle: v.optional(v.string()), }), ]); export function Assistant() { useModel('anthropic/claude-haiku-4-5'); const data = useInitialData<v.InferOutput<typeof initialDataSchema>>(); if (!data) throw new Error('This agent is created by the Linear channel dispatch.'); useTool(postMessage(data)); const issueTitle = data.issueTitle ? ` on "${data.issueTitle}"` : ''; return `Reply concisely in the bound Linear conversation${issueTitle}.`; } Assistant.initialData = initialDataSchema;
  • useModel指定本 Agent 使用的模型(示例为anthropic/claude-haiku-4-5);
  • useInitialData读取 dispatch 时传入的结构化初始数据,initialDataSchema用 valibot 的variant('type', …)type判别联合校验;Agent 只由 Linear 渠道 dispatch 创建,因此拿不到数据时直接抛错;
  • useTool(postMessage(data))把与当前会话绑定的post_linear_message工具注册给模型,Agent 可以在推理过程中调用它回复;
  • Assistant.initialData = initialDataSchema静态声明,供运行时对 dispatch 的initialData做 schema 校验。

应用自有工具:向 Issue 评论线程或 Agent 会话发消息

postMessage(linear.ts)是应用自有的出站工具,接收一个"会话引用"并构造出可被模型调用的 Flue 工具:

export type LinearMessageRef = | { type: 'agent-session'; agentSessionId: string } | { type: 'issue'; issueId: string; threadCommentId?: string }; export function postMessage(ref: LinearMessageRef) { return defineTool({ name: 'post_linear_message', description: 'Post a message to the Linear conversation bound to this agent.', input: v.object({ text: v.pipe(v.string(), v.minLength(1)) }), async run({ data }) { const { text } = data; if (ref.type === 'agent-session') { const result = await client.createAgentActivity({ agentSessionId: ref.agentSessionId, content: { type: 'response', body: text }, }); return { output: { success: result.success } }; } const result = await client.createComment({ issueId: ref.issueId, ...(ref.threadCommentId === undefined ? {} : { parentId: ref.threadCommentId }), body: text, }); return { output: { success: result.success, ...(result.commentId === undefined ? {} : { commentId: result.commentId }), }, }; }, }); }
  • 输入约束text必须是非空字符串(v.pipe(v.string(), v.minLength(1)));
  • Agent 会话分支:调用官方LinearClient.createAgentActivity,写入{ type: 'response', body: text }的活动内容;
  • Issue 分支:调用LinearClient.createComment,当ref.threadCommentId存在时把它作为parentId,即回复嵌套评论线程的根评论,否则直接在 Issue 下新建评论;
  • 返回结构:统一返回{ output: { success, commentId? } },模型可据此判断调用是否成功。

出站客户端在文件顶部统一构造:export const client = new LinearClient(linearCredentials());,凭证解析逻辑见上文环境变量一节。

运行与构建配置

示例是标准的 Flue + Vite 应用,相关配置:

  • vite.config.ts:仅启用flue()插件,由@flue/vite完成 Agent 扫描、运行时代码生成与开发环境装配;
  • package.json:声明@flue/linear@flue/runtime@linear/sdkhonovalibot等依赖,脚本提供buildvite build)与check:typestsc --noEmit);
  • 类型检查依赖typescript,部署/测试环境可选用@cloudflare/vitest-pool-workersvitest

运行前请确保上述环境变量已注入(Node 侧通过process.env读取,linear.ts 中的requiredEnv/optionalEnv分别对应必填与可选读取)。@flue/linear本身是无状态包,不包含出站客户端、OAuth 安装存储或模型工具——这些正是本示例用官方@linear/sdk补全的部分;如需脚手架自动生成可编辑的项目代码,可在 Flue 项目中执行flue add channel linear(见 @flue/linear 的 README)。

小结

围绕examples/linear-channel,本文覆盖了从 webhook 验证、事件分发到出站回复的完整链路:@flue/linear负责把 Linear 签名负载安全地送入应用(签名验证、60 秒时间窗防重放、可选的组织/Webhook 固定校验、UUID-v4 投递标识),应用通过dispatch把评论与 Agent 会话事件路由到由instanceId定位的 Agent 实例,Agent 再借助官方LinearClient驱动的post_linear_message工具把回复写回绑定的 Linear 会话。这种"渠道包负责验证与身份、应用自有客户端负责出站、Agent 工具负责业务动作"的分层,是 Flue 生态中接入外部 SaaS 事件驱动的通用范式,可平移到仓库中其他 channel 示例(如 slack-channel、github-channel)参考对照。

【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询