pub mod remote;
pub use remote::RemoteChannelAdapter;
use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use nexo_broker::AnyBroker;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OutboundMessage {
Text {
to: String,
body: String,
},
Media {
to: String,
url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
caption: Option<String>,
},
Custom(serde_json::Value),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OutboundAck {
pub message_id: String,
pub sent_at_unix: i64,
}
#[derive(Debug, Error)]
pub enum ChannelAdapterError {
#[error("channel adapter `{kind}` connection failure: {source}")]
Connection {
kind: String,
#[source]
source: anyhow::Error,
},
#[error("channel adapter `{kind}` authentication failure: {reason}")]
Authentication { kind: String, reason: String },
#[error("channel adapter `{kind}` recipient `{recipient}` invalid: {reason}")]
Recipient {
kind: String,
recipient: String,
reason: String,
},
#[error("channel adapter `{kind}` rate-limited: retry after {retry_after_secs}s")]
RateLimited { kind: String, retry_after_secs: u64 },
#[error("channel adapter `{kind}` does not support: {feature}")]
Unsupported { kind: String, feature: String },
#[error("channel adapter `{kind}` error: {source}")]
Other {
kind: String,
#[source]
source: anyhow::Error,
},
}
#[async_trait]
pub trait ChannelAdapter: Send + Sync + 'static {
fn kind(&self) -> &str;
async fn start(
&self,
broker: AnyBroker,
instance: Option<&str>,
) -> Result<(), ChannelAdapterError>;
async fn stop(&self) -> Result<(), ChannelAdapterError>;
async fn send_outbound(&self, msg: OutboundMessage)
-> Result<OutboundAck, ChannelAdapterError>;
}
#[derive(Default)]
pub struct ChannelAdapterRegistry {
inner: RwLock<BTreeMap<String, AdapterEntry>>,
}
impl std::fmt::Debug for ChannelAdapterRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let guard = self.inner.read().unwrap_or_else(|p| p.into_inner());
f.debug_struct("ChannelAdapterRegistry")
.field("kinds", &guard.keys().collect::<Vec<_>>())
.finish()
}
}
#[derive(Clone)]
struct AdapterEntry {
adapter: Arc<dyn ChannelAdapter>,
registered_by: String,
}
impl ChannelAdapterRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(
&self,
adapter: Arc<dyn ChannelAdapter>,
registered_by: impl Into<String>,
) -> Result<(), ChannelAdapterRegistrationError> {
let kind = adapter.kind().to_string();
let registered_by = registered_by.into();
let mut guard = self.inner.write().unwrap_or_else(|p| p.into_inner());
match guard.get(&kind) {
Some(prior) => Err(ChannelAdapterRegistrationError::KindAlreadyRegistered {
kind,
prior_registered_by: prior.registered_by.clone(),
attempted_by: registered_by,
}),
None => {
guard.insert(
kind,
AdapterEntry {
adapter,
registered_by,
},
);
Ok(())
}
}
}
pub fn get(&self, kind: &str) -> Option<Arc<dyn ChannelAdapter>> {
let guard = self.inner.read().unwrap_or_else(|p| p.into_inner());
guard.get(kind).map(|e| e.adapter.clone())
}
pub fn kinds(&self) -> Vec<String> {
let guard = self.inner.read().unwrap_or_else(|p| p.into_inner());
guard.keys().cloned().collect()
}
pub fn has_any(&self) -> bool {
let guard = self.inner.read().unwrap_or_else(|p| p.into_inner());
!guard.is_empty()
}
pub fn unregister(&self, kind: &str, plugin_id: &str) -> bool {
let mut guard = self.inner.write().unwrap_or_else(|p| p.into_inner());
match guard.get(kind) {
Some(entry) if entry.registered_by == plugin_id => {
guard.remove(kind);
true
}
_ => false,
}
}
}
#[derive(Debug, Error)]
pub enum ChannelAdapterRegistrationError {
#[error(
"channel adapter kind `{kind}` already registered by plugin `{prior_registered_by}` (attempted by `{attempted_by}`)"
)]
KindAlreadyRegistered {
kind: String,
prior_registered_by: String,
attempted_by: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
struct DummyAdapter {
kind: &'static str,
}
impl DummyAdapter {
fn new(kind: &'static str) -> Arc<Self> {
Arc::new(Self { kind })
}
}
#[async_trait]
impl ChannelAdapter for DummyAdapter {
fn kind(&self) -> &str {
self.kind
}
async fn start(
&self,
_broker: AnyBroker,
_instance: Option<&str>,
) -> Result<(), ChannelAdapterError> {
Ok(())
}
async fn stop(&self) -> Result<(), ChannelAdapterError> {
Ok(())
}
async fn send_outbound(
&self,
_msg: OutboundMessage,
) -> Result<OutboundAck, ChannelAdapterError> {
Ok(OutboundAck {
message_id: "dummy".into(),
sent_at_unix: 0,
})
}
}
#[test]
fn register_first_succeeds() {
let reg = ChannelAdapterRegistry::new();
assert!(!reg.has_any());
let r = reg.register(DummyAdapter::new("sms"), "plugin_a");
assert!(r.is_ok());
assert!(reg.has_any());
assert_eq!(reg.kinds(), vec!["sms".to_string()]);
assert!(reg.get("sms").is_some());
}
#[test]
fn register_duplicate_kind_rejected() {
let reg = ChannelAdapterRegistry::new();
reg.register(DummyAdapter::new("sms"), "plugin_a").unwrap();
let err = reg
.register(DummyAdapter::new("sms"), "plugin_b")
.expect_err("duplicate must fail");
match err {
ChannelAdapterRegistrationError::KindAlreadyRegistered {
kind,
prior_registered_by,
attempted_by,
} => {
assert_eq!(kind, "sms");
assert_eq!(prior_registered_by, "plugin_a");
assert_eq!(attempted_by, "plugin_b");
}
}
assert!(reg.get("sms").is_some());
assert_eq!(reg.kinds(), vec!["sms".to_string()]);
}
#[test]
fn kinds_returns_sorted_list() {
let reg = ChannelAdapterRegistry::new();
reg.register(DummyAdapter::new("zeta"), "p1").unwrap();
reg.register(DummyAdapter::new("alpha"), "p2").unwrap();
reg.register(DummyAdapter::new("mike"), "p3").unwrap();
assert_eq!(
reg.kinds(),
vec!["alpha".to_string(), "mike".to_string(), "zeta".to_string()]
);
}
#[test]
fn get_unknown_kind_returns_none() {
let reg = ChannelAdapterRegistry::new();
assert!(reg.get("anything").is_none());
reg.register(DummyAdapter::new("sms"), "p1").unwrap();
assert!(reg.get("nope").is_none());
}
#[test]
fn arc_clone_shares_registry_state() {
let reg = Arc::new(ChannelAdapterRegistry::new());
let clone_a = Arc::clone(®);
let clone_b = Arc::clone(®);
clone_a
.register(DummyAdapter::new("discord"), "plugin_a")
.unwrap();
assert!(clone_b.get("discord").is_some());
assert_eq!(clone_b.kinds(), vec!["discord".to_string()]);
}
#[test]
fn outbound_message_serde_round_trip() {
let cases = vec![
OutboundMessage::Text {
to: "+57111".into(),
body: "hi".into(),
},
OutboundMessage::Media {
to: "+57222".into(),
url: "https://x/y.png".into(),
caption: Some("alt".into()),
},
OutboundMessage::Custom(serde_json::json!({"discord_embed": {"title": "x"}})),
];
for case in cases {
let s = serde_json::to_string(&case).unwrap();
let back: OutboundMessage = serde_json::from_str(&s).unwrap();
assert_eq!(serde_json::to_string(&back).unwrap(), s);
}
let ack = OutboundAck {
message_id: "m1".into(),
sent_at_unix: 1700000000,
};
let s = serde_json::to_string(&ack).unwrap();
let back: OutboundAck = serde_json::from_str(&s).unwrap();
assert_eq!(back.message_id, "m1");
assert_eq!(back.sent_at_unix, 1700000000);
}
#[test]
fn unregister_only_removes_when_registered_by_matches() {
let reg = ChannelAdapterRegistry::new();
reg.register(DummyAdapter::new("sms"), "plugin_a").unwrap();
assert!(!reg.unregister("sms", "plugin_evil"));
assert!(reg.get("sms").is_some());
assert!(reg.unregister("sms", "plugin_a"));
assert!(reg.get("sms").is_none());
assert!(!reg.unregister("sms", "plugin_a"));
}
}