// Package ws provides agent and contact online/offline presence tracking // using Redis sorted sets and hashes. Mirrors Chatwoot's OnlineStatusTracker // which uses Redis to track which agents/contacts are currently online. package ws import ( "context" "fmt" "strconv" "strings" "time" "github.com/redis/go-redis/v9" "github.com/gochat/gochat/pkg/logger" ) // PresenceTracker tracks online/offline status of agents and contacts // using Redis sorted sets (score=timestamp) and hashes (member→status). // // Reference: Chatwoot OnlineStatusTracker — uses Redis to maintain: // - Online agents per account (for sidebar display) // - Online contacts per inbox (for real-time awareness) // - TTL-based expiry: agents expire after 20s, contacts after 90s // without heartbeat refresh type PresenceTracker struct { rdb *redis.Client relay *BroadcastRelay } // NewPresenceTracker creates a new presence tracker. func NewPresenceTracker(rdb *redis.Client, relay *BroadcastRelay) *PresenceTracker { return &PresenceTracker{ rdb: rdb, relay: relay, } } // SetAgentOnline marks an agent as online for a given account. // Adds the agent to the Redis sorted set with current timestamp as score, // and sets their status in the presence hash. // // Reference: Chatwoot OnlineStatusTracker.set_agent_online — adds to // Redis sorted set with score as epoch timestamp, refreshes on each heartbeat. func (p *PresenceTracker) SetAgentOnline(ctx context.Context, agentID, accountID uint) error { member := presenceMember(agentID, accountID) now := float64(time.Now().Unix()) // Add to sorted set (score = timestamp for expiry-based cleanup) if err := p.rdb.ZAdd(ctx, RedisKeyPresenceAgents, redis.Z{ Score: now, Member: member, }).Err(); err != nil { return fmt.Errorf("failed to set agent online: %w", err) } // Set status in hash if err := p.rdb.HSet(ctx, RedisKeyPresenceStatus, member, "online").Err(); err != nil { return fmt.Errorf("failed to set agent status: %w", err) } // Broadcast presence update event msg := &WSMessage{ Event: EventAgentOnline, Data: map[string]any{ "agent_id": agentID, "account_id": accountID, }, AccountID: accountID, } if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil { logger.L().Warnf("ws presence: failed to broadcast agent online: %v", err) } logger.L().Debugf("ws presence: agent %d online for account %d", agentID, accountID) return nil } // SetAgentOffline removes an agent from the online set and broadcasts offline event. // // Reference: Chatwoot OnlineStatusTracker.set_agent_offline — removes // from Redis sorted set and hash on disconnect or heartbeat timeout. func (p *PresenceTracker) SetAgentOffline(ctx context.Context, agentID, accountID uint) error { member := presenceMember(agentID, accountID) // Remove from sorted set if err := p.rdb.ZRem(ctx, RedisKeyPresenceAgents, member).Err(); err != nil { return fmt.Errorf("failed to remove agent from sorted set: %w", err) } // Remove status from hash if err := p.rdb.HDel(ctx, RedisKeyPresenceStatus, member).Err(); err != nil { return fmt.Errorf("failed to remove agent status: %w", err) } // Broadcast offline event msg := &WSMessage{ Event: EventAgentOffline, Data: map[string]any{ "agent_id": agentID, "account_id": accountID, }, AccountID: accountID, } if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil { logger.L().Warnf("ws presence: failed to broadcast agent offline: %v", err) } logger.L().Debugf("ws presence: agent %d offline for account %d", agentID, accountID) return nil } // SetAgentBusy marks an agent as busy (visible but not accepting new conversations). func (p *PresenceTracker) SetAgentBusy(ctx context.Context, agentID, accountID uint) error { member := presenceMember(agentID, accountID) now := float64(time.Now().Unix()) // Keep in sorted set (still "present") but mark as busy in hash if err := p.rdb.ZAdd(ctx, RedisKeyPresenceAgents, redis.Z{ Score: now, Member: member, }).Err(); err != nil { return fmt.Errorf("failed to set agent busy in sorted set: %w", err) } if err := p.rdb.HSet(ctx, RedisKeyPresenceStatus, member, "busy").Err(); err != nil { return fmt.Errorf("failed to set agent status busy: %w", err) } // Broadcast presence update msg := &WSMessage{ Event: EventPresenceUpdate, Data: map[string]any{ "agent_id": agentID, "account_id": accountID, "status": "busy", }, AccountID: accountID, } if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil { logger.L().Warnf("ws presence: failed to broadcast agent busy: %v", err) } return nil } // SetContactOnline marks a contact as online for a given account/inbox. // Uses longer TTL than agents (90s vs 20s) per Chatwoot convention. func (p *PresenceTracker) SetContactOnline(ctx context.Context, contactID, accountID uint) error { member := presenceMember(contactID, accountID) now := float64(time.Now().Unix()) if err := p.rdb.ZAdd(ctx, RedisKeyPresenceContacts, redis.Z{ Score: now, Member: member, }).Err(); err != nil { return fmt.Errorf("failed to set contact online: %w", err) } if err := p.rdb.HSet(ctx, RedisKeyPresenceStatus, member, "online").Err(); err != nil { return fmt.Errorf("failed to set contact status: %w", err) } logger.L().Debugf("ws presence: contact %d online for account %d", contactID, accountID) return nil } // SetContactOffline removes a contact from the online set. func (p *PresenceTracker) SetContactOffline(ctx context.Context, contactID, accountID uint) error { member := presenceMember(contactID, accountID) if err := p.rdb.ZRem(ctx, RedisKeyPresenceContacts, member).Err(); err != nil { return fmt.Errorf("failed to remove contact from sorted set: %w", err) } if err := p.rdb.HDel(ctx, RedisKeyPresenceStatus, member).Err(); err != nil { return fmt.Errorf("failed to remove contact status: %w", err) } logger.L().Debugf("ws presence: contact %d offline for account %d", contactID, accountID) return nil } // GetOnlineAgentsForAccount returns all currently online agent IDs for an account. // Only returns agents whose sorted-set score is within PresenceDurationAgentSec // of the current time (i.e., have sent a heartbeat recently). // // Reference: Chatwoot OnlineStatusTracker.get_online_agents — queries // Redis sorted set, filtering by timestamp threshold. func (p *PresenceTracker) GetOnlineAgentsForAccount(ctx context.Context, accountID uint) ([]uint, error) { // Calculate the minimum timestamp threshold minScore := float64(time.Now().Unix() - PresenceDurationAgentSec) // Get all members with score >= minScore results, err := p.rdb.ZRangeByScore(ctx, RedisKeyPresenceAgents, &redis.ZRangeBy{ Min: fmt.Sprintf("%f", minScore), Max: "+inf", }).Result() if err != nil { return nil, fmt.Errorf("failed to query online agents: %w", err) } // Filter by account prefix and extract IDs var agentIDs []uint for _, member := range results { id, acct := parsePresenceMember(member) if acct == accountID { agentIDs = append(agentIDs, id) } } return agentIDs, nil } // GetAgentStatus returns the current status string for an agent (online/busy/offline). func (p *PresenceTracker) GetAgentStatus(ctx context.Context, agentID, accountID uint) (string, error) { member := presenceMember(agentID, accountID) status, err := p.rdb.HGet(ctx, RedisKeyPresenceStatus, member).Result() if err != nil { if err == redis.Nil { return "offline", nil } return "", fmt.Errorf("failed to get agent status: %w", err) } return status, nil } // CleanupExpired removes stale presence entries whose timestamp score // has fallen below the expiry threshold. Should be called periodically // (e.g., every 30 seconds) via a background goroutine. // // Reference: Chatwoot OnlineStatusTracker.cleanup — periodically removes // expired entries from Redis sorted sets. func (p *PresenceTracker) CleanupExpired(ctx context.Context) error { agentThreshold := float64(time.Now().Unix() - PresenceDurationAgentSec) contactThreshold := float64(time.Now().Unix() - PresenceDurationContactSec) // Remove expired agents removedAgents, err := p.rdb.ZRemRangeByScore(ctx, RedisKeyPresenceAgents, "0", fmt.Sprintf("%f", agentThreshold)).Result() if err != nil { logger.L().Warnf("ws presence: failed to cleanup expired agents: %v", err) } // Remove expired contacts removedContacts, err := p.rdb.ZRemRangeByScore(ctx, RedisKeyPresenceContacts, "0", fmt.Sprintf("%f", contactThreshold)).Result() if err != nil { logger.L().Warnf("ws presence: failed to cleanup expired contacts: %v", err) } if removedAgents > 0 || removedContacts > 0 { logger.L().Infof("ws presence: cleanup removed %d agents, %d contacts", removedAgents, removedContacts) } return nil } // RefreshAgentPresence refreshes an agent's timestamp in the sorted set. // Called on each heartbeat to prevent the agent from being cleaned up. func (p *PresenceTracker) RefreshAgentPresence(ctx context.Context, agentID, accountID uint) error { member := presenceMember(agentID, accountID) now := float64(time.Now().Unix()) if err := p.rdb.ZAdd(ctx, RedisKeyPresenceAgents, redis.Z{ Score: now, Member: member, }).Err(); err != nil { return fmt.Errorf("failed to refresh agent presence: %w", err) } return nil } // RefreshContactPresence refreshes a contact's timestamp in the sorted set. func (p *PresenceTracker) RefreshContactPresence(ctx context.Context, contactID, accountID uint) error { member := presenceMember(contactID, accountID) now := float64(time.Now().Unix()) if err := p.rdb.ZAdd(ctx, RedisKeyPresenceContacts, redis.Z{ Score: now, Member: member, }).Err(); err != nil { return fmt.Errorf("failed to refresh contact presence: %w", err) } return nil } // --- Internal helpers --- // presenceMember creates a Redis sorted set member string from ID and account ID. // Format: "{id}:{account_id}" — e.g., "42:1" means user 42 in account 1. func presenceMember(id, accountID uint) string { return fmt.Sprintf("%d:%d", id, accountID) } // parsePresenceMember splits a Redis sorted set member string back into id and accountID. func parsePresenceMember(member string) (uint, uint) { parts := strings.SplitN(member, ":", 2) if len(parts) != 2 { return 0, 0 } id, _ := strconv.ParseUint(parts[0], 10, 32) acct, _ := strconv.ParseUint(parts[1], 10, 32) return uint(id), uint(acct) }