nexo-tool-meta 0.1.2

Wire-shape types shared between the Nexo agent runtime and any third-party microapp that consumes its events.
Documentation
//! Phase 82.10.e — `nexo/admin/pairing/*` wire types.
//!
//! Async pairing flow (e.g. WhatsApp QR scan):
//! 1. Microapp calls `pairing/start` → returns `challenge_id`.
//! 2. Daemon initiates the channel-plugin-specific pairing
//!    protocol off-band.
//! 3. As state evolves, daemon pushes
//!    `nexo/notify/pairing_status_changed` notifications.
//! 4. Microapp may also poll `pairing/status` or cancel via
//!    `pairing/cancel`.

use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// JSON-RPC notification method emitted by the daemon as the
/// pairing challenge state evolves. Frame shape:
/// `{"jsonrpc":"2.0","method":"nexo/notify/pairing_status_changed",
///  "params": <PairingStatus>}`.
///
/// Microapps register a listener via
/// `Microapp::with_notification_listener(PAIRING_STATUS_NOTIFY_METHOD, …)`
/// (Phase 83.4.c). Mirrors the
/// `http_server::TOKEN_ROTATED_NOTIFY_METHOD` shape so consumers
/// don't string-literal the method name.
pub const PAIRING_STATUS_NOTIFY_METHOD: &str = "nexo/notify/pairing_status_changed";

/// Params for `nexo/admin/pairing/start`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PairingStartInput {
    /// Agent the credential will be bound to once pairing
    /// completes. The handler does NOT bind here — binding lives
    /// with `credentials/register` invoked from the operator UI
    /// after the user confirms on their device.
    pub agent_id: String,
    /// Channel id (`whatsapp`, future `telegram` / …).
    pub channel: String,
    /// Optional instance discriminator. `None` for
    /// single-instance channels.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub instance: Option<String>,
}

/// Response for `nexo/admin/pairing/start`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PairingStartResponse {
    /// Stable id used to correlate subsequent status / cancel /
    /// notification frames. Generated by the daemon.
    pub challenge_id: Uuid,
    /// Epoch milliseconds at which the challenge expires if the
    /// user does not complete pairing.
    pub expires_at_ms: u64,
    /// Operator-friendly instructions ("scan the QR with the
    /// device WhatsApp app", "click the link sent to your
    /// email", …). UI displays verbatim.
    pub instructions: String,
}

/// Pairing challenge lifecycle state. Mirrors
/// channel-plugin-specific flows but converged into a single
/// stable enum so microapp UIs branch generically.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PairingState {
    /// Daemon accepted the challenge but the channel plugin has
    /// not yet produced the actual artifact (QR / link / token).
    Pending,
    /// Channel plugin produced the user-facing artifact (e.g. QR
    /// code rendered into `data.qr_ascii` / `data.qr_png_base64`).
    QrReady,
    /// User-side action pending — the QR was displayed and the
    /// daemon is waiting for the device confirmation.
    AwaitingUser,
    /// User completed pairing successfully. `data.device_jid` (or
    /// channel-specific equivalent) is filled.
    Linked,
    /// Challenge timed out before the user completed pairing.
    Expired,
    /// Microapp explicitly invoked `pairing/cancel`.
    Cancelled,
}

/// Params for `nexo/admin/pairing/status`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PairingStatusParams {
    /// Challenge id returned by `pairing/start`.
    pub challenge_id: Uuid,
}

/// Response for `nexo/admin/pairing/status` AND payload of
/// `nexo/notify/pairing_status_changed` notifications.
///
/// Same shape used in poll + push so microapp UIs route both
/// through the same renderer.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PairingStatus {
    /// Challenge id.
    pub challenge_id: Uuid,
    /// Current state.
    pub state: PairingState,
    /// State-specific payload. Empty object for `pending` /
    /// `expired` / `cancelled`.
    #[serde(default, skip_serializing_if = "PairingStatusData::is_empty")]
    pub data: PairingStatusData,
}

/// State-specific payload for [`PairingStatus::data`]. Fields are
/// populated only for the matching state(s); the rest stay
/// `None`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct PairingStatusData {
    /// `qr_ready` — terminal-friendly QR ASCII rendering.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub qr_ascii: Option<String>,
    /// `qr_ready` — base64 PNG bitmap for graphical UIs.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub qr_png_base64: Option<String>,
    /// `linked` — channel-specific stable peer id (whatsapp's
    /// device JID, future telegram bot id, …).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub device_jid: Option<String>,
    /// `expired` / `cancelled` — operator-readable reason hint.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

impl PairingStatusData {
    /// `true` when no field is populated. Used by the wire-shape
    /// `skip_serializing_if` to keep `pending` frames small.
    pub fn is_empty(&self) -> bool {
        self.qr_ascii.is_none()
            && self.qr_png_base64.is_none()
            && self.device_jid.is_none()
            && self.error.is_none()
    }
}

/// Params for `nexo/admin/pairing/cancel`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PairingCancelParams {
    /// Challenge id to cancel.
    pub challenge_id: Uuid,
}

/// Response for `nexo/admin/pairing/cancel`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct PairingCancelResponse {
    /// `true` when a pending challenge was cancelled. `false` =
    /// already expired / linked / unknown id (idempotent).
    pub cancelled: bool,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pairing_status_round_trip_qr_ready() {
        let s = PairingStatus {
            challenge_id: Uuid::nil(),
            state: PairingState::QrReady,
            data: PairingStatusData {
                qr_ascii: Some("##".into()),
                qr_png_base64: Some("AAAA".into()),
                device_jid: None,
                error: None,
            },
        };
        let v = serde_json::to_value(&s).unwrap();
        let back: PairingStatus = serde_json::from_value(v).unwrap();
        assert_eq!(s, back);
    }

    #[test]
    fn pairing_status_skips_empty_data() {
        let s = PairingStatus {
            challenge_id: Uuid::nil(),
            state: PairingState::Pending,
            data: PairingStatusData::default(),
        };
        let v = serde_json::to_value(&s).unwrap();
        let obj = v.as_object().unwrap();
        assert!(!obj.contains_key("data"));
    }

    #[test]
    fn pairing_state_serialises_snake_case() {
        let v = serde_json::to_value(PairingState::AwaitingUser).unwrap();
        assert_eq!(v, serde_json::json!("awaiting_user"));
        let v = serde_json::to_value(PairingState::QrReady).unwrap();
        assert_eq!(v, serde_json::json!("qr_ready"));
    }

    #[test]
    fn pairing_status_notify_method_constant() {
        assert_eq!(
            PAIRING_STATUS_NOTIFY_METHOD,
            "nexo/notify/pairing_status_changed"
        );
    }
}