基于 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 集成的三个核心要素,缺一不可:
- 已验证的 Linear webhook 入站:由
@flue/linear提供的createLinearChannel完成签名校验、时间戳防重放、固定组织/Webhook 身份校验,只把可信的负载交给应用。 - 项目自持的官方
LinearClient:直接使用 Linear 官方 SDK(@linear/sdk)而非抽象封装,出站能力完整。 - 应用自有的工具函数:通过
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/:id由createAgentRouter(Assistant)提供,用于按实例 id 触发 Agent(详见 createAgentRouter 的实现);POST /channels/linear/webhook由channel.route()挂载,是 Linear 回调的入口,路由路径由@flue/linear的 channel 定义生成(见 createLinearChannel 中的路由定义)。
必需环境变量与可选加固项
示例 README 声明了两组必需变量:
LINEAR_WEBHOOK_SECRET=... LINEAR_API_KEY=lin_api_...LINEAR_WEBHOOK_SECRET:与 Linear 后台配置的签名密钥一致,用于验证请求字节;LINEAR_API_KEY:lin_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 包:
| 选项 | 类型 | 说明 | 默认值 |
|---|---|---|---|
webhookSecret | string | 验证 Linear 请求原始字节的签名密钥,必填且非空 | 无 |
organizationId | string | 可选的固定组织 id,签名负载不匹配返回403 | 无 |
webhookId | string | 可选的固定 webhook id,签名负载不匹配返回403 | 无 |
bodyLimit | number | 请求体大小上限(字节) | 1 MiB(1024 * 1024) |
webhook | 函数 | 接收每个通过验证的 Linear 投递,参数为{ c, payload, deliveryId } | 必填 |
validateOptions(packages/linear/src/index.ts#L177-L193)会在创建时对空密钥、空字符串的可选身份字段以及缺失的 webhook 回调直接抛出TypeError,把配置错误提前到启动阶段。
签名验证与防重放:webhook 处理器底层逻辑
@flue/linear的验证管线实现在 webhook.ts:
- 内容类型检查:
Content-Type必须为application/json,否则返回415;Content-Length非法时返回400。 - 体积限制:超过
bodyLimit(默认 1 MiB)的请求在流式读取中途即被取消并返回413,避免恶意大包拖垮运行时。 - HMAC-SHA256 签名验证:读取
Linear-Signature头(要求 64 位十六进制),用webhookSecret作为密钥对原始请求字节做crypto.subtle.verify('HMAC', …),失败返回401。 - 时间戳防重放:Linear 签名负载中携带
webhookTimestamp,与服务器时钟差超过 60 秒(TIMESTAMP_TOLERANCE_MS = 60_000)即返回401。 - 固定身份校验:配置了
organizationId/webhookId时,与负载中的字段逐一比对,不匹配返回403。 - 投递标识校验:
Linear-Delivery头必须是 UUID-v4 格式,否则返回400。
通过全部校验后,回调收到的是 Linear官方原生负载payload(类型为从@linear/sdk/webhooks再导出的LinearWebhookPayload联合类型),以及deliveryId(Linear-Delivery头值)。@flue/linear不做去重——它把去重责任交给应用,官方推荐在dispatch时用idempotencyKey: deliveryId命名投递,使重投递收敛到原始提交上(见 @flue/linear 的 README)。
回调的返回值同样灵活:返回undefined得到空200,返回 JSON 兼容值得到 JSON 响应,返回 Hono/FetchResponse则原样透传(serializeHandlerResult)。
事件分发:把 Linear 负载路由到 Agent 实例
示例的webhook回调只处理两类事件,其余静默返回:
- 评论事件(Comment):当
payload.type === 'Comment'且'body' in payload.data时命中(isCommentEvent,见 linear.ts)。由于 Linear 官方联合类型中 catch-all 成员会把type拓宽,仅靠字面量判断无法收窄类型,因此需要结合判别字段'body' in payload.data才能收窄到EntityWebhookPayloadWithCommentData。 - 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仅在实例创建时记录一次,后续投递忽略:携带了type、issueId、可选的threadCommentId与issueTitle,Agent 无需解析实例 id 即可拿到结构化上下文。message以kind: 'signal'信号形式入站:type: 'linear.comment.created'标记事件来源,body是评论正文,attributes携带deliveryId(可用于幂等去重)以及可选的actorId/actorName。
Agent 会话事件的处理模式相同,只是initialData换为agentSessionId,信号类型变为linear.agent_session.${payload.action},并把promptContext、agentActivity、session序列化进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/sdk、hono、valibot等依赖,脚本提供build(vite build)与check:types(tsc --noEmit); - 类型检查依赖
typescript,部署/测试环境可选用@cloudflare/vitest-pool-workers与vitest。
运行前请确保上述环境变量已注入(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),仅供参考