[WhatsApp发信失败与允许名单策略] - WhatsApp Outbound Send Failure with Allowlist Policy
当使用 WhatsApp dmPolicy 'allowlist' 时,出站消息发送失败,错误信息为'Delivering to WhatsApp requires target',原因是 sendTo 目标必须在同一个控制入站访问的 allowFrom 列表中。
🔍 症状
主要错误表现
当尝试使用 message 工具向不在 allowFrom 列表中的联系人发送 WhatsApp 消息时:
Error: Delivering to WhatsApp requires target <E.164|group JID>
at resolveOutboundTarget (src/whatsapp/resolve-outbound-target.ts:XX)
at sendWhatsAppMessage (src/whatsapp/sender.ts:XX)
配置上下文
当存在以下配置时会出现此问题:
json { “channels”: { “whatsapp”: { “dmPolicy”: “allowlist”, “allowFrom”: ["+1234567890"] } } }
CLI 诊断命令
bash
尝试向未在列表中的联系人发送消息
$ openclaw tools call message ‘{“to”: “+0987654321”, “body”: “Hello”}’
预期:消息发送成功
实际:错误 - 发送到 WhatsApp 需要目标 <E.164|group JID>
次要症状:令人困惑的安全模型
用户观察到将联系人添加到 allowFrom 具有双重效果:
- 该联系人现在可以接收来自机器人的出站消息
- 该联系人也可以发送入站消息来触发机器人
这违反了最小权限原则,并造成安全混淆。
🧠 根因分析
架构分析
根本原因在于入站和出站访问控制逻辑之间的共享数据依赖。
文件:src/whatsapp/resolve-outbound-target.ts
typescript
export async function resolveOutboundTarget(
normalizedTo: string,
allowList: string[]
): Promise
const hasWildcard = allowList.includes("*");
if (hasWildcard || allowList.length === 0) { return { ok: true, to: normalizedTo }; }
if (allowList.includes(normalizedTo)) { return { ok: true, to: normalizedTo }; }
return {
ok: false,
error: Delivering to WhatsApp requires target <E.164|group JID>,
};
}
问题所在:此函数接收 allowFrom 数组作为 allowList 参数,这意味着出站权限由入站配置控制。
文件:src/web/inbound/access-control.ts
typescript export function checkInboundAccess( from: string, allowFrom: string[] ): InboundAccessResult { const hasWildcard = allowFrom.includes("*"); const isAllowed = hasWildcard || allowFrom.includes(from);
return { allowed: isAllowed, reason: isAllowed ? “allowed” : “inbound_not_authorized” }; }
共享控制点问题
| 配置 | 入站效果 | 出站效果 |
|---|---|---|
"allowFrom": ["+1234567890"] | 仅 +1234567890 可以触发机器人 | 机器人只能发送给 +1234567890 |
"allowFrom": ["*"] | 任何人都可以触发机器人 | 机器人可以发送给任何人 |
设计违规
当前实现违反了关注点分离原则。allowFrom 字段本应为入站访问控制而设计,但被重用于出站授权,造成了意想不到的耦合。
🛠️ 逐步修复
阶段 1:添加配置类型
文件:src/config/types.whatsapp.ts
修改前: typescript export interface WhatsAppConfig { dmPolicy: “allowlist” | “open”; allowFrom: string[]; // … other fields }
修改后: typescript export interface WhatsAppConfig { dmPolicy: “allowlist” | “open”; allowFrom: string[]; allowSendTo?: string[]; // 新增:独立的出站白名单 // … other fields }
阶段 2:更新出站解析逻辑
文件:src/whatsapp/resolve-outbound-target.ts
修改前:
typescript
export async function resolveOutboundTarget(
normalizedTo: string,
allowList: string[]
): Promise
const hasWildcard = allowList.includes("*");
if (hasWildcard || allowList.length === 0) { return { ok: true, to: normalizedTo }; }
if (allowList.includes(normalizedTo)) { return { ok: true, to: normalizedTo }; }
return {
ok: false,
error: Delivering to WhatsApp requires target <E.164|group JID>,
};
}
修改后:
typescript
export async function resolveOutboundTarget(
normalizedTo: string,
sendToList: string[] | undefined,
inboundAllowFrom: string[]
): Promise
// 如果 sendTo 明确配置,则使用它 if (sendToList !== undefined) { const hasWildcard = sendToList.includes("*");
if (hasWildcard || sendToList.length === 0) {
return { ok: true, to: normalizedTo };
}
if (sendToList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return {
ok: false,
error: `Target ${normalizedTo} is not in allowSendTo list`,
};
}
// 回退到遗留行为(使用入站 allowFrom 作为出站) const hasWildcard = inboundAllowFrom.includes("*");
if (hasWildcard || inboundAllowFrom.length === 0) { return { ok: true, to: normalizedTo }; }
if (inboundAllowFrom.includes(normalizedTo)) { return { ok: true, to: normalizedTo }; }
return {
ok: false,
error: Delivering to WhatsApp requires target <E.164|group JID>,
};
}
阶段 3:更新调用方
文件:src/whatsapp/sender.ts(或任何调用 resolveOutboundTarget 的地方)
修改前: typescript const target = await resolveOutboundTarget( normalizedTo, config.allowFrom // 传入入站列表用于出站检查 );
修改后: typescript const target = await resolveOutboundTarget( normalizedTo, config.allowSendTo, // 使用专用的出站列表 config.allowFrom // 传入用于向后兼容的回退 );
阶段 4:配置示例
推荐的生产环境配置:
json { “channels”: { “whatsapp”: { “dmPolicy”: “allowlist”, “allowFrom”: ["+1234567890", “+1111111111”], “allowSendTo”: ["*"] } } }
严格的出站配置:
json { “channels”: { “whatsapp”: { “dmPolicy”: “allowlist”, “allowFrom”: ["+1234567890"], “allowSendTo”: [ “+0987654321”, “+1122334455”, “12036301234567890-1234567890@g.us” ] } } }
🧪 验证
测试用例 1:发送到白名单中的 SendTo
bash
配置
“allowSendTo”: ["+0987654321"]
$ openclaw tools call message ‘{“to”: “+0987654321”, “body”: “Test”}’
预期输出: json { “ok”: true, “messageId”: “wamid.xxx…”, “timestamp”: “2024-01-15T10:30:00Z” }
测试用例 2:发送到不在列表中的 SendTo
bash
配置
“allowSendTo”: ["+0987654321"]
$ openclaw tools call message ‘{“to”: “+5555555555”, “body”: “Test”}’
预期输出: json { “ok”: false, “error”: “Target +5555555555 is not in allowSendTo list” }
测试用例 3:接收来自白名单发送者的入站消息
bash
配置
“allowFrom”: ["+0987654321"]
“allowSendTo”: ["*"]
发送消息 FROM +0987654321 TO 机器人
预期行为: 消息被处理并触发机器人响应。
测试用例 4:接收来自不在列表中的发送者的入站消息
bash
配置
“allowFrom”: ["+0987654321"]
发送消息 FROM +5555555555 TO 机器人
预期行为: 消息被拒绝,并显示入站访问控制错误。
测试用例 5:通配符 SendTo
bash
配置
“allowSendTo”: ["*"]
$ openclaw tools call message ‘{“to”: “+anyvalidnumber”, “body”: “Test”}’
预期输出: 消息发送成功。
验证脚本
typescript // test/whatsapp-outbound-permissions.test.ts
import { resolveOutboundTarget } from “../src/whatsapp/resolve-outbound-target”;
describe(“resolveOutboundTarget”, () => { test(“allows when target is in sendTo list”, async () => { const result = await resolveOutboundTarget( “+0987654321”, ["+0987654321", “+1122334455”], ["+1234567890"] ); expect(result.ok).toBe(true); });
test(“blocks when target is not in sendTo list”, async () => { const result = await resolveOutboundTarget( “+5555555555”, ["+0987654321"], ["+1234567890"] ); expect(result.ok).toBe(false); expect(result.error).toContain(“not in allowSendTo list”); });
test(“allows wildcard sendTo”, async () => { const result = await resolveOutboundTarget( “+5555555555”, ["*"], ["+1234567890"] ); expect(result.ok).toBe(true); });
test(“falls back to allowFrom when sendTo is undefined”, async () => { const result = await resolveOutboundTarget( “+1234567890”, undefined, // sendTo 未配置 ["+1234567890"] ); expect(result.ok).toBe(true); }); });
⚠️ 常见陷阱
陷阱 1:错误的 E.164 格式
WhatsApp 要求使用 E.164 格式的数字(例如 +1234567890)。使用没有前导 + 的格式会导致静默失败。
bash
错误
$ openclaw tools call message ‘{“to”: “1234567890”, “body”: “Test”}’
正确
$ openclaw tools call message ‘{“to”: “+1234567890”, “body”: “Test”}’
陷阱 2:群组 JID 与电话号码
群组 ID 使用的格式与电话号码不同。确保使用正确的 JID 语法:
json { “allowSendTo”: [ “+1234567890”, // 电话号码 “12036301234567890-1234567890@g.us” // 群组 JID ] }
陷阱 3:空数组与 undefined
空数组 allowSendTo: [] 与 allowSendTo 为 undefined 时的处理方式不同:
"allowSendTo": []— 阻止所有出站消息"allowSendTo": undefined— 回退到遗留的 allowFrom 行为
陷阱 4:Docker 环境变量映射
使用环境变量进行配置时:
bash
错误 - 这会创建一个字符串,而不是数组
WHATSAPP_ALLOW_SEND_TO=+1234567890,+0987654321
正确 - 使用 JSON 字符串表示数组
WHATSAPP_ALLOW_SEND_TO=["+1234567890","+0987654321"]
陷阱 5:缓存问题
更新配置后,确保运行中的进程重新加载:
bash
重启 OpenClaw 服务
$ systemctl restart openclaw
或对于 Docker
$ docker-compose down && docker-compose up -d
陷阱 6:从旧版配置迁移
没有 allowSendTo 的现有配置应通过回退机制继续工作。但需要进行彻底测试:
typescript // 验证回退机制仍然有效 const sendTo = config.allowSendTo ?? config.allowFrom;
陷阱 7:通配符安全影响
设置 "allowSendTo": ["*"] 允许向任何有效的 WhatsApp 号码发送消息。考虑以下因素:
- 消息工具的速率限制
- 额外的应用层授权
- 记录所有出站消息尝试
🔗 相关错误
E_DELIVERY_FAILED— WhatsApp API 拒绝消息时的通用投递失败E_INVALID_TARGET— 目标号码格式无效(不符合 E.164 规范)E_INBOUND_NOT_AUTHORIZED— 因白名单策略拒绝入站消息E_SESSION_NOT_READY— 出站尝试前 WhatsApp 会话未建立E_ALLOWLIST_BLOCKED— 出站目标不在配置的白名单中
相关 GitHub 问题
- Issue #XXX — WhatsApp dmPolicy 白名单阻止合法出站消息
- Issue #YYY — 请求:为 WhatsApp 分离入站/出站访问控制
- Issue #ZZZ — 文档:WhatsApp 渠道安全模型不清晰
配置参考
channels.whatsapp.dmPolicy— 控制入站访问模式("allowlist"|"open")channels.whatsapp.allowFrom— 入站白名单(电话号码和群组 JID)channels.whatsapp.allowSendTo— 出站白名单(电话号码和群组 JID)【新增】