Zendesk
快速开始
使用 Zendesk 蓝图为已有的 Flue 项目添加已验证的事件订阅入口和应用自有的工单 API 行为。在终端或你选择的编码智能体中运行以下命令:
flue add channel zendesk
概述
该蓝图安装 @flue/zendesk 和 lossless-json。它在 <source-root>/zendesk-client.ts 创建一个精简的 Fetch 客户端,在 <source-root>/channels/zendesk.ts 创建文件,包含命名的 channel 导出和项目自有的 client 导出、工单标识处理和工单绑定的检索工具。它将该工具连接到智能体,并仅在目标需要时添加 Node 类型;不安装社区 Zendesk SDK。
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() 辅助函数;完整的辅助函数出现在下方的渠道模块中。
匹配的工单事件被接受到绑定到该账户和工单的智能体,而已验证的其他事件收到空的成功响应。完整的生成模块在 subject 和 detail.id 中验证匹配的工单标识,处理评论事件,并允许绑定的智能体通过项目自有的客户端检索当前工单。该客户端保留大型 Zendesk 标识符,并在 Node 或 Cloudflare Workers 中运行。
挂载渠道
渠道仅在 app.ts 挂载它的地方提供 HTTP 路由。挂载模块的命名 channel 导出:
import { channel as zendesk } from './channels/zendesk.ts';
app.route('/channels/zendesk', zendesk.route());channel.route() 是一个纯路由器工厂,相对于挂载路径提供渠道声明的路由。本指南中的 webhook 路径假定使用常规的 /channels/zendesk 挂载路径;不同的挂载路径会相应调整。分发目标智能体模块携带 'use agent' 指令——该指令会注册它,因此仅用于分发的智能体不需要自己的 HTTP 挂载。
配置
| 变量 | 用途 |
|---|---|
ZENDESK_WEBHOOK_SIGNING_SECRET | 必需 — 验证入站事件体。 |
ZENDESK_ACCOUNT_ID | 必需 — 限制事件和资源标识到一个账户。 |
ZENDESK_WEBHOOK_ID | 可选 — 限制交付到一个配置的 webhook。 |
ZENDESK_SUBDOMAIN | 必需 — 选择账户的工单 API 源。 |
ZENDESK_EMAIL | 必需 — 标识用于 Basic 认证的 API 令牌用户。 |
ZENDESK_API_TOKEN | 必需 — 验证出站工单 API 请求。 |
它安装 @flue/zendesk 并创建一个渠道模块,包含命名的 channel 和项目自有的 client 导出。Zendesk 没有官方支持的 Node 服务器 SDK,因此蓝图使用精简的原生 Fetch 客户端,而不是添加社区包装器。
创建 JSON 事件订阅 webhook:
https://example.com/channels/zendesk/webhook
webhook 签名密钥和出站 API 令牌是不同的凭据。
渠道模块
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: '检索已绑定到此智能体的 Zendesk 工单。',
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;
}分组分支处理所选的工单事件,同时保持提供商目录开放。验证每个订阅类型消费的字段。该示例要求 subject 和 detail.id 中的工单 ID 一致,然后才将其用作应用标识。
项目自有的客户端
使用原始账户子域名并在受信任代码中绑定凭据:
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](?:[a-z0-9-]{0,61}[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 令牌 Basic 认证为 {email}/token:{api_token}。OAuth 承载令牌也可用,但授权设置、令牌刷新和安装存储仍然是应用自有的。
不要从模型或 webhook 字段接受任意的基础 URL。主机映射的 Help Center 域名不会替换账户的原始 <subdomain>.zendesk.com API 源。
为此客户端安装 lossless-json@4.3.0。Zendesk 标识符可能超出 JavaScript 的安全整数范围,因此不安全的数字 ID 保留为十进制字符串,而不是被四舍五入。
绑定工具
'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 是实例的创建数据:在事件创建实例时记录一次,之后忽略,因此渠道在每次分发时传递它。智能体使用 useInitialData() 读取它,根据智能体的 initialData 静态属性进行验证,而不是解析实例 ID。
该工具不接受来自模型的任何账户、工单 ID、API 主机或凭据。instanceId() 包含账户和工单标识,因为 Zendesk 资源 ID 是账户范围的。ID 仍然是标识符,而不是授权能力。parseInstanceId() 作为逃生舱口仍然可用于直接从 ID 恢复该标识。
验证
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。没有分隔符。@flue/zendesk 在 UTF-8 解码或 JSON 解析之前保留并验证这些字节。
HMAC 覆盖时间戳和体,不覆盖账户、webhook 或调用头。该包要求这些头,检查 payload account_id 与账户头的匹配,并可以限制配置的账户和 webhook ID。将头元数据视为提供商路由上下文,而不是独立的授权。
Zendesk 没有记录时间戳接受窗口或时钟偏差规则。渠道暴露 delivery.signatureTimestamp 但不发明新鲜度语义。
事件格式
回调接收 { c, payload, delivery },将 Flue 已验证的提供商原生 payload 与未签名的头元数据分开。
payload 是 Zendesk 自己的通用事件信封,使用提供商的 snake_case 字段名:
account_id,规范化为正十进制字符串;id,提供商事件 ID;type和zendesk_event_version,都是开放字符串;subject如zen:ticket:<id>,以及time;- 提供商原生的
detail和eventJSON 对象。
索引签名转发任何已认证的未来或未建模的字段,因此已验证的未来事件族仍然可观察。JSON 以无损方式解析:不安全的整数字面量保留其精确的十进制拼写为字符串,顶层整数 account_id 规范化为十进制字符串。
delivery 是从请求头读取的未签名路由元数据:webhookId、invocationId 和 signatureTimestamp。Zendesk 的 HMAC 不覆盖这些头,因此将它们视为提供商路由上下文,而不是授权。
Zendesk 当前文档关于工单交付设置不一致:事件目录和 Support UI 文档列出了工单订阅,而开发者 webhook 指南仍然推荐使用触发器或自动化来处理工单活动。仅当账户暴露这些事件订阅时,才使用分组工单示例。自定义触发器 payload 是开发者编写的,不会被当作固定通用事件信封来接受。
此初始渠道目标是提供商定义的 JSON 事件订阅。自定义触发器和自动化 webhook 可以使用开发者编写的 payload、其他媒体类型和其他方法,因此它们不会被静默视为相同协议。Sunshine Conversations 和 Zendesk AI Agent webhook 也有不同或不完整的认证和交付契约,仍然是单独的研究领域。
响应和交付
不返回任何内容会产生一个空的 200 响应。兼容 JSON 的值会变成 JSON 响应。普通的 Hono 或 Fetch Response 会原样传递。抛出的回调或不支持的返回值以可重试的 409 安全失败。
Zendesk 允许 12 秒来完成整个请求。渠道不强制执行截止时间,因为将回调与计时器竞赛实际上无法取消已经启动的 JavaScript 工作——超时的工作继续运行,同时返回误导性的失败。相反,及时提交持久性工作(例如 dispatch(...) 然后返回),并依靠幂等性而不是在确认之前阻塞慢操作。
Zendesk 最多重试 409 三次,有条件地重试 429 和 503(带短 Retry-After),超时最多重试五次。交付是尽力而为的,可能被复制或省略。当不允许重复入站时,在应用自有的存储中持久化已签名的 payload.id。未签名的 delivery.invocationId 用于关联提供商尝试,但不是防重放的去重键。使用精确的 200 进行普通确认。
Cloudflare Workers
入站使用 Web Crypto 和基于标准的 Fetch API。项目自有的客户端使用原生 Fetch 加上 Buffer 进行文档化的 Basic 认证。两条路径都在 workerd 中执行(使用 Flue 所需的 nodejs_compat 配置)。
在 Node 和 workerd 中使用注入的失败关闭 Fetch 测试真实的导出客户端。断言确切的 Zendesk 主机、工单路径、方法和授权头,并拒绝每个意外目标。为入站测试创建原始合成事件和本地 HMAC。不要创建 webhook、订阅实时事件、获取真实令牌或联系 Zendesk。