ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

在 Flue 中接入 Zendesk Channel:验签 Webhook 入站与票证绑定的 Fetch 客户端实战指南

在 Flue 中接入 Zendesk Channel:验签 Webhook 入站与票证绑定的 Fetch 客户端实战指南 在 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 ChannelZendesk 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_SUBDOMAINexample这类裸 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: Recordstring, 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 branchticket.created与ticket.comment_added归入同一分支处理而default分支静默放行——这保持了提供方事件目录的开放性未来新的事件族仍可被应用观察到。对每个订阅类型消费的字段都必须自行校验身份交叉验证示例要求subject形如zen:ticket:id与detail.id中的工单 ID 完全一致后才将其用作应用身份。detail.id既接受十进制正整数字符串也接受Number.isSafeInteger且大于 0 的数值requiredEnv在模块加载时即检查必需环境变量缺失则抛错快速失败。蓝图版本还提供optionalEnv来读取ZENDESK_WEBHOOK_IDretrieveTicket(ref)先校验ref.accountId与配置的账户一致再通过defineTool定义一个名为retrieve_zendesk_ticket的工具其run()直接调用客户端getTicket()。工具不接受模型提供的任何账户、工单 ID、API 主机或凭据。底层类型与实例 ID包flue/zendesk的公开类型定义在 packages/zendesk/src/index.tsZendeskTicketRef稳定的账户作用域工单身份accountId与ticketId均为正十进制字符串ZendeskEventZendesk 提供方原生的 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两段均经encodeURIComponentchannel.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 Recordstring, 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-json4.3.0flue/zendesk的依赖中也固定了该版本。Zendesk 标识符可能超过 JavaScript 的安全整数范围因此不安全的数值 ID 以十进制字符串原样保留不会被四舍五入——normalizeJsonValue用isLosslessNumber检测并决定转成Number还是保留字符串可注入 Fetchfetcher参数默认为globalThis.fetch便于在测试中注入 fail-closed 的 Fetch 实现见下文“测试策略”。绑定工具把工单上下文注入 Agent生成模块中的retrieveTicket()需要被绑定到目标 Agentuse 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 useInitialDatav.InferOutputtypeof 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 timestampexact request body两者之间没有任何分隔符。flue/zendesk在 UTF-8 解码或 JSON 解析之前先保留并验证这些原始字节。从 packages/zendesk/src/webhook.ts 可以看到完整的入站处理流水线按序Content-Type 检查非application/json返回415Content-Length 检查非数字返回400超过bodyLimit返回413bodyLimit默认1 MiB可通过createZendeskChannel的bodyLimit选项覆盖必须是正整数签名头解析缺失或不符合 base64 形态返回401元数据头读取account-id、webhook-id、invocation-id、signature-timestamp四个头全部必填且不能带首尾空白否则400请求体流式读取边读边累计字节数超限即413读取出错即400HMAC 验证用crypto.subtle.importKey(raw, ...)导入签名密钥对timestamp body字节做verify(HMAC, ...)失败返回401parseSignature还要求解码后恰为 32 字节UTF-8 解码使用fatal: true的TextDecoder非法 UTF-8 返回400JSON 解析与信封校验用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提供方事件 IDtype与zendesk_event_version均为开放字符串例如zen:event-type:ticket.created、2022-06-20subject形如zen:ticket:id以及timedetail与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 或 FetchResponse→ 原样透传回调抛错或返回不支持的值 →fail closed返回可重试的409packages/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 APIcrypto.subtle.importKey/verify、TextEncoder/TextDecoder、流式request.body.getReader()因此可运行于 workerd。项目自有客户端使用原生 Fetch 加Buffer生成 Basic 认证头——在 examples/zendesk-channel/wrangler.jsonc 这类配置下Flue 项目已启用所需的nodejs_compatprocess与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),仅供参考
返回列表