以短信为界面的产品
其吸引力是实实在在的:人们不喜欢安装应用或访问网站,但每个人都能发短信。陷阱在于:你并不拥有这个渠道。 美国运营商拥有它,并且他们有内容政策和审批流程,可能会永久拒绝你。这道大门决定了产品被允许成为什么,所以它必须优先处理。
第零步——大门。在设计之前做这件事,而不是之后。
自2025年2月起,美国运营商直接屏蔽未注册的A2P流量,因此品牌和活动必须在发送第一条消息之前获得批准。每次都要重新阅读最新的错误文档;运营商政策会变。
| 代码 | 触发原因 | 可重新提交? |
|---|---|---|
| 30953 | 露骨/暗示性语言,或链接的网站托管“色情、陪同、约会或成人娱乐内容” | 否 |
| 30885 | “高风险”——欺骗性营销,或虚假注册流量 | 否 |
| 30898 | 一个EIN下品牌过多,或看似重复的注册 | 是,需说明原因 |
规则:在第一次回复中大声说出产品的内容类别,并说明它是否在该拒绝列表中。 如果该类别(例如约会)在拒绝规则中是一个字面关键字,那么在五分钟内发现这一点将改变架构、落地页文案和备用计划。SHAFT是简写:性、仇恨、酒精、枪支、烟草——外加大麻,约会也紧随其后。
规则:将副项目注册为个体工商户品牌,而不是挂靠在现有公司的EIN下。 风险模式会跟随税号(30898),因此副项目的拒绝绝不能影响主业务的发送能力。个体工商户的范围仅限于没有税号的个人;吞吐量较低,而对于测试版来说,低也足够了。
规则:绝不要为了获得活动批准而构建一个诱饵落地页。 隐藏实际流量的页面正是30885模式,它会将一个号码的风险转化为你注册过的所有号码的风险。坦诚说明几率,让决策者做决定。
根据其实际功能描述服务,并且仅当描述属实(例如,当消息内容确实是日记和小团体问责时)。如果没有真实描述能通过大门,那么答案是不同的渠道,而不是一个更好的委婉说法。
审核更青睐你可以指出的事实:仅限入站选择加入、对话量小、定期消息中没有链接、优先处理STOP、在入口处拒绝露骨内容。
第零步半——阅读需求文档,关注数字和对象,而非意图
将创始人的需求文档原文保留为README.md,并在CLAUDE.md中声明它的优先级高于所有工程文档。
规则:在编写状态机之前,列出需求文档中的每个持续时间以及它作用的名词。如果有两个持续时间,那么就有两个功能。 一个需求文档要求朋友回复到达作者有一周延迟,而作者自己更新有两周锁定——很容易坍塌为一个七天的关于“条目”的持有期,而这个条目恰好是两个延迟都没有针对的那个对象——之后每一个下游错误都源于此合并。
规则:明确每个延迟的对象——“对谁持有”。 对朋友持有和对作者持有是截然不同的产品。
规则:当需求文档的字面设计无法在渠道上强制执行时,说出来并提供备选方案——不要静默地选择可强制执行的那个。(群聊的创建者本身是该群的成员,所以没有代码能扣留群里的回复不给他们看。)这是一个前提决策,前提决策属于产品所有者。
一个看似合理的误读感觉不像误读。 保留被废弃的设计段落,将其划线保留,而不是删除;它向后续会话展示了如何误读了需求文档。
第一步——渠道能做什么和不能做什么
- 通过代码发送群组短信是可行的。 Twilio Conversions仅在**+1长号码上支持群组MMS——不支持免费号码、短号码——上限为10个参与者(包括你自己的号码)**。不要凭记忆断言其他情况。
- 群组MMS短信参与者携带一个地址而没有ProxyAddress,所以一个号码可以服务于所有对话——但参与者/代理唯一性规则(50425)对于没有代理的参与者没有记录行为。绝不要设计多个同时存在的对话共享一个号码和同一个参与者集合。 每人一个持久对话具有稳定的参与者集合。
- 网页无法读取iPhone通讯录。 联系人选择器API仅适用于Chrome on Android。“同步联系人”从一开始就不可行;用一条用户转发的链接替代——接收者的入站短信是运营商想要的同意记录。
- Telegram bot无法创建群组或添加任何人进群,并且开启隐私模式时,它只能听到发给它的命令。两者都有帮助:创建者将bot添加到他们已经有的聊天中,并且一般的闲聊不会被误认为是输入。创建bot需要在@BotFather中人工操作(无API);请索要令牌。
- WhatsApp Cloud API没有群组消息功能。
- iMessage API(Sendblue、LoopMessage)支持蓝色气泡和真正的群聊,但成本约为每条线路每月100美元,外加设置费用。
第二步——能够承受渠道丢失的架构
因为渠道可能被拒绝,传输层必须是一个文件:
machine.js 纯函数:(context, event) -> {replies, effects} 无I/O,无时钟
engine.js 上下文组装 -> machine -> effects 完整路径
effects.js createEffects({db, channel}) 执行决策
twilio.js / telegram.js 可互换
api/*.js engine.js之上的薄层 约10行
sim/ 内存数据库 + 控制台渠道
- 决策核心将
now作为参数,不接触网络。 这类产品中的每个bug都是排序错误(重复触发的提醒、发送到空群组的消息、永远无法送达的持有项),并且没有任何一个可以在实时网络和七天计时器上进行测试。 - 适配器是参数,决不在效果层中导入——模拟器和备用渠道需要同样的重构。
- HTTP处理器内没有逻辑。 写在
api/telegram.js中的渠道特定用户引导是无法测试的;将其提取出来。
第三步——驱动,而非仅仅测试
node sim/drive.js 脚本驱动:完整循环,跳过一周,守卫检查
node sim/drive.js --repl 以任意数字输入;/as <n> /jump <days> /state
- 模拟器必须运行生产路径,而不是一个平行副本。
- 预计首次驱动会出现一些单元测试无法触及的真实缺陷:一个在任何人进入之前就自行打招呼的群组、一个用于一周后日期的普通星期几、一个没有说明具体内容的提醒、一个成员全部离开的群组。
- 当测试变红时,先检查测试本身(一个不允许
Sun, Aug 30中逗号的日期正则;一个在另一个合法消息上触发的doesNotMatch)。 - 绝不要根据存储的时间戳做决策或打印。 存储从真实时钟写入
created_at,而不是引擎注入的now,所以在跳转时钟下它会撒谎(并打印过去的日期)。仅根据machien写入的列做决策,并将相关日期挂在一个存储的锚点上。
第四步——锁屏文案
一个文件容纳所有对外字符串。
- 没有表情符号,没有感叹号,没有营销用语。
- 对于超过一两天的日期,绝不要使用普通星期几。
Sun Aug 30。 - 对每个关键词的标点和大小写保持宽容——
Wait.和Yes!必须被匹配到。 - 包含关键词的文本仍然只是文本:“we should wait and see”不是WAIT。匹配整个规范化的消息,而不是子字符串。
- STOP / HELP / START在所有应用状态和任何模型调用之前被解析,因此退出操作不会因其他地方的bug或API故障而损坏。
- 如果一个模型守卫内容,失败时关闭:如果检查无法运行,不发送任何内容并告知用户。
第五步——如果模型重写用户内容,在信任之前先运行它
一个从用户条目中删除第三方名字的守卫可以完美阅读,但仍然可能:
- 捏造事实——被要求将“Jane Doe”替换为“自然化名”,它写成了“来自应用的那个女孩”,尽管没有任何内容提到应用;接收者将捏造视为事实。
- 过度纠正并删除真实细节——“来自跑步俱乐部的Jane”变成了“她”。
规则:指令必须说明模型不得编造什么和不得删除什么。 可行形式:将替换建立在源内容基础上,替换标记而非其周围的短语,并优先使用中性占位符而非重复句子已有的细节。
规则:将每个实时模型的缺陷固定为可选测试,在没有API密钥时跳过,这样提示词编辑不会在每次测试运行时静默地回归它们并增加成本。每次守卫调用预算2到5秒。
成本(数量级;实时确认)
一个+1长号码每月一两美元;每条短信每段几分钱(入站和出站);彩信几分钱。个体工商户注册是一次性几美元加上每月少量活动费用。Telegram是免费的。
询问 vs. 决定
- 询问: 是否提交一份清楚知道可能被永久拒绝的诚实注册,或者从Telegram开始(金钱和不可逆的结果);任何改变产品本质的事情。
- 决定: 架构、文案风格、状态机、测试覆盖率、使用哪个适配器、日期格式、守卫行为。
在报告任何内容被屏蔽之前,枚举每一个候选凭据(磁盘上一个耗尽的API密钥并不意味着没有另一个可用的)。
另请参阅: build-remote-mcp-server(另一种“原生表面”模式)。