use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use dashmap::DashMap;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use super::domains::pairing::{PairingChallengeStore, PairingNotifier};
pub const PAIRING_DEFAULT_TIMEOUT: Duration = Duration::from_secs(180);
#[async_trait]
pub trait PairingChannelTrigger: Send + Sync + std::fmt::Debug {
fn channel_id(&self) -> &str;
async fn start(&self, ctx: PairingContext) -> Result<PairingHandle, PairingTriggerError>;
}
#[derive(Clone)]
pub struct PairingContext {
pub challenge_id: Uuid,
pub agent_id: String,
pub instance: Option<String>,
pub store: Arc<dyn PairingChallengeStore>,
pub notifier: Option<Arc<dyn PairingNotifier>>,
pub timeout: Duration,
pub cancel: CancellationToken,
}
impl std::fmt::Debug for PairingContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PairingContext")
.field("challenge_id", &self.challenge_id)
.field("agent_id", &self.agent_id)
.field("instance", &self.instance)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
#[derive(Debug)]
pub struct PairingHandle {
pub challenge_id: Uuid,
pub channel: String,
pub cancel: CancellationToken,
}
impl PairingHandle {
pub fn abort(&self) {
self.cancel.cancel();
}
}
#[derive(Debug, thiserror::Error)]
pub enum PairingTriggerError {
#[error("channel `{0}` not supported by this build")]
ChannelNotSupported(String),
#[error("instance `{0}` is already paired")]
AlreadyPaired(String),
#[error("instance `{0}` is not configured for this channel")]
InstanceNotConfigured(String),
#[error("transport: {0}")]
Transport(String),
#[error("internal: {0}")]
Internal(#[from] anyhow::Error),
}
#[derive(Clone, Default)]
pub struct PairingChannelTriggers {
inner: Arc<DashMap<String, Arc<dyn PairingChannelTrigger>>>,
}
impl PairingChannelTriggers {
pub fn new() -> Self {
Self::default()
}
pub fn insert(
&self,
channel_id: impl Into<String>,
trigger: Arc<dyn PairingChannelTrigger>,
) -> Option<Arc<dyn PairingChannelTrigger>> {
self.inner.insert(channel_id.into(), trigger)
}
pub fn get(&self, channel_id: &str) -> Option<Arc<dyn PairingChannelTrigger>> {
self.inner.get(channel_id).map(|e| e.value().clone())
}
pub fn contains_key(&self, channel_id: &str) -> bool {
self.inner.contains_key(channel_id)
}
pub fn len(&self) -> usize {
self.inner.len()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
}
impl std::fmt::Debug for PairingChannelTriggers {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut d = f.debug_struct("PairingChannelTriggers");
let channels: Vec<String> = self.inner.iter().map(|e| e.key().clone()).collect();
d.field("channels", &channels).finish()
}
}
impl FromIterator<(String, Arc<dyn PairingChannelTrigger>)> for PairingChannelTriggers {
fn from_iter<I: IntoIterator<Item = (String, Arc<dyn PairingChannelTrigger>)>>(
iter: I,
) -> Self {
let map: DashMap<String, Arc<dyn PairingChannelTrigger>> = iter.into_iter().collect();
Self {
inner: Arc::new(map),
}
}
}
impl From<HashMap<String, Arc<dyn PairingChannelTrigger>>> for PairingChannelTriggers {
fn from(map: HashMap<String, Arc<dyn PairingChannelTrigger>>) -> Self {
map.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug)]
struct FixedChannelTrigger {
channel: &'static str,
}
#[async_trait]
impl PairingChannelTrigger for FixedChannelTrigger {
fn channel_id(&self) -> &str {
self.channel
}
async fn start(&self, ctx: PairingContext) -> Result<PairingHandle, PairingTriggerError> {
Ok(PairingHandle {
challenge_id: ctx.challenge_id,
channel: self.channel.into(),
cancel: ctx.cancel,
})
}
}
#[test]
fn pairing_handle_abort_signals_cancel_token() {
let cancel = CancellationToken::new();
let handle = PairingHandle {
challenge_id: Uuid::nil(),
channel: "whatsapp".into(),
cancel: cancel.clone(),
};
assert!(!cancel.is_cancelled());
handle.abort();
assert!(cancel.is_cancelled());
}
#[test]
fn pairing_handle_abort_is_idempotent() {
let cancel = CancellationToken::new();
let handle = PairingHandle {
challenge_id: Uuid::nil(),
channel: "whatsapp".into(),
cancel: cancel.clone(),
};
handle.abort();
cancel.cancel();
assert!(cancel.is_cancelled());
}
#[test]
fn channel_id_returns_stable_string() {
let trigger = FixedChannelTrigger {
channel: "whatsapp",
};
assert_eq!(trigger.channel_id(), "whatsapp");
}
#[test]
fn pairing_triggers_registry_insert_and_get() {
let triggers = PairingChannelTriggers::new();
triggers.insert(
"whatsapp",
Arc::new(FixedChannelTrigger {
channel: "whatsapp",
}),
);
assert_eq!(triggers.len(), 1);
assert!(triggers.contains_key("whatsapp"));
assert!(!triggers.contains_key("telegram"));
let fetched = triggers.get("whatsapp").expect("trigger present");
assert_eq!(fetched.channel_id(), "whatsapp");
}
#[test]
fn pairing_triggers_clone_shares_underlying_map() {
let a = PairingChannelTriggers::new();
let b = a.clone();
a.insert(
"whatsapp",
Arc::new(FixedChannelTrigger {
channel: "whatsapp",
}),
);
assert!(b.contains_key("whatsapp"));
assert_eq!(b.len(), 1);
}
#[test]
fn pairing_triggers_from_hashmap_round_trip() {
let mut legacy: HashMap<String, Arc<dyn PairingChannelTrigger>> = HashMap::new();
legacy.insert(
"whatsapp".into(),
Arc::new(FixedChannelTrigger {
channel: "whatsapp",
}),
);
let triggers: PairingChannelTriggers = legacy.into();
assert!(triggers.contains_key("whatsapp"));
}
}