Files
gochat/backend/internal/ws/heartbeat.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
2026-07-07 14:44:12 +08:00

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)
}
}
}
}