From 02947700baf25d4c5196b4edb1ba693336486ed4 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Mar 2026 23:58:31 +0000 Subject: [PATCH] story-kit: merge 322_story_whatsapp_24_hour_messaging_window_and_template_support --- .story_kit/bot.toml.example | 19 ++ server/src/main.rs | 6 + server/src/matrix/bot.rs | 6 +- server/src/matrix/config.rs | 7 + server/src/transport.rs | 1 + server/src/whatsapp.rs | 473 ++++++++++++++++++++++++++++++++++-- 6 files changed, 496 insertions(+), 16 deletions(-) diff --git a/.story_kit/bot.toml.example b/.story_kit/bot.toml.example index b96e482..3219ba2 100644 --- a/.story_kit/bot.toml.example +++ b/.story_kit/bot.toml.example @@ -27,3 +27,22 @@ enabled = false # whatsapp_phone_number_id = "123456789012345" # whatsapp_access_token = "EAAx..." # whatsapp_verify_token = "my-secret-verify-token" +# +# ── 24-hour messaging window & notification templates ───────────────── +# WhatsApp only allows free-form text messages within 24 hours of the last +# inbound message from a user. For proactive pipeline notifications sent +# after the window expires, an approved Meta message template is used. +# +# Register the template in the Meta Business Manager: +# 1. Go to Business Settings → WhatsApp → Message Templates → Create. +# 2. Category: UTILITY +# 3. Template name: pipeline_notification (or your chosen name below) +# 4. Language: English (en_US) +# 5. Body text (example): +# Story *{{1}}* has moved to *{{2}}*. +# Where {{1}} = story name, {{2}} = pipeline stage. +# 6. Submit for review. Meta typically approves utility templates within +# minutes; transactional categories may take longer. +# +# Once approved, set the name below (default: "pipeline_notification"): +# whatsapp_notification_template = "pipeline_notification" diff --git a/server/src/main.rs b/server/src/main.rs index 41faaac..7427750 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -199,9 +199,14 @@ async fn main() -> Result<(), std::io::Error> { .and_then(|root| matrix::BotConfig::load(root)) .filter(|cfg| cfg.transport == "whatsapp") .map(|cfg| { + let template_name = cfg + .whatsapp_notification_template + .clone() + .unwrap_or_else(|| "pipeline_notification".to_string()); let transport = Arc::new(whatsapp::WhatsAppTransport::new( cfg.whatsapp_phone_number_id.clone().unwrap_or_default(), cfg.whatsapp_access_token.clone().unwrap_or_default(), + template_name, )); let bot_name = cfg .display_name @@ -219,6 +224,7 @@ async fn main() -> Result<(), std::io::Error> { ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), history: std::sync::Arc::new(tokio::sync::Mutex::new(history)), history_size: cfg.history_size, + window_tracker: Arc::new(whatsapp::MessagingWindowTracker::new()), }) }); diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 63d6aac..f392044 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -372,6 +372,10 @@ pub async fn run_bot( Arc::new(crate::whatsapp::WhatsAppTransport::new( config.whatsapp_phone_number_id.clone().unwrap_or_default(), config.whatsapp_access_token.clone().unwrap_or_default(), + config + .whatsapp_notification_template + .clone() + .unwrap_or_else(|| "pipeline_notification".to_string()), )) } _ => { @@ -1396,7 +1400,7 @@ mod tests { ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())), agents: Arc::new(AgentPool::new_test(3000)), htop_sessions: Arc::new(TokioMutex::new(HashMap::new())), - transport: Arc::new(crate::whatsapp::WhatsAppTransport::new("test-phone".to_string(), "test-token".to_string())), + transport: Arc::new(crate::whatsapp::WhatsAppTransport::new("test-phone".to_string(), "test-token".to_string(), "pipeline_notification".to_string())), }; // Clone must work (required by Matrix SDK event handler injection). let _cloned = ctx.clone(); diff --git a/server/src/matrix/config.rs b/server/src/matrix/config.rs index 4160298..9c5e585 100644 --- a/server/src/matrix/config.rs +++ b/server/src/matrix/config.rs @@ -80,6 +80,13 @@ pub struct BotConfig { /// and configure it in the Meta webhook settings). #[serde(default)] pub whatsapp_verify_token: Option, + /// Name of the approved Meta message template used for pipeline + /// notifications when the 24-hour messaging window has expired. + /// + /// The template must be registered in the Meta Business Manager before + /// use. Defaults to `"pipeline_notification"`. + #[serde(default)] + pub whatsapp_notification_template: Option, } fn default_transport() -> String { diff --git a/server/src/transport.rs b/server/src/transport.rs index 82348b5..95e7fb6 100644 --- a/server/src/transport.rs +++ b/server/src/transport.rs @@ -72,6 +72,7 @@ mod tests { Arc::new(crate::whatsapp::WhatsAppTransport::new( "test-phone".to_string(), "test-token".to_string(), + "pipeline_notification".to_string(), )); } diff --git a/server/src/whatsapp.rs b/server/src/whatsapp.rs index ce263b8..e4fb273 100644 --- a/server/src/whatsapp.rs +++ b/server/src/whatsapp.rs @@ -3,6 +3,7 @@ //! Provides: //! - [`WhatsAppTransport`] — a [`ChatTransport`] that sends messages via the //! Meta Graph API (`graph.facebook.com/v21.0/{phone_number_id}/messages`). +//! - [`MessagingWindowTracker`] — tracks the 24-hour messaging window per user. //! - [`webhook_verify`] / [`webhook_receive`] — Poem handlers for the WhatsApp //! webhook (GET verification handshake + POST incoming messages). @@ -21,45 +22,130 @@ use crate::transport::{ChatTransport, MessageId}; const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0"; +/// Graph API error code indicating the 24-hour messaging window has elapsed. +/// +/// When Meta returns this code the caller must fall back to an approved message +/// template instead of free-form text. +const ERROR_CODE_OUTSIDE_WINDOW: i64 = 131047; + +/// Sentinel error string returned by [`WhatsAppTransport::send_text`] when the +/// Graph API reports that the 24-hour messaging window has expired. +const OUTSIDE_WINDOW_ERR: &str = "OUTSIDE_MESSAGING_WINDOW"; + +// ── Messaging window tracker ───────────────────────────────────────────── + +/// Tracks the 24-hour messaging window per WhatsApp phone number. +/// +/// Meta's Business API only permits free-form text messages within 24 hours of +/// the last *inbound* message from that user. After that window expires, only +/// approved message templates may be sent. +/// +/// Call [`record_message`] whenever an inbound message is received. Before +/// sending a proactive notification, call [`is_within_window`] to choose +/// between free-form text and a template message. +pub struct MessagingWindowTracker { + last_message: std::sync::Mutex>, + window_duration: std::time::Duration, +} + +impl Default for MessagingWindowTracker { + fn default() -> Self { + Self::new() + } +} + +impl MessagingWindowTracker { + /// Create a tracker with the standard 24-hour window. + pub fn new() -> Self { + Self { + last_message: std::sync::Mutex::new(HashMap::new()), + window_duration: std::time::Duration::from_secs(24 * 60 * 60), + } + } + + /// Create a tracker with a custom window duration (useful in tests). + #[cfg(test)] + fn with_duration(window_duration: std::time::Duration) -> Self { + Self { + last_message: std::sync::Mutex::new(HashMap::new()), + window_duration, + } + } + + /// Record that `phone` sent an inbound message right now. + pub fn record_message(&self, phone: &str) { + self.last_message + .lock() + .unwrap() + .insert(phone.to_string(), std::time::Instant::now()); + } + + /// Returns `true` when the last inbound message from `phone` arrived within + /// the 24-hour window, meaning free-form replies are still permitted. + pub fn is_within_window(&self, phone: &str) -> bool { + let map = self.last_message.lock().unwrap(); + match map.get(phone) { + Some(&instant) => instant.elapsed() < self.window_duration, + None => false, + } + } +} + // ── WhatsApp Transport ────────────────────────────────────────────────── /// Real WhatsApp Business API transport. /// /// Sends text messages via `POST {GRAPH_API_BASE}/{phone_number_id}/messages`. +/// Falls back to approved notification templates when the 24-hour window has +/// elapsed (Meta error 131047). pub struct WhatsAppTransport { phone_number_id: String, access_token: String, client: reqwest::Client, + /// Name of the approved Meta message template used for notifications + /// outside the 24-hour messaging window. + notification_template_name: String, /// Optional base URL override for tests. api_base: String, } impl WhatsAppTransport { - pub fn new(phone_number_id: String, access_token: String) -> Self { + pub fn new( + phone_number_id: String, + access_token: String, + notification_template_name: String, + ) -> Self { Self { phone_number_id, access_token, client: reqwest::Client::new(), + notification_template_name, api_base: GRAPH_API_BASE.to_string(), } } #[cfg(test)] - fn with_api_base(phone_number_id: String, access_token: String, api_base: String) -> Self { + fn with_api_base( + phone_number_id: String, + access_token: String, + api_base: String, + ) -> Self { Self { phone_number_id, access_token, client: reqwest::Client::new(), + notification_template_name: "pipeline_notification".to_string(), api_base, } } - /// Send a text message to a WhatsApp user via the Graph API. + /// Send a free-form text message to a WhatsApp user via the Graph API. + /// + /// Returns [`OUTSIDE_WINDOW_ERR`] if the API responds with error code + /// 131047 (messaging window expired). All other errors are returned as + /// descriptive strings. async fn send_text(&self, to: &str, body: &str) -> Result { - let url = format!( - "{}/{}/messages", - self.api_base, self.phone_number_id - ); + let url = format!("{}/{}/messages", self.api_base, self.phone_number_id); let payload = GraphSendMessage { messaging_product: "whatsapp", @@ -84,9 +170,19 @@ impl WhatsAppTransport { .unwrap_or_else(|_| "".to_string()); if !status.is_success() { - return Err(format!( - "WhatsApp API returned {status}: {resp_text}" - )); + // Check for 'outside messaging window' (code 131047). Return a + // distinct sentinel so callers can fall back to a template without + // crashing. + if let Ok(err_body) = serde_json::from_str::(&resp_text) + && err_body.error.as_ref().and_then(|e| e.code) == Some(ERROR_CODE_OUTSIDE_WINDOW) + { + slog!( + "[whatsapp] Outside 24-hour messaging window for {to}; \ + template required (error 131047)" + ); + return Err(OUTSIDE_WINDOW_ERR.to_string()); + } + return Err(format!("WhatsApp API returned {status}: {resp_text}")); } // Extract the message ID from the response. @@ -102,6 +198,116 @@ impl WhatsAppTransport { Ok(msg_id) } + + /// Send an approved template notification message. + /// + /// Used when the 24-hour window has expired and free-form text is not + /// permitted. The template must already be approved in the Meta Business + /// Manager under the name configured in `bot.toml` + /// (`whatsapp_notification_template`, default `pipeline_notification`). + /// + /// The template body is expected to accept two positional parameters: + /// `{{1}}` = story name, `{{2}}` = pipeline stage. + pub async fn send_template_notification( + &self, + to: &str, + story_name: &str, + stage: &str, + ) -> Result { + let url = format!("{}/{}/messages", self.api_base, self.phone_number_id); + + let payload = GraphTemplateMessage { + messaging_product: "whatsapp", + to, + r#type: "template", + template: GraphTemplate { + name: &self.notification_template_name, + language: GraphLanguage { code: "en_US" }, + components: vec![GraphTemplateComponent { + r#type: "body", + parameters: vec![ + GraphTemplateParameter { + r#type: "text", + text: story_name.to_string(), + }, + GraphTemplateParameter { + r#type: "text", + text: stage.to_string(), + }, + ], + }], + }, + }; + + let resp = self + .client + .post(&url) + .bearer_auth(&self.access_token) + .json(&payload) + .send() + .await + .map_err(|e| format!("WhatsApp template API request failed: {e}"))?; + + let status = resp.status(); + let resp_text = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + + if !status.is_success() { + return Err(format!( + "WhatsApp template API returned {status}: {resp_text}" + )); + } + + let parsed: GraphSendResponse = serde_json::from_str(&resp_text).map_err(|e| { + format!("Failed to parse WhatsApp template API response: {e} — body: {resp_text}") + })?; + + let msg_id = parsed + .messages + .first() + .map(|m| m.id.clone()) + .unwrap_or_default(); + + Ok(msg_id) + } + + /// Send a pipeline notification, respecting the 24-hour messaging window. + /// + /// - Within the window: sends a free-form text message. + /// - Outside the window (or if the API returns 131047): sends an approved + /// template message instead. + /// + /// This method never crashes on a messaging-window error — it always + /// attempts the template fallback and logs what happened. + pub async fn send_notification( + &self, + to: &str, + tracker: &MessagingWindowTracker, + story_name: &str, + stage: &str, + ) -> Result { + if tracker.is_within_window(to) { + let text = format!("Story '{story_name}' has moved to {stage}."); + match self.send_text(to, &text).await { + Ok(id) => return Ok(id), + Err(ref e) if e == OUTSIDE_WINDOW_ERR => { + // Window expired between our check and the API call — + // fall through to the template path. + slog!( + "[whatsapp] Window expired mid-flight for {to}; \ + falling back to template" + ); + } + Err(e) => return Err(e), + } + } + + // Outside window — use the approved template. + slog!("[whatsapp] Sending template notification to {to} (outside 24h window)"); + self.send_template_notification(to, story_name, stage).await + } } #[async_trait] @@ -113,7 +319,24 @@ impl ChatTransport for WhatsAppTransport { _html: &str, ) -> Result { slog!("[whatsapp] send_message to {recipient}: {plain:.80}"); - self.send_text(recipient, plain).await + match self.send_text(recipient, plain).await { + Ok(id) => Ok(id), + Err(ref e) if e == OUTSIDE_WINDOW_ERR => { + // Graceful degradation: log and surface a meaningful error + // rather than crashing. Callers sending command responses + // should normally be within the window; this handles the edge + // case where processing was delayed. + slog!( + "[whatsapp] Cannot send to {recipient}: outside 24h window \ + (message dropped)" + ); + Err(format!( + "Outside 24-hour messaging window for {recipient}; \ + send a message to the bot first to re-open the window" + )) + } + Err(e) => Err(e), + } } async fn edit_message( @@ -160,6 +383,54 @@ struct GraphMessageId { id: String, } +// ── Graph API error response types ───────────────────────────────────── + +#[derive(Deserialize)] +struct GraphApiErrorResponse { + error: Option, +} + +#[derive(Deserialize)] +struct GraphApiError { + code: Option, + #[allow(dead_code)] + message: Option, +} + +// ── Template message types ────────────────────────────────────────────── + +#[derive(Serialize)] +struct GraphTemplateMessage<'a> { + messaging_product: &'a str, + to: &'a str, + r#type: &'a str, + template: GraphTemplate<'a>, +} + +#[derive(Serialize)] +struct GraphTemplate<'a> { + name: &'a str, + language: GraphLanguage, + components: Vec, +} + +#[derive(Serialize)] +struct GraphLanguage { + code: &'static str, +} + +#[derive(Serialize)] +struct GraphTemplateComponent { + r#type: &'static str, + parameters: Vec, +} + +#[derive(Serialize)] +struct GraphTemplateParameter { + r#type: &'static str, + text: String, +} + // ── Webhook types (Meta → us) ─────────────────────────────────────────── /// Top-level webhook payload from Meta. @@ -355,6 +626,8 @@ pub struct WhatsAppWebhookContext { pub history: WhatsAppConversationHistory, /// Maximum number of conversation entries to keep per sender. pub history_size: usize, + /// Tracks the 24-hour messaging window per user phone number. + pub window_tracker: Arc, } /// GET /webhook/whatsapp — Meta verification handshake. @@ -439,6 +712,9 @@ async fn handle_incoming_message( ) { use crate::matrix::commands::{CommandDispatch, try_handle_command}; + // Record this inbound message to keep the 24-hour window open. + ctx.window_tracker.record_message(sender); + let dispatch = CommandDispatch { bot_name: &ctx.bot_name, bot_user_id: &ctx.bot_user_id, @@ -668,6 +944,174 @@ async fn handle_llm_message( mod tests { use super::*; + // ── MessagingWindowTracker ──────────────────────────────────────── + + #[test] + fn window_tracker_unknown_user_is_outside_window() { + let tracker = MessagingWindowTracker::new(); + assert!(!tracker.is_within_window("15551234567")); + } + + #[test] + fn window_tracker_records_within_window() { + let tracker = MessagingWindowTracker::new(); + tracker.record_message("15551234567"); + assert!(tracker.is_within_window("15551234567")); + } + + #[test] + fn window_tracker_expired_window_returns_false() { + // Use a 1-nanosecond window so it expires immediately. + let tracker = MessagingWindowTracker::with_duration(std::time::Duration::from_nanos(1)); + tracker.record_message("15551234567"); + // Sleep briefly to ensure the instant has elapsed. + std::thread::sleep(std::time::Duration::from_millis(1)); + assert!(!tracker.is_within_window("15551234567")); + } + + #[test] + fn window_tracker_tracks_users_independently() { + let tracker = MessagingWindowTracker::new(); + tracker.record_message("111"); + assert!(tracker.is_within_window("111")); + assert!(!tracker.is_within_window("222")); + } + + // ── send_text error handling ─────────────────────────────────────── + + #[tokio::test] + async fn send_text_handles_131047_outside_window_error() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/123456/messages") + .with_status(400) + .with_body( + r#"{"error":{"message":"More than 24 hours have passed","type":"OAuthException","code":131047}}"#, + ) + .create_async() + .await; + + let transport = WhatsAppTransport::with_api_base( + "123456".to_string(), + "test-token".to_string(), + server.url(), + ); + + let result = transport.send_text("15551234567", "hello").await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), OUTSIDE_WINDOW_ERR); + } + + #[tokio::test] + async fn send_message_handles_outside_window_gracefully() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/123456/messages") + .with_status(400) + .with_body( + r#"{"error":{"message":"More than 24 hours have passed","type":"OAuthException","code":131047}}"#, + ) + .create_async() + .await; + + let transport = WhatsAppTransport::with_api_base( + "123456".to_string(), + "test-token".to_string(), + server.url(), + ); + + // send_message must not panic — it returns Err with a human-readable message. + let result = transport.send_message("15551234567", "hello", "").await; + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!(msg.contains("24-hour messaging window"), "unexpected: {msg}"); + } + + // ── send_template_notification ──────────────────────────────────── + + #[tokio::test] + async fn send_template_notification_calls_graph_api() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/123456/messages") + .match_header("authorization", "Bearer test-token") + .match_body(mockito::Matcher::PartialJsonString( + r#"{"type":"template"}"#.to_string(), + )) + .with_body(r#"{"messages": [{"id": "wamid.tpl123"}]}"#) + .create_async() + .await; + + let transport = WhatsAppTransport::with_api_base( + "123456".to_string(), + "test-token".to_string(), + server.url(), + ); + + let result = transport + .send_template_notification("15551234567", "my-story", "done") + .await; + assert!(result.is_ok(), "unexpected err: {:?}", result.err()); + assert_eq!(result.unwrap(), "wamid.tpl123"); + mock.assert_async().await; + } + + #[tokio::test] + async fn send_notification_uses_text_within_window() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/123456/messages") + .match_body(mockito::Matcher::PartialJsonString( + r#"{"type":"text"}"#.to_string(), + )) + .with_body(r#"{"messages": [{"id": "wamid.txt1"}]}"#) + .create_async() + .await; + + let transport = WhatsAppTransport::with_api_base( + "123456".to_string(), + "test-token".to_string(), + server.url(), + ); + let tracker = MessagingWindowTracker::new(); + tracker.record_message("15551234567"); + + let result = transport + .send_notification("15551234567", &tracker, "my-story", "done") + .await; + assert!(result.is_ok()); + mock.assert_async().await; + } + + #[tokio::test] + async fn send_notification_uses_template_outside_window() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/123456/messages") + .match_body(mockito::Matcher::PartialJsonString( + r#"{"type":"template"}"#.to_string(), + )) + .with_body(r#"{"messages": [{"id": "wamid.tpl2"}]}"#) + .create_async() + .await; + + let transport = WhatsAppTransport::with_api_base( + "123456".to_string(), + "test-token".to_string(), + server.url(), + ); + // No record_message call — user is outside the window. + let tracker = MessagingWindowTracker::new(); + + let result = transport + .send_notification("15551234567", &tracker, "my-story", "done") + .await; + assert!(result.is_ok()); + mock.assert_async().await; + } + + // ── Existing webhook / transport tests ──────────────────────────── + #[test] fn extract_text_messages_parses_valid_payload() { let json = r#"{ @@ -790,7 +1234,8 @@ mod tests { #[tokio::test] async fn transport_send_typing_succeeds() { - let transport = WhatsAppTransport::new("123".to_string(), "tok".to_string()); + let transport = + WhatsAppTransport::new("123".to_string(), "tok".to_string(), "tpl".to_string()); assert!(transport.send_typing("room1", true).await.is_ok()); assert!(transport.send_typing("room1", false).await.is_ok()); } @@ -811,9 +1256,7 @@ mod tests { server.url(), ); - let result = transport - .send_message("15551234567", "hello", "") - .await; + let result = transport.send_message("15551234567", "hello", "").await; assert!(result.is_err()); assert!(result.unwrap_err().contains("401")); }