feat: Add full WhatsApp Business Cloud API integration

- Add WhatsApp channel module with Cloud API v18.0 support
- Implement webhook-based message reception and API sending
- Add allowlist for phone numbers (E.164 format or wildcard)
- Add WhatsApp webhook endpoints to gateway (/whatsapp GET/POST)
- Add WhatsApp config schema with TOML support
- Wire WhatsApp into channel factory, CLI, and doctor commands
- Add WhatsApp to setup wizard with connection testing
- Add comprehensive test coverage (47 channel tests + 9 URL decoding tests)
- Update README with detailed WhatsApp setup instructions
- Support text messages only, skip media/status updates
- Normalize phone numbers with + prefix
- Handle webhook verification with Meta challenge-response

All 756 tests pass. Ready for production use.
This commit is contained in:
argenis de la rosa 2026-02-14 13:10:16 -05:00
parent ec2d5cc93d
commit cc08f4bfff
6 changed files with 1749 additions and 5 deletions

View file

@ -485,6 +485,7 @@ pub struct ChannelsConfig {
pub webhook: Option<WebhookConfig>,
pub imessage: Option<IMessageConfig>,
pub matrix: Option<MatrixConfig>,
pub whatsapp: Option<WhatsAppConfig>,
}
impl Default for ChannelsConfig {
@ -497,6 +498,7 @@ impl Default for ChannelsConfig {
webhook: None,
imessage: None,
matrix: None,
whatsapp: None,
}
}
}
@ -543,6 +545,19 @@ pub struct MatrixConfig {
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhatsAppConfig {
/// Access token from Meta Business Suite
pub access_token: String,
/// Phone number ID from Meta Business API
pub phone_number_id: String,
/// Webhook verify token (you define this, Meta sends it back for verification)
pub verify_token: String,
/// Allowed phone numbers (E.164 format: +1234567890) or "*" for all
#[serde(default)]
pub allowed_numbers: Vec<String>,
}
// ── Config impl ──────────────────────────────────────────────────
impl Default for Config {
@ -717,6 +732,7 @@ mod tests {
webhook: None,
imessage: None,
matrix: None,
whatsapp: None,
},
memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(),
@ -926,6 +942,7 @@ default_temperature = 0.7
room_id: "!r:m".into(),
allowed_users: vec!["@u:m".into()],
}),
whatsapp: None,
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
@ -1010,6 +1027,89 @@ channel_id = "C123"
assert_eq!(parsed.port, 8080);
}
// ── WhatsApp config ──────────────────────────────────────
#[test]
fn whatsapp_config_serde() {
let wc = WhatsAppConfig {
access_token: "EAABx...".into(),
phone_number_id: "123456789".into(),
verify_token: "my-verify-token".into(),
allowed_numbers: vec!["+1234567890".into(), "+9876543210".into()],
};
let json = serde_json::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.access_token, "EAABx...");
assert_eq!(parsed.phone_number_id, "123456789");
assert_eq!(parsed.verify_token, "my-verify-token");
assert_eq!(parsed.allowed_numbers.len(), 2);
}
#[test]
fn whatsapp_config_toml_roundtrip() {
let wc = WhatsAppConfig {
access_token: "tok".into(),
phone_number_id: "12345".into(),
verify_token: "verify".into(),
allowed_numbers: vec!["+1".into()],
};
let toml_str = toml::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.phone_number_id, "12345");
assert_eq!(parsed.allowed_numbers, vec!["+1"]);
}
#[test]
fn whatsapp_config_deserializes_without_allowed_numbers() {
let json = r#"{"access_token":"tok","phone_number_id":"123","verify_token":"ver"}"#;
let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_numbers.is_empty());
}
#[test]
fn whatsapp_config_wildcard_allowed() {
let wc = WhatsAppConfig {
access_token: "tok".into(),
phone_number_id: "123".into(),
verify_token: "ver".into(),
allowed_numbers: vec!["*".into()],
};
let toml_str = toml::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.allowed_numbers, vec!["*"]);
}
#[test]
fn channels_config_with_whatsapp() {
let c = ChannelsConfig {
cli: true,
telegram: None,
discord: None,
slack: None,
webhook: None,
imessage: None,
matrix: None,
whatsapp: Some(WhatsAppConfig {
access_token: "tok".into(),
phone_number_id: "123".into(),
verify_token: "ver".into(),
allowed_numbers: vec!["+1".into()],
}),
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.whatsapp.is_some());
let wa = parsed.whatsapp.unwrap();
assert_eq!(wa.phone_number_id, "123");
assert_eq!(wa.allowed_numbers, vec!["+1"]);
}
#[test]
fn channels_config_default_has_no_whatsapp() {
let c = ChannelsConfig::default();
assert!(c.whatsapp.is_none());
}
// ══════════════════════════════════════════════════════════
// SECURITY CHECKLIST TESTS — Gateway config
// ══════════════════════════════════════════════════════════