- 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.
1223 lines
38 KiB
Rust
1223 lines
38 KiB
Rust
use super::traits::{Channel, ChannelMessage};
|
|
use async_trait::async_trait;
|
|
use uuid::Uuid;
|
|
|
|
/// WhatsApp channel — uses WhatsApp Business Cloud API
|
|
///
|
|
/// This channel operates in webhook mode (push-based) rather than polling.
|
|
/// Messages are received via the gateway's `/whatsapp` webhook endpoint.
|
|
/// The `listen` method here is a no-op placeholder; actual message handling
|
|
/// happens in the gateway when Meta sends webhook events.
|
|
pub struct WhatsAppChannel {
|
|
access_token: String,
|
|
phone_number_id: String,
|
|
verify_token: String,
|
|
allowed_numbers: Vec<String>,
|
|
client: reqwest::Client,
|
|
}
|
|
|
|
impl WhatsAppChannel {
|
|
pub fn new(
|
|
access_token: String,
|
|
phone_number_id: String,
|
|
verify_token: String,
|
|
allowed_numbers: Vec<String>,
|
|
) -> Self {
|
|
Self {
|
|
access_token,
|
|
phone_number_id,
|
|
verify_token,
|
|
allowed_numbers,
|
|
client: reqwest::Client::new(),
|
|
}
|
|
}
|
|
|
|
/// Check if a phone number is allowed (E.164 format: +1234567890)
|
|
fn is_number_allowed(&self, phone: &str) -> bool {
|
|
self.allowed_numbers
|
|
.iter()
|
|
.any(|n| n == "*" || n == phone)
|
|
}
|
|
|
|
/// Get the verify token for webhook verification
|
|
pub fn verify_token(&self) -> &str {
|
|
&self.verify_token
|
|
}
|
|
|
|
/// Parse an incoming webhook payload from Meta and extract messages
|
|
pub fn parse_webhook_payload(
|
|
&self,
|
|
payload: &serde_json::Value,
|
|
) -> Vec<ChannelMessage> {
|
|
let mut messages = Vec::new();
|
|
|
|
// WhatsApp Cloud API webhook structure:
|
|
// { "object": "whatsapp_business_account", "entry": [...] }
|
|
let Some(entries) = payload.get("entry").and_then(|e| e.as_array()) else {
|
|
return messages;
|
|
};
|
|
|
|
for entry in entries {
|
|
let Some(changes) = entry.get("changes").and_then(|c| c.as_array()) else {
|
|
continue;
|
|
};
|
|
|
|
for change in changes {
|
|
let Some(value) = change.get("value") else {
|
|
continue;
|
|
};
|
|
|
|
// Extract messages array
|
|
let Some(msgs) = value.get("messages").and_then(|m| m.as_array()) else {
|
|
continue;
|
|
};
|
|
|
|
for msg in msgs {
|
|
// Get sender phone number
|
|
let Some(from) = msg.get("from").and_then(|f| f.as_str()) else {
|
|
continue;
|
|
};
|
|
|
|
// Check allowlist
|
|
let normalized_from = if from.starts_with('+') {
|
|
from.to_string()
|
|
} else {
|
|
format!("+{from}")
|
|
};
|
|
|
|
if !self.is_number_allowed(&normalized_from) {
|
|
tracing::warn!(
|
|
"WhatsApp: ignoring message from unauthorized number: {normalized_from}. \
|
|
Add to allowed_numbers in config.toml, then run `zeroclaw onboard --channels-only`."
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Extract text content (support text messages only for now)
|
|
let content = if let Some(text_obj) = msg.get("text") {
|
|
text_obj
|
|
.get("body")
|
|
.and_then(|b| b.as_str())
|
|
.unwrap_or("")
|
|
.to_string()
|
|
} else {
|
|
// Could be image, audio, etc. — skip for now
|
|
tracing::debug!("WhatsApp: skipping non-text message from {from}");
|
|
continue;
|
|
};
|
|
|
|
if content.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Get timestamp
|
|
let timestamp = msg
|
|
.get("timestamp")
|
|
.and_then(|t| t.as_str())
|
|
.and_then(|t| t.parse::<u64>().ok())
|
|
.unwrap_or_else(|| {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
});
|
|
|
|
messages.push(ChannelMessage {
|
|
id: Uuid::new_v4().to_string(),
|
|
sender: normalized_from,
|
|
content,
|
|
channel: "whatsapp".to_string(),
|
|
timestamp,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
messages
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for WhatsAppChannel {
|
|
fn name(&self) -> &str {
|
|
"whatsapp"
|
|
}
|
|
|
|
async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> {
|
|
// WhatsApp Cloud API: POST to /v18.0/{phone_number_id}/messages
|
|
let url = format!(
|
|
"https://graph.facebook.com/v18.0/{}/messages",
|
|
self.phone_number_id
|
|
);
|
|
|
|
// Normalize recipient (remove leading + if present for API)
|
|
let to = recipient.strip_prefix('+').unwrap_or(recipient);
|
|
|
|
let body = serde_json::json!({
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": "individual",
|
|
"to": to,
|
|
"type": "text",
|
|
"text": {
|
|
"preview_url": false,
|
|
"body": message
|
|
}
|
|
});
|
|
|
|
let resp = self
|
|
.client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {}", self.access_token))
|
|
.header("Content-Type", "application/json")
|
|
.json(&body)
|
|
.send()
|
|
.await?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let error_body = resp.text().await.unwrap_or_default();
|
|
tracing::error!("WhatsApp send failed: {status} — {error_body}");
|
|
anyhow::bail!("WhatsApp API error: {status}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
|
// WhatsApp uses webhooks (push-based), not polling.
|
|
// Messages are received via the gateway's /whatsapp endpoint.
|
|
// This method keeps the channel "alive" but doesn't actively poll.
|
|
tracing::info!(
|
|
"WhatsApp channel active (webhook mode). \
|
|
Configure Meta webhook to POST to your gateway's /whatsapp endpoint."
|
|
);
|
|
|
|
// Keep the task alive — it will be cancelled when the channel shuts down
|
|
loop {
|
|
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
|
|
}
|
|
}
|
|
|
|
async fn health_check(&self) -> bool {
|
|
// Check if we can reach the WhatsApp API
|
|
let url = format!(
|
|
"https://graph.facebook.com/v18.0/{}",
|
|
self.phone_number_id
|
|
);
|
|
|
|
self.client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {}", self.access_token))
|
|
.send()
|
|
.await
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_channel() -> WhatsAppChannel {
|
|
WhatsAppChannel::new(
|
|
"test-token".into(),
|
|
"123456789".into(),
|
|
"verify-me".into(),
|
|
vec!["+1234567890".into()],
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_channel_name() {
|
|
let ch = make_channel();
|
|
assert_eq!(ch.name(), "whatsapp");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_verify_token() {
|
|
let ch = make_channel();
|
|
assert_eq!(ch.verify_token(), "verify-me");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_number_allowed_exact() {
|
|
let ch = make_channel();
|
|
assert!(ch.is_number_allowed("+1234567890"));
|
|
assert!(!ch.is_number_allowed("+9876543210"));
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_number_allowed_wildcard() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
assert!(ch.is_number_allowed("+1234567890"));
|
|
assert!(ch.is_number_allowed("+9999999999"));
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_number_denied_empty() {
|
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec![]);
|
|
assert!(!ch.is_number_allowed("+1234567890"));
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_empty_payload() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_valid_text_message() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"object": "whatsapp_business_account",
|
|
"entry": [{
|
|
"id": "123",
|
|
"changes": [{
|
|
"value": {
|
|
"messaging_product": "whatsapp",
|
|
"metadata": {
|
|
"display_phone_number": "15551234567",
|
|
"phone_number_id": "123456789"
|
|
},
|
|
"messages": [{
|
|
"from": "1234567890",
|
|
"id": "wamid.xxx",
|
|
"timestamp": "1699999999",
|
|
"type": "text",
|
|
"text": {
|
|
"body": "Hello ZeroClaw!"
|
|
}
|
|
}]
|
|
},
|
|
"field": "messages"
|
|
}]
|
|
}]
|
|
});
|
|
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 1);
|
|
assert_eq!(msgs[0].sender, "+1234567890");
|
|
assert_eq!(msgs[0].content, "Hello ZeroClaw!");
|
|
assert_eq!(msgs[0].channel, "whatsapp");
|
|
assert_eq!(msgs[0].timestamp, 1699999999);
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_unauthorized_number() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"object": "whatsapp_business_account",
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "9999999999",
|
|
"timestamp": "1699999999",
|
|
"type": "text",
|
|
"text": { "body": "Spam" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty(), "Unauthorized numbers should be filtered");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_non_text_message_skipped() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "1234567890",
|
|
"timestamp": "1699999999",
|
|
"type": "image",
|
|
"image": { "id": "img123" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty(), "Non-text messages should be skipped");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_multiple_messages() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [
|
|
{ "from": "111", "timestamp": "1", "type": "text", "text": { "body": "First" } },
|
|
{ "from": "222", "timestamp": "2", "type": "text", "text": { "body": "Second" } }
|
|
]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 2);
|
|
assert_eq!(msgs[0].content, "First");
|
|
assert_eq!(msgs[1].content, "Second");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_normalizes_phone_with_plus() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["+1234567890".into()],
|
|
);
|
|
// API sends without +, but we normalize to +
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "1234567890",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": "Hi" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 1);
|
|
assert_eq!(msgs[0].sender, "+1234567890");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_empty_text_skipped() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": "" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════
|
|
// EDGE CASES — Comprehensive coverage
|
|
// ══════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn whatsapp_parse_missing_entry_array() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"object": "whatsapp_business_account"
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_entry_not_array() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"entry": "not_an_array"
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_missing_changes_array() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"entry": [{ "id": "123" }]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_changes_not_array() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": "not_an_array"
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_missing_value() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{ "field": "messages" }]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_missing_messages_array() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"metadata": {}
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_messages_not_array() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": "not_an_array"
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_missing_from_field() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": "No sender" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty(), "Messages without 'from' should be skipped");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_missing_text_body() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": {}
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty(), "Messages with empty text object should be skipped");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_null_text_body() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": null }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty(), "Messages with null body should be skipped");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_invalid_timestamp_uses_current() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "not_a_number",
|
|
"type": "text",
|
|
"text": { "body": "Hello" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 1);
|
|
// Timestamp should be current time (non-zero)
|
|
assert!(msgs[0].timestamp > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_missing_timestamp_uses_current() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"type": "text",
|
|
"text": { "body": "Hello" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 1);
|
|
assert!(msgs[0].timestamp > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_multiple_entries() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [
|
|
{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": "Entry 1" }
|
|
}]
|
|
}
|
|
}]
|
|
},
|
|
{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "222",
|
|
"timestamp": "2",
|
|
"type": "text",
|
|
"text": { "body": "Entry 2" }
|
|
}]
|
|
}
|
|
}]
|
|
}
|
|
]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 2);
|
|
assert_eq!(msgs[0].content, "Entry 1");
|
|
assert_eq!(msgs[1].content, "Entry 2");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_multiple_changes() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [
|
|
{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": "Change 1" }
|
|
}]
|
|
}
|
|
},
|
|
{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "222",
|
|
"timestamp": "2",
|
|
"type": "text",
|
|
"text": { "body": "Change 2" }
|
|
}]
|
|
}
|
|
}
|
|
]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 2);
|
|
assert_eq!(msgs[0].content, "Change 1");
|
|
assert_eq!(msgs[1].content, "Change 2");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_status_update_ignored() {
|
|
// Status updates have "statuses" instead of "messages"
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"statuses": [{
|
|
"id": "wamid.xxx",
|
|
"status": "delivered",
|
|
"timestamp": "1699999999"
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty(), "Status updates should be ignored");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_audio_message_skipped() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "audio",
|
|
"audio": { "id": "audio123", "mime_type": "audio/ogg" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_video_message_skipped() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "video",
|
|
"video": { "id": "video123" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_document_message_skipped() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "document",
|
|
"document": { "id": "doc123", "filename": "file.pdf" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_sticker_message_skipped() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "sticker",
|
|
"sticker": { "id": "sticker123" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_location_message_skipped() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "location",
|
|
"location": { "latitude": 40.7128, "longitude": -74.0060 }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_contacts_message_skipped() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "contacts",
|
|
"contacts": [{ "name": { "formatted_name": "John" } }]
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_reaction_message_skipped() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "reaction",
|
|
"reaction": { "message_id": "wamid.xxx", "emoji": "👍" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_mixed_authorized_unauthorized() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["+1111111111".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [
|
|
{ "from": "1111111111", "timestamp": "1", "type": "text", "text": { "body": "Allowed" } },
|
|
{ "from": "9999999999", "timestamp": "2", "type": "text", "text": { "body": "Blocked" } },
|
|
{ "from": "1111111111", "timestamp": "3", "type": "text", "text": { "body": "Also allowed" } }
|
|
]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 2);
|
|
assert_eq!(msgs[0].content, "Allowed");
|
|
assert_eq!(msgs[1].content, "Also allowed");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_unicode_message() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": "Hello 👋 世界 🌍 مرحبا" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 1);
|
|
assert_eq!(msgs[0].content, "Hello 👋 世界 🌍 مرحبا");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_very_long_message() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let long_text = "A".repeat(10_000);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": long_text }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 1);
|
|
assert_eq!(msgs[0].content.len(), 10_000);
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_whitespace_only_message_skipped() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": " " }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
// Whitespace-only is NOT empty, so it passes through
|
|
assert_eq!(msgs.len(), 1);
|
|
assert_eq!(msgs[0].content, " ");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_number_allowed_multiple_numbers() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["+1111111111".into(), "+2222222222".into(), "+3333333333".into()],
|
|
);
|
|
assert!(ch.is_number_allowed("+1111111111"));
|
|
assert!(ch.is_number_allowed("+2222222222"));
|
|
assert!(ch.is_number_allowed("+3333333333"));
|
|
assert!(!ch.is_number_allowed("+4444444444"));
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_number_allowed_case_sensitive() {
|
|
// Phone numbers should be exact match
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["+1234567890".into()],
|
|
);
|
|
assert!(ch.is_number_allowed("+1234567890"));
|
|
// Different number should not match
|
|
assert!(!ch.is_number_allowed("+1234567891"));
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_phone_already_has_plus() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["+1234567890".into()],
|
|
);
|
|
// If API sends with +, we should still handle it
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "+1234567890",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": "Hi" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 1);
|
|
assert_eq!(msgs[0].sender, "+1234567890");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_channel_fields_stored_correctly() {
|
|
let ch = WhatsAppChannel::new(
|
|
"my-access-token".into(),
|
|
"phone-id-123".into(),
|
|
"my-verify-token".into(),
|
|
vec!["+111".into(), "+222".into()],
|
|
);
|
|
assert_eq!(ch.verify_token(), "my-verify-token");
|
|
assert!(ch.is_number_allowed("+111"));
|
|
assert!(ch.is_number_allowed("+222"));
|
|
assert!(!ch.is_number_allowed("+333"));
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_empty_messages_array() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": []
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_empty_entry_array() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"entry": []
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_empty_changes_array() {
|
|
let ch = make_channel();
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": []
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert!(msgs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_newlines_preserved() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": "Line 1\nLine 2\nLine 3" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 1);
|
|
assert_eq!(msgs[0].content, "Line 1\nLine 2\nLine 3");
|
|
}
|
|
|
|
#[test]
|
|
fn whatsapp_parse_special_characters() {
|
|
let ch = WhatsAppChannel::new(
|
|
"tok".into(),
|
|
"123".into(),
|
|
"ver".into(),
|
|
vec!["*".into()],
|
|
);
|
|
let payload = serde_json::json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"from": "111",
|
|
"timestamp": "1",
|
|
"type": "text",
|
|
"text": { "body": "<script>alert('xss')</script> & \"quotes\" 'apostrophe'" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
let msgs = ch.parse_webhook_payload(&payload);
|
|
assert_eq!(msgs.len(), 1);
|
|
assert_eq!(msgs[0].content, "<script>alert('xss')</script> & \"quotes\" 'apostrophe'");
|
|
}
|
|
}
|