Jason Tudisco 66bbc44f75 fix: double messages on re-login, nostr profile fetch, show npub in profile
- Fix duplicate messages after logout→re-login: ws.disconnect() now clears
  event listeners so initChat() doesn't stack duplicate handlers
- Nostr profile fetch: race multiple relays (damus, nostr.band, nos.lol)
  for better reliability
- Add nostr_pubkey field to UserPublic — returned from me/login/rooms APIs
- Profile page shows truncated npub instead of email for Nostr users
- Avatar service handles external URLs (Nostr profile pictures)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:13:23 -06:00

320 lines
8.3 KiB
Rust

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<String>,
}
#[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<String>,
pub hash: Option<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nostr_pubkey: Option<String>,
}
#[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<UserPublic>,
}
#[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<String>,
#[serde(default)]
image_url: Option<String>,
},
#[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<String>,
pub is_ai: bool,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_meta: Option<AiMeta>,
#[serde(default)]
pub avatar_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
}
/// 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<Vec<ToolResult>>,
}
#[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<String>,
}
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<String>,
pub profile_picture: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NostrInviteRequest {
pub room_id: String,
pub nostr_pubkey: String,
}