Files
openfang/crates/openfang-channels/src/whatsapp.rs
T
Evan HuandClaude Opus 4.6 77ed954d18 wecom channel adapter
* feat: Add WeCom (WeChat Work) channel adapter

- Add wecom.rs channel adapter implementation
- Add WeComConfig in config.rs
- Register WeCom adapter in channel_bridge.rs

WeCom channel supports:
- Inbound messages via callback webhook
- Outbound messages via WeCom API
- Access token caching and auto-refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle WeCom callbacks and preserve hand extension tools

* fix: render WeCom replies as plain text

* fix: resolve clippy warnings in wecom adapter

- Remove unused WECOM_API_HOST constant
- Fix needless borrow in send_text call
- Replace assert_eq!(bool, true) with assert!()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt for wecom-related files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt --all

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: upgrade quinn-proto and add cargo audit ignore list

- Upgrade quinn-proto 0.11.13 → 0.11.14 (RUSTSEC-2026-0037 DoS fix)
- Add .cargo/audit.toml to ignore unmaintainable transitive deps
  (tauri GTK3 bindings, time pinned by mac-notification-sys, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:43 +03:00

363 lines
12 KiB
Rust

//! WhatsApp Cloud API channel adapter.
//!
//! Uses the official WhatsApp Business Cloud API to send and receive messages.
//! Requires a webhook endpoint for incoming messages and the Cloud API for outgoing.
use crate::types::{ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser};
use async_trait::async_trait;
use futures::Stream;
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::{mpsc, watch};
use tracing::{error, info};
use zeroize::Zeroizing;
const MAX_MESSAGE_LEN: usize = 4096;
/// WhatsApp Cloud API adapter.
///
/// Supports two modes:
/// - **Cloud API mode**: Uses the official WhatsApp Business Cloud API (requires Meta dev account).
/// - **Web/QR mode**: Routes outgoing messages through a local Baileys-based gateway process.
///
/// Mode is selected automatically: if `gateway_url` is set (from `WHATSAPP_WEB_GATEWAY_URL`),
/// the adapter uses Web mode. Otherwise it falls back to Cloud API mode.
pub struct WhatsAppAdapter {
/// WhatsApp Business phone number ID (Cloud API mode).
phone_number_id: String,
/// SECURITY: Access token is zeroized on drop.
access_token: Zeroizing<String>,
/// SECURITY: Verify token is zeroized on drop.
verify_token: Zeroizing<String>,
/// Port to listen for webhook callbacks (Cloud API mode).
webhook_port: u16,
/// HTTP client.
client: reqwest::Client,
/// Allowed phone numbers (empty = allow all).
allowed_users: Vec<String>,
/// Optional WhatsApp Web gateway URL for QR/Web mode (e.g. "http://127.0.0.1:3009").
gateway_url: Option<String>,
/// Shutdown signal.
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
}
impl WhatsAppAdapter {
/// Create a new WhatsApp Cloud API adapter.
pub fn new(
phone_number_id: String,
access_token: String,
verify_token: String,
webhook_port: u16,
allowed_users: Vec<String>,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
phone_number_id,
access_token: Zeroizing::new(access_token),
verify_token: Zeroizing::new(verify_token),
webhook_port,
client: reqwest::Client::new(),
allowed_users,
gateway_url: None,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
}
}
/// Create a new WhatsApp adapter with gateway URL for Web/QR mode.
///
/// When `gateway_url` is `Some`, outgoing messages are sent via `POST {gateway_url}/message/send`
/// instead of the Cloud API. Incoming messages are handled by the gateway itself.
pub fn with_gateway(mut self, gateway_url: Option<String>) -> Self {
self.gateway_url = gateway_url.filter(|u| !u.is_empty());
self
}
/// Send a text message via the WhatsApp Cloud API.
async fn api_send_message(
&self,
to: &str,
text: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
// Split long messages
let chunks = crate::types::split_message(text, MAX_MESSAGE_LEN);
for chunk in chunks {
let body = serde_json::json!({
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": { "body": chunk }
});
let resp = self
.client
.post(&url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
error!("WhatsApp API error {status}: {body}");
return Err(format!("WhatsApp API error {status}: {body}").into());
}
}
Ok(())
}
/// Mark a message as read.
#[allow(dead_code)]
async fn api_mark_read(&self, message_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
let body = serde_json::json!({
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id
});
let _ = self
.client
.post(&url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await;
Ok(())
}
/// Send a text message via the WhatsApp Web gateway.
async fn gateway_send_message(
&self,
gateway_url: &str,
to: &str,
text: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!("{}/message/send", gateway_url.trim_end_matches('/'));
let body = serde_json::json!({ "to": to, "text": text });
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
error!("WhatsApp gateway error {status}: {body}");
return Err(format!("WhatsApp gateway error {status}: {body}").into());
}
Ok(())
}
/// Check if a phone number is allowed.
#[allow(dead_code)]
fn is_allowed(&self, phone: &str) -> bool {
self.allowed_users.is_empty() || self.allowed_users.iter().any(|u| u == phone)
}
/// Returns true if this adapter is configured for Web/QR gateway mode.
#[allow(dead_code)]
pub fn is_gateway_mode(&self) -> bool {
self.gateway_url.is_some()
}
}
#[async_trait]
impl ChannelAdapter for WhatsAppAdapter {
fn name(&self) -> &str {
"whatsapp"
}
fn channel_type(&self) -> ChannelType {
ChannelType::WhatsApp
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let (_tx, rx) = mpsc::channel::<ChannelMessage>(256);
let port = self.webhook_port;
let _verify_token = self.verify_token.clone();
let _allowed_users = self.allowed_users.clone();
let _access_token = self.access_token.clone();
let _phone_number_id = self.phone_number_id.clone();
let mut shutdown_rx = self.shutdown_rx.clone();
info!("Starting WhatsApp webhook listener on port {port}");
tokio::spawn(async move {
// Simple webhook polling simulation
// In production, this would be an axum HTTP server handling webhook POSTs
// For now, log that the webhook is ready
info!("WhatsApp webhook ready on port {port} (verify_token configured)");
info!("Configure your webhook URL: https://your-domain:{port}/webhook");
// Wait for shutdown
let _ = shutdown_rx.changed().await;
info!("WhatsApp adapter stopped");
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(
&self,
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
// Web/QR gateway mode: route all messages through the gateway
if let Some(ref gw) = self.gateway_url {
let text = match &content {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Image { caption, .. } => caption
.clone()
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string()),
ChannelContent::File { filename, .. } => {
format!("(File: {filename} — not supported in Web mode)")
}
_ => "(Unsupported content type in Web mode)".to_string(),
};
// Split long messages the same way as Cloud API mode
let chunks = crate::types::split_message(&text, MAX_MESSAGE_LEN);
for chunk in chunks {
self.gateway_send_message(gw, &user.platform_id, chunk)
.await?;
}
return Ok(());
}
// Cloud API mode (default)
match content {
ChannelContent::Text(text) => {
self.api_send_message(&user.platform_id, &text).await?;
}
ChannelContent::Image { url, caption } => {
let body = serde_json::json!({
"messaging_product": "whatsapp",
"to": user.platform_id,
"type": "image",
"image": {
"link": url,
"caption": caption.unwrap_or_default()
}
});
let api_url = format!(
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
self.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await?;
}
ChannelContent::File { url, filename } => {
let body = serde_json::json!({
"messaging_product": "whatsapp",
"to": user.platform_id,
"type": "document",
"document": {
"link": url,
"filename": filename
}
});
let api_url = format!(
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
self.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await?;
}
ChannelContent::Location { lat, lon } => {
let body = serde_json::json!({
"messaging_product": "whatsapp",
"to": user.platform_id,
"type": "location",
"location": {
"latitude": lat,
"longitude": lon
}
});
let api_url = format!(
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
self.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await?;
}
_ => {
self.api_send_message(&user.platform_id, "(Unsupported content type)")
.await?;
}
}
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_whatsapp_adapter_creation() {
let adapter = WhatsAppAdapter::new(
"12345".to_string(),
"access_token".to_string(),
"verify_token".to_string(),
8443,
vec![],
);
assert_eq!(adapter.name(), "whatsapp");
assert_eq!(adapter.channel_type(), ChannelType::WhatsApp);
}
#[test]
fn test_allowed_users_check() {
let adapter = WhatsAppAdapter::new(
"12345".to_string(),
"token".to_string(),
"verify".to_string(),
8443,
vec!["+1234567890".to_string()],
);
assert!(adapter.is_allowed("+1234567890"));
assert!(!adapter.is_allowed("+9999999999"));
let open = WhatsAppAdapter::new(
"12345".to_string(),
"token".to_string(),
"verify".to_string(),
8443,
vec![],
);
assert!(open.is_allowed("+anything"));
}
}