跳转到内容

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

Intercom

最后更新 查看 Markdown 源文件@flue/intercom

快速开始

通过 Intercom 蓝图,将经过验证的 webhook 入口和应用自有 API 行为添加到现有 Flue 项目中。在终端或你选择的编码智能体中运行以下命令:

flue add channel intercom

概述

Intercom 蓝图安装 @flue/intercom 和官方 intercom-client SDK,在源码根目录的 intercom-client.ts 创建项目自有的客户端工厂,并创建 channels/intercom.ts。它还会更新选定的智能体,将对话检索工具绑定到经过验证的工作区和对话。

src/channels/intercom.ts(节选)
import { createIntercomChannel, type IntercomConversationRef } from '@flue/intercom';
import { dispatch } from '@flue/runtime';
import { Assistant } from '../agents/assistant.ts';
import { createIntercomClient } from '../intercom-client.ts';

export const client = createIntercomClient(process.env.INTERCOM_ACCESS_TOKEN!, { region: 'us' });

export const channel = createIntercomChannel({
  clientSecret: process.env.INTERCOM_CLIENT_SECRET!,
  async webhook({ notification }) {
    if (notification.topic !== 'conversation.user.replied') return;
    const conversationId = conversationIdFromItem(notification.data.item);
    if (!conversationId) return;

    const conversation: IntercomConversationRef = {
      workspaceId: notification.app_id,
      conversationId,
    };
    await dispatch(Assistant, {
      id: channel.instanceId(conversation),
      // 在此事件创建实例时记录一次;之后忽略。
      initialData: {
        workspaceId: conversation.workspaceId,
        conversationId: conversation.conversationId,
      },
      message: {
        kind: 'signal',
        type: 'intercom.conversation.user.replied',
        // 对话项是 Intercom 自己的消息负载;它没有单一的扁平文本字段,
        // 因此作为正文原样传输。
        body: JSON.stringify(notification.data.item),
        attributes: {
          ...(notification.id === null ? {} : { notificationId: notification.id }),
          createdAt: String(notification.created_at),
          deliveryAttempts: String(notification.delivery_attempts),
        },
      },
    });
  },
});

节选示例展示了一个分发的主题,省略了生成的环境、区域、对话 ID 和检索工具辅助函数。完整的生成模块分发 conversation.user.created 和 conversation.user.replied;其他经过验证的主题会到达回调,仍受应用策略约束。它将 SDK 固定到其类型化的 API 版本,选择配置的区域,并在智能体模块中绑定检索工具,因此分发的通知会到达工作区范围的智能体实例,该实例可以检索当前对话而不向模型暴露工作区 ID 或凭据。

挂载渠道

渠道仅在 app.ts 挂载的位置提供 HTTP 路由。挂载模块中命名导出的 channel:

src/app.ts
import { channel as intercom } from './channels/intercom.ts';

app.route('/channels/intercom', intercom.route());

channel.route() 是一个纯路由器工厂,相对于挂载路径提供渠道声明的路由。本指南中的 webhook 路径假定使用常规的 /channels/intercom 挂载;不同的挂载路径会相应调整。分发目标智能体模块携带 'use agent' 指令——该指令负责注册,因此仅用于分发的智能体不需要自己的 HTTP 挂载。

配置

变量用途
INTERCOM_CLIENT_SECRET必需 — 验证入站通知。
INTERCOM_ACCESS_TOKEN必需 — 认证出站 API 调用。
INTERCOM_WORKSPACE_ID必需 — 将资源身份限制到一个 Intercom 工作区。
INTERCOM_REGION可选 — 选择 us、eu 或 au;默认为 us。

它安装 @flue/intercom 和官方 intercom-client@7.0.3。蓝图创建命名的 channel 和项目自有的 client 导出。

在 Intercom 的开发者中心配置一个 URL:

https://example.com/channels/intercom/webhook

Intercom 使用 HEAD 验证该 URL,并使用 POST 向其发送通知。客户端密钥和出站访问令牌是单独的凭据。

渠道模块

src/channels/intercom.ts
import {
  createIntercomChannel,
  type IntercomConversationRef,
  type JsonValue,
} from '@flue/intercom';
import { defineTool, dispatch } from '@flue/runtime';
import { Assistant } from '../agents/assistant.ts';
import { createIntercomClient, type IntercomRegion } from '../intercom-client.ts';

const workspaceId = requiredEnv('INTERCOM_WORKSPACE_ID');

export const client = createIntercomClient(requiredEnv('INTERCOM_ACCESS_TOKEN'), {
  region: intercomRegion(),
});

export const channel = createIntercomChannel({
  clientSecret: requiredEnv('INTERCOM_CLIENT_SECRET'),

  // 路径:/channels/intercom/webhook (HEAD, POST)
  async webhook({ notification }) {
    switch (notification.topic) {
      case 'conversation.user.created':
      case 'conversation.user.replied': {
        const conversationId = conversationIdFromItem(notification.data.item);
        if (!conversationId) return;

        const conversation: IntercomConversationRef = {
          workspaceId: notification.app_id,
          conversationId,
        };
        await dispatch(Assistant, {
          id: channel.instanceId(conversation),
          // 在此事件创建实例时记录一次;之后忽略。
          initialData: {
            workspaceId: conversation.workspaceId,
            conversationId: conversation.conversationId,
          },
          message: {
            kind: 'signal',
            type: `intercom.${notification.topic}`,
            // 对话项是 Intercom 自己的消息负载;它没有单一的扁平文本字段,
            // 因此作为正文原样传输。
            body: JSON.stringify(notification.data.item),
            attributes: {
              ...(notification.id === null ? {} : { notificationId: notification.id }),
              createdAt: String(notification.created_at),
              deliveryAttempts: String(notification.delivery_attempts),
            },
          },
        });
        return;
      }
      default:
        return;
    }
  },
});

export function retrieveConversation(ref: IntercomConversationRef) {
  if (ref.workspaceId !== workspaceId) {
    throw new TypeError('Expected the configured Intercom workspace.');
  }
  return defineTool({
    name: 'retrieve_intercom_conversation',
    description: '检索绑定到此智能体的当前 Intercom 对话。',
    async run() {
      const conversation = await client.conversations.find({
        conversation_id: ref.conversationId,
        display_as: 'plaintext',
      });
      return { output: conversation };
    },
  });
}

function conversationIdFromItem(item: JsonValue): string | undefined {
  if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
  return typeof item.id === 'string' && item.id.length > 0 ? item.id : undefined;
}

function intercomRegion(): IntercomRegion {
  const value = process.env.INTERCOM_REGION || 'us';
  if (value === 'us' || value === 'eu' || value === 'au') return value;
  throw new Error('INTERCOM_REGION must be us, eu, or au.');
}

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required.`);
  return value;
}

该示例使用一个分组的 switch 分支处理两个对话主题。Intercom 的主题目录很广泛且与 API 版本相关,因此渠道保持 notification.topic 开放,并将 notification.data.item 表示为 JSON。验证每个选定主题使用的字段。经过验证的 ping 和未来的主题会到达同一个回调。

HMAC 验证的正文已携带 app_id,因此渠道不会重新检查工作区身份。资源 ID 在 Intercom 工作区之间不是全局唯一的,因此该示例将 notification.app_id 和对话 ID 组合成实例 ID。服务多个工作区的应用需要在 notification.app_id 本身上进行过滤,或使用应用自有的安装状态来选择凭据。

initialData 是实例的创建数据:在事件创建实例时记录一次,之后忽略,因此渠道在每次分发时传递它。它携带工具所需的工作区和对话标识符——智能体使用 useInitialData() 读取它们,而不是解析实例 ID。

官方客户端

将 REST 客户端保留在项目代码中:

src/intercom-client.ts
import { IntercomClient, IntercomEnvironment } from 'intercom-client';

export type IntercomRegion = 'us' | 'eu' | 'au';

export interface IntercomClientOptions {
  region?: IntercomRegion;
  fetch?: typeof globalThis.fetch;
  maxRetries?: number;
}

export function createIntercomClient(
  token: string,
  options: IntercomClientOptions = {},
): IntercomClient {
  if (!token) throw new TypeError('Intercom access token must be non-empty.');
  return new IntercomClient({
    token,
    version: '2.14',
    environment: environmentForRegion(options.region ?? 'us'),
    ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
    ...(options.maxRetries === undefined ? {} : { maxRetries: options.maxRetries }),
  });
}

function environmentForRegion(
  region: IntercomRegion,
): (typeof IntercomEnvironment)[keyof typeof IntercomEnvironment] {
  switch (region) {
    case 'us':
      return IntercomEnvironment.UsProduction;
    case 'eu':
      return IntercomEnvironment.EuProduction;
    case 'au':
      return IntercomEnvironment.AuProduction;
  }
}

该 SDK 支持 US、EU 和 AU API 环境。在可信配置中选择区域,而不是接受来自模型或 webhook 字段的 API 主机。

固定 version: '2.14'。intercom-client@7.0.3 为 API 版本 2.14 生成其 REST 请求和响应类型。较新的 webhook 主题文档不会使这些生成的 REST 类型与手动强制的 2.15 头部兼容。对于真正仅限 2.15 的操作,在官方 SDK 支持之前使用窄范围的 Fetch 客户端。

绑定工具

src/agents/assistant.ts
'use agent';
import { useInitialData, useModel, useTool } from '@flue/runtime';
import * as v from 'valibot';
import { retrieveConversation } from '../channels/intercom.ts';

const initialData = v.object({
  workspaceId: v.string(),
  conversationId: v.string(),
});

export function Assistant() {
  useModel('anthropic/claude-haiku-4-5');
  const data = useInitialData<v.InferOutput<typeof initialData>>();
  if (!data) throw new Error('此智能体由 Intercom 渠道分发创建。');
  useTool(retrieveConversation(data));
  return '协助处理入站的 Intercom 对话。在需要更多上下文时检索当前对话。';
}

Assistant.initialData = initialData;

该工具仅检索从经过验证的通知中选定的对话。它不接受来自模型的工作区、对话 ID、令牌或 API 主机。

实例 ID 是标识符,不是授权能力。对直接智能体路由应用项目的正常访问控制,并在选择安装令牌之前再次验证工作区。

端点验证和签名

发现的渠道同时提供:

HEAD /channels/intercom/webhook
POST /channels/intercom/webhook

未签名的 HEAD 路由为 Intercom 的端点验证返回空的 200,永远不会调用应用代码。

通知需要:

X-Hub-Signature: sha1=<40 个十六进制字符>

Intercom 使用开发者应用客户端密钥对精确的请求体计算 HMAC-SHA1。@flue/intercom 在 UTF-8 解码或 JSON 解析之前保留并验证这些字节。更改的正文、缺失或格式错误的签名、或错误的密钥会在 webhook 运行之前被拒绝。

回调接收 { c, notification }。通知是 Intercom 自己的对象,带有原生字段名称和嵌套:

  • topic 和工作区范围的 app_id;
  • 可空的 id;
  • created_at、delivery_attempts 和 first_sent_at;
  • data.item 下的提供者原生 JSON;
  • 可选的 self;
  • 任何未建模的顶层字段,原样转发。

信封经过结构检查,但项字段保持提供者原生。删除、工单、对话部分和未来的主题可能有不同的结构。不要假设每个与对话相关的主题都有 data.item.id,而应在没有验证该主题文档化负载的情况下使用。

Intercom 不提供签名时间戳或协议重放窗口。签名验证认证交付字节,但不提供去重或新鲜度。

响应和交付

不返回任何内容会产生空的 200。JSON 兼容的值会成为状态为 200 的 JSON 响应。普通的 Hono 或 Fetch Response 会原样传递。抛出的回调会作为 500 显示给框架错误处理程序。

Intercom 在任何 2xx 时确认。使用 200 进行普通确认。仅当其提供者行为是故意的时才返回其他状态:410 禁用订阅,429 限流。普通失败会在大约一分钟后重试一次。

Intercom 期望在大约五秒内收到 2xx,否则会在一分钟后重试通知。该渠道不会用计时器强制执行此操作,因为 Promise 超时无法取消任意的 JavaScript 工作。快速承认持久工作——分发并返回——并将长时间运行的处理推迟到确认路径之后。

通知可能会被复制并乱序到达。当不允许重复接收时,在应用自有的持久存储中使用非空的 notification.id,当顺序重要时考虑 created_at。设置或定期 ping 的 id 可能为空。

该包不安装应用、执行 OAuth、选择权限、创建订阅、存储令牌、去重通知、持久化对话或定义出站收件箱策略。

Cloudflare Workers

验证器使用 Web Crypto。官方 intercom-client@7.0.3 使用 Fetch,没有运行时依赖,并在 workerd 中使用 Flue 所需的 nodejs_compat 配置执行。该示例的 workerd 测试通过注入的假 Fetch 执行真实的 client.conversations.find() 请求,并确认预期的 EU URL、bearer 令牌、Intercom-Version: 2.14 和 workerd 运行时头。

该执行证明了此处显示的客户端操作,而非每个 SDK 方法。在实际的 Worker 目标上测试应用使用的每个额外操作。Cloudflare 项目可以使用类型化绑定代替 process.env;nodejs_compat 已经是 Flue Worker 配置的一部分。

创建原始合成通知正文和本地 HMAC-SHA1 签名。在 Node 和 workerd 中练习有效和篡改的精确字节、HEAD、ping、未来主题、格式错误的 JSON、正文限制、处理程序结果、作为 500 显示的抛出回调以及实例 ID 往返。

对于出站测试,将失败关闭的 Fetch 注入实际的官方客户端,禁用重试,断言精确的主机、路径、方法、授权、版本和区域,并拒绝每个意外的目标。不要注册 webhook、执行 OAuth、获取真实令牌或联系 Intercom。

参阅 @flue/intercom README。