Files
gochat/internal/ws/presence.go
T

404 lines
14 KiB
Go

// 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)
}
msg := presenceUpdateMessage(accountID, map[uint]string{agentID: "online"}, nil)
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)
}
msg := presenceUpdateMessage(accountID, map[uint]string{agentID: "offline"}, nil)
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)
}
msg := presenceUpdateMessage(accountID, map[uint]string{agentID: "busy"}, nil)
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)
}
msg := presenceUpdateMessage(accountID, nil, map[uint]string{contactID: "online"})
if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil {
logger.L().Warnf("ws presence: failed to broadcast contact online: %v", 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)
}
msg := presenceUpdateMessage(accountID, nil, map[uint]string{contactID: "offline"})
if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil {
logger.L().Warnf("ws presence: failed to broadcast contact offline: %v", err)
}
logger.L().Debugf("ws presence: contact %d offline for account %d", contactID, accountID)
return nil
}
func presenceUpdateMessage(accountID uint, users map[uint]string, contacts map[uint]string) *WSMessage {
if users == nil {
users = map[uint]string{}
}
if contacts == nil {
contacts = map[uint]string{}
}
return &WSMessage{
Event: EventPresenceUpdate,
Data: map[string]any{
"account_id": accountID,
"users": users,
"contacts": contacts,
},
AccountID: accountID,
}
}
// 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
}
// GetContactStatus returns whether a contact is currently present.
func (p *PresenceTracker) GetContactStatus(ctx context.Context, contactID, accountID uint) (string, error) {
member := presenceMember(contactID, accountID)
minScore := float64(time.Now().Unix() - PresenceDurationContactSec)
score, err := p.rdb.ZScore(ctx, RedisKeyPresenceContacts, member).Result()
if err != nil {
if err == redis.Nil {
return "offline", nil
}
return "", fmt.Errorf("failed to get contact presence: %w", err)
}
if score < minScore {
return "offline", nil
}
return "online", 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)
expiredAgents, agentListErr := p.rdb.ZRangeByScore(ctx, RedisKeyPresenceAgents, &redis.ZRangeBy{
Min: "0",
Max: fmt.Sprintf("%f", agentThreshold),
}).Result()
if agentListErr != nil {
logger.L().Warnf("ws presence: failed to list expired agents: %v", agentListErr)
expiredAgents = nil
}
expiredContacts, contactListErr := p.rdb.ZRangeByScore(ctx, RedisKeyPresenceContacts, &redis.ZRangeBy{
Min: "0",
Max: fmt.Sprintf("%f", contactThreshold),
}).Result()
if contactListErr != nil {
logger.L().Warnf("ws presence: failed to list expired contacts: %v", contactListErr)
expiredContacts = nil
}
// 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)
}
p.cleanupExpiredStatusesAndBroadcast(ctx, expiredAgents, expiredContacts)
if removedAgents > 0 || removedContacts > 0 {
logger.L().Infof("ws presence: cleanup removed %d agents, %d contacts",
removedAgents, removedContacts)
}
return nil
}
func (p *PresenceTracker) cleanupExpiredStatusesAndBroadcast(ctx context.Context, expiredAgents, expiredContacts []string) {
updates := map[uint]struct {
users map[uint]string
contacts map[uint]string
}{}
addUpdate := func(accountID uint) struct {
users map[uint]string
contacts map[uint]string
} {
update := updates[accountID]
if update.users == nil {
update.users = map[uint]string{}
}
if update.contacts == nil {
update.contacts = map[uint]string{}
}
updates[accountID] = update
return update
}
for _, member := range expiredAgents {
agentID, accountID := parsePresenceMember(member)
if agentID == 0 || accountID == 0 {
continue
}
update := addUpdate(accountID)
update.users[agentID] = "offline"
updates[accountID] = update
if err := p.rdb.HDel(ctx, RedisKeyPresenceStatus, member).Err(); err != nil {
logger.L().Warnf("ws presence: failed to delete expired agent status %s: %v", member, err)
}
}
for _, member := range expiredContacts {
contactID, accountID := parsePresenceMember(member)
if contactID == 0 || accountID == 0 {
continue
}
update := addUpdate(accountID)
update.contacts[contactID] = "offline"
updates[accountID] = update
if err := p.rdb.HDel(ctx, RedisKeyPresenceStatus, member).Err(); err != nil {
logger.L().Warnf("ws presence: failed to delete expired contact status %s: %v", member, err)
}
}
for accountID, update := range updates {
msg := presenceUpdateMessage(accountID, update.users, update.contacts)
if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil {
logger.L().Warnf("ws presence: failed to broadcast expired presence for account %d: %v", accountID, err)
}
}
}
// 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)
}