Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
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)
|
|
}
|
|
}
|
|
}
|
|
} |