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:
parent
ec2d5cc93d
commit
cc08f4bfff
6 changed files with 1749 additions and 5 deletions
45
README.md
45
README.md
|
|
@ -94,6 +94,10 @@ zeroclaw integrations info Telegram
|
||||||
# Manage background service
|
# Manage background service
|
||||||
zeroclaw service install
|
zeroclaw service install
|
||||||
zeroclaw service status
|
zeroclaw service status
|
||||||
|
|
||||||
|
# Migrate memory from OpenClaw (safe preview first)
|
||||||
|
zeroclaw migrate openclaw --dry-run
|
||||||
|
zeroclaw migrate openclaw
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Dev fallback (no global install):** prefix commands with `cargo run --release --` (example: `cargo run --release -- status`).
|
> **Dev fallback (no global install):** prefix commands with `cargo run --release --` (example: `cargo run --release -- status`).
|
||||||
|
|
@ -109,7 +113,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze
|
||||||
| Subsystem | Trait | Ships with | Extend |
|
| Subsystem | Trait | Ships with | Extend |
|
||||||
|-----------|-------|------------|--------|
|
|-----------|-------|------------|--------|
|
||||||
| **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API |
|
| **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API |
|
||||||
| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook | Any messaging API |
|
| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook | Any messaging API |
|
||||||
| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Markdown | Any persistence backend |
|
| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Markdown | Any persistence backend |
|
||||||
| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), composio (optional) | Any capability |
|
| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), composio (optional) | Any capability |
|
||||||
| **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
|
| **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
|
||||||
|
|
@ -197,6 +201,43 @@ rerun channel setup only:
|
||||||
zeroclaw onboard --channels-only
|
zeroclaw onboard --channels-only
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### WhatsApp Business Cloud API Setup
|
||||||
|
|
||||||
|
WhatsApp uses Meta's Cloud API with webhooks (push-based, not polling):
|
||||||
|
|
||||||
|
1. **Create a Meta Business App:**
|
||||||
|
- Go to [developers.facebook.com](https://developers.facebook.com)
|
||||||
|
- Create a new app → Select "Business" type
|
||||||
|
- Add the "WhatsApp" product
|
||||||
|
|
||||||
|
2. **Get your credentials:**
|
||||||
|
- **Access Token:** From WhatsApp → API Setup → Generate token (or create a System User for permanent tokens)
|
||||||
|
- **Phone Number ID:** From WhatsApp → API Setup → Phone number ID
|
||||||
|
- **Verify Token:** You define this (any random string) — Meta will send it back during webhook verification
|
||||||
|
|
||||||
|
3. **Configure ZeroClaw:**
|
||||||
|
```toml
|
||||||
|
[channels_config.whatsapp]
|
||||||
|
access_token = "EAABx..."
|
||||||
|
phone_number_id = "123456789012345"
|
||||||
|
verify_token = "my-secret-verify-token"
|
||||||
|
allowed_numbers = ["+1234567890"] # E.164 format, or ["*"] for all
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Start the gateway with a tunnel:**
|
||||||
|
```bash
|
||||||
|
zeroclaw gateway --port 8080
|
||||||
|
```
|
||||||
|
WhatsApp requires HTTPS, so use a tunnel (ngrok, Cloudflare, Tailscale Funnel).
|
||||||
|
|
||||||
|
5. **Configure Meta webhook:**
|
||||||
|
- In Meta Developer Console → WhatsApp → Configuration → Webhook
|
||||||
|
- **Callback URL:** `https://your-tunnel-url/whatsapp`
|
||||||
|
- **Verify Token:** Same as your `verify_token` in config
|
||||||
|
- Subscribe to `messages` field
|
||||||
|
|
||||||
|
6. **Test:** Send a message to your WhatsApp Business number — ZeroClaw will respond via the LLM.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Config: `~/.zeroclaw/config.toml` (created by `onboard`)
|
Config: `~/.zeroclaw/config.toml` (created by `onboard`)
|
||||||
|
|
@ -252,6 +293,8 @@ enabled = false # opt-in: 1000+ OAuth apps via composio.dev
|
||||||
| `/health` | GET | None | Health check (always public, no secrets leaked) |
|
| `/health` | GET | None | Health check (always public, no secrets leaked) |
|
||||||
| `/pair` | POST | `X-Pairing-Code` header | Exchange one-time code for bearer token |
|
| `/pair` | POST | `X-Pairing-Code` header | Exchange one-time code for bearer token |
|
||||||
| `/webhook` | POST | `Authorization: Bearer <token>` | Send message: `{"message": "your prompt"}` |
|
| `/webhook` | POST | `Authorization: Bearer <token>` | Send message: `{"message": "your prompt"}` |
|
||||||
|
| `/whatsapp` | GET | Query params | Meta webhook verification (hub.mode, hub.verify_token, hub.challenge) |
|
||||||
|
| `/whatsapp` | POST | None (Meta signature) | WhatsApp incoming message webhook |
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ pub mod matrix;
|
||||||
pub mod slack;
|
pub mod slack;
|
||||||
pub mod telegram;
|
pub mod telegram;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
pub mod whatsapp;
|
||||||
|
|
||||||
pub use cli::CliChannel;
|
pub use cli::CliChannel;
|
||||||
pub use discord::DiscordChannel;
|
pub use discord::DiscordChannel;
|
||||||
|
|
@ -13,6 +14,7 @@ pub use matrix::MatrixChannel;
|
||||||
pub use slack::SlackChannel;
|
pub use slack::SlackChannel;
|
||||||
pub use telegram::TelegramChannel;
|
pub use telegram::TelegramChannel;
|
||||||
pub use traits::Channel;
|
pub use traits::Channel;
|
||||||
|
pub use whatsapp::WhatsAppChannel;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::memory::{self, Memory};
|
use crate::memory::{self, Memory};
|
||||||
|
|
@ -236,6 +238,7 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul
|
||||||
("Webhook", config.channels_config.webhook.is_some()),
|
("Webhook", config.channels_config.webhook.is_some()),
|
||||||
("iMessage", config.channels_config.imessage.is_some()),
|
("iMessage", config.channels_config.imessage.is_some()),
|
||||||
("Matrix", config.channels_config.matrix.is_some()),
|
("Matrix", config.channels_config.matrix.is_some()),
|
||||||
|
("WhatsApp", config.channels_config.whatsapp.is_some()),
|
||||||
] {
|
] {
|
||||||
println!(" {} {name}", if configured { "✅" } else { "❌" });
|
println!(" {} {name}", if configured { "✅" } else { "❌" });
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +333,18 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref wa) = config.channels_config.whatsapp {
|
||||||
|
channels.push((
|
||||||
|
"WhatsApp",
|
||||||
|
Arc::new(WhatsAppChannel::new(
|
||||||
|
wa.access_token.clone(),
|
||||||
|
wa.phone_number_id.clone(),
|
||||||
|
wa.verify_token.clone(),
|
||||||
|
wa.allowed_numbers.clone(),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if channels.is_empty() {
|
if channels.is_empty() {
|
||||||
println!("No real-time channels configured. Run `zeroclaw onboard` first.");
|
println!("No real-time channels configured. Run `zeroclaw onboard` first.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -481,6 +496,15 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref wa) = config.channels_config.whatsapp {
|
||||||
|
channels.push(Arc::new(WhatsAppChannel::new(
|
||||||
|
wa.access_token.clone(),
|
||||||
|
wa.phone_number_id.clone(),
|
||||||
|
wa.verify_token.clone(),
|
||||||
|
wa.allowed_numbers.clone(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
if channels.is_empty() {
|
if channels.is_empty() {
|
||||||
println!("No channels configured. Run `zeroclaw onboard` to set up channels.");
|
println!("No channels configured. Run `zeroclaw onboard` to set up channels.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
|
||||||
1223
src/channels/whatsapp.rs
Normal file
1223
src/channels/whatsapp.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -485,6 +485,7 @@ pub struct ChannelsConfig {
|
||||||
pub webhook: Option<WebhookConfig>,
|
pub webhook: Option<WebhookConfig>,
|
||||||
pub imessage: Option<IMessageConfig>,
|
pub imessage: Option<IMessageConfig>,
|
||||||
pub matrix: Option<MatrixConfig>,
|
pub matrix: Option<MatrixConfig>,
|
||||||
|
pub whatsapp: Option<WhatsAppConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ChannelsConfig {
|
impl Default for ChannelsConfig {
|
||||||
|
|
@ -497,6 +498,7 @@ impl Default for ChannelsConfig {
|
||||||
webhook: None,
|
webhook: None,
|
||||||
imessage: None,
|
imessage: None,
|
||||||
matrix: None,
|
matrix: None,
|
||||||
|
whatsapp: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -543,6 +545,19 @@ pub struct MatrixConfig {
|
||||||
pub allowed_users: Vec<String>,
|
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 ──────────────────────────────────────────────────
|
// ── Config impl ──────────────────────────────────────────────────
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
|
@ -717,6 +732,7 @@ mod tests {
|
||||||
webhook: None,
|
webhook: None,
|
||||||
imessage: None,
|
imessage: None,
|
||||||
matrix: None,
|
matrix: None,
|
||||||
|
whatsapp: None,
|
||||||
},
|
},
|
||||||
memory: MemoryConfig::default(),
|
memory: MemoryConfig::default(),
|
||||||
tunnel: TunnelConfig::default(),
|
tunnel: TunnelConfig::default(),
|
||||||
|
|
@ -926,6 +942,7 @@ default_temperature = 0.7
|
||||||
room_id: "!r:m".into(),
|
room_id: "!r:m".into(),
|
||||||
allowed_users: vec!["@u:m".into()],
|
allowed_users: vec!["@u:m".into()],
|
||||||
}),
|
}),
|
||||||
|
whatsapp: None,
|
||||||
};
|
};
|
||||||
let toml_str = toml::to_string_pretty(&c).unwrap();
|
let toml_str = toml::to_string_pretty(&c).unwrap();
|
||||||
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
||||||
|
|
@ -1010,6 +1027,89 @@ channel_id = "C123"
|
||||||
assert_eq!(parsed.port, 8080);
|
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
|
// SECURITY CHECKLIST TESTS — Gateway config
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use crate::channels::{Channel, WhatsAppChannel};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::memory::{self, Memory, MemoryCategory};
|
use crate::memory::{self, Memory, MemoryCategory};
|
||||||
use crate::providers::{self, Provider};
|
use crate::providers::{self, Provider};
|
||||||
|
|
@ -50,6 +51,17 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||||
.and_then(|w| w.secret.as_deref())
|
.and_then(|w| w.secret.as_deref())
|
||||||
.map(Arc::from);
|
.map(Arc::from);
|
||||||
|
|
||||||
|
// WhatsApp channel (if configured)
|
||||||
|
let whatsapp_channel: Option<Arc<WhatsAppChannel>> =
|
||||||
|
config.channels_config.whatsapp.as_ref().map(|wa| {
|
||||||
|
Arc::new(WhatsAppChannel::new(
|
||||||
|
wa.access_token.clone(),
|
||||||
|
wa.phone_number_id.clone(),
|
||||||
|
wa.verify_token.clone(),
|
||||||
|
wa.allowed_numbers.clone(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
|
||||||
// ── Pairing guard ──────────────────────────────────────
|
// ── Pairing guard ──────────────────────────────────────
|
||||||
let pairing = Arc::new(PairingGuard::new(
|
let pairing = Arc::new(PairingGuard::new(
|
||||||
config.gateway.require_pairing,
|
config.gateway.require_pairing,
|
||||||
|
|
@ -78,9 +90,13 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||||
if let Some(ref url) = tunnel_url {
|
if let Some(ref url) = tunnel_url {
|
||||||
println!(" 🌐 Public URL: {url}");
|
println!(" 🌐 Public URL: {url}");
|
||||||
}
|
}
|
||||||
println!(" POST /pair — pair a new client (X-Pairing-Code header)");
|
println!(" POST /pair — pair a new client (X-Pairing-Code header)");
|
||||||
println!(" POST /webhook — {{\"message\": \"your prompt\"}}");
|
println!(" POST /webhook — {{\"message\": \"your prompt\"}}");
|
||||||
println!(" GET /health — health check");
|
if whatsapp_channel.is_some() {
|
||||||
|
println!(" GET /whatsapp — Meta webhook verification");
|
||||||
|
println!(" POST /whatsapp — WhatsApp message webhook");
|
||||||
|
}
|
||||||
|
println!(" GET /health — health check");
|
||||||
if let Some(code) = pairing.pairing_code() {
|
if let Some(code) = pairing.pairing_code() {
|
||||||
println!();
|
println!();
|
||||||
println!(" <20> PAIRING REQUIRED — use this one-time code:");
|
println!(" <20> PAIRING REQUIRED — use this one-time code:");
|
||||||
|
|
@ -108,6 +124,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||||
let auto_save = config.memory.auto_save;
|
let auto_save = config.memory.auto_save;
|
||||||
let secret = webhook_secret.clone();
|
let secret = webhook_secret.clone();
|
||||||
let pairing = pairing.clone();
|
let pairing = pairing.clone();
|
||||||
|
let whatsapp = whatsapp_channel.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Read with 30s timeout to prevent slow-loris attacks
|
// Read with 30s timeout to prevent slow-loris attacks
|
||||||
|
|
@ -136,6 +153,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||||
auto_save,
|
auto_save,
|
||||||
secret.as_ref(),
|
secret.as_ref(),
|
||||||
&pairing,
|
&pairing,
|
||||||
|
whatsapp.as_ref(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -171,6 +189,7 @@ async fn handle_request(
|
||||||
auto_save: bool,
|
auto_save: bool,
|
||||||
webhook_secret: Option<&Arc<str>>,
|
webhook_secret: Option<&Arc<str>>,
|
||||||
pairing: &PairingGuard,
|
pairing: &PairingGuard,
|
||||||
|
whatsapp: Option<&Arc<WhatsAppChannel>>,
|
||||||
) {
|
) {
|
||||||
match (method, path) {
|
match (method, path) {
|
||||||
// Health check — always public (no secrets leaked)
|
// Health check — always public (no secrets leaked)
|
||||||
|
|
@ -214,6 +233,16 @@ async fn handle_request(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WhatsApp webhook verification (Meta sends GET to verify)
|
||||||
|
("GET", "/whatsapp") => {
|
||||||
|
handle_whatsapp_verify(stream, request, whatsapp).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsApp incoming message webhook
|
||||||
|
("POST", "/whatsapp") => {
|
||||||
|
handle_whatsapp_message(stream, request, provider, model, temperature, mem, auto_save, whatsapp).await;
|
||||||
|
}
|
||||||
|
|
||||||
("POST", "/webhook") => {
|
("POST", "/webhook") => {
|
||||||
// ── Bearer token auth (pairing) ──
|
// ── Bearer token auth (pairing) ──
|
||||||
if pairing.require_pairing() {
|
if pairing.require_pairing() {
|
||||||
|
|
@ -311,6 +340,172 @@ async fn handle_webhook(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle webhook verification (GET /whatsapp)
|
||||||
|
/// Meta sends: `GET /whatsapp?hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<challenge>`
|
||||||
|
async fn handle_whatsapp_verify(
|
||||||
|
stream: &mut tokio::net::TcpStream,
|
||||||
|
request: &str,
|
||||||
|
whatsapp: Option<&Arc<WhatsAppChannel>>,
|
||||||
|
) {
|
||||||
|
let Some(wa) = whatsapp else {
|
||||||
|
let err = serde_json::json!({"error": "WhatsApp not configured"});
|
||||||
|
let _ = send_json(stream, 404, &err).await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse query string from the request line
|
||||||
|
// GET /whatsapp?hub.mode=subscribe&hub.verify_token=xxx&hub.challenge=yyy HTTP/1.1
|
||||||
|
let first_line = request.lines().next().unwrap_or("");
|
||||||
|
let query = first_line
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|path| path.split('?').nth(1))
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let mut mode = None;
|
||||||
|
let mut token = None;
|
||||||
|
let mut challenge = None;
|
||||||
|
|
||||||
|
for pair in query.split('&') {
|
||||||
|
if let Some((key, value)) = pair.split_once('=') {
|
||||||
|
match key {
|
||||||
|
"hub.mode" => mode = Some(value),
|
||||||
|
"hub.verify_token" => token = Some(value),
|
||||||
|
"hub.challenge" => challenge = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the token matches
|
||||||
|
if mode == Some("subscribe") && token == Some(wa.verify_token()) {
|
||||||
|
if let Some(ch) = challenge {
|
||||||
|
// URL-decode the challenge (basic: replace %XX)
|
||||||
|
let decoded = urlencoding_decode(ch);
|
||||||
|
tracing::info!("WhatsApp webhook verified successfully");
|
||||||
|
let _ = send_response(stream, 200, &decoded).await;
|
||||||
|
} else {
|
||||||
|
let _ = send_response(stream, 400, "Missing hub.challenge").await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("WhatsApp webhook verification failed — token mismatch");
|
||||||
|
let _ = send_response(stream, 403, "Forbidden").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple URL decoding (handles %XX sequences)
|
||||||
|
fn urlencoding_decode(s: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(s.len());
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '%' {
|
||||||
|
let hex: String = chars.by_ref().take(2).collect();
|
||||||
|
// Require exactly 2 hex digits for valid percent encoding
|
||||||
|
if hex.len() == 2 {
|
||||||
|
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
||||||
|
result.push(byte as char);
|
||||||
|
} else {
|
||||||
|
result.push('%');
|
||||||
|
result.push_str(&hex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Incomplete percent encoding - preserve as-is
|
||||||
|
result.push('%');
|
||||||
|
result.push_str(&hex);
|
||||||
|
}
|
||||||
|
} else if c == '+' {
|
||||||
|
result.push(' ');
|
||||||
|
} else {
|
||||||
|
result.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle incoming message webhook (POST /whatsapp)
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn handle_whatsapp_message(
|
||||||
|
stream: &mut tokio::net::TcpStream,
|
||||||
|
request: &str,
|
||||||
|
provider: &Arc<dyn Provider>,
|
||||||
|
model: &str,
|
||||||
|
temperature: f64,
|
||||||
|
mem: &Arc<dyn Memory>,
|
||||||
|
auto_save: bool,
|
||||||
|
whatsapp: Option<&Arc<WhatsAppChannel>>,
|
||||||
|
) {
|
||||||
|
let Some(wa) = whatsapp else {
|
||||||
|
let err = serde_json::json!({"error": "WhatsApp not configured"});
|
||||||
|
let _ = send_json(stream, 404, &err).await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract JSON body
|
||||||
|
let body_str = request
|
||||||
|
.split("\r\n\r\n")
|
||||||
|
.nth(1)
|
||||||
|
.or_else(|| request.split("\n\n").nth(1))
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let Ok(payload) = serde_json::from_str::<serde_json::Value>(body_str) else {
|
||||||
|
let err = serde_json::json!({"error": "Invalid JSON payload"});
|
||||||
|
let _ = send_json(stream, 400, &err).await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse messages from the webhook payload
|
||||||
|
let messages = wa.parse_webhook_payload(&payload);
|
||||||
|
|
||||||
|
if messages.is_empty() {
|
||||||
|
// Acknowledge the webhook even if no messages (could be status updates)
|
||||||
|
let _ = send_response(stream, 200, "OK").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each message
|
||||||
|
for msg in &messages {
|
||||||
|
tracing::info!(
|
||||||
|
"WhatsApp message from {}: {}",
|
||||||
|
msg.sender,
|
||||||
|
if msg.content.len() > 50 {
|
||||||
|
format!("{}...", &msg.content[..50])
|
||||||
|
} else {
|
||||||
|
msg.content.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-save to memory
|
||||||
|
if auto_save {
|
||||||
|
let _ = mem
|
||||||
|
.store(
|
||||||
|
&format!("whatsapp_{}", msg.sender),
|
||||||
|
&msg.content,
|
||||||
|
MemoryCategory::Conversation,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the LLM
|
||||||
|
match provider.chat(&msg.content, model, temperature).await {
|
||||||
|
Ok(response) => {
|
||||||
|
// Send reply via WhatsApp
|
||||||
|
if let Err(e) = wa.send(&response, &msg.sender).await {
|
||||||
|
tracing::error!("Failed to send WhatsApp reply: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("LLM error for WhatsApp message: {e}");
|
||||||
|
let _ = wa.send(&format!("⚠️ Error: {e}"), &msg.sender).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledge the webhook
|
||||||
|
let _ = send_response(stream, 200, "OK").await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_response(
|
async fn send_response(
|
||||||
stream: &mut tokio::net::TcpStream,
|
stream: &mut tokio::net::TcpStream,
|
||||||
status: u16,
|
status: u16,
|
||||||
|
|
@ -525,4 +720,65 @@ mod tests {
|
||||||
fn extract_header_newline_only_request() {
|
fn extract_header_newline_only_request() {
|
||||||
assert_eq!(extract_header("\r\n\r\n", "X-Webhook-Secret"), None);
|
assert_eq!(extract_header("\r\n\r\n", "X-Webhook-Secret"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── URL decoding tests ────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_decode_plain_text() {
|
||||||
|
assert_eq!(urlencoding_decode("hello"), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_decode_spaces() {
|
||||||
|
assert_eq!(urlencoding_decode("hello+world"), "hello world");
|
||||||
|
assert_eq!(urlencoding_decode("hello%20world"), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_decode_special_chars() {
|
||||||
|
assert_eq!(urlencoding_decode("%21%40%23"), "!@#");
|
||||||
|
assert_eq!(urlencoding_decode("%3F%3D%26"), "?=&");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_decode_mixed() {
|
||||||
|
assert_eq!(urlencoding_decode("hello%20world%21"), "hello world!");
|
||||||
|
assert_eq!(urlencoding_decode("a+b%2Bc"), "a b+c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_decode_empty() {
|
||||||
|
assert_eq!(urlencoding_decode(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_decode_invalid_hex() {
|
||||||
|
// Invalid hex should be preserved
|
||||||
|
assert_eq!(urlencoding_decode("%ZZ"), "%ZZ");
|
||||||
|
assert_eq!(urlencoding_decode("%G1"), "%G1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_decode_incomplete_percent() {
|
||||||
|
// Incomplete percent encoding at end - function takes available chars
|
||||||
|
// "%2" -> takes "2" as hex, fails to parse, outputs "%2"
|
||||||
|
assert_eq!(urlencoding_decode("test%2"), "test%2");
|
||||||
|
// "%" alone -> takes "" as hex, fails to parse, outputs "%"
|
||||||
|
assert_eq!(urlencoding_decode("test%"), "test%");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_decode_challenge_token() {
|
||||||
|
// Typical Meta webhook challenge
|
||||||
|
assert_eq!(
|
||||||
|
urlencoding_decode("1234567890"),
|
||||||
|
"1234567890"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_decode_unicode_percent() {
|
||||||
|
// URL-encoded UTF-8 bytes for emoji (simplified test)
|
||||||
|
assert_eq!(urlencoding_decode("%41%42%43"), "ABC");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use crate::config::{
|
||||||
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
|
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
|
||||||
RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig,
|
RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig,
|
||||||
};
|
};
|
||||||
|
use crate::config::schema::WhatsAppConfig;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use console::style;
|
use console::style;
|
||||||
use dialoguer::{Confirm, Input, Select};
|
use dialoguer::{Confirm, Input, Select};
|
||||||
|
|
@ -945,6 +946,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
webhook: None,
|
webhook: None,
|
||||||
imessage: None,
|
imessage: None,
|
||||||
matrix: None,
|
matrix: None,
|
||||||
|
whatsapp: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -989,6 +991,14 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
"— self-hosted chat"
|
"— self-hosted chat"
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
format!(
|
||||||
|
"WhatsApp {}",
|
||||||
|
if config.whatsapp.is_some() {
|
||||||
|
"✅ connected"
|
||||||
|
} else {
|
||||||
|
"— Business Cloud API"
|
||||||
|
}
|
||||||
|
),
|
||||||
format!(
|
format!(
|
||||||
"Webhook {}",
|
"Webhook {}",
|
||||||
if config.webhook.is_some() {
|
if config.webhook.is_some() {
|
||||||
|
|
@ -1003,7 +1013,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
let choice = Select::new()
|
let choice = Select::new()
|
||||||
.with_prompt(" Connect a channel (or Done to continue)")
|
.with_prompt(" Connect a channel (or Done to continue)")
|
||||||
.items(&options)
|
.items(&options)
|
||||||
.default(6)
|
.default(7)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
match choice {
|
match choice {
|
||||||
|
|
@ -1425,6 +1435,91 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
5 => {
|
5 => {
|
||||||
|
// ── WhatsApp ──
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
style("WhatsApp Setup").white().bold(),
|
||||||
|
style("— Business Cloud API").dim()
|
||||||
|
);
|
||||||
|
print_bullet("1. Go to developers.facebook.com and create a WhatsApp app");
|
||||||
|
print_bullet("2. Add the WhatsApp product and get your phone number ID");
|
||||||
|
print_bullet("3. Generate a temporary access token (System User)");
|
||||||
|
print_bullet("4. Configure webhook URL to: https://your-domain/whatsapp");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let access_token: String = Input::new()
|
||||||
|
.with_prompt(" Access token (from Meta Developers)")
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
if access_token.trim().is_empty() {
|
||||||
|
println!(" {} Skipped", style("→").dim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phone_number_id: String = Input::new()
|
||||||
|
.with_prompt(" Phone number ID (from WhatsApp app settings)")
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
if phone_number_id.trim().is_empty() {
|
||||||
|
println!(" {} Skipped — phone number ID required", style("→").dim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let verify_token: String = Input::new()
|
||||||
|
.with_prompt(" Webhook verify token (create your own)")
|
||||||
|
.default("zeroclaw-whatsapp-verify".into())
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
print!(" {} Testing connection... ", style("⏳").dim());
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let url = format!(
|
||||||
|
"https://graph.facebook.com/v18.0/{}",
|
||||||
|
phone_number_id.trim()
|
||||||
|
);
|
||||||
|
match client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", access_token.trim()))
|
||||||
|
.send()
|
||||||
|
{
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
println!(
|
||||||
|
"\r {} Connected to WhatsApp API ",
|
||||||
|
style("✅").green().bold()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
println!(
|
||||||
|
"\r {} Connection failed — check access token and phone number ID",
|
||||||
|
style("❌").red().bold()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let users_str: String = Input::new()
|
||||||
|
.with_prompt(" Allowed phone numbers (comma-separated +1234567890, or * for all)")
|
||||||
|
.default("*".into())
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let allowed_numbers = if users_str.trim() == "*" {
|
||||||
|
vec!["*".into()]
|
||||||
|
} else {
|
||||||
|
users_str
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
config.whatsapp = Some(WhatsAppConfig {
|
||||||
|
access_token: access_token.trim().to_string(),
|
||||||
|
phone_number_id: phone_number_id.trim().to_string(),
|
||||||
|
verify_token: verify_token.trim().to_string(),
|
||||||
|
allowed_numbers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
// ── Webhook ──
|
// ── Webhook ──
|
||||||
println!();
|
println!();
|
||||||
println!(
|
println!(
|
||||||
|
|
@ -1479,6 +1574,9 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
if config.matrix.is_some() {
|
if config.matrix.is_some() {
|
||||||
active.push("Matrix");
|
active.push("Matrix");
|
||||||
}
|
}
|
||||||
|
if config.whatsapp.is_some() {
|
||||||
|
active.push("WhatsApp");
|
||||||
|
}
|
||||||
if config.webhook.is_some() {
|
if config.webhook.is_some() {
|
||||||
active.push("Webhook");
|
active.push("Webhook");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue