use serde::{Deserialize, Serialize}; // ── Database models ── #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct User { pub id: String, pub email: String, pub display_name: String, pub password_hash: String, pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Room { pub id: String, pub name: String, pub model_id: String, pub created_by: String, pub ai_always_respond: bool, pub system_prompt: String, pub ai_name: String, pub created_at: String, pub deleted_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Message { pub id: String, pub room_id: String, pub sender_id: String, pub sender_name: String, pub content: String, pub mentions: String, pub is_ai: bool, pub created_at: String, pub ai_meta: Option, pub hash: Option, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Invite { pub id: String, pub room_id: String, pub invited_by: String, pub email: String, pub token: String, pub used: bool, pub created_at: String, } // ── API request/response types ── #[derive(Debug, Deserialize)] pub struct RegisterRequest { pub email: String, pub password: String, pub display_name: String, } #[derive(Debug, Deserialize)] pub struct LoginRequest { pub email: String, pub password: String, } #[derive(Debug, Serialize)] pub struct AuthResponse { pub token: String, pub user: UserPublic, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserPublic { pub id: String, pub email: String, pub display_name: String, #[serde(skip_serializing_if = "Option::is_none")] pub avatar_url: Option, } #[derive(Debug, Deserialize)] pub struct CreateRoomRequest { pub name: String, pub model_id: String, #[serde(default)] pub ai_always_respond: bool, #[serde(default = "default_system_prompt")] pub system_prompt: String, #[serde(default = "default_ai_name")] pub ai_name: String, } fn default_ai_name() -> String { let names = [ "Nova", "Atlas", "Sage", "Echo", "Pixel", "Cosmo", "Ember", "Flux", "Lyra", "Onyx", ]; let idx = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as usize % names.len()) .unwrap_or(0); names[idx].to_string() } fn default_system_prompt() -> String { "You are a helpful AI assistant participating in a group chat. Be conversational, helpful, and concise. You can see messages from all participants. When mentioned with @ai, respond helpfully.\n\nYou have access to tools:\n- **brave_search**: Search the web for current information. Use this when asked about recent events, news, facts you're unsure about, or anything that needs up-to-date information.\n- **web_fetch**: Fetch and read the content of a web page. Use this when a user shares a URL and wants you to read/summarize it, or when you need more details from a search result.\n\nUse tools proactively when they would help answer the question better. You don't need to ask permission to use them.".to_string() } #[derive(Debug, Serialize)] pub struct RoomResponse { pub id: String, pub name: String, pub model_id: String, pub created_by: String, pub ai_always_respond: bool, pub system_prompt: String, pub ai_name: String, pub created_at: String, pub members: Vec, } #[derive(Debug, Deserialize)] pub struct CreateInviteRequest { pub room_id: String, pub email: String, } // ── WebSocket event types ── #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum WsClientMessage { #[serde(rename = "send_message")] SendMessage { room_id: String, content: String, #[serde(default)] mentions: Vec, #[serde(default)] image_url: Option, }, #[serde(rename = "join_room")] JoinRoom { room_id: String }, #[serde(rename = "typing")] Typing { room_id: String }, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum WsServerMessage { #[serde(rename = "new_message")] NewMessage { message: MessagePayload, }, #[serde(rename = "ai_typing")] AiTyping { room_id: String, }, #[serde(rename = "user_typing")] UserTyping { room_id: String, user_id: String, display_name: String, }, #[serde(rename = "error")] Error { message: String, }, #[serde(rename = "joined")] Joined { room_id: String, }, #[serde(rename = "room_deleted")] RoomDeleted { room_id: String, }, #[serde(rename = "room_cleared")] RoomCleared { room_id: String, }, #[serde(rename = "ai_tool_usage")] AiToolUsage { room_id: String, tool_name: String, status: String, }, #[serde(rename = "ai_stream_chunk")] AiStreamChunk { room_id: String, message_id: String, delta: String, }, #[serde(rename = "ai_stream_end")] AiStreamEnd { room_id: String, message_id: String, }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MessagePayload { pub id: String, pub room_id: String, pub sender_id: String, pub sender_name: String, pub content: String, pub mentions: Vec, pub is_ai: bool, pub created_at: String, #[serde(skip_serializing_if = "Option::is_none")] pub ai_meta: Option, #[serde(default)] pub avatar_hash: String, #[serde(skip_serializing_if = "Option::is_none")] pub avatar_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub image_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hash: Option, } /// Compute Gravatar-compatible MD5 hash from an email address. pub fn gravatar_hash(email: &str) -> String { use md5::{Md5, Digest}; let normalized = email.trim().to_lowercase(); let result = Md5::digest(normalized.as_bytes()); format!("{:x}", result) } /// Compute SHA-256 integrity hash from created_at timestamp + message content. pub fn message_hash(created_at: &str, content: &str) -> String { use sha2::{Sha256, Digest}; let mut hasher = Sha256::new(); hasher.update(created_at.as_bytes()); hasher.update(content.as_bytes()); format!("{:x}", hasher.finalize()) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AiMeta { pub model: String, pub prompt_tokens: u32, pub completion_tokens: u32, pub total_tokens: u32, pub response_ms: u64, #[serde(skip_serializing_if = "Option::is_none")] pub tool_results: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolResult { pub tool: String, pub input: String, pub result: String, } // ── Broadcast event (internal channel) ── #[derive(Debug, Clone)] pub struct BroadcastEvent { pub room_id: String, pub message: WsServerMessage, } // ── JWT Claims ── #[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub sub: String, // user_id pub email: String, pub display_name: String, pub exp: usize, } // ── Pagination ── #[derive(Debug, Deserialize)] pub struct PaginationParams { #[serde(default = "default_limit")] pub limit: i64, pub before: Option, } fn default_limit() -> i64 { 50 } /// Returns "" if the email is a sentinel nostr: value, otherwise returns it as-is. pub fn public_email(email: &str) -> String { if email.starts_with("nostr:") { String::new() } else { email.to_string() } } // ── Nostr auth types ── #[derive(Debug, Serialize)] pub struct NostrChallengeResponse { pub challenge: String, } #[derive(Debug, Deserialize)] pub struct NostrVerifyRequest { pub signed_event: String, pub challenge: String, pub profile_name: Option, pub profile_picture: Option, } #[derive(Debug, Deserialize)] pub struct NostrInviteRequest { pub room_id: String, pub nostr_pubkey: String, }