跳转到内容

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

Twilio

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

快速开始

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

flue add channel twilio

概述

Twilio 蓝图安装 @flue/twilio,在源码根目录创建项目自有的 Fetch 客户端 twilio-client.ts,并创建 channels/twilio.ts。它还会更新所选智能体以将生成的回复工具绑定到已验证的对话。

src/channels/twilio.ts(简略版)
import { createTwilioChannel } from '@flue/twilio';
import { dispatch } from '@flue/runtime';
import { Assistant } from '../agents/assistant.ts';
import { TwilioClient } from '../twilio-client.ts';

export const client = new TwilioClient({
  accountSid: process.env.TWILIO_ACCOUNT_SID!,
  authToken: process.env.TWILIO_AUTH_TOKEN!,
});

export const channel = createTwilioChannel({
  accountSid: process.env.TWILIO_ACCOUNT_SID!,
  authToken: process.env.TWILIO_AUTH_TOKEN!,
  webhookUrl: process.env.TWILIO_WEBHOOK_URL!,
  destination: {
    type: 'address',
    address: process.env.TWILIO_PHONE_NUMBER!,
  },
  async webhook({ payload, conversation }) {
    if (payload.OptOutType === 'STOP') return;
    await dispatch(Assistant, {
      id: channel.instanceId(conversation),
      // 此事件创建实例时记录一次;之后忽略。
      initialData:
        conversation.type === 'messaging-service'
          ? {
              type: conversation.type,
              messagingServiceSid: conversation.messagingServiceSid,
              participant: conversation.participant,
            }
          : {
              type: conversation.type,
              address: conversation.address,
              participant: conversation.participant,
            },
      message: {
        kind: 'signal',
        type: 'twilio.message',
        body: payload.Body,
        attributes: { messageSid: payload.MessageSid, from: payload.From },
      },
    });
  },
});

简略版示例省略了生成的 postMessage() 工具和 Fetch 客户端实现。完整蓝图将该工具绑定到使用 useInitialData() 读取的智能体创建数据,因此已验证的入站消息到达对应的智能体实例,且回复发送给同一参与者。Cloudflare 项目使用生成的基于标准的客户端,而不是 Twilio 的仅 Node 辅助程序;Messaging Service 目标和可选的交付状态回调作为次要更改配置。

挂载渠道

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

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

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

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

配置

变量用途
TWILIO_ACCOUNT_SID必需 — 限制入站请求并标识出站 API 调用。
TWILIO_AUTH_TOKEN必需 — 验证入站签名并认证 API 调用。
TWILIO_WEBHOOK_URL必需 — 提供用于签名检查的确切公共 URL。
TWILIO_PHONE_NUMBER基于地址的目标必需 — 绑定基于地址的目标。
TWILIO_MESSAGING_SERVICE_SIDMessaging Service 目标必需 — 绑定 Messaging Service 目标。
TWILIO_STATUS_CALLBACK_URL启用状态回调时必需 — 提供确切的公共状态回调 URL。

它为已验证的入站安装 @flue/twilio,并创建可编辑的 Fetch 客户端用于出站可编程消息。官方 Twilio Node 辅助程序不是规范路径,因为它是仅 Node 的;生成的 REST 客户端在 Node 和 workerd 中均可运行(使用 Flue 所需的 nodejs_compat 配置)。

将入站 webhook URL 设置为:

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

设置账户 SID、认证令牌、目标和确切的公共 webhook URL。Twilio 对外部配置的 URL 加上每个表单参数进行签名。代理后面的应用无法可靠地从请求中重建该 URL,因此 webhookUrl 是必需的,并且必须包含任何外部挂载前缀或查询字符串。

受信任的代理可以在请求到达 Flue 之前剥离外部路径前缀。签名验证仍然使用 webhookUrl;固定渠道路由拥有内部路径。传入请求自身的查询字符串不会被重新检查——它已经是签名字节的一部分,因此任何篡改都会导致签名失败(401)。

连接覆盖片段可能保留在配置的 URL 中。它们被排除在签名验证之外,因为 Twilio 不发送或签名 URL 片段。

对于 Messaging Service,配置:

destination: {
  type: 'messaging-service',
  messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID!,
},

该包拒绝针对其他账户或目标的签名请求。

渠道模块

src/channels/twilio.ts
import { createTwilioChannel } from '@flue/twilio';
import { defineTool, dispatch } from '@flue/runtime';
import * as v from 'valibot';
import { Assistant } from '../agents/assistant.ts';
import { TwilioClient } from '../twilio-client.ts';

export const client = new TwilioClient({
  accountSid: process.env.TWILIO_ACCOUNT_SID!,
  authToken: process.env.TWILIO_AUTH_TOKEN!,
});

export const channel = createTwilioChannel({
  accountSid: process.env.TWILIO_ACCOUNT_SID!,
  authToken: process.env.TWILIO_AUTH_TOKEN!,
  webhookUrl: process.env.TWILIO_WEBHOOK_URL!,
  destination: {
    type: 'address',
    address: process.env.TWILIO_PHONE_NUMBER!,
  },

  // 路径:/channels/twilio/webhook
  async webhook({ payload, conversation }) {
    if (payload.OptOutType === 'STOP') return;
    const attributes: Record<string, string> = {
      messageSid: payload.MessageSid,
      from: payload.From,
    };
    const numMedia = Number(payload.NumMedia ?? '0');
    if (numMedia > 0) {
      attributes.numMedia = String(numMedia);
      for (let index = 0; index < numMedia; index += 1) {
        const contentType = payload[`MediaContentType${index}`];
        if (typeof contentType === 'string') {
          attributes[`mediaContentType${index}`] = contentType;
        }
      }
    }
    await dispatch(Assistant, {
      id: channel.instanceId(conversation),
      // 此事件创建实例时记录一次;之后忽略。
      initialData:
        conversation.type === 'messaging-service'
          ? {
              type: conversation.type,
              messagingServiceSid: conversation.messagingServiceSid,
              participant: conversation.participant,
            }
          : {
              type: conversation.type,
              address: conversation.address,
              participant: conversation.participant,
            },
      message: {
        kind: 'signal',
        type: 'twilio.message',
        body: payload.Body,
        attributes,
      },
    });
  },
});

export function postMessage(
  ref:
    | { type: 'address'; address: string; participant: string }
    | { type: 'messaging-service'; messagingServiceSid: string; participant: string },
) {
  return defineTool({
    name: 'post_twilio_message',
    description: '发送到绑定到此智能体的 Twilio 对话。',
    input: v.object({ text: v.pipe(v.string(), v.minLength(1)) }),
    async run({ data: { text } }) {
      const result = await client.messages.create({
        to: ref.participant,
        body: text,
        ...(ref.type === 'messaging-service'
          ? { messagingServiceSid: ref.messagingServiceSid }
          : { from: ref.address }),
      });
      return { output: { messageSid: result.sid } };
    },
  });
}

蓝图创建 src/twilio-client.ts,包含上面使用的 Fetch 客户端。initialData 是实例的创建数据:在事件创建实例时记录一次,之后忽略,因此渠道在每次分发时传递它。它携带回复工具所需的对话引用字段。

连接智能体

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

const initialData = v.variant('type', [
  v.object({ type: v.literal('address'), address: v.string(), participant: v.string() }),
  v.object({
    type: v.literal('messaging-service'),
    messagingServiceSid: v.string(),
    participant: 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 Twilio channel dispatch.');
  useTool(postMessage(data));
  return 'Reply concisely in the bound Twilio conversation.';
}

Assistant.initialData = initialData;

智能体的 initialData 静态属性在实例创建时验证分发的 initialData;useInitialData() 在每次渲染时返回解析后的值——智能体通过这种方式读取对话引用,而不是从实例 ID 中解析。

消息行为

已验证的消息以 { c, payload, conversation, idempotencyToken? } 到达处理程序。payload 是 Twilio 签名的提供商原生已验证表单:字段名使用 Twilio 的 PascalCase 线路拼写(MessageSid、From、To、Body、NumMedia、MediaUrl0、OptOutType 等),每个值都是 string,Twilio 重复的参数变成 readonly string[]。渠道不重命名、缩小或强制转换字段;Twilio 添加的新参数通过索引签名到达处理程序,因此直接使用其线路名称读取它们。在应用代码中解析段计数、MMS 元数据、退出状态、地理和富消息字段。conversation 是从已验证目标和发送者派生的规范引用;idempotencyToken 在存在时携带 Twilio 的 I-Twilio-Idempotency-Token。

将 STOP 视为控制输入,而不是将其分发给智能体或发送应用回复。

不返回任何内容会产生一个空的 TwiML <Response/>,状态码为 200。返回普通的 Hono 或 Fetch Response 以获取显式 TwiML、状态或头。

MMS URL 需要 Twilio 凭据。仅在受信任的应用代码中获取媒体,避免将已认证的内容或原始表单放入模型上下文。

交付状态

同时添加 statusCallbackUrl 和 statusCallback 以发布:

https://example.com/channels/twilio/status

在出站消息上设置相同的 URL 作为 StatusCallback。状态处理程序输入镜像入站形状:payload 携带逐字转发的确切 MessageStatus 字符串——永远不会缩小为冻结的联合——以及每个其他已签名的状态参数(发送者、接收者、错误、通道和交付回执字段),使用相同的 string[] 规则和索引签名转发。仅当已签名字段标识配置的目标时,conversation 才存在:From 必须匹配地址目标,或 MessagingServiceSid 必须匹配 Messaging Service 目标。

Twilio 可能会以退避方式重试状态回调,并且可能重复或乱序交付。通过消息 SID 幂等地持久化转换;渠道是无状态的,暴露 MessageSid 和 I-Twilio-Idempotency-Token 而不声明持久去重。重试的请求可以重用幂等性令牌,但应用仍然拥有持久幂等性。

Twilio 不保证每个状态回调中都有 MessagingServiceSid。当该字段缺失或不匹配时,渠道仍然转发已验证的回调,但省略 conversation;它仅从精确已签名的 SID 匹配派生 Messaging Service 对话标识。当原始值很重要时,在应用代码中读取 payload.MessagingServiceSid。

截止时间

Twilio 对 webhook 响应应用 15 秒读取超时,并建议快速确认并异步处理。渠道不强制执行自己的截止时间。入站消息 webhook 默认不重试:在错误或超时时,Twilio 使用配置的 Fallback URL。webhook URL 上的连接覆盖可以通过 rc(重试计数)和 rp(重试策略)选择加入重试,例如 #rc=2&rp=all;该片段被排除在已签名 URL 之外。在慢操作之前确认,并在启用重试时使入站具有幂等性。

参见 @flue/twilio README。