use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use nexo_broker::{AnyBroker, BrokerHandle, Message};
use serde_json::{json, Value};
use tokio::task::JoinHandle;
use tracing::{debug, warn};
use uuid::Uuid;
use super::domains::pairing::{PairingChallengeStore, PairingNotifier};
use super::pairing_trigger::{
PairingChannelTrigger, PairingContext, PairingHandle, PairingTriggerError,
PAIRING_DEFAULT_TIMEOUT,
};
use nexo_tool_meta::admin::pairing::{PairingState, PairingStatus, PairingStatusData};
const DEFAULT_FORWARD_TIMEOUT: Duration = PAIRING_DEFAULT_TIMEOUT;
#[derive(Clone)]
pub struct BrokerPairingTrigger {
channel_id: String,
broker: AnyBroker,
start_method: String,
cancel_method: String,
admin_method_prefix: String,
admin_broker_prefix: String,
timeout: Duration,
}
impl std::fmt::Debug for BrokerPairingTrigger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BrokerPairingTrigger")
.field("channel_id", &self.channel_id)
.field("start_method", &self.start_method)
.field("cancel_method", &self.cancel_method)
.field("admin_method_prefix", &self.admin_method_prefix)
.field("admin_broker_prefix", &self.admin_broker_prefix)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
impl BrokerPairingTrigger {
pub fn new(
channel_id: impl Into<String>,
broker: AnyBroker,
trigger: &nexo_plugin_manifest::pairing::PairingTriggerSection,
admin_method_prefix: impl Into<String>,
admin_broker_prefix: impl Into<String>,
) -> Self {
let timeout = trigger
.timeout_seconds
.map(Duration::from_secs)
.unwrap_or(DEFAULT_FORWARD_TIMEOUT);
Self {
channel_id: channel_id.into(),
broker,
start_method: trigger.start_method.clone(),
cancel_method: trigger.cancel_method.clone(),
admin_method_prefix: admin_method_prefix.into(),
admin_broker_prefix: admin_broker_prefix.into(),
timeout,
}
}
fn broker_subject_for(&self, method: &str) -> String {
let suffix = method
.strip_prefix(&self.admin_method_prefix)
.unwrap_or(method)
.replace('/', ".");
format!("{}.{suffix}", self.admin_broker_prefix)
}
async fn forward(&self, method: &str, params: Value) -> Result<Value, String> {
let subject = self.broker_subject_for(method);
let payload = json!({ "method": method, "params": params });
let msg = Message::new(subject.clone(), payload);
let reply = self
.broker
.request(&subject, msg, self.timeout)
.await
.map_err(|e| format!("broker request `{subject}`: {e}"))?;
Ok(reply.payload)
}
fn parse_admin_response(payload: &Value) -> Result<(), String> {
let ok = payload.get("ok").and_then(Value::as_bool).unwrap_or(false);
if ok {
Ok(())
} else {
let err = payload
.get("error")
.and_then(Value::as_str)
.unwrap_or("plugin refused without error message")
.to_string();
Err(err)
}
}
}
#[async_trait]
impl PairingChannelTrigger for BrokerPairingTrigger {
fn channel_id(&self) -> &str {
&self.channel_id
}
async fn start(&self, ctx: PairingContext) -> Result<PairingHandle, PairingTriggerError> {
let params = json!({
"challenge_id": ctx.challenge_id.to_string(),
"agent_id": ctx.agent_id,
"instance": ctx.instance,
});
let resp = self
.forward(&self.start_method, params)
.await
.map_err(PairingTriggerError::Transport)?;
Self::parse_admin_response(&resp).map_err(PairingTriggerError::Transport)?;
let cancel = ctx.cancel.clone();
let cancel_signal = cancel.clone();
let trigger = self.clone();
let challenge_id = ctx.challenge_id;
tokio::spawn(async move {
cancel_signal.cancelled().await;
let params = json!({ "challenge_id": challenge_id.to_string() });
match trigger.forward(&trigger.cancel_method, params).await {
Ok(resp) => {
if let Err(err) = BrokerPairingTrigger::parse_admin_response(&resp) {
warn!(
channel = %trigger.channel_id,
%challenge_id,
error = %err,
"plugin rejected pairing cancel"
);
}
}
Err(err) => warn!(
channel = %trigger.channel_id,
%challenge_id,
error = %err,
"broker pairing cancel forward failed"
),
}
});
Ok(PairingHandle {
challenge_id: ctx.challenge_id,
channel: self.channel_id.clone(),
cancel,
})
}
}
pub fn spawn_pairing_inbound_subscriber(
broker: AnyBroker,
channel_id: impl Into<String>,
store: Arc<dyn PairingChallengeStore>,
notifier: Option<Arc<dyn PairingNotifier>>,
) -> JoinHandle<()> {
let channel = channel_id.into();
tokio::spawn(async move {
let pattern = format!("plugin.inbound.{channel}.>");
let mut subscription = match broker.subscribe(&pattern).await {
Ok(sub) => sub,
Err(err) => {
warn!(
channel = %channel,
%pattern,
error = %err,
"pairing inbound subscriber failed to subscribe"
);
return;
}
};
debug!(channel = %channel, %pattern, "pairing inbound subscriber up");
while let Some(event) = subscription.next().await {
handle_inbound_event(
&channel,
&event.topic,
&event.payload,
&*store,
notifier.as_deref(),
);
}
debug!(channel = %channel, "pairing inbound subscriber drained");
})
}
fn handle_inbound_event(
channel: &str,
topic: &str,
payload: &Value,
store: &dyn PairingChallengeStore,
notifier: Option<&dyn PairingNotifier>,
) {
let parts: Vec<&str> = topic.split('.').collect();
if parts.len() < 6
|| parts[0] != "plugin"
|| parts[1] != "inbound"
|| parts[2] != channel
|| parts[parts.len() - 2] != "pairing"
{
return;
}
let kind = parts[parts.len() - 1];
let challenge_id = match payload
.get("challenge_id")
.and_then(Value::as_str)
.and_then(|s| Uuid::parse_str(s).ok())
{
Some(id) => id,
None => {
warn!(channel, topic, "pairing inbound missing challenge_id");
return;
}
};
match kind {
"qr" => {
let png = payload
.get("png_base64")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let ascii = payload
.get("ascii")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let expires_at_ms = payload
.get("expires_at_ms")
.and_then(Value::as_u64)
.unwrap_or(0);
match store.update_qr(challenge_id, png.clone(), ascii.clone(), expires_at_ms) {
Ok(true) => {
if let Some(n) = notifier {
let status = PairingStatus {
challenge_id,
state: PairingState::QrReady,
data: PairingStatusData {
qr_ascii: (!ascii.is_empty()).then_some(ascii),
qr_png_base64: (!png.is_empty()).then_some(png),
device_jid: None,
error: None,
},
};
n.notify_status(&status);
}
}
Ok(false) => debug!(
channel,
%challenge_id,
"pairing inbound qr ignored (challenge terminal/unknown)"
),
Err(err) => warn!(
channel,
%challenge_id,
error = %err,
"pairing inbound qr update_qr failed"
),
}
}
"state" => {
let state = match payload
.get("state")
.and_then(Value::as_str)
.and_then(parse_state)
{
Some(s) => s,
None => {
warn!(
channel,
topic,
payload = %payload,
"pairing inbound state has unknown `state` value"
);
return;
}
};
let data = PairingStatusData {
qr_ascii: None,
qr_png_base64: None,
device_jid: payload
.get("device_jid")
.and_then(Value::as_str)
.map(str::to_string),
error: payload
.get("error")
.and_then(Value::as_str)
.map(str::to_string),
};
match store.update_state(challenge_id, state, data.clone()) {
Ok(true) => {
if let Some(n) = notifier {
let status = PairingStatus {
challenge_id,
state,
data,
};
n.notify_status(&status);
}
}
Ok(false) => debug!(
channel,
%challenge_id,
"pairing inbound state ignored (challenge terminal/unknown)"
),
Err(err) => warn!(
channel,
%challenge_id,
error = %err,
"pairing inbound state update_state failed"
),
}
}
other => warn!(
channel,
kind = other,
"pairing inbound unknown event kind (expected `qr` or `state`)"
),
}
}
fn parse_state(raw: &str) -> Option<PairingState> {
Some(match raw {
"pending" => PairingState::Pending,
"qr_ready" => PairingState::QrReady,
"awaiting_user" => PairingState::AwaitingUser,
"linked" => PairingState::Linked,
"expired" => PairingState::Expired,
"cancelled" => PairingState::Cancelled,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::admin_rpc::domains::pairing::{PairingChallengeStore, PairingNotifier};
use nexo_broker::{Event, LocalBroker};
use std::sync::Mutex;
use tokio::sync::oneshot;
use tokio::time::{timeout, Duration as TDuration};
use tokio_util::sync::CancellationToken;
#[derive(Default)]
struct CollectingStore {
qrs: Mutex<Vec<(Uuid, String, String, u64)>>,
states: Mutex<Vec<(Uuid, PairingState, PairingStatusData)>>,
}
impl PairingChallengeStore for CollectingStore {
fn create_challenge(
&self,
_agent_id: &str,
_channel: &str,
_instance: Option<&str>,
_ttl_secs: u64,
) -> anyhow::Result<(Uuid, u64)> {
Ok((Uuid::nil(), 0))
}
fn read_challenge(&self, _challenge_id: Uuid) -> anyhow::Result<Option<PairingStatus>> {
Ok(None)
}
fn cancel_challenge(&self, _challenge_id: Uuid) -> anyhow::Result<bool> {
Ok(true)
}
fn update_qr(
&self,
challenge_id: Uuid,
qr_png_base64: String,
qr_ascii: String,
expires_at_ms: u64,
) -> anyhow::Result<bool> {
self.qrs
.lock()
.unwrap()
.push((challenge_id, qr_png_base64, qr_ascii, expires_at_ms));
Ok(true)
}
fn update_state(
&self,
challenge_id: Uuid,
state: PairingState,
data: PairingStatusData,
) -> anyhow::Result<bool> {
self.states
.lock()
.unwrap()
.push((challenge_id, state, data));
Ok(true)
}
}
#[derive(Default)]
struct CollectingNotifier {
notifications: Mutex<Vec<PairingStatus>>,
}
impl PairingNotifier for CollectingNotifier {
fn notify_status(&self, status: &PairingStatus) {
self.notifications.lock().unwrap().push(status.clone());
}
}
#[test]
fn broker_subject_strips_admin_prefix_and_dots_slashes() {
let broker = AnyBroker::Local(LocalBroker::new());
let trigger = BrokerPairingTrigger::new(
"whatsapp",
broker,
&nexo_plugin_manifest::pairing::PairingTriggerSection {
start_method: "nexo/admin/whatsapp/pairing/start".into(),
cancel_method: "nexo/admin/whatsapp/pairing/cancel".into(),
timeout_seconds: Some(5),
},
"nexo/admin/whatsapp/",
"plugin.whatsapp.admin",
);
assert_eq!(
trigger.broker_subject_for("nexo/admin/whatsapp/pairing/start"),
"plugin.whatsapp.admin.pairing.start"
);
assert_eq!(
trigger.broker_subject_for("nexo/admin/whatsapp/pairing/cancel"),
"plugin.whatsapp.admin.pairing.cancel"
);
}
#[test]
fn parse_admin_response_ok_true_passes() {
let v = json!({ "ok": true, "result": {} });
BrokerPairingTrigger::parse_admin_response(&v).unwrap();
}
#[test]
fn parse_admin_response_ok_false_returns_error_message() {
let v = json!({ "ok": false, "error": "instance already paired" });
let err = BrokerPairingTrigger::parse_admin_response(&v).unwrap_err();
assert_eq!(err, "instance already paired");
}
#[test]
fn parse_admin_response_missing_ok_treated_as_error() {
let v = json!({});
let err = BrokerPairingTrigger::parse_admin_response(&v).unwrap_err();
assert!(err.contains("plugin refused"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn start_forwards_to_broker_and_returns_handle_on_ok() {
let broker = AnyBroker::Local(LocalBroker::new());
let trigger_broker = broker.clone();
let mut sub = broker
.subscribe("plugin.whatsapp.admin.pairing.start")
.await
.unwrap();
let (done_tx, done_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = done_rx.await;
});
let responder_broker = broker.clone();
let responder = tokio::spawn(async move {
if let Some(event) = sub.next().await {
if let Some(reply_to) = event.payload.get("reply_to").and_then(Value::as_str) {
let reply = Message::new(reply_to, json!({ "ok": true, "result": {} }));
let _ = responder_broker
.publish(
reply_to,
Event::new(reply_to.to_string(), "test", reply.payload),
)
.await;
}
}
});
let trigger = BrokerPairingTrigger::new(
"whatsapp",
trigger_broker,
&nexo_plugin_manifest::pairing::PairingTriggerSection {
start_method: "nexo/admin/whatsapp/pairing/start".into(),
cancel_method: "nexo/admin/whatsapp/pairing/cancel".into(),
timeout_seconds: Some(2),
},
"nexo/admin/whatsapp/",
"plugin.whatsapp.admin",
);
let ctx = PairingContext {
challenge_id: Uuid::new_v4(),
agent_id: "ag-1".into(),
instance: Some("default".into()),
store: Arc::new(CollectingStore::default()),
notifier: None,
timeout: TDuration::from_secs(2),
cancel: CancellationToken::new(),
};
let result = timeout(TDuration::from_secs(3), trigger.start(ctx)).await;
responder.abort();
let _ = done_tx;
match result {
Ok(Ok(handle)) => {
assert_eq!(handle.channel, "whatsapp");
}
Ok(Err(PairingTriggerError::Transport(_))) => {
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn handle_inbound_event_qr_updates_store_and_notifies() {
let store = Arc::new(CollectingStore::default());
let notifier = Arc::new(CollectingNotifier::default());
let cid = Uuid::new_v4();
let payload = json!({
"challenge_id": cid.to_string(),
"png_base64": "ZmFrZQ==",
"ascii": "##",
"expires_at_ms": 1234,
});
handle_inbound_event(
"whatsapp",
"plugin.inbound.whatsapp.default.pairing.qr",
&payload,
&*store,
Some(&*notifier),
);
let qrs = store.qrs.lock().unwrap();
assert_eq!(qrs.len(), 1);
assert_eq!(qrs[0].0, cid);
assert_eq!(qrs[0].1, "ZmFrZQ==");
assert_eq!(qrs[0].2, "##");
assert_eq!(qrs[0].3, 1234);
let notes = notifier.notifications.lock().unwrap();
assert_eq!(notes.len(), 1);
assert!(matches!(notes[0].state, PairingState::QrReady));
}
#[test]
fn handle_inbound_event_state_linked_updates_and_notifies() {
let store = Arc::new(CollectingStore::default());
let notifier = Arc::new(CollectingNotifier::default());
let cid = Uuid::new_v4();
let payload = json!({
"challenge_id": cid.to_string(),
"state": "linked",
"device_jid": "57300@s.whatsapp.net",
});
handle_inbound_event(
"whatsapp",
"plugin.inbound.whatsapp.default.pairing.state",
&payload,
&*store,
Some(&*notifier),
);
let states = store.states.lock().unwrap();
assert_eq!(states.len(), 1);
assert!(matches!(states[0].1, PairingState::Linked));
assert_eq!(
states[0].2.device_jid.as_deref(),
Some("57300@s.whatsapp.net")
);
let notes = notifier.notifications.lock().unwrap();
assert_eq!(notes.len(), 1);
}
#[test]
fn handle_inbound_event_state_error_carries_error_message() {
let store = Arc::new(CollectingStore::default());
let notifier = Arc::new(CollectingNotifier::default());
let cid = Uuid::new_v4();
let payload = json!({
"challenge_id": cid.to_string(),
"state": "expired",
"error": "qr ttl exceeded",
});
handle_inbound_event(
"whatsapp",
"plugin.inbound.whatsapp.default.pairing.state",
&payload,
&*store,
Some(&*notifier),
);
let states = store.states.lock().unwrap();
assert_eq!(states.len(), 1);
assert!(matches!(states[0].1, PairingState::Expired));
assert_eq!(states[0].2.error.as_deref(), Some("qr ttl exceeded"));
}
#[test]
fn handle_inbound_event_rejects_wrong_channel() {
let store = Arc::new(CollectingStore::default());
let payload = json!({
"challenge_id": Uuid::new_v4().to_string(),
"state": "linked",
});
handle_inbound_event(
"whatsapp",
"plugin.inbound.telegram.default.pairing.state",
&payload,
&*store,
None,
);
assert!(store.states.lock().unwrap().is_empty());
}
#[test]
fn handle_inbound_event_rejects_topic_outside_pairing_namespace() {
let store = Arc::new(CollectingStore::default());
let payload = json!({
"challenge_id": Uuid::new_v4().to_string(),
"state": "linked",
});
handle_inbound_event(
"whatsapp",
"plugin.inbound.whatsapp.default.message.text",
&payload,
&*store,
None,
);
assert!(store.states.lock().unwrap().is_empty());
}
#[test]
fn handle_inbound_event_rejects_missing_challenge_id() {
let store = Arc::new(CollectingStore::default());
let payload = json!({ "state": "linked" });
handle_inbound_event(
"whatsapp",
"plugin.inbound.whatsapp.default.pairing.state",
&payload,
&*store,
None,
);
assert!(store.states.lock().unwrap().is_empty());
}
#[test]
fn handle_inbound_event_rejects_unknown_state_string() {
let store = Arc::new(CollectingStore::default());
let payload = json!({
"challenge_id": Uuid::new_v4().to_string(),
"state": "bluetooth_paired",
});
handle_inbound_event(
"whatsapp",
"plugin.inbound.whatsapp.default.pairing.state",
&payload,
&*store,
None,
);
assert!(store.states.lock().unwrap().is_empty());
}
#[test]
fn parse_state_round_trip() {
assert!(matches!(
parse_state("pending"),
Some(PairingState::Pending)
));
assert!(matches!(
parse_state("qr_ready"),
Some(PairingState::QrReady)
));
assert!(matches!(
parse_state("awaiting_user"),
Some(PairingState::AwaitingUser)
));
assert!(matches!(parse_state("linked"), Some(PairingState::Linked)));
assert!(matches!(
parse_state("expired"),
Some(PairingState::Expired)
));
assert!(matches!(
parse_state("cancelled"),
Some(PairingState::Cancelled)
));
assert!(parse_state("nope").is_none());
assert!(parse_state("").is_none());
}
}