use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct Message {
pub id: Uuid,
pub conversation_id: String,
pub user_id: String,
pub message_type: MessageType,
#[validate(length(min = 1, max = 100_000))]
pub content: String,
pub attachments: Vec<Attachment>,
pub metadata: HashMap<String, serde_json::Value>,
pub timestamp: DateTime<Utc>,
pub parent_id: Option<Uuid>,
pub flags: MessageFlags,
}
impl Message {
#[must_use]
pub fn text(content: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
conversation_id: Uuid::new_v4().to_string(),
user_id: "anonymous".to_string(),
message_type: MessageType::Text,
content: content.into(),
attachments: Vec::new(),
metadata: HashMap::new(),
timestamp: Utc::now(),
parent_id: None,
flags: MessageFlags::default(),
}
}
#[must_use]
pub fn with_type(content: impl Into<String>, message_type: MessageType) -> Self {
let mut message = Self::text(content);
message.message_type = message_type;
message
}
#[must_use]
pub fn with_conversation_id(mut self, id: impl Into<String>) -> Self {
self.conversation_id = id.into();
self
}
#[must_use]
pub fn with_user_id(mut self, id: impl Into<String>) -> Self {
self.user_id = id.into();
self
}
#[must_use]
pub fn with_attachment(mut self, attachment: Attachment) -> Self {
self.attachments.push(attachment);
self
}
#[must_use]
pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.metadata.insert(key.into(), value);
self
}
#[must_use]
pub fn with_parent(mut self, parent_id: Uuid) -> Self {
self.parent_id = Some(parent_id);
self
}
#[must_use]
pub fn with_flags(mut self, flags: MessageFlags) -> Self {
self.flags = flags;
self
}
pub fn validate(&self) -> Result<()> {
Validate::validate(self).map_err(|e| Error::Validation(e.to_string()))?;
if self.content.is_empty() && self.attachments.is_empty() {
return Err(Error::InvalidInput(
"Message must have content or attachments".to_string(),
));
}
Ok(())
}
#[must_use]
pub fn is_system(&self) -> bool {
matches!(self.message_type, MessageType::System)
}
#[must_use]
pub fn has_attachments(&self) -> bool {
!self.attachments.is_empty()
}
#[must_use]
pub fn attachment_size(&self) -> usize {
self.attachments.iter().map(|a| a.size).sum()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageType {
Text,
Command,
System,
Error,
Embed,
File,
Image,
Audio,
Video,
}
impl MessageType {
#[must_use]
pub fn is_media(&self) -> bool {
matches!(self, Self::File | Self::Image | Self::Audio | Self::Video)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MessageFlags {
pub urgent: bool,
pub private: bool,
pub ephemeral: bool,
pub sensitive: bool,
pub bypass_filters: bool,
pub no_log: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
pub id: Uuid,
pub filename: String,
pub mime_type: String,
pub size: usize,
pub url: String,
pub thumbnail_url: Option<String>,
pub metadata: HashMap<String, serde_json::Value>,
}
impl Attachment {
#[must_use]
pub fn new(
filename: impl Into<String>,
mime_type: impl Into<String>,
size: usize,
url: impl Into<String>,
) -> Self {
Self {
id: Uuid::new_v4(),
filename: filename.into(),
mime_type: mime_type.into(),
size,
url: url.into(),
thumbnail_url: None,
metadata: HashMap::new(),
}
}
#[must_use]
pub fn is_image(&self) -> bool {
self.mime_type.starts_with("image/")
}
#[must_use]
pub fn is_video(&self) -> bool {
self.mime_type.starts_with("video/")
}
#[must_use]
pub fn is_audio(&self) -> bool {
self.mime_type.starts_with("audio/")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response {
pub id: Uuid,
pub conversation_id: String,
pub content: String,
pub response_type: ResponseType,
pub error: Option<ResponseError>,
pub metadata: HashMap<String, serde_json::Value>,
pub timestamp: DateTime<Utc>,
pub usage: Option<TokenUsage>,
pub flags: ResponseFlags,
pub suggestions: Vec<Suggestion>,
}
impl Response {
#[must_use]
pub fn text(conversation_id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
conversation_id: conversation_id.into(),
content: content.into(),
response_type: ResponseType::Text,
error: None,
metadata: HashMap::new(),
timestamp: Utc::now(),
usage: None,
flags: ResponseFlags::default(),
suggestions: Vec::new(),
}
}
#[must_use]
pub fn error(conversation_id: impl Into<String>, error: ResponseError) -> Self {
let mut response = Self::text(conversation_id, error.message.clone());
response.response_type = ResponseType::Error;
response.error = Some(error);
response
}
#[must_use]
pub fn with_usage(mut self, usage: TokenUsage) -> Self {
self.usage = Some(usage);
self
}
#[must_use]
pub fn with_suggestion(mut self, suggestion: Suggestion) -> Self {
self.suggestions.push(suggestion);
self
}
#[must_use]
pub fn with_flags(mut self, flags: ResponseFlags) -> Self {
self.flags = flags;
self
}
#[must_use]
pub fn is_error(&self) -> bool {
self.error.is_some() || matches!(self.response_type, ResponseType::Error)
}
#[must_use]
pub fn total_tokens(&self) -> usize {
self.usage.as_ref().map_or(0, |u| u.total_tokens)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ResponseType {
Text,
Markdown,
Html,
Json,
Error,
Stream,
Embed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseError {
pub code: String,
pub message: String,
pub retryable: bool,
pub retry_after: Option<u64>,
}
impl ResponseError {
#[must_use]
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
retryable: false,
retry_after: None,
}
}
#[must_use]
pub fn retryable(mut self, retryable: bool) -> Self {
self.retryable = retryable;
self
}
#[must_use]
pub fn retry_after(mut self, seconds: u64) -> Self {
self.retry_after = Some(seconds);
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResponseFlags {
pub truncated: bool,
pub partial: bool,
pub cached: bool,
pub sensitive: bool,
pub no_cache: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenUsage {
pub input_tokens: usize,
pub output_tokens: usize,
pub total_tokens: usize,
pub estimated_cost: f64,
pub model: String,
}
impl TokenUsage {
#[must_use]
pub fn new(input_tokens: usize, output_tokens: usize, model: impl Into<String>) -> Self {
let model_string = model.into();
let total_tokens = input_tokens + output_tokens;
let estimated_cost = Self::calculate_cost(input_tokens, output_tokens, &model_string);
Self {
input_tokens,
output_tokens,
total_tokens,
estimated_cost,
model: model_string,
}
}
fn calculate_cost(input_tokens: usize, output_tokens: usize, model: &str) -> f64 {
let (input_rate, output_rate) = match model {
"anthropic.claude-opus-4-1" => (0.015, 0.075),
"anthropic.claude-sonnet-4" => (0.003, 0.015),
"anthropic.claude-haiku" => (0.00025, 0.00125),
_ => (0.001, 0.002),
};
(input_tokens as f64 / 1000.0)
.mul_add(input_rate, output_tokens as f64 / 1000.0 * output_rate)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Suggestion {
pub text: String,
pub action: SuggestionAction,
pub icon: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SuggestionAction {
Message(String),
Command(String),
Url(String),
Custom(serde_json::Value),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_message_creation() {
let message = Message::text("Hello, bot!");
assert_eq!(message.content, "Hello, bot!");
assert_eq!(message.message_type, MessageType::Text);
assert!(message.validate().is_ok());
}
#[test]
fn test_message_builder() {
let attachment = Attachment::new(
"image.png",
"image/png",
1024,
"http://example.com/image.png",
);
let message = Message::text("Check this out")
.with_conversation_id("conv-123")
.with_user_id("user-456")
.with_attachment(attachment)
.with_metadata("key", serde_json::json!("value"));
assert_eq!(message.conversation_id, "conv-123");
assert_eq!(message.user_id, "user-456");
assert_eq!(message.attachments.len(), 1);
assert!(message.metadata.contains_key("key"));
}
#[test]
fn test_empty_message_validation() {
let mut message = Message::text("");
message.content.clear();
assert!(message.validate().is_err());
}
#[test]
fn test_response_creation() {
let response = Response::text("conv-123", "Hello, user!");
assert_eq!(response.content, "Hello, user!");
assert_eq!(response.conversation_id, "conv-123");
assert!(!response.is_error());
}
#[test]
fn test_error_response() {
let error = ResponseError::new("E001", "Something went wrong")
.retryable(true)
.retry_after(60);
let response = Response::error("conv-123", error);
assert!(response.is_error());
assert!(response.error.is_some());
let error = response.error.unwrap();
assert_eq!(error.code, "E001");
assert!(error.retryable);
assert_eq!(error.retry_after, Some(60));
}
#[test]
fn test_token_usage() {
let usage = TokenUsage::new(100, 50, "anthropic.claude-opus-4-1");
assert_eq!(usage.total_tokens, 150);
assert!(usage.estimated_cost > 0.0);
}
#[test]
fn test_attachment_types() {
let image = Attachment::new(
"photo.jpg",
"image/jpeg",
2048,
"http://example.com/photo.jpg",
);
assert!(image.is_image());
assert!(!image.is_video());
assert!(!image.is_audio());
let video = Attachment::new(
"movie.mp4",
"video/mp4",
1_048_576,
"http://example.com/movie.mp4",
);
assert!(!video.is_image());
assert!(video.is_video());
assert!(!video.is_audio());
let audio = Attachment::new(
"song.mp3",
"audio/mpeg",
4096,
"http://example.com/song.mp3",
);
assert!(!audio.is_image());
assert!(!audio.is_video());
assert!(audio.is_audio());
}
#[cfg(feature = "property-testing")]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn test_message_id_uniqueness(content in any::<String>()) {
let msg1 = Message::text(content.clone());
let msg2 = Message::text(content);
prop_assert_ne!(msg1.id, msg2.id);
}
#[test]
fn test_token_cost_calculation(
input in 0usize..100_000,
output in 0usize..100_000
) {
let usage = TokenUsage::new(input, output, "test-model");
prop_assert_eq!(usage.total_tokens, input + output);
prop_assert!(usage.estimated_cost >= 0.0);
}
}
}
}