147 lines
5.6 KiB
Go
147 lines
5.6 KiB
Go
// Package ws provides heartbeat and presence lifecycle management for
|
|
// WebSocket connections. Handles periodic ping/pong, presence refresh,
|
|
// and presence broadcast on connect/disconnect.
|
|
package ws
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// HeartbeatConfig holds configuration for WebSocket heartbeat and presence intervals.
|
|
type HeartbeatConfig struct {
|
|
// PingInterval is how often the server sends ping frames to clients.
|
|
// Default: 30 seconds (mirrors Chatwoot's ActionCable ping interval).
|
|
PingInterval time.Duration
|
|
|
|
// WriteTimeout is the maximum time to wait for a pong response after ping.
|
|
// Default: 10 seconds. Connections that don't respond are disconnected.
|
|
WriteTimeout time.Duration
|
|
|
|
// PresenceRefreshInterval is how often agent presence is refreshed in Redis.
|
|
// Default: 10 seconds. Must be < PresenceDurationAgentSec (20s) to stay online.
|
|
PresenceRefreshInterval time.Duration
|
|
|
|
// PresenceCleanupInterval is how often expired presence entries are cleaned.
|
|
// Default: 30 seconds.
|
|
PresenceCleanupInterval time.Duration
|
|
}
|
|
|
|
// DefaultHeartbeatConfig returns sensible defaults mirroring Chatwoot.
|
|
func DefaultHeartbeatConfig() *HeartbeatConfig {
|
|
return &HeartbeatConfig{
|
|
PingInterval: 30 * time.Second,
|
|
WriteTimeout: 10 * time.Second,
|
|
PresenceRefreshInterval: 10 * time.Second,
|
|
PresenceCleanupInterval: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
// PresenceManager coordinates presence lifecycle for WebSocket connections.
|
|
// When a client connects, it marks the user as online; when disconnecting,
|
|
// it marks them as offline. Between connect/disconnect, it periodically
|
|
// refreshes the agent's presence in Redis to prevent expiry-based cleanup.
|
|
//
|
|
// Reference: Chatwoot's OnlineStatusTracker is refreshed by ActionCable
|
|
// ping/pong — each ping from the server triggers a presence refresh
|
|
// for the connected agent.
|
|
type PresenceManager struct {
|
|
presence *PresenceTracker
|
|
config *HeartbeatConfig
|
|
}
|
|
|
|
// NewPresenceManager creates a presence lifecycle manager.
|
|
func NewPresenceManager(presence *PresenceTracker, config *HeartbeatConfig) *PresenceManager {
|
|
return &PresenceManager{
|
|
presence: presence,
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
// OnAgentConnect marks an agent as online when they establish a WebSocket connection.
|
|
// Starts a background goroutine that periodically refreshes their presence.
|
|
//
|
|
// Returns a context.CancelFunc that should be called when the agent disconnects
|
|
// to stop the refresh loop and mark them as offline.
|
|
func (pm *PresenceManager) OnAgentConnect(ctx context.Context, agentID, accountID uint) context.CancelFunc {
|
|
// Mark online immediately
|
|
if err := pm.presence.SetAgentOnline(ctx, agentID, accountID); err != nil {
|
|
logger.L().Warnf("ws heartbeat: failed to mark agent %d online: %v", agentID, err)
|
|
}
|
|
|
|
// Start periodic presence refresh
|
|
refreshCtx, cancel := context.WithCancel(ctx)
|
|
go pm.agentPresenceRefreshLoop(refreshCtx, agentID, accountID)
|
|
|
|
return cancel
|
|
}
|
|
|
|
// OnAgentDisconnect marks an agent as offline when their WebSocket connection closes.
|
|
// The cancel function from OnAgentConnect should also be called to stop refresh loops.
|
|
func (pm *PresenceManager) OnAgentDisconnect(ctx context.Context, agentID, accountID uint) {
|
|
if err := pm.presence.SetAgentOffline(ctx, agentID, accountID); err != nil {
|
|
logger.L().Warnf("ws heartbeat: failed to mark agent %d offline: %v", agentID, err)
|
|
}
|
|
}
|
|
|
|
// OnContactConnect marks a contact as online when they establish a WebSocket connection.
|
|
func (pm *PresenceManager) OnContactConnect(ctx context.Context, contactID, accountID uint) {
|
|
if err := pm.presence.SetContactOnline(ctx, contactID, accountID); err != nil {
|
|
logger.L().Warnf("ws heartbeat: failed to mark contact %d online: %v", contactID, err)
|
|
}
|
|
}
|
|
|
|
// OnContactDisconnect marks a contact as offline when their WebSocket connection closes.
|
|
func (pm *PresenceManager) OnContactDisconnect(ctx context.Context, contactID, accountID uint) {
|
|
if err := pm.presence.SetContactOffline(ctx, contactID, accountID); err != nil {
|
|
logger.L().Warnf("ws heartbeat: failed to mark contact %d offline: %v", contactID, err)
|
|
}
|
|
}
|
|
|
|
// StartPresenceCleanup starts a background goroutine that periodically
|
|
// removes expired presence entries from Redis. Returns a cancel function
|
|
// to stop the cleanup loop.
|
|
func (pm *PresenceManager) StartPresenceCleanup(ctx context.Context) context.CancelFunc {
|
|
cleanupCtx, cancel := context.WithCancel(ctx)
|
|
go pm.presenceCleanupLoop(cleanupCtx)
|
|
return cancel
|
|
}
|
|
|
|
// agentPresenceRefreshLoop periodically refreshes an agent's presence in Redis.
|
|
// Stops when the context is cancelled (i.e., when the agent disconnects).
|
|
func (pm *PresenceManager) agentPresenceRefreshLoop(ctx context.Context, agentID, accountID uint) {
|
|
ticker := time.NewTicker(pm.config.PresenceRefreshInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
logger.L().Debugf("ws heartbeat: presence refresh stopped for agent %d", agentID)
|
|
return
|
|
case <-ticker.C:
|
|
if err := pm.presence.RefreshAgentPresence(ctx, agentID, accountID); err != nil {
|
|
logger.L().Warnf("ws heartbeat: failed to refresh agent %d presence: %v", agentID, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// presenceCleanupLoop periodically removes expired presence entries from Redis.
|
|
func (pm *PresenceManager) presenceCleanupLoop(ctx context.Context) {
|
|
ticker := time.NewTicker(pm.config.PresenceCleanupInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
logger.L().Info("ws heartbeat: presence cleanup loop stopped")
|
|
return
|
|
case <-ticker.C:
|
|
if err := pm.presence.CleanupExpired(ctx); err != nil {
|
|
logger.L().Warnf("ws heartbeat: presence cleanup failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
} |