Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
1379 lines
39 KiB
Rust
1379 lines
39 KiB
Rust
//! Presence manager for real-time collaboration layer.
|
|
//!
|
|
//! This module manages user online status, cursor positions, and activity tracking
|
|
//! for collaborative sessions. It uses DashMap for high-concurrency support.
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use dashmap::DashMap;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
use tracing::debug;
|
|
use uuid::Uuid;
|
|
|
|
/// Unique identifier for a WebSocket connection.
|
|
pub type ConnectionId = Uuid;
|
|
|
|
/// Unique identifier for a collaborative session.
|
|
pub type CollabSessionId = Uuid;
|
|
|
|
/// User presence status.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum PresenceStatus {
|
|
/// User is actively interacting.
|
|
Active,
|
|
/// User has been idle for a short period.
|
|
Idle,
|
|
/// User is away (idle for an extended period).
|
|
Away,
|
|
}
|
|
|
|
impl Default for PresenceStatus {
|
|
fn default() -> Self {
|
|
Self::Active
|
|
}
|
|
}
|
|
|
|
/// Cursor position in a message/conversation context.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct PresenceCursor {
|
|
/// Index of the message in the conversation.
|
|
pub message_index: usize,
|
|
/// Start character position within the message.
|
|
pub char_start: usize,
|
|
/// End character position within the message (for selections).
|
|
pub char_end: usize,
|
|
}
|
|
|
|
impl Default for PresenceCursor {
|
|
fn default() -> Self {
|
|
Self {
|
|
message_index: 0,
|
|
char_start: 0,
|
|
char_end: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PresenceCursor {
|
|
/// Create a new cursor at a specific position.
|
|
pub fn new(message_index: usize, char_start: usize, char_end: usize) -> Self {
|
|
Self {
|
|
message_index,
|
|
char_start,
|
|
char_end,
|
|
}
|
|
}
|
|
|
|
/// Create a cursor at the beginning of a message.
|
|
pub fn at_message(message_index: usize) -> Self {
|
|
Self {
|
|
message_index,
|
|
char_start: 0,
|
|
char_end: 0,
|
|
}
|
|
}
|
|
|
|
/// Check if the cursor represents a selection (range).
|
|
pub fn is_selection(&self) -> bool {
|
|
self.char_start != self.char_end
|
|
}
|
|
|
|
/// Get the length of the selection (0 if not a selection).
|
|
pub fn selection_length(&self) -> usize {
|
|
if self.char_start < self.char_end {
|
|
self.char_end - self.char_start
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
}
|
|
|
|
/// User presence information.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PresenceUser {
|
|
/// Unique connection identifier.
|
|
pub connection_id: ConnectionId,
|
|
/// Display name of the user.
|
|
pub display_name: String,
|
|
/// Current presence status.
|
|
pub status: PresenceStatus,
|
|
/// Current cursor position (if any).
|
|
pub cursor: Option<PresenceCursor>,
|
|
/// Timestamp of the last activity.
|
|
pub last_activity: DateTime<Utc>,
|
|
/// User's color for UI display (hex code).
|
|
pub color: String,
|
|
/// Session ID the user is currently in.
|
|
pub session_id: CollabSessionId,
|
|
}
|
|
|
|
impl PresenceUser {
|
|
/// Create a new presence user.
|
|
pub fn new(connection_id: ConnectionId, display_name: String, session_id: CollabSessionId) -> Self {
|
|
Self {
|
|
connection_id,
|
|
display_name,
|
|
status: PresenceStatus::Active,
|
|
cursor: None,
|
|
last_activity: Utc::now(),
|
|
color: generate_user_color(connection_id),
|
|
session_id,
|
|
}
|
|
}
|
|
|
|
/// Update the user's last activity timestamp.
|
|
pub fn touch(&mut self) {
|
|
self.last_activity = Utc::now();
|
|
}
|
|
|
|
/// Update the user's cursor position.
|
|
pub fn set_cursor(&mut self, cursor: PresenceCursor) {
|
|
self.cursor = Some(cursor);
|
|
self.touch();
|
|
}
|
|
|
|
/// Clear the user's cursor.
|
|
pub fn clear_cursor(&mut self) {
|
|
self.cursor = None;
|
|
self.touch();
|
|
}
|
|
|
|
/// Update the user's status.
|
|
pub fn set_status(&mut self, status: PresenceStatus) {
|
|
self.status = status;
|
|
self.touch();
|
|
}
|
|
|
|
/// Check if the user has been idle for longer than the given duration.
|
|
pub fn is_idle_for(&self, duration: Duration) -> bool {
|
|
let now = Utc::now();
|
|
let elapsed = now.signed_duration_since(self.last_activity);
|
|
elapsed.num_seconds() as u64 > duration.as_secs()
|
|
}
|
|
}
|
|
|
|
/// Session information for collaboration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CollabSession {
|
|
/// Unique session identifier.
|
|
pub id: CollabSessionId,
|
|
/// The agent/session this collaboration is for.
|
|
pub agent_session_id: uuid::Uuid,
|
|
/// Owner's connection ID.
|
|
pub owner_connection_id: ConnectionId,
|
|
/// Share mode (e.g., "read", "write", "admin").
|
|
pub share_mode: String,
|
|
/// Maximum number of participants.
|
|
pub max_participants: usize,
|
|
/// When the session was created.
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl CollabSession {
|
|
/// Create a new collaboration session.
|
|
pub fn new(
|
|
agent_session_id: uuid::Uuid,
|
|
owner_connection_id: ConnectionId,
|
|
share_mode: String,
|
|
max_participants: usize,
|
|
) -> Self {
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
agent_session_id,
|
|
owner_connection_id,
|
|
share_mode,
|
|
max_participants,
|
|
created_at: Utc::now(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Configuration for presence management.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PresenceConfig {
|
|
/// Duration after which a user is considered idle.
|
|
#[serde(with = "duration_serde")]
|
|
pub idle_timeout: Duration,
|
|
/// Duration after which a user is considered away.
|
|
#[serde(with = "duration_serde")]
|
|
pub away_timeout: Duration,
|
|
/// Duration after which a user is automatically removed.
|
|
#[serde(with = "duration_serde")]
|
|
pub cleanup_timeout: Duration,
|
|
/// Interval for running cleanup tasks.
|
|
#[serde(with = "duration_serde")]
|
|
pub cleanup_interval: Duration,
|
|
}
|
|
|
|
impl Default for PresenceConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
idle_timeout: Duration::from_secs(30), // 30 seconds
|
|
away_timeout: Duration::from_secs(120), // 2 minutes
|
|
cleanup_timeout: Duration::from_secs(300), // 5 minutes
|
|
cleanup_interval: Duration::from_secs(60), // 1 minute
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Custom serialization for Duration (as seconds).
|
|
mod duration_serde {
|
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|
use std::time::Duration;
|
|
|
|
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
duration.as_secs().serialize(serializer)
|
|
}
|
|
|
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let secs = u64::deserialize(deserializer)?;
|
|
Ok(Duration::from_secs(secs))
|
|
}
|
|
}
|
|
|
|
/// Presence manager for tracking users in collaborative sessions.
|
|
#[derive(Debug)]
|
|
pub struct PresenceManager {
|
|
/// Configuration.
|
|
config: PresenceConfig,
|
|
/// All active users by connection ID.
|
|
users: DashMap<ConnectionId, PresenceUser>,
|
|
/// Users indexed by session ID.
|
|
session_users: DashMap<CollabSessionId, Vec<ConnectionId>>,
|
|
/// Active collaboration sessions.
|
|
sessions: DashMap<CollabSessionId, CollabSession>,
|
|
/// Sessions indexed by agent session ID.
|
|
agent_sessions: DashMap<uuid::Uuid, CollabSessionId>,
|
|
}
|
|
|
|
impl PresenceManager {
|
|
/// Create a new presence manager with default configuration.
|
|
pub fn new() -> Self {
|
|
Self::with_config(PresenceConfig::default())
|
|
}
|
|
|
|
/// Create a new presence manager with custom configuration.
|
|
pub fn with_config(config: PresenceConfig) -> Self {
|
|
Self {
|
|
config,
|
|
users: DashMap::new(),
|
|
session_users: DashMap::new(),
|
|
sessions: DashMap::new(),
|
|
agent_sessions: DashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Create a collaboration session for an agent.
|
|
pub fn create_session(
|
|
&self,
|
|
agent_session_id: uuid::Uuid,
|
|
owner_connection_id: ConnectionId,
|
|
share_mode: String,
|
|
max_participants: usize,
|
|
) -> CollabSession {
|
|
// Check if a session already exists for this agent
|
|
if let Some(existing_id) = self.agent_sessions.get(&agent_session_id) {
|
|
if let Some(session) = self.sessions.get(existing_id.value()) {
|
|
return session.clone();
|
|
}
|
|
}
|
|
|
|
let session = CollabSession::new(
|
|
agent_session_id,
|
|
owner_connection_id,
|
|
share_mode,
|
|
max_participants,
|
|
);
|
|
|
|
// Index by session ID
|
|
self.sessions.insert(session.id, session.clone());
|
|
// Index by agent session ID
|
|
self.agent_sessions.insert(agent_session_id, session.id);
|
|
// Initialize empty user list
|
|
self.session_users.insert(session.id, Vec::new());
|
|
|
|
debug!(
|
|
session_id = %session.id,
|
|
agent_session_id = %agent_session_id,
|
|
owner = %owner_connection_id,
|
|
"Created collaboration session"
|
|
);
|
|
|
|
session
|
|
}
|
|
|
|
/// Get a collaboration session by ID.
|
|
pub fn get_session(&self, session_id: CollabSessionId) -> Option<CollabSession> {
|
|
self.sessions.get(&session_id).map(|s| s.clone())
|
|
}
|
|
|
|
/// Get a collaboration session by agent session ID.
|
|
pub fn get_session_by_agent(&self, agent_session_id: uuid::Uuid) -> Option<CollabSession> {
|
|
self.agent_sessions
|
|
.get(&agent_session_id)
|
|
.and_then(|id| self.sessions.get(id.value()).map(|s| s.clone()))
|
|
}
|
|
|
|
/// Remove a collaboration session.
|
|
pub fn remove_session(&self, session_id: CollabSessionId) -> Option<CollabSession> {
|
|
let session = self.sessions.remove(&session_id).map(|(_, s)| s);
|
|
|
|
if let Some(ref session) = session {
|
|
// Remove all users from the session
|
|
if let Some((_, user_ids)) = self.session_users.remove(&session_id) {
|
|
for user_id in user_ids {
|
|
self.users.remove(&user_id);
|
|
}
|
|
}
|
|
// Remove from agent session index
|
|
self.agent_sessions.remove(&session.agent_session_id);
|
|
|
|
debug!(
|
|
session_id = %session_id,
|
|
"Removed collaboration session"
|
|
);
|
|
}
|
|
|
|
session
|
|
}
|
|
|
|
/// User joins a session.
|
|
pub fn join_session(
|
|
&self,
|
|
session_id: CollabSessionId,
|
|
connection_id: ConnectionId,
|
|
display_name: String,
|
|
) -> Result<PresenceUser, PresenceError> {
|
|
// Check if session exists
|
|
let session = self
|
|
.sessions
|
|
.get(&session_id)
|
|
.ok_or(PresenceError::SessionNotFound(session_id))?
|
|
.clone();
|
|
|
|
// Check if user is already in another session
|
|
if let Some(existing_user) = self.users.get(&connection_id) {
|
|
if existing_user.session_id != session_id {
|
|
// Leave the previous session first
|
|
self.leave_session(connection_id)?;
|
|
} else {
|
|
// Already in this session
|
|
return Ok(existing_user.clone());
|
|
}
|
|
}
|
|
|
|
// Check max participants
|
|
if let Some(users) = self.session_users.get(&session_id) {
|
|
if users.len() >= session.max_participants {
|
|
return Err(PresenceError::SessionFull(session_id));
|
|
}
|
|
}
|
|
|
|
// Create the user
|
|
let user = PresenceUser::new(connection_id, display_name, session_id);
|
|
|
|
// Add to users map
|
|
self.users.insert(connection_id, user.clone());
|
|
|
|
// Add to session users index
|
|
self.session_users
|
|
.entry(session_id)
|
|
.or_default()
|
|
.push(connection_id);
|
|
|
|
debug!(
|
|
session_id = %session_id,
|
|
connection_id = %connection_id,
|
|
display_name = %user.display_name,
|
|
"User joined session"
|
|
);
|
|
|
|
Ok(user)
|
|
}
|
|
|
|
/// User leaves a session.
|
|
pub fn leave_session(&self, connection_id: ConnectionId) -> Result<PresenceUser, PresenceError> {
|
|
let user = self
|
|
.users
|
|
.remove(&connection_id)
|
|
.ok_or(PresenceError::UserNotFound(connection_id))?
|
|
.1;
|
|
|
|
// Remove from session users index
|
|
if let Some(mut users) = self.session_users.get_mut(&user.session_id) {
|
|
users.retain(|id| *id != connection_id);
|
|
}
|
|
|
|
debug!(
|
|
session_id = %user.session_id,
|
|
connection_id = %connection_id,
|
|
"User left session"
|
|
);
|
|
|
|
Ok(user)
|
|
}
|
|
|
|
/// Update user's cursor position.
|
|
pub fn update_cursor(
|
|
&self,
|
|
connection_id: ConnectionId,
|
|
cursor: PresenceCursor,
|
|
) -> Result<(), PresenceError> {
|
|
let mut user = self
|
|
.users
|
|
.get_mut(&connection_id)
|
|
.ok_or(PresenceError::UserNotFound(connection_id))?;
|
|
|
|
user.set_cursor(cursor);
|
|
user.status = PresenceStatus::Active;
|
|
|
|
debug!(
|
|
connection_id = %connection_id,
|
|
message_index = cursor.message_index,
|
|
char_start = cursor.char_start,
|
|
char_end = cursor.char_end,
|
|
"Updated cursor"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Clear user's cursor.
|
|
pub fn clear_cursor(&self, connection_id: ConnectionId) -> Result<(), PresenceError> {
|
|
let mut user = self
|
|
.users
|
|
.get_mut(&connection_id)
|
|
.ok_or(PresenceError::UserNotFound(connection_id))?;
|
|
|
|
user.clear_cursor();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Update user's status.
|
|
pub fn update_status(
|
|
&self,
|
|
connection_id: ConnectionId,
|
|
status: PresenceStatus,
|
|
) -> Result<(), PresenceError> {
|
|
let mut user = self
|
|
.users
|
|
.get_mut(&connection_id)
|
|
.ok_or(PresenceError::UserNotFound(connection_id))?;
|
|
|
|
user.set_status(status);
|
|
|
|
debug!(
|
|
connection_id = %connection_id,
|
|
status = ?status,
|
|
"Updated status"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Record user activity (heartbeat).
|
|
pub fn heartbeat(&self, connection_id: ConnectionId) -> Result<(), PresenceError> {
|
|
let mut user = self
|
|
.users
|
|
.get_mut(&connection_id)
|
|
.ok_or(PresenceError::UserNotFound(connection_id))?;
|
|
|
|
user.touch();
|
|
user.status = PresenceStatus::Active;
|
|
|
|
debug!(
|
|
connection_id = %connection_id,
|
|
"Heartbeat received"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get all users in a session.
|
|
pub fn get_session_users(&self, session_id: CollabSessionId) -> Vec<PresenceUser> {
|
|
self.session_users
|
|
.get(&session_id)
|
|
.map(|ids| {
|
|
ids.iter()
|
|
.filter_map(|id| self.users.get(id).map(|u| u.clone()))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Get a specific user by connection ID.
|
|
pub fn get_user(&self, connection_id: ConnectionId) -> Option<PresenceUser> {
|
|
self.users.get(&connection_id).map(|u| u.clone())
|
|
}
|
|
|
|
/// Get the count of users in a session.
|
|
pub fn get_session_user_count(&self, session_id: CollabSessionId) -> usize {
|
|
self.session_users
|
|
.get(&session_id)
|
|
.map(|ids| ids.len())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Get all active sessions.
|
|
pub fn list_sessions(&self) -> Vec<CollabSession> {
|
|
self.sessions.iter().map(|s| s.clone()).collect()
|
|
}
|
|
|
|
/// Update user statuses based on activity timeouts.
|
|
pub fn update_idle_statuses(&self) {
|
|
let now = Utc::now();
|
|
|
|
for mut entry in self.users.iter_mut() {
|
|
let user = entry.value_mut();
|
|
let elapsed = now.signed_duration_since(user.last_activity);
|
|
let elapsed_secs = elapsed.num_seconds() as u64;
|
|
|
|
let new_status = if elapsed_secs > self.config.away_timeout.as_secs() {
|
|
PresenceStatus::Away
|
|
} else if elapsed_secs > self.config.idle_timeout.as_secs() {
|
|
PresenceStatus::Idle
|
|
} else {
|
|
PresenceStatus::Active
|
|
};
|
|
|
|
if user.status != new_status {
|
|
user.status = new_status;
|
|
debug!(
|
|
connection_id = %user.connection_id,
|
|
status = ?new_status,
|
|
"Auto-updated user status"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clean up users that have been inactive for too long.
|
|
/// Returns the list of removed connection IDs.
|
|
pub fn cleanup_inactive_users(&self) -> Vec<ConnectionId> {
|
|
let mut removed = Vec::new();
|
|
let now = Utc::now();
|
|
|
|
// Find users to remove
|
|
for entry in self.users.iter() {
|
|
let user = entry.value();
|
|
let elapsed = now.signed_duration_since(user.last_activity);
|
|
|
|
if elapsed.num_seconds() as u64 > self.config.cleanup_timeout.as_secs() {
|
|
removed.push(user.connection_id);
|
|
}
|
|
}
|
|
|
|
// Remove the users
|
|
for connection_id in &removed {
|
|
if let Some((_, user)) = self.users.remove(connection_id) {
|
|
// Remove from session index
|
|
if let Some(mut users) = self.session_users.get_mut(&user.session_id) {
|
|
users.retain(|id| *id != *connection_id);
|
|
}
|
|
|
|
debug!(
|
|
connection_id = %connection_id,
|
|
"Cleaned up inactive user"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Also clean up empty sessions
|
|
let empty_sessions: Vec<CollabSessionId> = self
|
|
.session_users
|
|
.iter()
|
|
.filter(|entry| entry.value().is_empty())
|
|
.map(|entry| *entry.key())
|
|
.collect();
|
|
|
|
for session_id in empty_sessions {
|
|
self.remove_session(session_id);
|
|
}
|
|
|
|
removed
|
|
}
|
|
|
|
/// Get presence statistics.
|
|
pub fn stats(&self) -> PresenceStats {
|
|
let total_users = self.users.len();
|
|
let total_sessions = self.sessions.len();
|
|
|
|
let mut status_counts = HashMap::new();
|
|
for entry in self.users.iter() {
|
|
let status = entry.value().status;
|
|
*status_counts.entry(status).or_insert(0) += 1;
|
|
}
|
|
|
|
PresenceStats {
|
|
total_users,
|
|
total_sessions,
|
|
active_users: *status_counts.get(&PresenceStatus::Active).unwrap_or(&0),
|
|
idle_users: *status_counts.get(&PresenceStatus::Idle).unwrap_or(&0),
|
|
away_users: *status_counts.get(&PresenceStatus::Away).unwrap_or(&0),
|
|
}
|
|
}
|
|
|
|
/// Get the configuration.
|
|
pub fn config(&self) -> &PresenceConfig {
|
|
&self.config
|
|
}
|
|
|
|
/// Check if a user is in a specific session.
|
|
pub fn is_user_in_session(&self, connection_id: ConnectionId, session_id: CollabSessionId) -> bool {
|
|
self.users
|
|
.get(&connection_id)
|
|
.map(|u| u.session_id == session_id)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Check if a connection is the owner of a session.
|
|
pub fn is_session_owner(&self, connection_id: ConnectionId, session_id: CollabSessionId) -> bool {
|
|
self.sessions
|
|
.get(&session_id)
|
|
.map(|s| s.owner_connection_id == connection_id)
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
impl Default for PresenceManager {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Presence statistics.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PresenceStats {
|
|
/// Total number of users.
|
|
pub total_users: usize,
|
|
/// Total number of sessions.
|
|
pub total_sessions: usize,
|
|
/// Number of active users.
|
|
pub active_users: usize,
|
|
/// Number of idle users.
|
|
pub idle_users: usize,
|
|
/// Number of away users.
|
|
pub away_users: usize,
|
|
}
|
|
|
|
/// Errors for presence operations.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum PresenceError {
|
|
/// Session not found.
|
|
#[error("Session not found: {0}")]
|
|
SessionNotFound(CollabSessionId),
|
|
|
|
/// User not found.
|
|
#[error("User not found: {0}")]
|
|
UserNotFound(ConnectionId),
|
|
|
|
/// Session is full.
|
|
#[error("Session is full: {0}")]
|
|
SessionFull(CollabSessionId),
|
|
|
|
/// User already in session.
|
|
#[error("User {0} is already in session {1}")]
|
|
AlreadyInSession(ConnectionId, CollabSessionId),
|
|
|
|
/// Invalid cursor position.
|
|
#[error("Invalid cursor position")]
|
|
InvalidCursor,
|
|
}
|
|
|
|
/// Generate a consistent color for a user based on their connection ID.
|
|
fn generate_user_color(connection_id: ConnectionId) -> String {
|
|
// Use the connection ID bytes to generate a color
|
|
let bytes = connection_id.as_bytes();
|
|
|
|
// Create a hue from the first few bytes
|
|
let hue = (bytes[0] as u16 * 256 + bytes[1] as u16) % 360;
|
|
|
|
// Convert HSL to hex (saturation: 70%, lightness: 50%)
|
|
hsl_to_hex(hue, 70, 50)
|
|
}
|
|
|
|
/// Convert HSL color to hex string.
|
|
fn hsl_to_hex(h: u16, s: u8, l: u8) -> String {
|
|
let s = s as f64 / 100.0;
|
|
let l = l as f64 / 100.0;
|
|
|
|
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
|
let x = c * (1.0 - ((h as f64 / 60.0) % 2.0 - 1.0).abs());
|
|
let m = l - c / 2.0;
|
|
|
|
let (r, g, b) = if h < 60 {
|
|
(c, x, 0.0)
|
|
} else if h < 120 {
|
|
(x, c, 0.0)
|
|
} else if h < 180 {
|
|
(0.0, c, x)
|
|
} else if h < 240 {
|
|
(0.0, x, c)
|
|
} else if h < 300 {
|
|
(x, 0.0, c)
|
|
} else {
|
|
(c, 0.0, x)
|
|
};
|
|
|
|
let r = ((r + m) * 255.0).round() as u8;
|
|
let g = ((g + m) * 255.0).round() as u8;
|
|
let b = ((b + m) * 255.0).round() as u8;
|
|
|
|
format!("#{:02X}{:02X}{:02X}", r, g, b)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
fn setup() -> PresenceManager {
|
|
PresenceManager::new()
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_session() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
assert!(manager.get_session(session.id).is_some());
|
|
assert!(manager.get_session_by_agent(agent_session_id).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_join_session() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
let user = manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
assert_eq!(user.connection_id, user_id);
|
|
assert_eq!(user.display_name, "Alice");
|
|
assert_eq!(user.status, PresenceStatus::Active);
|
|
|
|
let users = manager.get_session_users(session.id);
|
|
assert_eq!(users.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_leave_session() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
let left_user = manager.leave_session(user_id).unwrap();
|
|
assert_eq!(left_user.connection_id, user_id);
|
|
|
|
let users = manager.get_session_users(session.id);
|
|
assert_eq!(users.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_cursor() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
let cursor = PresenceCursor::new(5, 10, 20);
|
|
manager.update_cursor(user_id, cursor).unwrap();
|
|
|
|
let user = manager.get_user(user_id).unwrap();
|
|
assert!(user.cursor.is_some());
|
|
let c = user.cursor.unwrap();
|
|
assert_eq!(c.message_index, 5);
|
|
assert_eq!(c.char_start, 10);
|
|
assert_eq!(c.char_end, 20);
|
|
assert!(c.is_selection());
|
|
assert_eq!(c.selection_length(), 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clear_cursor() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
let cursor = PresenceCursor::new(5, 10, 20);
|
|
manager.update_cursor(user_id, cursor).unwrap();
|
|
manager.clear_cursor(user_id).unwrap();
|
|
|
|
let user = manager.get_user(user_id).unwrap();
|
|
assert!(user.cursor.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_status() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
manager.update_status(user_id, PresenceStatus::Idle).unwrap();
|
|
|
|
let user = manager.get_user(user_id).unwrap();
|
|
assert_eq!(user.status, PresenceStatus::Idle);
|
|
}
|
|
|
|
#[test]
|
|
fn test_heartbeat() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
// Set to idle
|
|
manager.update_status(user_id, PresenceStatus::Idle).unwrap();
|
|
|
|
// Heartbeat should set back to active
|
|
manager.heartbeat(user_id).unwrap();
|
|
|
|
let user = manager.get_user(user_id).unwrap();
|
|
assert_eq!(user.status, PresenceStatus::Active);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_full() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
2, // max 2 participants
|
|
);
|
|
|
|
// First user
|
|
let user1 = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user1, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
// Second user
|
|
let user2 = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user2, "Bob".to_string())
|
|
.unwrap();
|
|
|
|
// Third user should fail
|
|
let user3 = Uuid::new_v4();
|
|
let result = manager.join_session(session.id, user3, "Charlie".to_string());
|
|
assert!(matches!(result, Err(PresenceError::SessionFull(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_not_found() {
|
|
let manager = setup();
|
|
let user_id = Uuid::new_v4();
|
|
|
|
let result = manager.update_status(user_id, PresenceStatus::Idle);
|
|
assert!(matches!(result, Err(PresenceError::UserNotFound(_))));
|
|
|
|
let result = manager.update_cursor(user_id, PresenceCursor::default());
|
|
assert!(matches!(result, Err(PresenceError::UserNotFound(_))));
|
|
|
|
let result = manager.leave_session(user_id);
|
|
assert!(matches!(result, Err(PresenceError::UserNotFound(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_not_found() {
|
|
let manager = setup();
|
|
let session_id = Uuid::new_v4();
|
|
let user_id = Uuid::new_v4();
|
|
|
|
let result = manager.join_session(session_id, user_id, "Alice".to_string());
|
|
assert!(matches!(result, Err(PresenceError::SessionNotFound(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_idle_statuses() {
|
|
let config = PresenceConfig {
|
|
idle_timeout: Duration::from_millis(50),
|
|
away_timeout: Duration::from_millis(100),
|
|
cleanup_timeout: Duration::from_secs(300),
|
|
cleanup_interval: Duration::from_secs(60),
|
|
};
|
|
let manager = PresenceManager::with_config(config);
|
|
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
// Initially active
|
|
let user = manager.get_user(user_id).unwrap();
|
|
assert_eq!(user.status, PresenceStatus::Active);
|
|
|
|
// Wait for idle timeout
|
|
thread::sleep(Duration::from_millis(60));
|
|
manager.update_idle_statuses();
|
|
|
|
let user = manager.get_user(user_id).unwrap();
|
|
assert_eq!(user.status, PresenceStatus::Idle);
|
|
|
|
// Wait for away timeout
|
|
thread::sleep(Duration::from_millis(60));
|
|
manager.update_idle_statuses();
|
|
|
|
let user = manager.get_user(user_id).unwrap();
|
|
assert_eq!(user.status, PresenceStatus::Away);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cleanup_inactive_users() {
|
|
let config = PresenceConfig {
|
|
idle_timeout: Duration::from_secs(30),
|
|
away_timeout: Duration::from_secs(60),
|
|
cleanup_timeout: Duration::from_millis(100),
|
|
cleanup_interval: Duration::from_secs(60),
|
|
};
|
|
let manager = PresenceManager::with_config(config);
|
|
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
// User should be present
|
|
assert!(manager.get_user(user_id).is_some());
|
|
|
|
// Wait for cleanup timeout
|
|
thread::sleep(Duration::from_millis(150));
|
|
|
|
let removed = manager.cleanup_inactive_users();
|
|
assert_eq!(removed.len(), 1);
|
|
assert_eq!(removed[0], user_id);
|
|
|
|
// User should be removed
|
|
assert!(manager.get_user(user_id).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_session() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
// Add users
|
|
let user1 = Uuid::new_v4();
|
|
let user2 = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user1, "Alice".to_string())
|
|
.unwrap();
|
|
manager
|
|
.join_session(session.id, user2, "Bob".to_string())
|
|
.unwrap();
|
|
|
|
// Remove session
|
|
let removed = manager.remove_session(session.id).unwrap();
|
|
assert_eq!(removed.id, session.id);
|
|
|
|
// Session should be gone
|
|
assert!(manager.get_session(session.id).is_none());
|
|
assert!(manager.get_session_by_agent(agent_session_id).is_none());
|
|
|
|
// Users should be removed
|
|
assert!(manager.get_user(user1).is_none());
|
|
assert!(manager.get_user(user2).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_stats() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user1 = Uuid::new_v4();
|
|
let user2 = Uuid::new_v4();
|
|
let user3 = Uuid::new_v4();
|
|
|
|
manager
|
|
.join_session(session.id, user1, "Alice".to_string())
|
|
.unwrap();
|
|
manager
|
|
.join_session(session.id, user2, "Bob".to_string())
|
|
.unwrap();
|
|
manager
|
|
.join_session(session.id, user3, "Charlie".to_string())
|
|
.unwrap();
|
|
|
|
manager.update_status(user2, PresenceStatus::Idle).unwrap();
|
|
manager.update_status(user3, PresenceStatus::Away).unwrap();
|
|
|
|
let stats = manager.stats();
|
|
assert_eq!(stats.total_users, 3);
|
|
assert_eq!(stats.total_sessions, 1);
|
|
assert_eq!(stats.active_users, 1);
|
|
assert_eq!(stats.idle_users, 1);
|
|
assert_eq!(stats.away_users, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_user_in_session() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
assert!(manager.is_user_in_session(user_id, session.id));
|
|
assert!(!manager.is_user_in_session(Uuid::new_v4(), session.id));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_session_owner() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
assert!(manager.is_session_owner(owner, session.id));
|
|
assert!(!manager.is_session_owner(user_id, session.id));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cursor_default() {
|
|
let cursor = PresenceCursor::default();
|
|
assert_eq!(cursor.message_index, 0);
|
|
assert_eq!(cursor.char_start, 0);
|
|
assert_eq!(cursor.char_end, 0);
|
|
assert!(!cursor.is_selection());
|
|
}
|
|
|
|
#[test]
|
|
fn test_cursor_at_message() {
|
|
let cursor = PresenceCursor::at_message(5);
|
|
assert_eq!(cursor.message_index, 5);
|
|
assert_eq!(cursor.char_start, 0);
|
|
assert_eq!(cursor.char_end, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_touch() {
|
|
let mut user = PresenceUser::new(
|
|
Uuid::new_v4(),
|
|
"Alice".to_string(),
|
|
Uuid::new_v4(),
|
|
);
|
|
|
|
let before = user.last_activity;
|
|
thread::sleep(Duration::from_millis(10));
|
|
user.touch();
|
|
|
|
assert!(user.last_activity > before);
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_is_idle_for() {
|
|
let mut user = PresenceUser::new(
|
|
Uuid::new_v4(),
|
|
"Alice".to_string(),
|
|
Uuid::new_v4(),
|
|
);
|
|
|
|
// Manually set last activity to the past
|
|
user.last_activity = Utc::now() - chrono::Duration::seconds(10);
|
|
|
|
assert!(user.is_idle_for(Duration::from_secs(5)));
|
|
assert!(!user.is_idle_for(Duration::from_secs(20)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_user_color() {
|
|
let id1 = Uuid::new_v4();
|
|
let id2 = Uuid::new_v4();
|
|
|
|
let color1 = generate_user_color(id1);
|
|
let color2 = generate_user_color(id2);
|
|
|
|
// Colors should be valid hex codes
|
|
assert!(color1.starts_with('#'));
|
|
assert_eq!(color1.len(), 7);
|
|
assert!(color2.starts_with('#'));
|
|
assert_eq!(color2.len(), 7);
|
|
|
|
// Same ID should produce same color
|
|
let color1_again = generate_user_color(id1);
|
|
assert_eq!(color1, color1_again);
|
|
}
|
|
|
|
#[test]
|
|
fn test_hsl_to_hex() {
|
|
// Test red
|
|
let red = hsl_to_hex(0, 70, 50);
|
|
assert!(red.starts_with('#'));
|
|
|
|
// Test green
|
|
let green = hsl_to_hex(120, 70, 50);
|
|
assert!(green.starts_with('#'));
|
|
|
|
// Test blue
|
|
let blue = hsl_to_hex(240, 70, 50);
|
|
assert!(blue.starts_with('#'));
|
|
}
|
|
|
|
#[test]
|
|
fn test_join_same_session_twice() {
|
|
let manager = setup();
|
|
let agent_session_id = Uuid::new_v4();
|
|
let owner = Uuid::new_v4();
|
|
|
|
let session = manager.create_session(
|
|
agent_session_id,
|
|
owner,
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
// Joining the same session again should return the existing user
|
|
let user2 = manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
assert_eq!(user2.connection_id, user_id);
|
|
|
|
// Should still be only 1 user
|
|
let users = manager.get_session_users(session.id);
|
|
assert_eq!(users.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejoin_different_session() {
|
|
let manager = setup();
|
|
|
|
// Create two sessions
|
|
let session1 = manager.create_session(
|
|
Uuid::new_v4(),
|
|
Uuid::new_v4(),
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
let session2 = manager.create_session(
|
|
Uuid::new_v4(),
|
|
Uuid::new_v4(),
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// Join first session
|
|
manager
|
|
.join_session(session1.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
assert_eq!(manager.get_session_user_count(session1.id), 1);
|
|
|
|
// Join second session (should auto-leave first)
|
|
manager
|
|
.join_session(session2.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
|
|
assert_eq!(manager.get_session_user_count(session1.id), 0);
|
|
assert_eq!(manager.get_session_user_count(session2.id), 1);
|
|
|
|
let user = manager.get_user(user_id).unwrap();
|
|
assert_eq!(user.session_id, session2.id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_sessions() {
|
|
let manager = setup();
|
|
|
|
let session1 = manager.create_session(
|
|
Uuid::new_v4(),
|
|
Uuid::new_v4(),
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
let session2 = manager.create_session(
|
|
Uuid::new_v4(),
|
|
Uuid::new_v4(),
|
|
"read".to_string(),
|
|
5,
|
|
);
|
|
|
|
let sessions = manager.list_sessions();
|
|
assert_eq!(sessions.len(), 2);
|
|
|
|
let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
|
|
assert!(ids.contains(&session1.id));
|
|
assert!(ids.contains(&session2.id));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_session_cleanup() {
|
|
let manager = setup();
|
|
|
|
let session = manager.create_session(
|
|
Uuid::new_v4(),
|
|
Uuid::new_v4(),
|
|
"write".to_string(),
|
|
10,
|
|
);
|
|
|
|
// Add and remove a user
|
|
let user_id = Uuid::new_v4();
|
|
manager
|
|
.join_session(session.id, user_id, "Alice".to_string())
|
|
.unwrap();
|
|
manager.leave_session(user_id).unwrap();
|
|
|
|
// Session should still exist
|
|
assert!(manager.get_session(session.id).is_some());
|
|
|
|
// Cleanup should remove empty session
|
|
manager.cleanup_inactive_users();
|
|
|
|
assert!(manager.get_session(session.id).is_none());
|
|
}
|
|
}
|