跳转到内容

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

WhatsApp

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

快速开始

使用 WhatsApp 蓝图为已有的 Flue 项目添加已验证的 WhatsApp Business Cloud webhook 入口和项目自有的出站 WhatsApp 访问。在终端或你选择的编码智能体中运行以下命令:

flue add channel whatsapp

概述

该蓝图安装 @flue/whatsapp 和 @kapso/whatsapp-cloud-api,在源码根目录创建 channels/whatsapp.ts 模块,其中包含命名的 channel 导出和项目自有的 client 导出,并修改所选智能体以绑定生成的消息工具。

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

src/app.ts
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 版本显式,并在更改之前测试升级。

渠道模块

src/channels/whatsapp.ts
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 上。

连接智能体

src/agents/assistant.ts
'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 整体转发到模型上下文中。

参见 @flue/whatsapp README。