快速开始
使用 WhatsApp 蓝图为已有的 Flue 项目添加已验证的 WhatsApp Business Cloud webhook 入口和项目自有的出站 WhatsApp 访问。在终端或你选择的编码智能体中运行以下命令:
flue add channel whatsapp
概述
该蓝图安装 @flue/whatsapp 和 @kapso/whatsapp-cloud-api,在源码根目录创建 channels/whatsapp.ts 模块,其中包含命名的 channel 导出和项目自有的 client 导出,并修改所选智能体以绑定生成的消息工具。
import { createWhatsAppChannel } from '@flue/whatsapp';
import { dispatch } from '@flue/runtime';
import { WhatsAppClient } from '@kapso/whatsapp-cloud-api';
import { Assistant } from '../agents/assistant.ts';
export const client = new WhatsAppClient({
accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
graphVersion: 'v25.0',
});
export const channel = createWhatsAppChannel({
appSecret: process.env.WHATSAPP_APP_SECRET!,
verifyToken: process.env.WHATSAPP_VERIFY_TOKEN!,
async webhook({ payload }) {
for (const entry of payload.entry) {
for (const change of entry.changes) {
if (change.field !== 'messages') continue;
if (change.value.metadata.phone_number_id !== process.env.WHATSAPP_PHONE_NUMBER_ID)
continue;
for (const message of change.value.messages ?? []) {
if (message.type !== 'text' && message.type !== 'interactive') continue;
const body =
message.type === 'text'
? message.text.body
: (message.interactive.button_reply?.title ??
message.interactive.list_reply?.title ??
message.interactive.nfm_reply?.body ??
'');
const ref = conversationRef(entry.id, change.value, message);
await dispatch(Assistant, {
id: channel.instanceId(ref),
// 此事件创建实例时记录一次;之后忽略。
initialData: {
phoneNumberId: ref.phoneNumberId,
destination: ref.type === 'individual' ? ref.destination : undefined,
groupId: ref.type === 'group' ? ref.groupId : undefined,
contactName: change.value.contacts?.[0]?.profile?.name,
},
message: {
kind: 'signal',
type: `whatsapp.${message.type}`,
body,
attributes: { messageId: message.id },
},
});
}
}
}
},
});简略版示例省略了生成的 conversationRef 辅助函数和出站消息工具。配置完成后,支持的消息会继续处理已验证的业务范围用户或群组的智能体实例,绑定的客户端工具会回复到相同的目标。基于 Fetch 的客户端在 Node 和 Cloudflare Workers 上均可运行(使用 Flue 的 nodejs_compat 设置)。
挂载渠道
渠道仅在 app.ts 挂载它的地方提供 HTTP 路由。挂载模块的命名 channel 导出:
import { channel as whatsapp } from './channels/whatsapp.ts';
app.route('/channels/whatsapp', whatsapp.route());channel.route() 是一个纯路由器工厂,相对于挂载路径提供渠道声明的路由。本指南中的 webhook 路径假定使用常规的 /channels/whatsapp 挂载路径;不同的挂载路径会相应调整。分发目标智能体模块携带 'use agent' 指令——该指令会注册它,因此仅用于分发的智能体不需要自己的 HTTP 挂载。
配置
| 变量 | 用途 |
|---|---|
WHATSAPP_APP_SECRET | 必需 — 验证签名的入站 webhook 体。 |
WHATSAPP_VERIFY_TOKEN | 必需 — 验证 Meta 的回调设置挑战。 |
WHATSAPP_ACCESS_TOKEN | 必需 — 验证出站 Graph API 调用。 |
WHATSAPP_PHONE_NUMBER_ID | 必需 — 限制处理范围为配置的电话号码。 |
WHATSAPP_BUSINESS_ACCOUNT_ID | 可选 — 作为应用策略按业务账户限制处理。 |
它为已验证的入站安装 @flue/whatsapp,为项目自有的 Graph API 访问安装 @kapso/whatsapp-cloud-api。@flue/whatsapp 需要 Node 24,因为其选择的 webhook 类型包声明了该引擎下限。客户端基于 Fetch,在 Node 和 workerd 中均可运行(使用 Flue 所需的 nodejs_compat 配置)。
将回调 URL 设置为:
https://example.com/channels/whatsapp/webhook
使用上述路由和随机 WHATSAPP_VERIFY_TOKEN 配置 Meta 应用。将 WhatsApp Business Account 订阅到 messages 字段。
Meta 发送 GET 请求进行 hub.challenge 验证,并使用应用密钥在 X-Hub-Signature-256 中签名 POST 体。该包验证精确字节,然后原样转发 Meta 的提供商原生 payload。它不按业务账户或电话号码过滤;限制为你配置的电话号码(metadata.phone_number_id)或业务账户(entry[].id)是应用策略,如下方处理程序所示。
使用系统用户或业务访问令牌进行生产出站调用。保持 Graph API 版本显式,并在更改之前测试升级。
渠道模块
import {
createWhatsAppChannel,
type WebhookMessage,
type WebhookValue,
type WhatsAppConversationRef,
} from '@flue/whatsapp';
import { defineTool, dispatch } from '@flue/runtime';
import { WhatsAppClient, type SendMessageResponse } from '@kapso/whatsapp-cloud-api';
import * as v from 'valibot';
import { Assistant } from '../agents/assistant.ts';
export const client = new WhatsAppClient({
accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
graphVersion: 'v25.0',
});
export const channel = createWhatsAppChannel({
appSecret: process.env.WHATSAPP_APP_SECRET!,
verifyToken: process.env.WHATSAPP_VERIFY_TOKEN!,
// 路径:GET 和 POST /channels/whatsapp/webhook
async webhook({ payload }) {
const expectedPhoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID!;
for (const entry of payload.entry) {
for (const change of entry.changes) {
if (change.field !== 'messages') continue;
const value = change.value;
// 按电话号码过滤已认证的交付是应用策略。
if (value.metadata.phone_number_id !== expectedPhoneNumberId) continue;
for (const message of value.messages ?? []) {
if (message.type !== 'text' && message.type !== 'interactive') continue;
const body =
message.type === 'text'
? message.text.body
: (message.interactive.button_reply?.title ??
message.interactive.list_reply?.title ??
message.interactive.nfm_reply?.body ??
'');
const ref = conversationRef(entry.id, value, message);
await dispatch(Assistant, {
id: channel.instanceId(ref),
// 此事件创建实例时记录一次;之后忽略。
initialData: {
phoneNumberId: ref.phoneNumberId,
destination: ref.type === 'individual' ? ref.destination : undefined,
groupId: ref.type === 'group' ? ref.groupId : undefined,
contactName: value.contacts?.[0]?.profile?.name,
},
message: {
kind: 'signal',
type: `whatsapp.${message.type}`,
body,
attributes: { messageId: message.id },
},
});
}
}
}
},
});
// 从业务范围的用户 ID 派生稳定的个体标识。
function conversationRef(
businessAccountId: string,
value: WebhookValue,
message: WebhookMessage,
): WhatsAppConversationRef {
const phoneNumberId = value.metadata.phone_number_id;
if (message.group_id) {
return { type: 'group', businessAccountId, phoneNumberId, groupId: message.group_id };
}
return {
type: 'individual',
businessAccountId,
phoneNumberId,
destination: { type: 'user-id', userId: message.from_user_id },
};
}
// `WhatsAppConversationRef` 中 `sendTextMessage()` 实际发送的字段。
export type WhatsAppSendRef =
| {
type: 'individual';
phoneNumberId: string;
destination:
{ type: 'phone-number'; phoneNumber: string } | { type: 'user-id'; userId: string };
}
| { type: 'group'; phoneNumberId: string; groupId: string };
function sendTextMessage(ref: WhatsAppSendRef, body: string): Promise<SendMessageResponse> {
if (ref.type === 'group') {
return client.messages.sendText({
phoneNumberId: ref.phoneNumberId,
recipientType: 'group',
to: ref.groupId,
body,
});
}
if (ref.destination.type === 'phone-number') {
return client.messages.sendText({
phoneNumberId: ref.phoneNumberId,
recipientType: 'individual',
to: ref.destination.phoneNumber,
body,
});
}
return client.request<SendMessageResponse>('POST', `${ref.phoneNumberId}/messages`, {
body: {
messaging_product: 'whatsapp',
recipient_type: 'individual',
recipient: ref.destination.userId,
type: 'text',
text: { body },
},
responseType: 'json',
});
}
export function postMessage(ref: WhatsAppSendRef) {
return defineTool({
name: 'post_whatsapp_message',
description: '发送到绑定到此智能体的 WhatsApp 对话。',
input: v.object({
text: v.pipe(v.string(), v.minLength(1), v.maxLength(4096)),
}),
async run({ data: { text } }) {
const result = await sendTextMessage(ref, text);
return { output: { messageId: result.messages[0]?.id ?? null } };
},
});
}initialData 是实例的创建数据:在事件创建实例时记录一次,之后忽略,因此渠道在每次分发时传递它。它携带对话的目标信息——智能体使用 useInitialData() 读取它们,而不是从实例 ID 中解析——加上小型实例常量上下文,如联系人的显示名称。每条消息的信息保留在信号的 attributes 上。
连接智能体
'use agent';
import { useInitialData, useModel, useTool } from '@flue/runtime';
import * as v from 'valibot';
import { postMessage, type WhatsAppSendRef } from '../channels/whatsapp.ts';
const initialData = v.object({
phoneNumberId: v.string(),
destination: v.optional(
v.union([
v.object({ type: v.literal('phone-number'), phoneNumber: v.string() }),
v.object({ type: v.literal('user-id'), userId: v.string() }),
]),
),
groupId: v.optional(v.string()),
contactName: v.optional(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 WhatsApp channel dispatch.');
let ref: WhatsAppSendRef;
if (data.groupId !== undefined) {
ref = { type: 'group', phoneNumberId: data.phoneNumberId, groupId: data.groupId };
} else if (data.destination !== undefined) {
ref = { type: 'individual', phoneNumberId: data.phoneNumberId, destination: data.destination };
} else {
throw new Error('WhatsApp instance data is missing a destination.');
}
useTool(postMessage(ref));
const contactName = data.contactName ? ` with ${data.contactName}` : '';
return `Reply concisely in the bound WhatsApp conversation${contactName}.`;
}
Assistant.initialData = initialData;智能体的 initialData 静态属性在实例创建时验证分发的 initialData;useInitialData() 在每次渲染时返回解析后的值。受信任的应用代码选择目标;模型仅选择消息文本。parseInstanceId() 作为逃生舱口仍然可用于直接从 ID 恢复该目标。
交付行为
一个 POST 可以包含多个 entry、change、message 和 status。回调运行一次,包含完整的已验证交付;payload 是 Meta 的提供商原生 webhook 对象,原样转发并由第三方社区维护的 @whatsapp-cloudapi/types 包进行类型化。按 Meta 发送的顺序遍历 payload.entry[].changes[],对 change.field 进行缩小,然后对 message.type 或 status 进行缩小,并在返回之前处理每个适用的项。
message.type 判别器涵盖文本、图片、音频、视频、文档、贴纸、位置、联系人、交互式按钮/列表/流回复、旧版按钮、反应、订单、系统和不支持的消息。已认证的未来形状在运行时仍然转发,但可能需要应用类型转换或类型守卫,直到类型包对其进行建模。status 判别器保留 sent、delivered、read、played 和 failed。
不返回任何内容会产生一个空的 200 响应。兼容 JSON 的值会变成响应体;Hono 或 Fetch Response 会原样传递。抛出的处理程序不会被吞掉,会到达 Hono 的错误处理程序。
Meta 期望快速的 200 响应(在几秒内),否则可能会将 webhook 标记为非活动,并以递减频率重试非 200 交付长达七天,因此重复是预期的。及时提交持久性工作(分发,然后返回),而不是在慢操作上阻塞。渠道是无状态的,不去重;当不允许重复入站时,在分发之前在持久应用存储中声明消息 ID。
对话标识
Meta 在传入消息 webhook 中提供 Business-Scoped User ID(from_user_id),并可能随着账户功能的发展省略或更改发送者电话号码(from)。上面的 conversationRef 辅助函数始终使用 from_user_id 作为稳定的入站个体标识,即使 from 存在。群组目标使用提供商的 group_id。
当前 SDK 版本暴露了广泛的 Graph API 辅助函数,但其高级文本辅助函数仅建模了 to。该示例保留完整的导出 SDK 客户端,并使用其已认证的低级 request() 方法处理文档化的 BSUID recipient 格式。在 Node 和 workerd 中针对假 Fetch 测试每个依赖的操作。
原生媒体 payload 携带承载认证的媒体 id(以及在较新 API 版本上的临时 url)。将两者都视为传输凭据:使用项目自有的客户端通过已验证的 ID 下载媒体,避免将原始 payload 或媒体 URL 整体转发到模型上下文中。