在 Flue 中接入 Zendesk Channel:验签 Webhook 入站与票证绑定的 Fetch 客户端实战指南
【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue
本文以 Flue 项目的 Zendesk Channel 蓝图为骨架,系统讲解如何为现有 Flue 项目添加带签名验证的 Zendesk 事件订阅入站(ingress)与应用自有的 Ticketing API 出站行为:从flue add channel zendesk快速初始化、环境变量配置、挂载路由,到频道模块与项目自有客户端的完整源码剖析,再到 HMAC-SHA256 签名验证、事件形状、投递语义与 Cloudflare Workers 兼容性。读完本文,你将能够独立在 Flue 项目中接入 Zendesk 工单事件流,并让 Agent 通过受控工具安全地检索当前绑定的工单。
快速开始:用蓝图添加 Zendesk Channel
Zendesk Channel 以蓝图为载体提供给开发者。在已有 Flue 项目的终端(或你惯用的编码 Agent)中运行:
flue add channel zendesk该命令会完成以下工作:
- 安装
@flue/zendesk与lossless-json两个依赖; - 在源码根目录(按序探测
<root>/.flue/、<root>/src/、<root>/)下生成项目自有的<source-root>/zendesk-client.ts与<source-root>/channels/zendesk.ts; - 生成具名的
channel与client导出、工单身份处理逻辑,以及一个绑定工单的检索工具(tool); - 将该工具接入目标 Agent,并仅在目标确实需要时补充 Node 类型(
@types/node,用于process/Buffer); - 不安装任何社区版 Zendesk SDK——Zendesk 没有官方支持的 Node 服务端 SDK,蓝图因此选择跨 Node 与 Cloudflare Workers 均可移植的原生 Fetch 客户端。
蓝图的完整安装语义可参考仓库中的 blueprints/channel--zendesk.md,其中包含与上述命令等价的手工落地步骤与逐段解释。
整体架构:入站与出站的分工
生成后的频道模块(abridged 版本)如下,展示了两条主线的分工:
import { createZendeskChannel } from '@flue/zendesk'; import { dispatch } from '@flue/runtime'; import { Assistant } from '../agents/assistant.ts'; import { createZendeskClient } from '../zendesk-client.ts'; export const client = createZendeskClient({ subdomain: process.env.ZENDESK_SUBDOMAIN!, email: process.env.ZENDESK_EMAIL!, apiToken: process.env.ZENDESK_API_TOKEN!, }); export const channel = createZendeskChannel({ signingSecret: process.env.ZENDESK_WEBHOOK_SIGNING_SECRET!, accountId: process.env.ZENDESK_ACCOUNT_ID!, async webhook({ payload, delivery }) { if (payload.type !== 'zen:event-type:ticket.created') return; const ticketId = ticketIdFromEvent(payload.subject, payload.detail); if (!ticketId) return; await dispatch(Assistant, { id: channel.instanceId({ accountId: payload.account_id, ticketId }), message: { kind: 'signal', type: `zendesk.${payload.type}`, // `event` 是 Zendesk 提供方原生的变更对象,其字段随事件类型而变化。 body: JSON.stringify(payload.event), attributes: { eventId: payload.id, ticketId, occurredAt: payload.time, invocationId: delivery.invocationId, }, }, }); }, });(上述简化示例省略了ticketIdFromEvent()辅助函数,完整实现见下文“频道模块”一节。)
核心设计原则是权责分离:
- Flue 侧(包
@flue/zendesk)负责:精确请求体的签名验证、必需的投递元数据、账户一致性校验、请求体大小限制,以及透传 Zendesk 提供方原生的 common event envelope; - 应用侧(项目自有代码)负责:webhook 的创建与订阅选择、API Token 与 OAuth、租户凭据查找、去重、持久化、工单策略,以及所有出站工具。
只有匹配到该账户与工单的事件才会被接纳(admitted)到绑定该账户/工单的 Agent;其他通过验证的事件则收到一个空的成功响应。完整的生成模块会在subject与detail.id中校验匹配的工单身份、处理评论事件,并让绑定 Agent 通过项目自有客户端检索当前工单——该客户端能够无损保留 Zendesk 的大整数 ID,且同时运行于 Node 与 Cloudflare Workers。
挂载频道:让路由真正生效
频道只有被app.ts挂载后才对外提供 HTTP 路由。挂载模块的具名channel导出:
import { channel as zendesk } from './channels/zendesk.ts'; app.route('/channels/zendesk', zendesk.route());channel.route()是一个纯路由工厂(pure router factory),按挂载路径的相对位置提供该频道声明的全部路由,没有任何注册副作用,可安全地多次调用。其底层实现位于 packages/runtime/src/runtime/channel-routes.ts 的createChannelRouter():它会急切地校验路由声明(非法方法/路径/handler 形状、重复路由都会直接抛错),未知路径与挂载根渲染规范的route_not_found错误信封,已知路径但方法错误则渲染带Allow头的method_not_allowed,同时负责跨 realm 的 Response 归一化与运行时 activity-lease(优雅排空)保留。
本指南中的 webhook 路径均假定使用惯例挂载点/channels/zendesk;若改用其他挂载路径,所有提供方 URL 会相应平移。被dispatch()指向的 Agent 模块带有'use agent'指令——正是该指令完成了 Agent 的注册,因此一个仅被 dispatch 的 Agent 无需自身的 HTTP 挂载。若希望 Agent 也能通过 HTTP 直接访问,可在app.ts中额外添加app.route('/agents/<name>', createAgentRouter(Assistant))(来自@flue/runtime/routing)。完整的示例入口见 examples/zendesk-channel/src/app.ts,其中同时挂载了 Agent 路由与频道路由。
环境变量配置
生成模块消费以下环境变量:
| 变量 | 用途 |
|---|---|
ZENDESK_WEBHOOK_SIGNING_SECRET | 必填— 校验入站事件请求体。 |
ZENDESK_ACCOUNT_ID | 必填— 将事件与资源身份限制在单一账户。 |
ZENDESK_WEBHOOK_ID | 可选— 将投递限制在单一已配置的 webhook。 |
ZENDESK_SUBDOMAIN | 必填— 选择该账户 Ticketing API 的源(origin)。 |
ZENDESK_EMAIL | 必填— 标识 API Token 用户,用于 Basic 认证。 |
ZENDESK_API_TOKEN | 必填— 认证出站的 Ticketing API 请求。 |
要点:
ZENDESK_WEBHOOK_SIGNING_SECRET(验签入站)与ZENDESK_API_TOKEN(出站 API 认证)是两套相互独立的凭据;- 示例项目 examples/zendesk-channel/README.md 给出了完整的
.env形态,ZENDESK_SUBDOMAIN=example这类裸 DNS 标签即可。
随后在 Zendesk 侧创建一个 JSON 事件订阅 webhook,指向:
https://example.com/channels/zendesk/webhook频道模块完整解析
下面是完整的生成模块(与仓库示例 examples/zendesk-channel/src/channels/zendesk.ts 一致):
import { createZendeskChannel, type JsonValue, type ZendeskTicketRef } from '@flue/zendesk'; import { defineTool, dispatch } from '@flue/runtime'; import { Assistant } from '../agents/assistant.ts'; import { createZendeskClient } from '../zendesk-client.ts'; const accountId = requiredEnv('ZENDESK_ACCOUNT_ID'); export const client = createZendeskClient({ subdomain: requiredEnv('ZENDESK_SUBDOMAIN'), email: requiredEnv('ZENDESK_EMAIL'), apiToken: requiredEnv('ZENDESK_API_TOKEN'), }); export const channel = createZendeskChannel({ signingSecret: requiredEnv('ZENDESK_WEBHOOK_SIGNING_SECRET'), accountId, webhookId: process.env.ZENDESK_WEBHOOK_ID || undefined, // 路径:/channels/zendesk/webhook async webhook({ c, payload, delivery }) { switch (payload.type) { case 'zen:event-type:ticket.created': case 'zen:event-type:ticket.comment_added': { const ticketId = ticketIdFromEvent(payload.subject, payload.detail); if (!ticketId) { return c.json({ error: 'Expected a Zendesk ticket event.' }, 400); } const ticket: ZendeskTicketRef = { accountId: payload.account_id, ticketId, }; await dispatch(Assistant, { id: channel.instanceId(ticket), // 仅在该事件创建实例时记录一次;此后被忽略。 initialData: { accountId: ticket.accountId, ticketId: ticket.ticketId, }, message: { kind: 'signal', type: `zendesk.${payload.type}`, // `event` 是 Zendesk 提供方原生的变更对象;字段随事件类型变化。 body: JSON.stringify(payload.event), attributes: { eventId: payload.id, ticketId, occurredAt: payload.time, invocationId: delivery.invocationId, }, }, }); return; } default: return; } }, }); export function retrieveTicket(ref: ZendeskTicketRef) { if (ref.accountId !== accountId) { throw new TypeError('Expected the configured Zendesk account.'); } return defineTool({ name: 'retrieve_zendesk_ticket', description: 'Retrieve the Zendesk ticket already bound to this agent.', async run() { return { output: await client.getTicket(ref.ticketId) }; }, }); } function ticketIdFromEvent(subject: string, detail: Record<string, JsonValue>): string | undefined { const match = /^zen:ticket:([1-9]\d*)$/.exec(subject); if (!match?.[1]) return undefined; const id = detail.id; if (!( (typeof id === 'string' && /^[1-9]\d*$/.test(id)) || (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) )) { return undefined; } return String(id) === match[1] ? match[1] : undefined; } function requiredEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is required.`); return value; }逐段说明:
- 分组分支(grouped branch):
ticket.created与ticket.comment_added归入同一分支处理,而default分支静默放行——这保持了提供方事件目录的开放性,未来新的事件族仍可被应用观察到。对每个订阅类型消费的字段都必须自行校验; - 身份交叉验证:示例要求
subject(形如zen:ticket:<id>)与detail.id中的工单 ID 完全一致后,才将其用作应用身份。detail.id既接受十进制正整数字符串,也接受Number.isSafeInteger且大于 0 的数值; requiredEnv:在模块加载时即检查必需环境变量,缺失则抛错快速失败。蓝图版本还提供optionalEnv来读取ZENDESK_WEBHOOK_ID;retrieveTicket(ref):先校验ref.accountId与配置的账户一致,再通过defineTool定义一个名为retrieve_zendesk_ticket的工具,其run()直接调用客户端getTicket()。工具不接受模型提供的任何账户、工单 ID、API 主机或凭据。
底层类型与实例 ID
包@flue/zendesk的公开类型定义在 packages/zendesk/src/index.ts:
ZendeskTicketRef:稳定的账户作用域工单身份,accountId与ticketId均为正十进制字符串;ZendeskEvent:Zendesk 提供方原生的 common event envelope(字段名、嵌套、判别字段与官方文档一致),其中account_id以无损失的正十进制字符串形式保留;type、zendesk_event_version、detail、event刻意保持宽泛开放,由应用针对自己消费的事件族收窄;ZendeskDelivery:未签名的投递元数据(webhookId、invocationId、signatureTimestamp)。
channel.instanceId(ref)生成规范实例 ID,格式为zendesk:v1:account:<accountId>:ticket:<ticketId>(两段均经encodeURIComponent);channel.parseInstanceId(id)是恢复该身份的反向逃生舱,仅接受规范 ID,非法或非规范输入抛出InvalidZendeskInstanceIdError(定义于 packages/zendesk/src/errors.ts)。Zendesk 资源 ID 是账户作用域的,因此实例 ID 中包含账户与工单双重身份——但它只是标识符,不是授权凭证。Agent 通常通过initialData接收结构化事实,而非自行解析实例 ID。
项目自有客户端:无损大整数与严格主机
项目自有客户端(由蓝图生成于src/zendesk-client.ts)在可信代码中绑定原始账户子域与凭据:
import { isLosslessNumber, isSafeNumber, parse } from 'lossless-json'; type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; export function createZendeskClient({ subdomain, email, apiToken, fetcher = globalThis.fetch, }: { subdomain: string; email: string; apiToken: string; fetcher?: typeof globalThis.fetch; }) { if (!/^a-z0-9?$/i.test(subdomain)) { throw new TypeError('Zendesk subdomain must be a bare DNS label.'); } const authorization = `Basic ${Buffer.from(`${email}/token:${apiToken}`).toString('base64')}`; return { async getTicket(ticketId: string) { if (!/^[1-9]\d*$/.test(ticketId)) { throw new TypeError('Zendesk ticket id must be a positive integer.'); } const response = await fetcher( `https://${subdomain}.zendesk.com/api/v2/tickets/${ticketId}.json`, { headers: { accept: 'application/json', authorization, }, }, ); if (!response.ok) { throw new Error(`Zendesk API request failed with ${response.status}.`); } const body = normalizeJsonValue(parse(await response.text())); if (!isRecord(body) || !isRecord(body.ticket) || !isZendeskId(body.ticket.id)) { throw new TypeError('Zendesk returned an invalid ticket response.'); } return body.ticket; }, }; } function isZendeskId(value: unknown): value is string | number { if (typeof value === 'string') return /^[1-9]\d*$/.test(value); return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; } function normalizeJsonValue(value: unknown): JsonValue | undefined { if ( value === null || typeof value === 'boolean' || typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)) ) { return value; } if (isLosslessNumber(value)) { return isSafeNumber(value.value) ? Number(value.value) : value.value; } if (Array.isArray(value)) { const result: JsonValue[] = []; for (const item of value) { const normalized = normalizeJsonValue(item); if (normalized === undefined) return undefined; result.push(normalized); } return result; } if (!isRecord(value)) return undefined; const result: { [key: string]: JsonValue } = {}; for (const [key, item] of Object.entries(value)) { const normalized = normalizeJsonValue(item); if (normalized === undefined) return undefined; result[key] = normalized; } return result; } function isRecord(value: unknown): value is Record<string, unknown> { return ( typeof value === 'object' && value !== null && !Array.isArray(value) && !isLosslessNumber(value) && Object.getPrototypeOf(value) === Object.prototype ); }设计要点:
- 认证:Zendesk 文档化的 API Token Basic 认证格式为
{email}/token:{api_token}。OAuth Bearer Token 也可用,但 Token 的获取、刷新与安装存储完全由应用侧负责; - 主机固定:客户端只接受裸 DNS 标签形式的子域,始终请求该账户的
https://<subdomain>.zendesk.com/api/v2源。不要接受模型或 webhook 字段提供的任意 Base URL——Host 映射的 Help Center 域名不能替代账户原始的*.zendesk.comAPI 源,否则可能把凭据发往非预期目的地; - 大整数无损失:本客户端要求安装
lossless-json@4.3.0(@flue/zendesk的依赖中也固定了该版本)。Zendesk 标识符可能超过 JavaScript 的安全整数范围,因此不安全的数值 ID 以十进制字符串原样保留,不会被四舍五入——normalizeJsonValue用isLosslessNumber检测并决定转成Number还是保留字符串; - 可注入 Fetch:
fetcher参数默认为globalThis.fetch,便于在测试中注入 fail-closed 的 Fetch 实现(见下文“测试策略”)。
绑定工具:把工单上下文注入 Agent
生成模块中的retrieveTicket()需要被绑定到目标 Agent:
'use agent'; import { useInitialData, useModel, useTool } from '@flue/runtime'; import * as v from 'valibot'; import { retrieveTicket } from '../channels/zendesk.ts'; const initialData = v.object({ accountId: v.string(), ticketId: v.string(), }); export function Assistant() { useModel('anthropic/claude-haiku-4-5'); const data = useInitialData<v.InferOutput<typeof initialData>>(); if (!data) throw new Error('This agent is created by the Zendesk channel dispatch.'); useTool(retrieveTicket(data)); return 'Review the inbound Zendesk ticket event. Retrieve the current ticket when more context is needed.'; } Assistant.initialData = initialData;关键语义:
initialData是实例的创建数据:仅在事件创建该实例时记录一次,此后被忽略,因此频道在每次 dispatch 时都会传入它。Agent 通过useInitialData()读取,并由 Agent 的initialData静态属性(valibot schema)校验——而不是解析实例 ID。每条消息的临时事实则放在 signal 的attributes上;- 零模型可控参数:该工具不接受来自模型的账户、工单 ID、API 主机或凭据——模型只能检索已经绑定到该 Agent的工单,从而把出站面收敛到最小;
'use agent'指令是模块的首条语句,正是它完成了 Agent 注册。频道回调中的dispatch(Assistant, ...)无需任何app.ts挂载;- 循环依赖可行:频道模块 import Agent、Agent 又 import 频道的
retrieveTicket——这种 channel-agent 导入环是被支持的,因为被导入的绑定只在延迟回调与 Agent 函数体内读取(Flue 的dispatch支持这一点)。
签名验证:HMAC-SHA256 的精确字节语义
Zendesk 投递时发送以下头部:
X-Zendesk-Account-Id X-Zendesk-Webhook-Id X-Zendesk-Webhook-Invocation-Id X-Zendesk-Webhook-Signature X-Zendesk-Webhook-Signature-Timestamp签名是base64 编码的 HMAC-SHA256,其输入为“签名时间戳直接拼接精确请求体字节”:
<signature timestamp><exact request body>两者之间没有任何分隔符。@flue/zendesk在 UTF-8 解码或 JSON 解析之前,先保留并验证这些原始字节。从 packages/zendesk/src/webhook.ts 可以看到完整的入站处理流水线(按序):
- Content-Type 检查:非
application/json返回415; - Content-Length 检查:非数字返回
400,超过bodyLimit返回413(bodyLimit默认1 MiB,可通过createZendeskChannel的bodyLimit选项覆盖,必须是正整数); - 签名头解析:缺失或不符合 base64 形态返回
401; - 元数据头读取:
account-id、webhook-id、invocation-id、signature-timestamp四个头全部必填且不能带首尾空白,否则400; - 请求体流式读取:边读边累计字节数,超限即
413,读取出错即400; - HMAC 验证:用
crypto.subtle.importKey('raw', ...)导入签名密钥,对timestamp + body字节做verify('HMAC', ...),失败返回401(parseSignature还要求解码后恰为 32 字节); - UTF-8 解码:使用
fatal: true的TextDecoder,非法 UTF-8 返回400; - JSON 解析与信封校验:用
lossless-json的parse,要求是普通对象且必填字段齐全(id、type、zendesk_event_version、subject、time、detail、event),否则400; - 账户一致性:payload 的
account_id必须与账户头一致;配置了options.accountId/options.webhookId时再逐一比对,任一不匹配返回403; - 全部通过后,将
{ c, payload, delivery }交给应用回调。
需要强调的安全语义:HMAC 只覆盖时间戳与请求体,不覆盖账户、webhook、invocation 这些头部。因此包要求这些头存在,并把 payloadaccount_id与账户头做一致性校验,但头部元数据应视为提供方路由上下文,而不是独立的授权能力。另外,Zendesk 没有文档化时间戳接受窗口或时钟偏移规则,频道仅暴露delivery.signatureTimestamp,不会自行发明时效性语义。
事件形状:common event envelope
回调收到{ c, payload, delivery },将 Flue 已验证的提供方原生 payload 与未签名的头部元数据分开:
payload即 Zendesk 自身的 common event envelope,保留提供方的 snake_case 字段名:
account_id:归一化为正十进制字符串(避免大整数被 JS 舍入);id:提供方事件 ID;type与zendesk_event_version:均为开放字符串(例如zen:event-type:ticket.created、2022-06-20);subject:形如zen:ticket:<id>,以及time;detail与event:提供方原生的 JSON 对象。
ZendeskEvent类型带索引签名,会转发任何通过验证的未来或未建模字段,使未来的事件族保持可观测。JSON 以无损失方式解析:不安全的整数字面量以十进制字符串保留原拼写,顶层整数account_id归一化为十进制字符串(见 packages/zendesk/src/webhook.ts 的parseEvent/normalizeAccountId)。
delivery是从请求头读取的未签名路由元数据:webhookId、invocationId、signatureTimestamp。由于 Zendesk 的 HMAC 不覆盖这些头,它们只是路由/尝试关联上下文,不是授权。
一个务实的提醒:Zendesk 当前文档关于工单投递设置存在不一致——事件目录与 Support UI 文档列出工单订阅,而开发者 webhook 指南仍推荐用 triggers/automations 处理工单活动。只有账户确实暴露这些事件订阅时,才应使用分组工单示例;自定义 trigger 的 payload 由开发者编写,不应被当作固定 common event envelope 接受。本频道初版面向提供方定义的 JSON 事件订阅;Sunshine Conversations 与 Zendesk AI Agent 的 webhook 认证与投递契约不同或不完整,属于独立的后续研究范畴,不会被静默当作同一协议处理(蓝图 blueprints/channel--zendesk.md 的 “Scope boundaries” 一节有明确界定)。
响应与投递语义:尽快承认,依赖幂等
回调的返回值决定响应:
- 返回
undefined(什么都不返回)→ 空200; - 返回 JSON 兼容值 → JSON 响应;
- 返回标准 Hono 或 Fetch
Response→ 原样透传; - 回调抛错或返回不支持的值 →fail closed,返回可重试的
409(packages/zendesk/src/webhook.ts 中RETRYABLE_FAILURE_STATUS = 409,错误会先记录到日志再返回,避免被频道路由器的通用 500 吞掉)。
Zendesk 允许完整请求耗时12 秒。频道不强制 deadline,因为用一个计时器与回调赛跑并不能真正取消已经开始执行的 JavaScript——超时的工作仍会继续运行,却返回了误导性的失败。正确姿势是:尽快承认持久性工作(例如先dispatch(...)再返回),依赖幂等性而非在确认前阻塞慢操作。
投递语义方面:Zendesk 对409最多重试 3 次,对带较短Retry-After的429/503条件性重试,对超时最多重试 5 次(蓝图补充:Retry-After小于 60 秒才重试)。投递是尽力而为的,可能重复或丢失:
- 当重复接纳不可接受时,必须将已签名的
payload.id持久化到应用自有存储作为去重键; - 未签名的
delivery.invocationId只适合关联提供方投递尝试,不是防重放的去重键; - 普通确认请使用精确的
200;仅在有意利用重试行为时才使用自定义状态码。
Cloudflare Workers 兼容性
入站路径使用 Web Crypto 与标准 Fetch API(crypto.subtle.importKey/verify、TextEncoder/TextDecoder、流式request.body.getReader()),因此可运行于 workerd。项目自有客户端使用原生 Fetch 加Buffer生成 Basic 认证头——在 examples/zendesk-channel/wrangler.jsonc 这类配置下,Flue 项目已启用所需的nodejs_compat,process与Buffer由此提供。包@flue/zendesk仅依赖hono与lossless-json,无平台绑定代码(见 packages/zendesk/package.json)。
测试策略:不接触 Zendesk 的端到端验证
在接入过程中遵循如下测试纪律(详见蓝图“Test without Zendesk”一节):
- 运行项目的严格类型检查、针对目标的
vite build以及真实的 workerd 测试; - 为入站构造原创的合成 common event envelope 与本地签名密钥:将请求体序列化一次,前置精确签名时间戳,HMAC-SHA256 后再 base64 编码。覆盖:有效字节与改动单字节后的拒绝、缺失/畸形/错误的签名输入、四个必需头的缺失、payload/头账户不一致与配置账户/webhook 不一致、选定工单事件加未来事件类型与 schema 版本、不安全的数值
account_id无舍入保留、畸形 UTF-8/JSON、媒体类型与声明/流式 body 上限、无返回值/JSON/普通Response三种结果、回调抛错 fail closed 为409,以及规范实例 ID 的往返; - 为出站客户端在 Node 与 workerd 中注入 fail-closed 的 Fetch:断言精确的
*.zendesk.com/api/v2/tickets/{id}.jsonURL、GET方法、Basic 授权头与响应解析,拒绝每一个非预期目标; - 绝不在实现或测试过程中创建/修改线上 webhook、订阅真实事件、申请真实 Token 或联系 Zendesk。
@flue/zendesk的公开 API 与类型契约集中在 packages/zendesk/src/index.ts,入站处理细节集中在 packages/zendesk/src/webhook.ts;希望进一步探索的读者可参考这两个文件,以及可整体运行验证的示例项目 examples/zendesk-channel。包自身的 README 位于 packages/zendesk/README.md。
【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考