storkit: merge 382_story_whatsapp_transport_supports_twilio_api_as_alternative_to_meta_cloud_api
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3274,6 +3274,7 @@ dependencies = [
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
|
||||
@@ -18,7 +18,7 @@ notify = { workspace = true }
|
||||
poem = { workspace = true, features = ["websocket"] }
|
||||
poem-openapi = { workspace = true, features = ["swagger-ui"] }
|
||||
portable-pty = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
reqwest = { workspace = true, features = ["json", "stream", "form"] }
|
||||
rust-embed = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -272,15 +272,25 @@ 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 provider = cfg.whatsapp_provider.clone();
|
||||
let transport: Arc<dyn crate::transport::ChatTransport> =
|
||||
if provider == "twilio" {
|
||||
Arc::new(whatsapp::TwilioWhatsAppTransport::new(
|
||||
cfg.twilio_account_sid.clone().unwrap_or_default(),
|
||||
cfg.twilio_auth_token.clone().unwrap_or_default(),
|
||||
cfg.twilio_whatsapp_number.clone().unwrap_or_default(),
|
||||
))
|
||||
} else {
|
||||
let template_name = cfg
|
||||
.whatsapp_notification_template
|
||||
.clone()
|
||||
.unwrap_or_else(|| "pipeline_notification".to_string());
|
||||
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
|
||||
.clone()
|
||||
@@ -289,6 +299,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
let history = whatsapp::load_whatsapp_history(&root);
|
||||
Arc::new(whatsapp::WhatsAppWebhookContext {
|
||||
verify_token: cfg.whatsapp_verify_token.clone().unwrap_or_default(),
|
||||
provider,
|
||||
transport,
|
||||
project_root: root,
|
||||
agents: Arc::clone(&startup_agents),
|
||||
|
||||
@@ -369,15 +369,24 @@ pub async fn run_bot(
|
||||
// Create the transport abstraction based on the configured transport type.
|
||||
let transport: Arc<dyn ChatTransport> = match config.transport.as_str() {
|
||||
"whatsapp" => {
|
||||
slog!("[matrix-bot] Using WhatsApp transport");
|
||||
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()),
|
||||
))
|
||||
if config.whatsapp_provider == "twilio" {
|
||||
slog!("[matrix-bot] Using WhatsApp/Twilio transport");
|
||||
Arc::new(crate::whatsapp::TwilioWhatsAppTransport::new(
|
||||
config.twilio_account_sid.clone().unwrap_or_default(),
|
||||
config.twilio_auth_token.clone().unwrap_or_default(),
|
||||
config.twilio_whatsapp_number.clone().unwrap_or_default(),
|
||||
))
|
||||
} else {
|
||||
slog!("[matrix-bot] Using WhatsApp/Meta transport");
|
||||
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()),
|
||||
))
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
slog!("[matrix-bot] Using Matrix transport");
|
||||
|
||||
@@ -87,6 +87,26 @@ pub struct BotConfig {
|
||||
/// use. Defaults to `"pipeline_notification"`.
|
||||
#[serde(default)]
|
||||
pub whatsapp_notification_template: Option<String>,
|
||||
/// Which WhatsApp provider to use: `"meta"` (default, direct Graph API)
|
||||
/// or `"twilio"` (Twilio REST API as alternative to Meta).
|
||||
///
|
||||
/// When `"twilio"`, the Twilio-specific fields below are required instead
|
||||
/// of the Meta `whatsapp_phone_number_id` / `whatsapp_access_token` pair.
|
||||
#[serde(default = "default_whatsapp_provider")]
|
||||
pub whatsapp_provider: String,
|
||||
|
||||
// ── Twilio WhatsApp fields ─────────────────────────────────────────
|
||||
// Only required when `transport = "whatsapp"` and `whatsapp_provider = "twilio"`.
|
||||
|
||||
/// Twilio Account SID (starts with `AC`).
|
||||
#[serde(default)]
|
||||
pub twilio_account_sid: Option<String>,
|
||||
/// Twilio Auth Token.
|
||||
#[serde(default)]
|
||||
pub twilio_auth_token: Option<String>,
|
||||
/// Twilio WhatsApp sender number in E.164 format, e.g. `+14155551234`.
|
||||
#[serde(default)]
|
||||
pub twilio_whatsapp_number: Option<String>,
|
||||
|
||||
// ── Slack Bot API fields ─────────────────────────────────────────
|
||||
// These are only required when `transport = "slack"`.
|
||||
@@ -106,6 +126,10 @@ fn default_transport() -> String {
|
||||
"matrix".to_string()
|
||||
}
|
||||
|
||||
fn default_whatsapp_provider() -> String {
|
||||
"meta".to_string()
|
||||
}
|
||||
|
||||
impl BotConfig {
|
||||
/// Load bot configuration from `.storkit/bot.toml`.
|
||||
///
|
||||
@@ -133,27 +157,52 @@ impl BotConfig {
|
||||
}
|
||||
|
||||
if config.transport == "whatsapp" {
|
||||
// Validate WhatsApp-specific fields.
|
||||
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_phone_number_id"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.whatsapp_access_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_access_token"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.whatsapp_verify_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_verify_token"
|
||||
);
|
||||
return None;
|
||||
if config.whatsapp_provider == "twilio" {
|
||||
// Validate Twilio-specific fields.
|
||||
if config.twilio_account_sid.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||
twilio_account_sid"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.twilio_auth_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||
twilio_auth_token"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.twilio_whatsapp_number.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||
twilio_whatsapp_number"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
// Validate Meta (default) WhatsApp fields.
|
||||
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_phone_number_id"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.whatsapp_access_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_access_token"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.whatsapp_verify_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_verify_token"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else if config.transport == "slack" {
|
||||
// Validate Slack-specific fields.
|
||||
@@ -722,6 +771,128 @@ whatsapp_access_token = "EAAtoken"
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
// ── Twilio config tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn load_twilio_whatsapp_reads_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "whatsapp"
|
||||
whatsapp_provider = "twilio"
|
||||
twilio_account_sid = "ACtest"
|
||||
twilio_auth_token = "authtest"
|
||||
twilio_whatsapp_number = "+14155551234"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.transport, "whatsapp");
|
||||
assert_eq!(config.whatsapp_provider, "twilio");
|
||||
assert_eq!(config.twilio_account_sid.as_deref(), Some("ACtest"));
|
||||
assert_eq!(config.twilio_auth_token.as_deref(), Some("authtest"));
|
||||
assert_eq!(
|
||||
config.twilio_whatsapp_number.as_deref(),
|
||||
Some("+14155551234")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_whatsapp_provider_defaults_to_meta() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "whatsapp"
|
||||
whatsapp_phone_number_id = "123456"
|
||||
whatsapp_access_token = "EAAtoken"
|
||||
whatsapp_verify_token = "my-verify"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.whatsapp_provider, "meta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_twilio_returns_none_when_missing_account_sid() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "whatsapp"
|
||||
whatsapp_provider = "twilio"
|
||||
twilio_auth_token = "authtest"
|
||||
twilio_whatsapp_number = "+14155551234"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_twilio_returns_none_when_missing_auth_token() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "whatsapp"
|
||||
whatsapp_provider = "twilio"
|
||||
twilio_account_sid = "ACtest"
|
||||
twilio_whatsapp_number = "+14155551234"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_twilio_returns_none_when_missing_whatsapp_number() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "whatsapp"
|
||||
whatsapp_provider = "twilio"
|
||||
twilio_account_sid = "ACtest"
|
||||
twilio_auth_token = "authtest"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
// ── Slack config tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -94,4 +94,19 @@ mod tests {
|
||||
let _: Arc<dyn ChatTransport> =
|
||||
Arc::new(crate::slack::SlackTransport::new("xoxb-test".to_string()));
|
||||
}
|
||||
|
||||
/// Verify that TwilioWhatsAppTransport satisfies the ChatTransport trait
|
||||
/// and can be used as `Arc<dyn ChatTransport>` (compile-time check).
|
||||
#[test]
|
||||
fn twilio_transport_satisfies_trait() {
|
||||
fn assert_transport<T: ChatTransport>() {}
|
||||
assert_transport::<crate::whatsapp::TwilioWhatsAppTransport>();
|
||||
|
||||
let _: Arc<dyn ChatTransport> =
|
||||
Arc::new(crate::whatsapp::TwilioWhatsAppTransport::new(
|
||||
"ACtest".to_string(),
|
||||
"authtoken".to_string(),
|
||||
"+14155551234".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ use crate::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||
use crate::slog;
|
||||
use crate::transport::{ChatTransport, MessageId};
|
||||
|
||||
// ── Graph API base URL (overridable for tests) ──────────────────────────
|
||||
// ── API base URLs (overridable for tests) ────────────────────────────────
|
||||
|
||||
const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0";
|
||||
const TWILIO_API_BASE: &str = "https://api.twilio.com";
|
||||
|
||||
/// Graph API error code indicating the 24-hour messaging window has elapsed.
|
||||
///
|
||||
@@ -357,6 +358,181 @@ impl ChatTransport for WhatsAppTransport {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Twilio Transport ────────────────────────────────────────────────────
|
||||
|
||||
/// WhatsApp transport that routes through Twilio's REST API.
|
||||
///
|
||||
/// Sends messages via `POST {TWILIO_API_BASE}/2010-04-01/Accounts/{account_sid}/Messages.json`
|
||||
/// using HTTP Basic Auth (Account SID as username, Auth Token as password).
|
||||
///
|
||||
/// Inbound messages from Twilio arrive as `application/x-www-form-urlencoded`
|
||||
/// POST bodies; use [`extract_twilio_text_messages`] to parse them.
|
||||
pub struct TwilioWhatsAppTransport {
|
||||
account_sid: String,
|
||||
auth_token: String,
|
||||
/// Sender number in E.164 format, e.g. `+14155551234`.
|
||||
from_number: String,
|
||||
client: reqwest::Client,
|
||||
/// Optional base URL override for tests.
|
||||
api_base: String,
|
||||
}
|
||||
|
||||
impl TwilioWhatsAppTransport {
|
||||
pub fn new(account_sid: String, auth_token: String, from_number: String) -> Self {
|
||||
Self {
|
||||
account_sid,
|
||||
auth_token,
|
||||
from_number,
|
||||
client: reqwest::Client::new(),
|
||||
api_base: TWILIO_API_BASE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_api_base(
|
||||
account_sid: String,
|
||||
auth_token: String,
|
||||
from_number: String,
|
||||
api_base: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
account_sid,
|
||||
auth_token,
|
||||
from_number,
|
||||
client: reqwest::Client::new(),
|
||||
api_base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a WhatsApp message via Twilio's Messaging REST API.
|
||||
async fn send_text(&self, to: &str, body: &str) -> Result<String, String> {
|
||||
let url = format!(
|
||||
"{}/2010-04-01/Accounts/{}/Messages.json",
|
||||
self.api_base, self.account_sid
|
||||
);
|
||||
|
||||
// Twilio expects the WhatsApp number with a "whatsapp:" prefix.
|
||||
let from = if self.from_number.starts_with("whatsapp:") {
|
||||
self.from_number.clone()
|
||||
} else {
|
||||
format!("whatsapp:{}", self.from_number)
|
||||
};
|
||||
let to_wa = if to.starts_with("whatsapp:") {
|
||||
to.to_string()
|
||||
} else {
|
||||
format!("whatsapp:{}", to)
|
||||
};
|
||||
|
||||
let params = [("From", from.as_str()), ("To", to_wa.as_str()), ("Body", body)];
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.basic_auth(&self.account_sid, Some(&self.auth_token))
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Twilio 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!("Twilio API returned {status}: {resp_text}"));
|
||||
}
|
||||
|
||||
let parsed: TwilioSendResponse = serde_json::from_str(&resp_text).map_err(|e| {
|
||||
format!("Failed to parse Twilio API response: {e} — body: {resp_text}")
|
||||
})?;
|
||||
|
||||
Ok(parsed.sid.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatTransport for TwilioWhatsAppTransport {
|
||||
async fn send_message(
|
||||
&self,
|
||||
recipient: &str,
|
||||
plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
slog!("[whatsapp/twilio] send_message to {recipient}: {plain:.80}");
|
||||
self.send_text(recipient, plain).await
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
recipient: &str,
|
||||
_original_message_id: &str,
|
||||
plain: &str,
|
||||
html: &str,
|
||||
) -> Result<(), String> {
|
||||
// Twilio does not support message editing — send a new message.
|
||||
slog!("[whatsapp/twilio] edit_message — Twilio 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> {
|
||||
// Twilio WhatsApp API does not expose typing indicators.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Twilio API request/response types ──────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TwilioSendResponse {
|
||||
sid: Option<String>,
|
||||
}
|
||||
|
||||
// ── Twilio webhook types (Twilio → us) ─────────────────────────────────
|
||||
|
||||
/// Form-encoded fields from a Twilio WhatsApp inbound webhook POST.
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct TwilioWebhookForm {
|
||||
/// Sender number with `whatsapp:` prefix, e.g. `whatsapp:+15551234567`.
|
||||
#[serde(rename = "From")]
|
||||
pub from: Option<String>,
|
||||
/// Message body text.
|
||||
#[serde(rename = "Body")]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract text messages from a Twilio form-encoded webhook body.
|
||||
///
|
||||
/// Returns `(sender_phone, message_body)` pairs, with the `whatsapp:` prefix
|
||||
/// stripped from the sender number.
|
||||
pub fn extract_twilio_text_messages(bytes: &[u8]) -> Vec<(String, String)> {
|
||||
let form: TwilioWebhookForm = match serde_urlencoded::from_bytes(bytes) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
slog!("[whatsapp/twilio] Failed to parse webhook form body: {e}");
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let from = match form.from {
|
||||
Some(f) => f,
|
||||
None => return vec![],
|
||||
};
|
||||
let body = match form.body {
|
||||
Some(b) if !b.is_empty() => b,
|
||||
_ => return vec![],
|
||||
};
|
||||
|
||||
// Strip the "whatsapp:" prefix so the sender is stored as a plain phone number.
|
||||
let sender = from
|
||||
.strip_prefix("whatsapp:")
|
||||
.unwrap_or(&from)
|
||||
.to_string();
|
||||
|
||||
vec![(sender, body)]
|
||||
}
|
||||
|
||||
// ── Graph API request/response types ────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -615,7 +791,9 @@ pub struct VerifyQuery {
|
||||
/// Shared context for webhook handlers, injected via Poem's `Data` extractor.
|
||||
pub struct WhatsAppWebhookContext {
|
||||
pub verify_token: String,
|
||||
pub transport: Arc<WhatsAppTransport>,
|
||||
/// Active provider: `"meta"` (Meta Graph API) or `"twilio"` (Twilio REST API).
|
||||
pub provider: String,
|
||||
pub transport: Arc<dyn ChatTransport>,
|
||||
pub project_root: PathBuf,
|
||||
pub agents: Arc<AgentPool>,
|
||||
pub bot_name: String,
|
||||
@@ -630,15 +808,21 @@ pub struct WhatsAppWebhookContext {
|
||||
pub window_tracker: Arc<MessagingWindowTracker>,
|
||||
}
|
||||
|
||||
/// GET /webhook/whatsapp — Meta verification handshake.
|
||||
/// GET /webhook/whatsapp — webhook verification.
|
||||
///
|
||||
/// Meta sends `hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<challenge>`.
|
||||
/// We return the challenge if the token matches.
|
||||
/// For Meta: responds to the `hub.mode=subscribe` challenge handshake.
|
||||
/// For Twilio: Twilio does not send GET verification; always returns 200 OK.
|
||||
#[handler]
|
||||
pub async fn webhook_verify(
|
||||
Query(q): Query<VerifyQuery>,
|
||||
ctx: poem::web::Data<&Arc<WhatsAppWebhookContext>>,
|
||||
) -> Response {
|
||||
// Twilio does not use a GET challenge; just acknowledge.
|
||||
if ctx.provider == "twilio" {
|
||||
return Response::builder().status(StatusCode::OK).body("ok");
|
||||
}
|
||||
|
||||
// Meta verification handshake.
|
||||
if q.hub_mode.as_deref() == Some("subscribe")
|
||||
&& q.hub_verify_token.as_deref() == Some(&ctx.verify_token)
|
||||
&& let Some(challenge) = q.hub_challenge
|
||||
@@ -654,7 +838,13 @@ pub async fn webhook_verify(
|
||||
.body("Verification failed")
|
||||
}
|
||||
|
||||
/// POST /webhook/whatsapp — receive incoming messages from Meta.
|
||||
/// POST /webhook/whatsapp — receive incoming messages.
|
||||
///
|
||||
/// Dispatches to the appropriate parser based on the configured provider:
|
||||
/// - `"meta"`: parses Meta's JSON `WebhookPayload`.
|
||||
/// - `"twilio"`: parses Twilio's `application/x-www-form-urlencoded` body.
|
||||
///
|
||||
/// Both providers expect a `200 OK` response, even on parse errors.
|
||||
#[handler]
|
||||
pub async fn webhook_receive(
|
||||
req: &Request,
|
||||
@@ -672,23 +862,31 @@ pub async fn webhook_receive(
|
||||
}
|
||||
};
|
||||
|
||||
let payload: WebhookPayload = match serde_json::from_slice(&bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
slog!("[whatsapp] Failed to parse webhook payload: {e}");
|
||||
// Meta expects 200 even on parse errors to avoid retries.
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body("ok");
|
||||
let messages = if ctx.provider == "twilio" {
|
||||
let msgs = extract_twilio_text_messages(&bytes);
|
||||
if msgs.is_empty() {
|
||||
slog!("[whatsapp/twilio] No text messages in webhook body; ignoring");
|
||||
}
|
||||
msgs
|
||||
} else {
|
||||
let payload: WebhookPayload = match serde_json::from_slice(&bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
slog!("[whatsapp] Failed to parse webhook payload: {e}");
|
||||
// Meta expects 200 even on parse errors to avoid retries.
|
||||
return Response::builder().status(StatusCode::OK).body("ok");
|
||||
}
|
||||
};
|
||||
let msgs = extract_text_messages(&payload);
|
||||
if msgs.is_empty() {
|
||||
// Status updates, read receipts, etc. — acknowledge silently.
|
||||
return Response::builder().status(StatusCode::OK).body("ok");
|
||||
}
|
||||
msgs
|
||||
};
|
||||
|
||||
let messages = extract_text_messages(&payload);
|
||||
if messages.is_empty() {
|
||||
// Status updates, read receipts, etc. — acknowledge silently.
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body("ok");
|
||||
return Response::builder().status(StatusCode::OK).body("ok");
|
||||
}
|
||||
|
||||
let ctx = Arc::clone(*ctx);
|
||||
@@ -1356,6 +1554,133 @@ mod tests {
|
||||
assert_eq!(conv.entries[1].content, "hi there!");
|
||||
}
|
||||
|
||||
// ── TwilioWhatsAppTransport tests ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn twilio_send_message_calls_twilio_api() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json")
|
||||
.with_body(r#"{"sid": "SMtest123"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport = TwilioWhatsAppTransport::with_api_base(
|
||||
"ACtest".to_string(),
|
||||
"authtoken".to_string(),
|
||||
"+14155551234".to_string(),
|
||||
server.url(),
|
||||
);
|
||||
|
||||
let result = transport.send_message("+15551234567", "hello", "").await;
|
||||
assert!(result.is_ok(), "unexpected err: {:?}", result.err());
|
||||
assert_eq!(result.unwrap(), "SMtest123");
|
||||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn twilio_send_message_returns_err_on_api_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
server
|
||||
.mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json")
|
||||
.with_status(401)
|
||||
.with_body(r#"{"message": "Unauthorized"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport = TwilioWhatsAppTransport::with_api_base(
|
||||
"ACtest".to_string(),
|
||||
"badtoken".to_string(),
|
||||
"+14155551234".to_string(),
|
||||
server.url(),
|
||||
);
|
||||
|
||||
let result = transport.send_message("+15551234567", "hello", "").await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("401"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn twilio_edit_message_sends_new_message() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json")
|
||||
.with_body(r#"{"sid": "SMedit456"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport = TwilioWhatsAppTransport::with_api_base(
|
||||
"ACtest".to_string(),
|
||||
"authtoken".to_string(),
|
||||
"+14155551234".to_string(),
|
||||
server.url(),
|
||||
);
|
||||
|
||||
let result = transport
|
||||
.edit_message("+15551234567", "old-sid", "updated text", "")
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn twilio_send_typing_is_noop() {
|
||||
let transport = TwilioWhatsAppTransport::new(
|
||||
"ACtest".to_string(),
|
||||
"authtoken".to_string(),
|
||||
"+14155551234".to_string(),
|
||||
);
|
||||
assert!(transport.send_typing("+15551234567", true).await.is_ok());
|
||||
}
|
||||
|
||||
// ── extract_twilio_text_messages tests ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_twilio_text_messages_parses_valid_form() {
|
||||
let body = b"From=whatsapp%3A%2B15551234567&Body=hello+world&To=whatsapp%3A%2B14155551234&MessageSid=SMtest";
|
||||
let msgs = extract_twilio_text_messages(body);
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].0, "+15551234567");
|
||||
assert_eq!(msgs[0].1, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_twilio_text_messages_strips_whatsapp_prefix() {
|
||||
let body = b"From=whatsapp%3A%2B15551234567&Body=hi";
|
||||
let msgs = extract_twilio_text_messages(body);
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].0, "+15551234567");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_twilio_text_messages_returns_empty_on_missing_from() {
|
||||
let body = b"Body=hello";
|
||||
let msgs = extract_twilio_text_messages(body);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_twilio_text_messages_returns_empty_on_missing_body() {
|
||||
let body = b"From=whatsapp%3A%2B15551234567";
|
||||
let msgs = extract_twilio_text_messages(body);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_twilio_text_messages_returns_empty_on_empty_body() {
|
||||
let body = b"From=whatsapp%3A%2B15551234567&Body=";
|
||||
let msgs = extract_twilio_text_messages(body);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_twilio_text_messages_returns_empty_on_invalid_form() {
|
||||
let body = b"not valid form encoded {{{{";
|
||||
// serde_urlencoded is lenient, so this might parse or return empty
|
||||
// Either way it must not panic.
|
||||
let _msgs = extract_twilio_text_messages(body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_whatsapp_history_returns_empty_when_file_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user