404 lines
14 KiB
Go
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)
|
|
}
|