Skip to main content

ferro_cli/commands/
make_whatsapp.rs

1use console::style;
2use std::fs;
3use std::path::Path;
4
5// ---------------------------------------------------------------------------
6// Template generators
7// ---------------------------------------------------------------------------
8
9fn whatsapp_mod_template() -> String {
10    r#"pub mod listeners;
11pub mod webhook;
12
13use ferro::WhatsApp;
14
15/// Initialize WhatsApp. Call from bootstrap.rs.
16pub fn init() {
17    let config = ferro::WhatsAppConfig::from_env(Box::new(|phone| {
18        // TODO: Replace with your owner phone number check.
19        // Phone numbers arrive in E.164 format without '+' (e.g. "393401234567").
20        phone == std::env::var("OWNER_PHONE").as_deref().unwrap_or("")
21    }))
22    .expect("WhatsApp configuration missing. Set WHATSAPP_APP_SECRET, WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, and WHATSAPP_VERIFY_TOKEN.");
23    WhatsApp::init(config);
24}
25"#
26    .to_string()
27}
28
29fn whatsapp_webhook_template() -> String {
30    r#"use ferro::{handler, HttpResponse, Request, Response, WhatsApp};
31use ferro::{verify_whatsapp_webhook, ProcessWhatsAppWebhook, queue_dispatch};
32
33/// GET /whatsapp/webhook — Meta challenge verification endpoint.
34///
35/// Meta sends a GET request to verify the webhook endpoint before enabling it.
36/// Must respond with hub.challenge as plain text.
37#[handler]
38pub async fn whatsapp_webhook_verify(req: Request) -> Response {
39    let mode = req.query("hub.mode").unwrap_or_default();
40    let token = req.query("hub.verify_token").unwrap_or_default();
41    let challenge = req.query("hub.challenge").unwrap_or_default();
42
43    if mode == "subscribe" && token == WhatsApp::config().verify_token {
44        Ok(HttpResponse::text(challenge))
45    } else {
46        Err(HttpResponse::text("Forbidden").status(403))
47    }
48}
49
50/// POST /whatsapp/webhook — Inbound message and status update handler.
51///
52/// Verifies HMAC-SHA256 signature, acknowledges immediately, then queues
53/// ProcessWhatsAppWebhook job for async processing.
54#[handler]
55pub async fn whatsapp_webhook(req: Request) -> Response {
56    let sig = req
57        .header("x-hub-signature-256")
58        .ok_or_else(|| HttpResponse::text("Missing x-hub-signature-256").status(400))?;
59    let body = req
60        .body_string()
61        .await
62        .map_err(|_| HttpResponse::text("Failed to read body").status(400))?;
63
64    verify_whatsapp_webhook(body.as_bytes(), &sig, &WhatsApp::config().app_secret)
65        .map_err(|_| HttpResponse::text("Invalid signature").status(400))?;
66
67    let job = ProcessWhatsAppWebhook {
68        payload_json: body,
69    };
70    queue_dispatch(job)
71        .await
72        .map_err(|e| HttpResponse::text(format!("Queue error: {e}")).status(500))?;
73
74    Ok(HttpResponse::json(serde_json::json!({"received": true})))
75}
76"#
77    .to_string()
78}
79
80fn whatsapp_listeners_template() -> String {
81    r#"use ferro::{async_trait, EventError, Listener};
82use ferro::{WhatsAppTextReceived, WhatsAppStatusUpdate};
83
84pub struct HandleInboundMessage;
85
86#[async_trait]
87impl Listener<WhatsAppTextReceived> for HandleInboundMessage {
88    async fn handle(&self, event: &WhatsAppTextReceived) -> Result<(), EventError> {
89        // TODO: Handle inbound text message from sender.
90        // event.sender_identity — Owner or Customer with phone number
91        // event.text — the message text body
92        // event.wamid — WhatsApp message ID for dedup/correlation
93        println!("WhatsApp message received: {}", event.wamid);
94        Ok(())
95    }
96}
97
98pub struct HandleDeliveryStatus;
99
100#[async_trait]
101impl Listener<WhatsAppStatusUpdate> for HandleDeliveryStatus {
102    async fn handle(&self, event: &WhatsAppStatusUpdate) -> Result<(), EventError> {
103        // TODO: Handle delivery status update (Sent/Delivered/Read/Failed).
104        // event.wamid — correlates with SendResult.wamid from WhatsApp::send()
105        // event.status — DeliveryStatus enum variant
106        println!("WhatsApp status update: {:?} for {}", event.status, event.wamid);
107        Ok(())
108    }
109}
110"#
111    .to_string()
112}
113
114// ---------------------------------------------------------------------------
115// File generation helpers
116// ---------------------------------------------------------------------------
117
118/// Write a file only if it does not already exist.
119/// Returns true if the file was created, false if skipped.
120fn write_if_not_exists(path: &Path, content: &str, label: &str) -> bool {
121    if path.exists() {
122        println!(
123            "{} {} already exists, skipping",
124            style("Skip:").yellow().bold(),
125            label
126        );
127        return false;
128    }
129    if let Err(e) = fs::write(path, content) {
130        eprintln!(
131            "{} Failed to write {}: {}",
132            style("Error:").red().bold(),
133            label,
134            e
135        );
136        return false;
137    }
138    println!("{} {}", style("Created:").green().bold(), label);
139    true
140}
141
142fn ensure_dir(path: &Path) -> bool {
143    if path.exists() {
144        return true;
145    }
146    if let Err(e) = fs::create_dir_all(path) {
147        eprintln!(
148            "{} Failed to create directory {}: {}",
149            style("Error:").red().bold(),
150            path.display(),
151            e
152        );
153        return false;
154    }
155    println!(
156        "{} Created directory {}",
157        style("Created:").green().bold(),
158        path.display()
159    );
160    true
161}
162
163// ---------------------------------------------------------------------------
164// Public API
165// ---------------------------------------------------------------------------
166
167/// Execute `ferro make:whatsapp`.
168///
169/// Generates the WhatsApp integration scaffold into the current Ferro project.
170pub fn execute(project_root: &Path) {
171    println!("Scaffolding WhatsApp integration...\n");
172
173    let whatsapp_dir = project_root.join("src/whatsapp");
174
175    if !ensure_dir(&whatsapp_dir) {
176        std::process::exit(1);
177    }
178
179    // mod.rs
180    write_if_not_exists(
181        &whatsapp_dir.join("mod.rs"),
182        &whatsapp_mod_template(),
183        "src/whatsapp/mod.rs",
184    );
185
186    // webhook.rs
187    write_if_not_exists(
188        &whatsapp_dir.join("webhook.rs"),
189        &whatsapp_webhook_template(),
190        "src/whatsapp/webhook.rs",
191    );
192
193    // listeners.rs
194    write_if_not_exists(
195        &whatsapp_dir.join("listeners.rs"),
196        &whatsapp_listeners_template(),
197        "src/whatsapp/listeners.rs",
198    );
199
200    // Print env hints
201    println!("\n{}", style("Add to your .env file:").bold());
202    println!("  WHATSAPP_APP_SECRET=<from Meta Developer Dashboard -> App Settings -> Basic -> App Secret>");
203    println!("  WHATSAPP_ACCESS_TOKEN=<from Meta Developer Dashboard -> WhatsApp -> API Setup -> Permanent Token>");
204    println!("  WHATSAPP_PHONE_NUMBER_ID=<from Meta Developer Dashboard -> WhatsApp -> API Setup -> Phone Number ID>");
205    println!("  WHATSAPP_VERIFY_TOKEN=<a secret string you choose for webhook verification>");
206
207    // Print next steps
208    print_next_steps();
209}
210
211fn print_next_steps() {
212    println!("\n{}", style("Next steps:").bold());
213    println!(
214        "\n  {} Call WhatsApp::init() from your bootstrap.rs:",
215        style("1.").dim()
216    );
217    println!("     {}", style("crate::whatsapp::init();").cyan());
218
219    println!(
220        "\n  {} Register webhook routes in src/routes.rs:",
221        style("2.").dim()
222    );
223    println!(
224        "     {}",
225        style("use crate::whatsapp::webhook::{whatsapp_webhook, whatsapp_webhook_verify};").cyan()
226    );
227    println!(
228        "     {}",
229        style("get!(\"/whatsapp/webhook\", whatsapp_webhook_verify)").cyan()
230    );
231    println!(
232        "     {}",
233        style("post!(\"/whatsapp/webhook\", whatsapp_webhook)").cyan()
234    );
235
236    println!(
237        "\n  {} Register event listeners in bootstrap.rs:",
238        style("3.").dim()
239    );
240    println!(
241        "     {}",
242        style("use crate::whatsapp::listeners::{HandleInboundMessage, HandleDeliveryStatus};")
243            .cyan()
244    );
245    println!(
246        "     {}",
247        style("ferro::register_listener::<WhatsAppTextReceived, HandleInboundMessage>();").cyan()
248    );
249    println!(
250        "     {}",
251        style("ferro::register_listener::<WhatsAppStatusUpdate, HandleDeliveryStatus>();").cyan()
252    );
253
254    println!(
255        "\n  {} Configure the webhook URL in Meta Developer Dashboard:",
256        style("4.").dim()
257    );
258    println!(
259        "     {}",
260        style("Meta Developer Dashboard -> Your App -> WhatsApp -> Configuration -> Webhook URL")
261            .dim()
262    );
263    println!(
264        "     {}",
265        style("Set Callback URL to: https://yourdomain.com/whatsapp/webhook").dim()
266    );
267    println!(
268        "     {}",
269        style("Set Verify Token to the value of WHATSAPP_VERIFY_TOKEN").dim()
270    );
271}
272
273// ---------------------------------------------------------------------------
274// Tests
275// ---------------------------------------------------------------------------
276
277/// Generate the scaffold files in a temp directory for testing.
278#[cfg(test)]
279pub fn generate_in_dir(base_dir: &Path) {
280    let whatsapp_dir = base_dir.join("src/whatsapp");
281    fs::create_dir_all(&whatsapp_dir).unwrap();
282
283    fs::write(whatsapp_dir.join("mod.rs"), whatsapp_mod_template()).unwrap();
284    fs::write(whatsapp_dir.join("webhook.rs"), whatsapp_webhook_template()).unwrap();
285    fs::write(
286        whatsapp_dir.join("listeners.rs"),
287        whatsapp_listeners_template(),
288    )
289    .unwrap();
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use tempfile::TempDir;
296
297    fn read_file(path: &Path) -> String {
298        fs::read_to_string(path).unwrap_or_else(|e| panic!("Failed to read {path:?}: {e}"))
299    }
300
301    // --- mod.rs template tests ---
302
303    #[test]
304    fn test_mod_template_has_correct_imports() {
305        let tmpl = whatsapp_mod_template();
306        assert!(tmpl.contains("pub mod listeners;"));
307        assert!(tmpl.contains("pub mod webhook;"));
308        assert!(tmpl.contains("use ferro::WhatsApp;"));
309        assert!(tmpl.contains("pub fn init()"));
310        assert!(tmpl.contains("ferro::WhatsAppConfig::from_env("));
311        assert!(tmpl.contains("WhatsApp::init(config);"));
312    }
313
314    #[test]
315    fn test_mod_template_has_is_owner_closure() {
316        let tmpl = whatsapp_mod_template();
317        // is_owner is a closure passed to from_env
318        assert!(tmpl.contains("Box::new(|phone|"));
319    }
320
321    // --- webhook.rs template tests ---
322
323    #[test]
324    fn test_webhook_template_uses_queue_dispatch() {
325        let tmpl = whatsapp_webhook_template();
326        assert!(tmpl.contains("queue_dispatch(job)"));
327        assert!(!tmpl.contains("dispatch_event"));
328        assert!(tmpl.contains("verify_whatsapp_webhook("));
329        assert!(tmpl.contains("x-hub-signature-256"));
330        assert!(tmpl.contains(r#"{"received": true}"#));
331    }
332
333    #[test]
334    fn test_webhook_template_has_verify_handler() {
335        let tmpl = whatsapp_webhook_template();
336        assert!(tmpl.contains("whatsapp_webhook_verify"));
337        assert!(tmpl.contains("hub.mode"));
338        assert!(tmpl.contains("hub.verify_token"));
339        assert!(tmpl.contains("hub.challenge"));
340        assert!(tmpl.contains("HttpResponse::text(challenge)"));
341    }
342
343    #[test]
344    fn test_webhook_template_uses_ferro_imports() {
345        let tmpl = whatsapp_webhook_template();
346        assert!(tmpl.contains("use ferro::{"));
347        assert!(tmpl.contains("ProcessWhatsAppWebhook"));
348    }
349
350    // --- listeners.rs template tests ---
351
352    #[test]
353    fn test_listeners_template_has_both_event_types() {
354        let tmpl = whatsapp_listeners_template();
355        assert!(tmpl.contains("WhatsAppTextReceived"));
356        assert!(tmpl.contains("WhatsAppStatusUpdate"));
357        assert!(tmpl.contains("impl Listener<WhatsAppTextReceived> for HandleInboundMessage"));
358        assert!(tmpl.contains("impl Listener<WhatsAppStatusUpdate> for HandleDeliveryStatus"));
359        assert!(tmpl.contains("async fn handle("));
360        assert!(tmpl.contains("use ferro::{async_trait, EventError, Listener};"));
361    }
362
363    // --- file generation tests ---
364
365    #[test]
366    fn test_generates_three_required_files() {
367        let tmp = TempDir::new().unwrap();
368        generate_in_dir(tmp.path());
369
370        let whatsapp_dir = tmp.path().join("src/whatsapp");
371        assert!(
372            whatsapp_dir.exists(),
373            "src/whatsapp directory should be created"
374        );
375        assert!(
376            whatsapp_dir.join("mod.rs").exists(),
377            "mod.rs should be created"
378        );
379        assert!(
380            whatsapp_dir.join("webhook.rs").exists(),
381            "webhook.rs should be created"
382        );
383        assert!(
384            whatsapp_dir.join("listeners.rs").exists(),
385            "listeners.rs should be created"
386        );
387    }
388
389    #[test]
390    fn test_generated_files_have_correct_content() {
391        let tmp = TempDir::new().unwrap();
392        generate_in_dir(tmp.path());
393
394        let whatsapp_dir = tmp.path().join("src/whatsapp");
395
396        let mod_content = read_file(&whatsapp_dir.join("mod.rs"));
397        assert!(mod_content.contains("use ferro::WhatsApp;"));
398
399        let webhook_content = read_file(&whatsapp_dir.join("webhook.rs"));
400        assert!(webhook_content.contains("queue_dispatch"));
401        assert!(webhook_content.contains("verify_whatsapp_webhook"));
402
403        let listeners_content = read_file(&whatsapp_dir.join("listeners.rs"));
404        assert!(listeners_content.contains("WhatsAppTextReceived"));
405        assert!(listeners_content.contains("WhatsAppStatusUpdate"));
406    }
407
408    #[test]
409    fn test_does_not_overwrite_existing_files() {
410        let tmp = TempDir::new().unwrap();
411
412        // Test write_if_not_exists directly
413        let out_path = tmp.path().join("test_file.txt");
414        write_if_not_exists(&out_path, "original content", "test_file.txt");
415        assert_eq!(fs::read_to_string(&out_path).unwrap(), "original content");
416
417        // Second write should be skipped
418        write_if_not_exists(&out_path, "overwritten content", "test_file.txt");
419        assert_eq!(
420            fs::read_to_string(&out_path).unwrap(),
421            "original content",
422            "write_if_not_exists must not overwrite existing files"
423        );
424    }
425
426    #[test]
427    fn test_generated_webhook_uses_queue_not_events() {
428        let tmp = TempDir::new().unwrap();
429        generate_in_dir(tmp.path());
430
431        let webhook_path = tmp.path().join("src/whatsapp/webhook.rs");
432        let content = read_file(&webhook_path);
433
434        assert!(
435            content.contains("queue_dispatch"),
436            "webhook.rs must use queue_dispatch"
437        );
438        assert!(
439            !content.contains("dispatch_event"),
440            "webhook.rs must NOT use dispatch_event"
441        );
442    }
443}