use crate::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use uuid::Uuid;
fn ensure_https(url: &str) -> anyhow::Result<()> {
if !url.starts_with("https://") {
anyhow::bail!(
"Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https"
);
}
Ok(())
}
pub struct WhatsAppChannel {
access_token: String,
endpoint_id: String,
verify_token: String,
allowed_numbers: Vec<String>,
http_client: reqwest::Client,
}
impl WhatsAppChannel {
pub fn new(
access_token: String,
endpoint_id: String,
verify_token: String,
allowed_numbers: Vec<String>,
) -> Self {
Self {
access_token,
endpoint_id,
verify_token,
allowed_numbers,
http_client: reqwest::Client::new(),
}
}
pub fn with_http_client(
access_token: String,
endpoint_id: String,
verify_token: String,
allowed_numbers: Vec<String>,
http_client: reqwest::Client,
) -> Self {
Self {
access_token,
endpoint_id,
verify_token,
allowed_numbers,
http_client,
}
}
fn http_client(&self) -> reqwest::Client {
self.http_client.clone()
}
fn is_number_allowed(&self, phone: &str) -> bool {
self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
}
pub fn verify_token(&self) -> &str {
&self.verify_token
}
pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
let mut messages = Vec::new();
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;
};
let Some(msgs) = value.get("messages").and_then(|m| m.as_array()) else {
continue;
};
for msg in msgs {
let Some(from) = msg.get("from").and_then(|f| f.as_str()) else {
continue;
};
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 configure channels in the web UI."
);
continue;
}
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 {
tracing::debug!("WhatsApp: skipping non-text message from {from}");
continue;
};
if content.is_empty() {
continue;
}
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(),
reply_target: normalized_from.clone(),
sender: normalized_from,
content,
channel: "whatsapp".to_string(),
timestamp,
thread_ts: None,
});
}
}
}
messages
}
}
#[async_trait]
impl Channel for WhatsAppChannel {
fn name(&self) -> &str {
"whatsapp"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let url = format!(
"https://graph.facebook.com/v18.0/{}/messages",
self.endpoint_id
);
let to = message
.recipient
.strip_prefix('+')
.unwrap_or(&message.recipient);
let body = serde_json::json!({
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "text",
"text": {
"preview_url": false,
"body": message.content
}
});
ensure_https(&url)?;
let resp = self
.http_client()
.post(&url)
.bearer_auth(&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<()> {
tracing::info!(
"WhatsApp channel active (webhook mode). \
Configure Meta to POST webhook events to your deployed HTTPS webhook URL."
);
loop {
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
}
}
async fn health_check(&self) -> bool {
let url = format!("https://graph.facebook.com/v18.0/{}", self.endpoint_id);
if ensure_https(&url).is_err() {
return false;
}
self.http_client()
.get(&url)
.bearer_auth(&self.access_token)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
}
#[cfg(test)]
#[path = "whatsapp_tests.rs"]
mod tests;