Files
huskies/server/src/chat/transport/whatsapp/meta.rs
T

585 lines
20 KiB
Rust

//! WhatsApp Meta (Cloud API) transport — sends and receives messages via the Meta Graph API.
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::chat::{ChatTransport, MessageId};
use crate::slog;
use super::history::MessagingWindowTracker;
// ── API base URLs (overridable for tests) ────────────────────────────────
pub(super) 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.
pub(super) 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.
pub(super) const OUTSIDE_WINDOW_ERR: &str = "OUTSIDE_MESSAGING_WINDOW";
// ── 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.
#[allow(dead_code)] // Used by Meta provider path (send_template_notification)
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,
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)]
pub(crate) 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 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<String, String> {
let url = format!("{}/{}/messages", self.api_base, self.phone_number_id);
let payload = GraphSendMessage {
messaging_product: "whatsapp",
to,
r#type: "text",
text: GraphTextBody { body },
};
let resp = self
.client
.post(&url)
.bearer_auth(&self.access_token)
.json(&payload)
.send()
.await
.map_err(|e| format!("WhatsApp API request failed: {e}"))?;
let status = resp.status();
let resp_text = resp
.text()
.await
.unwrap_or_else(|_| "<no body>".to_string());
if !status.is_success() {
// 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::<GraphApiErrorResponse>(&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.
let parsed: GraphSendResponse = serde_json::from_str(&resp_text).map_err(|e| {
format!("Failed to parse WhatsApp API response: {e} — body: {resp_text}")
})?;
let msg_id = parsed
.messages
.first()
.map(|m| m.id.clone())
.unwrap_or_default();
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.
#[allow(dead_code)] // Meta provider path — template fallback for expired 24h window
pub async fn send_template_notification(
&self,
to: &str,
story_name: &str,
stage: &str,
) -> Result<String, String> {
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(|_| "<no body>".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.
#[allow(dead_code)] // Meta provider path — window-aware notification dispatch
pub async fn send_notification(
&self,
to: &str,
tracker: &MessagingWindowTracker,
story_name: &str,
stage: &str,
) -> Result<String, String> {
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]
impl ChatTransport for WhatsAppTransport {
async fn send_message(
&self,
recipient: &str,
plain: &str,
_html: &str,
) -> Result<MessageId, String> {
slog!("[whatsapp] send_message to {recipient}: {plain:.80}");
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(
&self,
recipient: &str,
_original_message_id: &str,
plain: &str,
html: &str,
) -> Result<(), String> {
// WhatsApp does not support message editing — send a new message.
slog!("[whatsapp] edit_message — WhatsApp does not support edits, sending new message");
self.send_message(recipient, plain, html).await.map(|_| ())
}
async fn send_typing(&self, _recipient: &str, _typing: bool) -> Result<(), String> {
// WhatsApp Business API does not expose typing indicators.
Ok(())
}
}
// ── Graph API request/response types ────────────────────────────────────
#[derive(Serialize)]
struct GraphSendMessage<'a> {
messaging_product: &'a str,
to: &'a str,
r#type: &'a str,
text: GraphTextBody<'a>,
}
#[derive(Serialize)]
struct GraphTextBody<'a> {
body: &'a str,
}
#[derive(Deserialize)]
struct GraphSendResponse {
#[serde(default)]
messages: Vec<GraphMessageId>,
}
#[derive(Deserialize)]
struct GraphMessageId {
id: String,
}
// ── Graph API error response types ─────────────────────────────────────
#[derive(Deserialize)]
struct GraphApiErrorResponse {
error: Option<GraphApiError>,
}
#[derive(Deserialize)]
struct GraphApiError {
code: Option<i64>,
#[allow(dead_code)]
message: Option<String>,
}
// ── Template message types ──────────────────────────────────────────────
#[allow(dead_code)] // Meta provider path — template message types
#[derive(Serialize)]
struct GraphTemplateMessage<'a> {
messaging_product: &'a str,
to: &'a str,
r#type: &'a str,
template: GraphTemplate<'a>,
}
#[allow(dead_code)]
#[derive(Serialize)]
struct GraphTemplate<'a> {
name: &'a str,
language: GraphLanguage,
components: Vec<GraphTemplateComponent>,
}
#[allow(dead_code)]
#[derive(Serialize)]
struct GraphLanguage {
code: &'static str,
}
#[allow(dead_code)]
#[derive(Serialize)]
struct GraphTemplateComponent {
r#type: &'static str,
parameters: Vec<GraphTemplateParameter>,
}
#[allow(dead_code)]
#[derive(Serialize)]
struct GraphTemplateParameter {
r#type: &'static str,
text: String,
}
// ── Tests ───────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::transport::whatsapp::history::MessagingWindowTracker;
// ── 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;
}
#[tokio::test]
async fn transport_send_message_calls_graph_api() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/123456/messages")
.match_header("authorization", "Bearer test-token")
.with_body(r#"{"messages": [{"id": "wamid.abc123"}]}"#)
.create_async()
.await;
let transport = WhatsAppTransport::with_api_base(
"123456".to_string(),
"test-token".to_string(),
server.url(),
);
let result = transport
.send_message("15551234567", "hello", "<p>hello</p>")
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "wamid.abc123");
mock.assert_async().await;
}
#[tokio::test]
async fn transport_edit_sends_new_message() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/123456/messages")
.with_body(r#"{"messages": [{"id": "wamid.xyz"}]}"#)
.create_async()
.await;
let transport = WhatsAppTransport::with_api_base(
"123456".to_string(),
"test-token".to_string(),
server.url(),
);
let result = transport
.edit_message("15551234567", "old-msg-id", "updated", "<p>updated</p>")
.await;
assert!(result.is_ok());
mock.assert_async().await;
}
#[tokio::test]
async fn transport_send_typing_succeeds() {
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());
}
#[tokio::test]
async fn transport_handles_api_error() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/123456/messages")
.with_status(401)
.with_body(r#"{"error": {"message": "Invalid token"}}"#)
.create_async()
.await;
let transport = WhatsAppTransport::with_api_base(
"123456".to_string(),
"bad-token".to_string(),
server.url(),
);
let result = transport.send_message("15551234567", "hello", "").await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("401"));
}
}