关于 connector-twilio
这是一个实验性的 Motoko 连接器,可从 Caffeine canister 使用 Twilio 消息功能,涵盖 SMS、MMS 和消息配置,不包含语音或通话 API;消息发送尚未经过验证。
主要功能
- 01发送 SMS 和 MMS 消息
- 02查询和列出消息记录
- 03管理消息服务与电话号码
- 04支持美国 A2P 10DLC 注册流程
- 05通过管理员权限配置凭据
适用场景
- 01从 Caffeine canister 发送通知
- 02查询发送状态或检索已发消息
- 03设置电话号码和 Twilio Messaging Service
- 04为美国消息注册 A2P 10DLC
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ caffeinelabs/ skills --skill connector-twilio安装后在 Claude Code 中输入/connector-twilio确认已加载。
详细介绍
Twilio 连接器(实验性)
从 Caffeine canister 发送 SMS / MMS 并配置 Twilio 消息服务。
⚠️ 实验性(
[email protected])——此客户端从未成功发送过消息。 它的写入路径直到最近才有可能正常工作:此前,每次写入都会丢弃参数并发送空请求体。现在,线路格式与 Twilio 文档一致(表单编码的请求体、百分号编码的值、省略可选字段),且全部 118 个文件均通过类型检查,但结构正确不代表已验证。将首次成功发送作为验收测试;在此之前,不要向用户宣传 Twilio 是完全受支持的平台功能。发送需要付费,因此实验失败也并非没有代价。
范围——此软件包仅涵盖消息功能。
twilio-client精简为 35 个 API 模块(包含完整的 Messaging v1,以及 v2010 消息路径:Account、Message、Media、IncomingPhoneNumber 及其变体、AvailablePhoneNumber、A2P 注册)。Voice / 通话、录音、会议、队列、应用、SIP 和用量记录均不在此软件包中——如果构建需要这些功能,它们不属于此连接器。(统计:35 个 API 模块、82 个模型、118 个文件,全部通过类型检查。)
编排器路由说明
当用户、规格或先前任务提及发送短信、SMS / MMS、通过电话通知某人、购买或列出电话号码,或任何 Twilio 消息服务概念时,加载此技能。直接对 *.twilio.com 发起 ic.http_request 属于反模式,因为这会手动重新实现身份验证、主机路由、百分号编码和 JSON 解析;而且,如果实现草率,每条消息会发送约 13 次。
意图 → 功能映射:
| 用户意图 | 功能 |
|---|---|
| 发送 SMS | 使用 from = Twilio 号码的 Api20100401MessageApi.createMessage |
| 发送 MMS(图片) | 同上,设置 mediaUrl = ["https://…"] 和 sendAsMms = true |
| 通过 Messaging Service 发送(美国流量推荐) | 同上,设置 from = "" + messagingServiceSid |
| 检查送达状态 | fetchMessage(status、error_code) |
| 列出 / 搜索已发送的消息 | listMessage(分页) |
| 查看自有号码或浏览可用号码 | Api20100401IncomingPhoneNumberApi、…AvailablePhoneNumberCountryApi |
| 设置 Messaging Service | MessagingV1ServiceApi.createService |
| 注册美国 A2P 10DLC | MessagingV1BrandRegistrationApi → MessagingV1UsAppToPersonApi → MessagingV1PhoneNumberApi(按此顺序——见美国 A2P 10DLC) |
| 验证免费电话号码 | MessagingV1TollfreeVerificationApi |
Twilio 凭据需要由真人登录控制台获取,因此后端编译通过并不代表构建完成——还需要让应用告诉管理员去哪里获取凭据,并提供粘贴凭据的位置。请参阅身份验证模型,然后查看前端,了解必须随产品发布的页面,并在完成消息中重复这些步骤。
编写代码前先询问:由哪个号码发送? 面向美国用户的生产应用需要 Messaging Service + A2P 注册(准备时间需数周,并产生实际费用);演示 / 内部应用可以使用单个试用号码,但只能向已验证的收件人发送。请将选择及其影响反馈给发出请求的用户。
认证模型 — HTTP Basic,两种形式
两种形式使用相同的 #basicAuth { user; password } 凭证,客户端对它们的处理方式也完全相同;区别在于影响范围。
| 形式 | user / password | 适用场景 |
|---|---|---|
| API Key (默认 — 优先使用此项) | API Key SID (SK…) / 对应的 Secret | 生产环境。可撤销且权限受限:泄露一个密钥不会导致整个账户失陷。 |
| 账户 SID + Auth Token | 账户 SID (AC…) / Auth Token | 仅用于开发环境。Auth Token 就代表整个账户 — 它可以创建子账户、购买号码并产生费用。 |
无论使用哪种形式,每个 v2010 操作都必须将账户 SID (AC…) 作为位置参数传入(它位于 URL 路径中)。因此,使用 API Key 的应用需要存储三个值:账户 SID、Key SID、Key Secret。
获取凭证
- 在 https://console.twilio.com 登录。
- 账户 SID (
AC…) 位于控制台仪表板上 — 将其复制。 - 在生产环境中,前往 Account → API keys & tokens → Create API key(Standard);复制 SID (
SK…) 和 Secret。Secret 只会显示一次 — 如果管理员离开此页面,就无法找回,只能重新生成。 仅用于开发环境时,可改用仪表板上的 Auth Token。 - 购买发送号码:前往 Phone Numbers → Manage → Buy a number,并勾选 SMS 功能(并非每个号码都支持该功能)。
- 对于 试用账户:在 Phone Numbers → Verified Caller IDs 下验证每个收件人,否则发送会因
21608而失败;试用消息还会带有 "Sent from your Twilio trial account" 前缀。
将凭证交给 canister
管理员通过一个仅限管理员的 setter 粘贴凭证 — 该 setter 通过 AccessControl.hasPermission(state, caller, #admin) 进行权限校验。凭证仅由 canister 持有,绝不会返回给前端。
⚠️ 绝不要通过“首个调用者获得所有权”的机制来保护 setter。 在 IC 上,所有未经身份验证的调用者都是同一个匿名 principal。因此,如果某个匿名调用先声明了所有权,所有匿名调用者都会通过
caller == owner检查,并可以覆盖凭证 — 而这组凭证还会产生费用。
canister 仅通过 config.auth = ?#basicAuth { user; password } 将凭证交给客户端,每个方法都会将其转换为 Authorization: Basic … 标头。没有任何方法接受凭证参数,也没有方法将凭证放入 URL,因此凭证不会通过记录的查询字符串泄露。
Outcall 已默认不复制 — 更正此前的指导
defaultConfig 已设置 is_replicated = ?false,因此,通过记录更新从它派生的配置无需修改即可正确使用。不必记住任何内容,也不必额外设置。
⚠️ 不要将其设为
?true或null,并忽略任何建议这样设置的旧说明。 此 SKILL 的旧版本声称写入应保持复制“以便 IC 共识去重重试”。这是错误的,而且成本高昂。 复制的 outcall 会由子网中的每个节点执行:请求会发送约 13 次,因此会发送约 13 条 SMS,并产生约 13 次费用;凭证也会离开每个节点。此外,由于 Twilio 会为每条回复加上唯一的sid(因此各条响应的字节内容永远无法完全一致),共识仍会失败。这是 Gmail 连接器通过slack-client0.1.0 导致约 13 封重复邮件的同类缺陷。
读取操作(fetch* / list*)同样适合采用不复制方式:你需要的正是某个节点所见的消息日志,而且这也是成本更低的路径。
后端
添加依赖项
以下示例中的管理员权限校验需要在客户端之外添加授权组件:
mops add [email protected]
mops add [email protected]
调用形式 — 自由函数或类门面
每个模块都提供这两种形式。自由函数将 config 作为第一个参数,并且是 async*;
module class 会捕获 config,并且是 async:
// Illustrative sketch, not a file to copy: `cfg`/`accountSid` are assumed to
// exist and the argument lists are elided. Marked motoko-check:skip for that
// reason — the compiled examples are the three mixins below.
import MessageApi "mo:twilio-client/Apis/Api20100401MessageApi";
// free function — config passed explicitly
let m = await* MessageApi.createMessage(cfg, accountSid, /* … */);
// class facade — config captured once
let messages = MessageApi.Api20100401MessageApi(cfg);
let m2 = await messages.createMessage(accountSid, /* … */);
所有参数都是位置参数,createMessage 一共有 27 个参数。
对于不使用的参数,传入 "" / false / 0 / 0.0 / [] / null —
可选枚举参数恰好使用 ?T,因此传入 null 会将其从请求中省略。
请仔细核对;空字符串放错位置会悄悄发送错误的字段。参数顺序如下:
config, accountSid, to, statusCallback, applicationSid, maxPrice, provideFeedback, attempt, validityPeriod, forceDelivery, contentRetention, addressRetention, smartEncoded, persistentAction, trafficType, shortenUrls, scheduleType, sendAt, sendAsMms, contentVariables, riskCheck, from, fallbackFrom, messagingServiceSid, body, mediaUrl, contentSid
操作步骤
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinTwilioConfig "mixins/twilio-config";
import MixinTwilioMessaging "mixins/twilio-messaging";
actor {
let accessControlState : AccessControl.AccessControlState;
include MixinAuthorization(accessControlState, null);
// Admin-held Twilio credentials — never returned to the frontend.
let twilioConfig : {
var accountSid : Text; // AC… — also a positional arg on every v2010 call
var keySid : Text; // SK… (or the Account SID again, in dev)
var keySecret : Text; // the API-key secret (or the Auth Token, in dev)
var fromNumber : Text; // E.164, e.g. "+15551234567"
};
include MixinTwilioConfig(accessControlState, twilioConfig);
include MixinTwilioMessaging(twilioConfig);
};
迁移链的头部:
import AccessControl "mo:caffeineai-authorization/access-control";
module {
type NewActor = {
accessControlState : AccessControl.AccessControlState;
twilioConfig : {
var accountSid : Text;
var keySid : Text;
var keySecret : Text;
var fromNumber : Text;
};
};
public func migration(_old : {}) : NewActor {
{
accessControlState = AccessControl.initState();
twilioConfig = {
var accountSid = "";
var keySid = "";
var keySecret = "";
var fromNumber = "";
};
};
};
};
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";
mixin (
accessControlState : AccessControl.AccessControlState,
twilioConfig : {
var accountSid : Text;
var keySid : Text;
var keySecret : Text;
var fromNumber : Text;
},
) {
// All THREE are required, and this must agree with the guard in
// twilio-messaging.mo: `keySid` is the Basic-Auth *username*, so a blank one
// means every request goes out unauthenticated and Twilio answers 20003 —
// while the UI cheerfully reports "Configured".
public query func isTwilioConfigured() : async Bool {
twilioConfig.accountSid.size() > 0 and twilioConfig.keySid.size() > 0 and twilioConfig.keySecret.size() > 0;
};
// The sending number is not a secret — the UI may display it.
public query func getTwilioFromNumber() : async Text {
twilioConfig.fromNumber;
};
// Admin-only. NOTE `#admin` — never a first-caller-claims-ownership check,
// which the shared anonymous principal would defeat.
public shared ({ caller }) func setTwilioCredentials(
accountSid : Text,
keySid : Text,
keySecret : Text,
) : async () {
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
Runtime.trap("Unauthorized: Only admins can set Twilio credentials");
};
twilioConfig.accountSid := accountSid;
twilioConfig.keySid := keySid;
twilioConfig.keySecret := keySecret;
};
public shared ({ caller }) func setTwilioFromNumber(number : Text) : async () {
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
Runtime.trap("Unauthorized: Only admins can set the sending number");
};
twilioConfig.fromNumber := number;
};
};
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import { createMessage } "mo:twilio-client/Apis/Api20100401MessageApi";
import { defaultConfig; type Config } "mo:twilio-client/Config";
mixin (
twilioConfig : {
var accountSid : Text;
var keySid : Text;
var keySecret : Text;
var fromNumber : Text;
},
) {
// Credentials ride config.auth; defaultConfig is already non-replicated.
func twilioClientConfig() : Config {
{
defaultConfig with
auth = ?#basicAuth { user = twilioConfig.keySid; password = twilioConfig.keySecret };
max_response_bytes = ?(200_000 : Nat64);
};
};
/// Send an SMS to `to` (E.164). Returns the message SID.
public shared ({ caller }) func sendSms(to : Text, body : Text) : async Text {
if (caller.isAnonymous()) Runtime.trap("Sign in to send messages");
// Same three-way check as isTwilioConfigured(): accountSid goes in the URL
// path, keySid is the Basic-Auth user, keySecret the password. Missing any
// one of them fails at Twilio, not here, so check before spending cycles.
if (
twilioConfig.accountSid.size() == 0 or twilioConfig.keySid.size() == 0 or twilioConfig.keySecret.size() == 0
) {
Runtime.trap("Twilio is not configured (an admin must set all three credentials)");
};
let msg = await* createMessage(
twilioClientConfig(),
twilioConfig.accountSid, // accountSid — in the URL path, not the credential
to, // to (E.164)
"", // statusCallback
"", // applicationSid
0.0, // maxPrice (0 = no cap)
false, // provideFeedback
0, // attempt
0, // validityPeriod
false, // forceDelivery
null, // contentRetention (omitted)
null, // addressRetention (omitted)
false, // smartEncoded
[], // persistentAction
null, // trafficType (omitted)
false, // shortenUrls
null, // scheduleType — MUST be null for an immediate send
"", // sendAt (scheduled sends only)
false, // sendAsMms
"", // contentVariables
null, // riskCheck (omitted)
twilioConfig.fromNumber, // from (use EITHER from OR messagingServiceSid)
"", // fallbackFrom
"", // messagingServiceSid
body, // body
[], // mediaUrl (set for MMS)
"", // contentSid (Content API templates)
);
// `sid` is optional in the generated model because the spec marks it
// nullable, though Twilio always sets it on a successful create. Fall back
// to "" rather than trapping: the outcall has already happened, so a trap
// would roll back this canister's own state while the SMS stays delivered.
switch (msg.sid) { case (?sid) sid; case null "" };
};
};
对于 MMS:设置 mediaUrl = ["https://example.com/image.jpg"] 和 sendAsMms = true。
要通过 Messaging Service 发送,请将 from = "",并改为设置
messagingServiceSid。
地址格式 — E.164,以及发送方选择
to必须采用 E.164 格式:+、国家代码,且不能有空格、连字符或括号 —"+15551234567"。"555-1234"会因21211错误而失败。在前端进行规范化, 并在 canister 中再次规范化;不要单独信任任何一方。from和messagingServiceSid— 必须且只能设置一个。 两者同时设置会报错。 对于非美国流量和演示,直接使用from号码即可;发往美国的生产流量 应通过 Messaging Service 发送(发送方池、固定发送方;A2P 注册也绑定到该服务)。- 发送号码必须具备 SMS 功能,并非所有可购买的号码都具备此功能。浏览
Api20100401AvailablePhoneNumberCountryApi时,请按此功能筛选。
美国 A2P 10DLC — 按顺序创建三个资源
任何美国长号码向美国目的地发送短信之前,必须先创建这三项资源。缺少这些资源时, 美国运营商会直接拒绝流量。
- 品牌注册 —
MessagingV1BrandRegistrationApi.createBrandRegistrations, 引用 Trust Hub 的customerProfileBundleSid+a2PProfileBundleSid(需在此流程之外创建)。在开发环境中传入mock = true可跳过费用。状态初始为PENDING,数小时至数天后会变为APPROVED/FAILED;如果企业信息不完整、 格式不一致,或与注册信息不匹配,注册就会失败。 - A2P 活动 —
MessagingV1UsAppToPersonApi.createUsAppToPerson, 同时引用 Messaging Service 和品牌。大多数入驻失败都发生在这一步。 如果messageFlow未说明用户如何选择加入,或messageSamples与声明的usAppToPersonUsecase不匹配,T-Mobile 就会拒绝该活动。 - 号码 → 服务分配 —
MessagingV1PhoneNumberApi.createPhoneNumber(cfg, serviceSid, phoneNumberSid)。 一个号码同一时间只能属于一个 Messaging Service;重新分配前必须先调用deletePhoneNumber。
当前生效的注册截止日期: 如果没有可用的 privacyPolicyUrl
和 termsAndConditionsUrl,自 2026-06-30 起请求会直接返回 400 错误。在
createUsAppToPerson 中,这两个 URL 都是位置参数,传入 "" 会失败;URL 必须指向
公开的 HTTPS 页面,因为 Twilio 会在注册期间访问这些页面。
免费电话使用单独的流程 —
MessagingV1TollfreeVerificationApi — 不走 A2P。
可用的 API 范围
本文配方侧重文档和消息功能:
| 模块 | 用途 |
|---|---|
Api20100401MessageApi | 发送 / 获取 / 列出 / 更新 / 删除消息 |
Api20100401MediaApi, …MediaInstanceApi | 消息上的 MMS 媒体 |
Api20100401IncomingPhoneNumberApi(+ Local/Mobile/TollFree) | 你拥有的号码;删除 = 释放 |
Api20100401AvailablePhoneNumberCountryApi | 浏览可购买的号码 |
Api20100401BalanceApi, …AccountApi | 账户余额和账户记录 |
Api20100401UserDefinedMessageApi(+ Subscription) | 用户定义的消息事件 |
MessagingV1ServiceApi | Messaging Services(发送方池) |
MessagingV1BrandRegistrationApi(+ Otp, BrandVettingApi) | A2P 品牌 |
MessagingV1UsAppToPersonApi(+ UsecaseApi) | A2P 活动 |
MessagingV1PhoneNumberApi, …ShortCodeApi, …AlphaSenderApi, …ChannelSenderApi | 发送方池成员 |
MessagingV1TollfreeVerificationApi | 免费电话号码验证 |
MessagingV1Linkshortening*, …DomainConfig*, …DomainCertsApi | 品牌化链接缩短 |
MessagingV1DeactivationsApi | 运营商停用列表 |
此包不包含(已从生成的接口中裁剪):通话、录音、会议、参与者、队列、应用、SIP 域和凭据、用量记录和触发器、地址、密钥、令牌、余额交易。此包仅提供消息功能;以上内容均不适用于此连接器。
错误和分页
- 方法在 2xx 状态码时返回解码后的记录,在 4xx/5xx 状态码时
throw Error.reject("HTTP <status> body[…]: …")。已启用diagnostics,因此 reject 文本会包含 Twilio 自身的错误正文(code、message、more_info)。请用try { … } catch (e) { Error.message(e) }进行包装。 - 值得映射为实际 UI 文本的代码:20003 身份验证失败(凭据错误),21211
To无效,21408 区域未获授权(在控制台中启用目标国家/地区的地理权限),21608 试用账户中的收件人未验证,21610 收件人已退订(STOP),21703 发送方池已耗尽,21704 Messaging Service 没有号码,21714 池大小已达上限。 - 2xx 不代表已送达。
createMessage会返回status = #queued或#accepted;送达是异步的。请轮询fetchMessage,检查是否为#delivered/#undelivered/#failed,并读取error_code;也可以配置statusCallbackURL(需要入站 HTTP 端点,本文不涉及)。 - 两个 API 版本的分页方式不同。 v2010 列表——
listMessage和所有其他Api20100401*列表——返回顶层next_page_uri/previous_page_uri(?Text,以及/2010-04-01/…这样的路径,而非完整 URL)。Messaging v1 列表(listService、listPhoneNumber、A2P 注册表)的分页信息则嵌套在meta下,字段为next_page_url/previous_page_url(完整 URL)和page_size。70 个列表响应中只有 10 个使用meta格式;listMessage不在其中。每个 v1 列表的meta字段都被类型化为?ListAlphaSenderResponseMeta,包括ListServiceResponse——代码生成时,相同记录会去重并合并到一个共享模块中,因此该名称取决于排序最靠前的列表,而非你调用的端点。 pageSize默认为 50,最大为 1000。每次列表调用都应设置上限;在繁忙账户上不设上限的listMessage会导致max_response_bytes超限。
查看这两种格式:
// Illustrative sketch, not a file to copy — `res` is assumed to be the decoded
// list response. Marked motoko-check:skip for that reason.
// v2010 (listMessage and every other Api20100401* list): top-level, a path
switch (res.next_page_uri) { case (?path) { /* fetch the next page */ }; case null {} };
// Messaging v1 (listService, listPhoneNumber, the A2P registries): nested, a full URL
switch (res.meta) { case (?m) { m.next_page_url }; case null null };
字段注意事项
createService中的usecase是Text,不是变体。有效值:notifications、marketing、verification、discussion、poll、undeclared。任何其他值都会返回 400。usAppToPersonUsecase是一个不同的枚举,其可用项取决于品牌层级——查询MessagingV1UsAppToPersonUsecaseApi.fetchUsAppToPersonUsecase,了解特定品牌可以使用哪些值。- 可选枚举参数的类型为
?T——传入null表示省略,且应优先采用这种方式。 这些变体是封闭的:contentRetention为#retain/#discard,addressRetention为#retain/#obfuscate,trafficType为#free,scheduleType为#fixed,riskCheck为#enable/#disable。没有#Text变通选项——无法表示规范中未列出的值。立即发送时, 为scheduleType传入?#fixed会导致 400: Twilio 会将其视为定时消息,随后发现没有SendAt。除非你明确需要相应行为,否则这些参数的正确值都是null。 - **
maxPrice为0.0时会被省略,这正是你想要的。**发送MaxPrice=0会将消息价格上限设为零,导致 Twilio 拒绝付费投递;省略该参数则表示“无上限”。传入0.0即可省略。 xTwilioApiVersion(用于UsAppToPerson方法)——除非 Twilio 支持人员另有要求,否则传入""。- **吞吐量按发送方计算:**长号码为每秒 1 条消息,免费电话约为 3 条, 国际长号码约为 10 条,短号码为 100 条。无法提高单个号码的 MPS—— 可通过向 Messaging Service 的发送方池添加号码来扩容。
stickySender/areaCodeGeomatch仅适用于美国和加拿大。Config.baseUrl未被使用。每项操作都带有硬编码主机 (v2010 使用api.twilio.com,v1 使用messaging.twilio.com),并在代码生成时从合并后的规范中固定下来。不要设置它,也不要指望它能重定向流量。
前端
Twilio 不需要 OAuth:凭据是一组由管理员粘贴的长期有效信息,因此不需要重定向 URI、不需要 /connect/twilio 路由,也不需要按用户进行握手。不要构建这些功能。Twilio 集成必须提供的功能是让管理员能够获取并输入凭据的页面——这是验收标准,不是建议;缺少这些功能的构建是有缺陷的,而非仅仅不完整:
- **凭据页面必须存在且可访问。**没有输入凭据入口的“发送 SMS”功能无法使用。已登录的管理员必须能够从导航栏或未配置提示访问
/settings/twilio。 - 控制台操作步骤必须显示在 UI 中,不能只在聊天回复中提供——管理员可能在数周后才回来,而聊天记录已不复存在。
- **API-Key 密钥由 Twilio 仅显示一次。**应在输入框旁注明这一点,否则管理员离开页面后就不得不再创建一个密钥。
-
必须提供登录流程。
setTwilioCredentials仅允许#admin,因此应用需要支持非匿名调用方。请从extension-authorization中获取登录、useInternetIdentity/useActor接线代码和管理员角色门控。 -
管理设置页面 —
/settings/twilio(仅管理员可访问)。要求:- 在输入框上方显示“如何获取 Twilio 凭据”面板,将其描述为一次性约 5 分钟的设置流程,并列出以下编号步骤(完成消息必须逐字重复这些步骤):
- 登录 https://console.twilio.com;
- 从控制面板复制 Account SID(
AC…); - 选择 Account → API keys & tokens → Create API key(Standard);复制 SID(
SK…)和 Secret — Secret 只会显示一次; - 选择 Phone Numbers → Manage → Buy a number,并启用 SMS 功能;
- 在下方粘贴这三个值和号码,然后保存;
- 使用试用账户时,在 Verified Caller IDs 下验证每个收件人。 提供一个便捷链接,用于打开 Twilio 控制台。
- 三个输入框:Account SID(纯文本,不是密钥)、Key SID、Key Secret(密码输入框)。绑定到
setTwilioCredentials;成功后清除 Secret;保留可重新提交的表单,因为密钥可能会轮换。 - 一个绑定到
setTwilioFromNumber的发送号码字段,旁边提供 E.164 格式示例(+15551234567),并进行客户端验证。 - 状态由
isTwilioConfigured()(Bool)驱动 — “已配置”/“未配置”。该谓词要求全部三个值,包括 Key SID:它是 Basic-Auth 用户名,因此留空会导致每个请求都未经身份验证,Twilio 会返回20003,而页面却显示已配置。绝不要回显 Secret,即使是掩码形式也不行。可以显示发送号码(getTwilioFromNumber);它不是密钥。 - 确保页面可访问。 当
isCallerAdmin为 true 时,共享 Layout 导航栏必须链接到此页面;否则隐藏该链接。在导航栏定义处添加链接,不要在此页面内添加。
- 在输入框上方显示“如何获取 Twilio 凭据”面板,将其描述为一次性约 5 分钟的设置流程,并列出以下编号步骤(完成消息必须逐字重复这些步骤):
-
空状态提示。 当
isTwilioConfigured()为false时,绝不要显示不可用的“发送”按钮:管理员应看到指向/settings/twilio的“设置 Twilio”链接;非匿名的非管理员用户应看到说明,例如:“尚未设置短信功能 — 管理员需要在设置中添加 Twilio 凭据。” -
翻译 Twilio 错误。 失败会以携带 Twilio
code的拒绝调用形式返回。至少将以下错误映射为解决操作,而不是显示原始拒绝信息:20003→ “Twilio 凭据有误 — 管理员应重新粘贴凭据”21211→ “电话号码无效 — 请使用 +15551234567 格式”21408→ “此 Twilio 账户尚未启用向该国家/地区发送短信的功能”21608→ “使用试用账户时,必须先在 Twilio 中验证收件人”21610→ “该号码已回复 STOP,无法向其发送短信”
-
绝不要承诺消息已送达。 调用成功表示消息已排队,而非已送达。界面应使用相应措辞(“消息已排队”);如果需要关注送达情况,则显示通过
fetchMessage轮询获得的status。
建议的路由布局:
/ → Main UI (any signed-in user; empty-state when unconfigured)
/settings/twilio → Admin credentials + sending number (admin-only)
# No /connect/twilio: Twilio uses pasted long-lived credentials, not a redirect flow.
撰写者必须告知 Caffeine 用户的内容
在人工创建 Twilio 账户、购买号码并粘贴凭据之前,应用无法发送任何内容——因此,完成消息是交付内容的一部分,而不是对它的总结。它必须按以下顺序包含:
- 说明需要凭据,以及由谁填写——管理员在登录后前往
/settings/twilio填写,该页面可从导航栏访问。 - 原文列出 Frontend 第 2 项中的六个编号步骤,包括说明 API 密钥的 Secret 仅显示一次。
- 说明 Twilio 服务需要付费——按消息计费,另加每月号码费用;试用账户只能向已验证的号码发送短信,而且每条消息都会带有试用通知前缀。
- 对于发往美国的流量:说明 A2P 10DLC 要求,明确提到需要数周的准备时间和额外费用,并按顺序列出三个资源——否则,用户发布的应用可能会无法向美国手机发送消息,而且不会收到任何提示。
- 逐行列出故障排查映射:
20003→ 重新粘贴凭据;21211→ 使用 E.164 格式;21408→ 启用目标国家/地区;21608→ 验证收件人(试用账户);21610→ 收件人已退订。
不要将这些内容缩减成“在 Settings 中配置 Twilio”,也不要换成 Twilio 文档链接。此处的措辞必须与设置页面面板中的措辞一致,以免两处内容不一致。
已知限制
- 仅提供消息功能。 此软件包已精简至消息路径;不包含语音、录音、SIP、用量以及其他功能。
- 不在范围内的是接收消息。 接收 SMS 和
statusCallback投递回执需要 canister 上有一个入站 HTTP 端点;这属于另一个组件,不属于此客户端。 - 无法上传二进制媒体。
mediaUrl接受 Twilio 会抓取的公开 URL;canister 无法通过此客户端 POST 图像字节。 - 没有幂等键。 Twilio 的消息 API 不支持幂等键,因此超时后重试可能会导致重复发送。应在应用层进行防护(为每次逻辑发送使用一个稳定变量去重键),而不是盲目重试。非副本默认设置消除了约 13 倍的放大效应,但不会改变重试语义。
- 有一个字段被丢弃。
POST …/IncomingPhoneNumbers/{Sid}.json接受一个AccountSid表单字段(用于在子账户之间移动号码),同时AccountSid也是它的路径参数。生成器对两者使用同一个命名空间,因此表单字段副本会被丢弃,无法通过此客户端将号码转移到子账户。其他所有端点均不受影响。 - 这里的任何内容都未经过真实 Twilio 环境的测试。 线路格式至少在结构上是正确的——写入操作会发送一个包含百分号编码参数的
application/x-www-form-urlencoded请求体,这是 Twilio 要求的格式——但尚未发起任何调用。应将首次成功发送视为真正的验收测试。 - 规范版本: 根据 Twilio 发布的 OpenAPI 规范(由
spec-merge合并的 Messaging v1 + API v2010)生成,之后精简至消息功能范围。此处不包含这些规范中缺失的较新 Twilio 功能。
相关内容
mops add [email protected]— 生成的 Twilio REST 绑定(35 个消息模块)。- Twilio Messaging 文档 — 此工具封装的 API。
- 无需
chat的快速入门:发送 SMS —Message资源及其字段和状态。 - Twilio 错误代码 — 拒绝消息中显示的数字代码。
- A2P 10DLC 概览 — 品牌、营销活动和号码分配。
- API 密钥与 Auth Token — 说明生产环境为何使用
SK…。 - extension-authorization — 必需的前置条件。Internet Identity 登录、
useInternetIdentity/useActor接线,以及凭据设置器所需的#admin门控。