- Server: nostr crate, migration 008 (nostr_pubkey column), challenge/verify endpoints for Schnorr-signed NIP-07 auth, invite-by-nostr endpoint - Client: NIP-07 extension detection, relay profile fetch, Nostr login button on login/register pages, Nostr tab in invite modal, profile page handles no-email Nostr users - Sentinel emails (nostr:<prefix>) hidden at API boundary via public_email() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
318 lines
8.2 KiB
Rust
318 lines
8.2 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>,
|
|
}
|
|
|
|
#[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,
|
|
}
|