Intercom
快速开始
通过 Intercom 蓝图,将经过验证的 webhook 入口和应用自有 API 行为添加到现有 Flue 项目中。在终端或你选择的编码智能体中运行以下命令:
flue add channel intercom
概述
Intercom 蓝图安装 @flue/intercom 和官方 intercom-client SDK,在源码根目录的 intercom-client.ts 创建项目自有的客户端工厂,并创建 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:
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 向其发送通知。客户端密钥和出站访问令牌是单独的凭据。
渠道模块
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 客户端保留在项目代码中:
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 客户端。
绑定工具
'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。