oxios-gateway 1.0.0

Channel-agnostic message gateway for Oxios
//! Message types for the gateway.
//!
//! Messages are channel-agnostic: they carry content and metadata
//! without depending on any specific channel implementation.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;

/// A message arriving from a channel.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IncomingMessage {
    /// Unique message identifier.
    pub id: uuid::Uuid,
    /// Name of the source channel.
    pub channel: String,
    /// Identifier for the user who sent the message.
    pub user_id: String,
    /// Message content.
    pub content: String,
    /// Timestamp of message creation.
    pub timestamp: DateTime<Utc>,
    /// Optional metadata (e.g., session_id for multi-turn conversations).
    #[serde(default)]
    pub metadata: HashMap<String, String>,
}

impl IncomingMessage {
    /// Creates a new incoming message with the current timestamp and empty metadata.
    pub fn new(
        channel: impl Into<String>,
        user_id: impl Into<String>,
        content: impl Into<String>,
    ) -> Self {
        Self {
            id: uuid::Uuid::new_v4(),
            channel: channel.into(),
            user_id: user_id.into(),
            content: content.into(),
            timestamp: Utc::now(),
            metadata: HashMap::new(),
        }
    }
}

/// Orchestration result metadata.
///
/// Attached by `Gateway::dispatch()` from `OrchestrationResult`.
/// The legacy `HashMap<String, String>` metadata is retained for channel-specific data
/// (chat_id, message_id, etc.).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseMeta {
    /// Session ID for multi-turn conversations.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub session_id: Option<String>,
    /// Primary project ID that handled the message.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project_id: Option<String>,
    /// Project decoration tag (e.g., "[🔧 oxios]").
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project_tag: Option<String>,
    /// Seed ID created during orchestration.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub seed_id: Option<String>,
    /// Furthest phase reached (Interview | Seed | Execute | Evaluate | Evolve).
    pub phase: String,
    /// Whether evaluation passed.
    pub evaluation_passed: bool,
    /// Wall-clock duration of the dispatch in milliseconds.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub duration_ms: Option<u64>,
    /// Structured error, if this is an error response.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<UserFacingError>,
}

/// A user-facing structured error.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserFacingError {
    /// User-visible message (Korean).
    pub message: String,
    /// Error classification.
    pub kind: ErrorKind,
    /// Recovery suggestion.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub suggestion: Option<String>,
}

/// Error kind classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ErrorKind {
    /// Agent execution failed.
    ExecutionFailed,
    /// LLM provider error (rate limit, API error, etc.).
    ProviderError,
    /// Timeout.
    Timeout,
    /// Insufficient permissions.
    PermissionDenied,
    /// Input validation failed.
    ValidationError,
    /// Internal system error (details not exposed to user).
    Internal,
}

/// A message being sent to a channel.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutgoingMessage {
    /// Unique message identifier.
    pub id: uuid::Uuid,
    /// Name of the target channel.
    pub channel: String,
    /// Identifier for the user who should receive the message.
    pub user_id: String,
    /// Message content.
    pub content: String,
    /// Timestamp of message creation.
    pub timestamp: DateTime<Utc>,
    /// Optional metadata (e.g., session_id, phase, evaluation_passed).
    #[serde(default)]
    pub metadata: HashMap<String, String>,
    /// RFC-014: typed orchestration metadata.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub meta: Option<ResponseMeta>,
}

impl OutgoingMessage {
    /// Creates a new outgoing message with the current timestamp.
    pub fn new(
        channel: impl Into<String>,
        user_id: impl Into<String>,
        content: impl Into<String>,
    ) -> Self {
        Self::with_id(uuid::Uuid::new_v4(), channel, user_id, content)
    }

    /// Creates a new outgoing message with a specific ID (preserving correlation with the request).
    pub fn with_id(
        id: uuid::Uuid,
        channel: impl Into<String>,
        user_id: impl Into<String>,
        content: impl Into<String>,
    ) -> Self {
        Self {
            id,
            channel: channel.into(),
            user_id: user_id.into(),
            content: content.into(),
            timestamp: Utc::now(),
            metadata: HashMap::new(),
            meta: None,
        }
    }

    /// Creates a new outgoing message with metadata.
    pub fn with_metadata(
        channel: impl Into<String>,
        user_id: impl Into<String>,
        content: impl Into<String>,
        metadata: HashMap<String, String>,
    ) -> Self {
        Self::with_id(uuid::Uuid::new_v4(), channel, user_id, content).with_metadata_only(metadata)
    }

    /// Creates a new outgoing message with a specific ID and metadata.
    pub fn with_id_and_metadata(
        id: uuid::Uuid,
        channel: impl Into<String>,
        user_id: impl Into<String>,
        content: impl Into<String>,
        metadata: HashMap<String, String>,
    ) -> Self {
        Self {
            id,
            channel: channel.into(),
            user_id: user_id.into(),
            content: content.into(),
            timestamp: Utc::now(),
            metadata,
            meta: None,
        }
    }

    /// Sets metadata on this message (builder pattern).
    pub fn with_metadata_only(mut self, metadata: HashMap<String, String>) -> Self {
        self.metadata = metadata;
        self
    }

    /// Creates a success response with typed metadata.
    ///
    /// Combines channel-specific metadata (chat_id, message_id, etc.) with
    /// structured orchestration metadata.
    pub fn success(
        correlation_id: Uuid,
        channel: &str,
        user_id: &str,
        content: &str,
        channel_meta: HashMap<String, String>,
        response_meta: ResponseMeta,
    ) -> Self {
        Self {
            id: correlation_id,
            channel: channel.to_string(),
            user_id: user_id.to_string(),
            content: content.to_string(),
            timestamp: Utc::now(),
            metadata: channel_meta,
            meta: Some(response_meta),
        }
    }

    /// Creates an error response.
    ///
    /// The `UserFacingError` provides structured error information.
    /// Callers can set `session_id` etc. on the returned message's metadata
    /// to preserve conversation continuity.
    pub fn error(correlation_id: Uuid, channel: &str, user_id: &str, err: UserFacingError) -> Self {
        Self {
            id: correlation_id,
            channel: channel.to_string(),
            user_id: user_id.to_string(),
            content: err.message.clone(),
            timestamp: Utc::now(),
            metadata: HashMap::new(),
            meta: Some(ResponseMeta {
                session_id: None,
                project_id: None,
                project_tag: None,
                seed_id: None,
                phase: String::new(),
                evaluation_passed: false,
                duration_ms: None,
                error: Some(err),
            }),
        }
    }
}