跳转到内容

输入关键词开始搜索文档。

Zendesk

最后更新 查看 Markdown 源文件@flue/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。

src/channels/zendesk.ts(简略版)
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 导出:

src/app.ts
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 令牌是不同的凭据。

渠道模块

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: '检索已绑定到此智能体的 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 一致,然后才将其用作应用标识。

项目自有的客户端

使用原始账户子域名并在受信任代码中绑定凭据:

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](?:[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 保留为十进制字符串,而不是被四舍五入。

绑定工具

src/agents/assistant.ts
'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 和 event JSON 对象。

索引签名转发任何已认证的未来或未建模的字段,因此已验证的未来事件族仍然可观察。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。

参见 @flue/zendesk README。