story-kit: merge 322_story_whatsapp_24_hour_messaging_window_and_template_support
This commit is contained in:
@@ -27,3 +27,22 @@ enabled = false
|
|||||||
# whatsapp_phone_number_id = "123456789012345"
|
# whatsapp_phone_number_id = "123456789012345"
|
||||||
# whatsapp_access_token = "EAAx..."
|
# whatsapp_access_token = "EAAx..."
|
||||||
# whatsapp_verify_token = "my-secret-verify-token"
|
# 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"
|
||||||
|
|||||||
@@ -199,9 +199,14 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
.and_then(|root| matrix::BotConfig::load(root))
|
.and_then(|root| matrix::BotConfig::load(root))
|
||||||
.filter(|cfg| cfg.transport == "whatsapp")
|
.filter(|cfg| cfg.transport == "whatsapp")
|
||||||
.map(|cfg| {
|
.map(|cfg| {
|
||||||
|
let template_name = cfg
|
||||||
|
.whatsapp_notification_template
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "pipeline_notification".to_string());
|
||||||
let transport = Arc::new(whatsapp::WhatsAppTransport::new(
|
let transport = Arc::new(whatsapp::WhatsAppTransport::new(
|
||||||
cfg.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
cfg.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
||||||
cfg.whatsapp_access_token.clone().unwrap_or_default(),
|
cfg.whatsapp_access_token.clone().unwrap_or_default(),
|
||||||
|
template_name,
|
||||||
));
|
));
|
||||||
let bot_name = cfg
|
let bot_name = cfg
|
||||||
.display_name
|
.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())),
|
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||||
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
|
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
|
||||||
history_size: cfg.history_size,
|
history_size: cfg.history_size,
|
||||||
|
window_tracker: Arc::new(whatsapp::MessagingWindowTracker::new()),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -372,6 +372,10 @@ pub async fn run_bot(
|
|||||||
Arc::new(crate::whatsapp::WhatsAppTransport::new(
|
Arc::new(crate::whatsapp::WhatsAppTransport::new(
|
||||||
config.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
config.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
||||||
config.whatsapp_access_token.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())),
|
ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())),
|
||||||
agents: Arc::new(AgentPool::new_test(3000)),
|
agents: Arc::new(AgentPool::new_test(3000)),
|
||||||
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
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).
|
// Clone must work (required by Matrix SDK event handler injection).
|
||||||
let _cloned = ctx.clone();
|
let _cloned = ctx.clone();
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ pub struct BotConfig {
|
|||||||
/// and configure it in the Meta webhook settings).
|
/// and configure it in the Meta webhook settings).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub whatsapp_verify_token: Option<String>,
|
pub whatsapp_verify_token: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_transport() -> String {
|
fn default_transport() -> String {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ mod tests {
|
|||||||
Arc::new(crate::whatsapp::WhatsAppTransport::new(
|
Arc::new(crate::whatsapp::WhatsAppTransport::new(
|
||||||
"test-phone".to_string(),
|
"test-phone".to_string(),
|
||||||
"test-token".to_string(),
|
"test-token".to_string(),
|
||||||
|
"pipeline_notification".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! Provides:
|
//! Provides:
|
||||||
//! - [`WhatsAppTransport`] — a [`ChatTransport`] that sends messages via the
|
//! - [`WhatsAppTransport`] — a [`ChatTransport`] that sends messages via the
|
||||||
//! Meta Graph API (`graph.facebook.com/v21.0/{phone_number_id}/messages`).
|
//! 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_verify`] / [`webhook_receive`] — Poem handlers for the WhatsApp
|
||||||
//! webhook (GET verification handshake + POST incoming messages).
|
//! 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";
|
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<HashMap<String, std::time::Instant>>,
|
||||||
|
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 ──────────────────────────────────────────────────
|
// ── WhatsApp Transport ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Real WhatsApp Business API transport.
|
/// Real WhatsApp Business API transport.
|
||||||
///
|
///
|
||||||
/// Sends text messages via `POST {GRAPH_API_BASE}/{phone_number_id}/messages`.
|
/// 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 {
|
pub struct WhatsAppTransport {
|
||||||
phone_number_id: String,
|
phone_number_id: String,
|
||||||
access_token: String,
|
access_token: String,
|
||||||
client: reqwest::Client,
|
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.
|
/// Optional base URL override for tests.
|
||||||
api_base: String,
|
api_base: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WhatsAppTransport {
|
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 {
|
Self {
|
||||||
phone_number_id,
|
phone_number_id,
|
||||||
access_token,
|
access_token,
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
|
notification_template_name,
|
||||||
api_base: GRAPH_API_BASE.to_string(),
|
api_base: GRAPH_API_BASE.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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 {
|
Self {
|
||||||
phone_number_id,
|
phone_number_id,
|
||||||
access_token,
|
access_token,
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
|
notification_template_name: "pipeline_notification".to_string(),
|
||||||
api_base,
|
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<String, String> {
|
async fn send_text(&self, to: &str, body: &str) -> Result<String, String> {
|
||||||
let url = format!(
|
let url = format!("{}/{}/messages", self.api_base, self.phone_number_id);
|
||||||
"{}/{}/messages",
|
|
||||||
self.api_base, self.phone_number_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let payload = GraphSendMessage {
|
let payload = GraphSendMessage {
|
||||||
messaging_product: "whatsapp",
|
messaging_product: "whatsapp",
|
||||||
@@ -84,9 +170,19 @@ impl WhatsAppTransport {
|
|||||||
.unwrap_or_else(|_| "<no body>".to_string());
|
.unwrap_or_else(|_| "<no body>".to_string());
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(format!(
|
// Check for 'outside messaging window' (code 131047). Return a
|
||||||
"WhatsApp API returned {status}: {resp_text}"
|
// 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.
|
// Extract the message ID from the response.
|
||||||
@@ -102,6 +198,116 @@ impl WhatsAppTransport {
|
|||||||
|
|
||||||
Ok(msg_id)
|
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<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.
|
||||||
|
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]
|
#[async_trait]
|
||||||
@@ -113,7 +319,24 @@ impl ChatTransport for WhatsAppTransport {
|
|||||||
_html: &str,
|
_html: &str,
|
||||||
) -> Result<MessageId, String> {
|
) -> Result<MessageId, String> {
|
||||||
slog!("[whatsapp] send_message to {recipient}: {plain:.80}");
|
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(
|
async fn edit_message(
|
||||||
@@ -160,6 +383,54 @@ struct GraphMessageId {
|
|||||||
id: String,
|
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 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[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<GraphTemplateComponent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GraphLanguage {
|
||||||
|
code: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GraphTemplateComponent {
|
||||||
|
r#type: &'static str,
|
||||||
|
parameters: Vec<GraphTemplateParameter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GraphTemplateParameter {
|
||||||
|
r#type: &'static str,
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Webhook types (Meta → us) ───────────────────────────────────────────
|
// ── Webhook types (Meta → us) ───────────────────────────────────────────
|
||||||
|
|
||||||
/// Top-level webhook payload from Meta.
|
/// Top-level webhook payload from Meta.
|
||||||
@@ -355,6 +626,8 @@ pub struct WhatsAppWebhookContext {
|
|||||||
pub history: WhatsAppConversationHistory,
|
pub history: WhatsAppConversationHistory,
|
||||||
/// Maximum number of conversation entries to keep per sender.
|
/// Maximum number of conversation entries to keep per sender.
|
||||||
pub history_size: usize,
|
pub history_size: usize,
|
||||||
|
/// Tracks the 24-hour messaging window per user phone number.
|
||||||
|
pub window_tracker: Arc<MessagingWindowTracker>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /webhook/whatsapp — Meta verification handshake.
|
/// GET /webhook/whatsapp — Meta verification handshake.
|
||||||
@@ -439,6 +712,9 @@ async fn handle_incoming_message(
|
|||||||
) {
|
) {
|
||||||
use crate::matrix::commands::{CommandDispatch, try_handle_command};
|
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 {
|
let dispatch = CommandDispatch {
|
||||||
bot_name: &ctx.bot_name,
|
bot_name: &ctx.bot_name,
|
||||||
bot_user_id: &ctx.bot_user_id,
|
bot_user_id: &ctx.bot_user_id,
|
||||||
@@ -668,6 +944,174 @@ async fn handle_llm_message(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn extract_text_messages_parses_valid_payload() {
|
fn extract_text_messages_parses_valid_payload() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
@@ -790,7 +1234,8 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn transport_send_typing_succeeds() {
|
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", true).await.is_ok());
|
||||||
assert!(transport.send_typing("room1", false).await.is_ok());
|
assert!(transport.send_typing("room1", false).await.is_ok());
|
||||||
}
|
}
|
||||||
@@ -811,9 +1256,7 @@ mod tests {
|
|||||||
server.url(),
|
server.url(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = transport
|
let result = transport.send_message("15551234567", "hello", "").await;
|
||||||
.send_message("15551234567", "hello", "")
|
|
||||||
.await;
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().contains("401"));
|
assert!(result.unwrap_err().contains("401"));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user