1use console::style;
2use std::fs;
3use std::path::Path;
4
5fn 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
114fn 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
163pub 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 write_if_not_exists(
181 &whatsapp_dir.join("mod.rs"),
182 &whatsapp_mod_template(),
183 "src/whatsapp/mod.rs",
184 );
185
186 write_if_not_exists(
188 &whatsapp_dir.join("webhook.rs"),
189 &whatsapp_webhook_template(),
190 "src/whatsapp/webhook.rs",
191 );
192
193 write_if_not_exists(
195 &whatsapp_dir.join("listeners.rs"),
196 &whatsapp_listeners_template(),
197 "src/whatsapp/listeners.rs",
198 );
199
200 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();
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#[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 #[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 assert!(tmpl.contains("Box::new(|phone|"));
319 }
320
321 #[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 #[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 #[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 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 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}