use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Clone)]
pub struct OutboundMessage {
pub channel: String,
pub account_id: String,
pub to: String,
pub body: String,
pub msg_kind: String,
pub attachments: Vec<Value>,
pub reply_to_msg_id: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct OutboundAck {
pub outbound_message_id: Option<String>,
}
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum ChannelOutboundError {
#[error("channel_unavailable: no adapter registered for `{0}`")]
ChannelUnavailable(String),
#[error("invalid_params: {0}")]
InvalidParams(String),
#[error("transport: {0}")]
Transport(String),
}
#[async_trait]
pub trait ChannelOutboundDispatcher: Send + Sync + std::fmt::Debug {
async fn send(&self, msg: OutboundMessage) -> Result<OutboundAck, ChannelOutboundError>;
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Debug, Default)]
pub struct CapturingDispatcher {
pub sent: Mutex<Vec<OutboundMessage>>,
pub respond_with_id: Option<String>,
}
#[async_trait]
impl ChannelOutboundDispatcher for CapturingDispatcher {
async fn send(&self, msg: OutboundMessage) -> Result<OutboundAck, ChannelOutboundError> {
self.sent.lock().unwrap().push(msg);
Ok(OutboundAck {
outbound_message_id: self.respond_with_id.clone(),
})
}
}
#[derive(Debug, Default)]
struct AlwaysFailingDispatcher;
#[async_trait]
impl ChannelOutboundDispatcher for AlwaysFailingDispatcher {
async fn send(&self, _msg: OutboundMessage) -> Result<OutboundAck, ChannelOutboundError> {
Err(ChannelOutboundError::Transport("simulated".into()))
}
}
#[tokio::test]
async fn capturing_dispatcher_records_payload() {
let d = CapturingDispatcher::default();
let msg = OutboundMessage {
channel: "whatsapp".into(),
account_id: "wa.0".into(),
to: "wa.42".into(),
body: "hello".into(),
msg_kind: "text".into(),
attachments: vec![],
reply_to_msg_id: None,
};
let ack = d.send(msg.clone()).await.unwrap();
assert!(ack.outbound_message_id.is_none());
let sent = d.sent.lock().unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].body, "hello");
}
#[tokio::test]
async fn capturing_dispatcher_round_trips_provider_id() {
let d = CapturingDispatcher {
respond_with_id: Some("msg-42".into()),
..Default::default()
};
let ack = d
.send(OutboundMessage {
channel: "whatsapp".into(),
account_id: "wa.0".into(),
to: "wa.99".into(),
body: "x".into(),
msg_kind: "text".into(),
attachments: vec![],
reply_to_msg_id: None,
})
.await
.unwrap();
assert_eq!(ack.outbound_message_id.as_deref(), Some("msg-42"));
}
#[tokio::test]
async fn failing_dispatcher_returns_transport_error() {
let d = AlwaysFailingDispatcher;
let r = d
.send(OutboundMessage {
channel: "whatsapp".into(),
account_id: "wa.0".into(),
to: "wa.99".into(),
body: "x".into(),
msg_kind: "text".into(),
attachments: vec![],
reply_to_msg_id: None,
})
.await;
assert!(matches!(r, Err(ChannelOutboundError::Transport(_))));
}
}