Twilio
快速开始
使用 Twilio 蓝图为已有的 Flue 项目添加已验证的 SMS 和 MMS webhook 入口和项目自有的出站消息。在终端或你选择的编码智能体中运行以下命令:
flue add channel twilio
概述
Twilio 蓝图安装 @flue/twilio,在源码根目录创建项目自有的 Fetch 客户端 twilio-client.ts,并创建 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 导出:
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_SID | Messaging 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!,
},
该包拒绝针对其他账户或目标的签名请求。
渠道模块
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 是实例的创建数据:在事件创建实例时记录一次,之后忽略,因此渠道在每次分发时传递它。它携带回复工具所需的对话引用字段。
连接智能体
'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 之外。在慢操作之前确认,并在启用重试时使入站具有幂等性。