Files
gochat/internal/autoassignment/service.go
T

363 lines
12 KiB
Go

package autoassignment
// AssignmentService provides the core auto-assignment logic.
//
// Reference: Chatwoot AutoAssignment pattern
// - AssignmentService: bulk assigns conversations in an inbox.
// Finds unassigned open conversations, filters by team and rate limit,
// then uses RoundRobinSelector.
// - AgentAssignmentService: assigns a specific conversation.
// Intersects online agents with allowed agents, then round-robin.
//
// In gochat, we combine both into a single AssignmentService that handles:
// - Bulk assignment of unassigned conversations in an inbox
// - Single conversation assignment for a specific conversation
// - Rate limiting per agent via RateLimiter
import (
"context"
"errors"
"fmt"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// AssignmentService handles auto-assignment of conversations to agents.
type AssignmentService struct {
db *gorm.DB
redis *redis.Client
roundRobin *RoundRobinSelector
lowestLoad *LowestLoadSelector
rateLimiter *RateLimiter
}
// NewAssignmentService creates a new AssignmentService.
func NewAssignmentService(db *gorm.DB, rdb *redis.Client) *AssignmentService {
rr := NewRoundRobinSelector(rdb)
ll := NewLowestLoadSelector(db)
rl := NewRateLimiter(rdb)
return &AssignmentService{
db: db,
redis: rdb,
roundRobin: rr,
lowestLoad: ll,
rateLimiter: rl,
}
}
// AssignUnassignedConversations bulk assigns all unassigned open conversations
// in the given inbox.
// Reference: Chatwoot AssignmentService.assign_unassigned_conversations
//
// Steps:
// 1. Find all unassigned open conversations for the inbox
// 2. Get the inbox's assignment policy and rate limits
// 3. Get the list of eligible agents (online + members of the inbox)
// 4. For each conversation, select an agent via round-robin + rate limit
// 5. Assign the conversation to the selected agent
func (s *AssignmentService) AssignUnassignedConversations(ctx context.Context, inboxID uint, accountID uint) ([]uint, error) {
// Step 1: Check if auto-assignment is enabled for this inbox
inbox, err := s.getInbox(ctx, inboxID)
if err != nil {
return nil, fmt.Errorf("get inbox: %w", err)
}
if !inbox.EnableAutoAssignment {
applogger.L().Infof("auto-assignment disabled for inbox %d", inboxID)
return nil, nil
}
// Step 2: Find unassigned open conversations
conversations, err := s.findUnassignedConversations(ctx, inboxID, accountID)
if err != nil {
return nil, fmt.Errorf("find unassigned: %w", err)
}
if len(conversations) == 0 {
return nil, nil
}
// Step 3: Get eligible agents
agents, err := s.getEligibleAgents(ctx, inboxID, accountID)
if err != nil {
return nil, fmt.Errorf("get eligible agents: %w", err)
}
if len(agents) == 0 {
applogger.L().Infof("no eligible agents for inbox %d", inboxID)
return nil, nil
}
// Step 4: Get assignment policy and limits
accountPolicy := s.getAccountPolicy(ctx, accountID)
inboxPolicy := s.getInboxPolicy(ctx, inboxID)
policy := EffectivePolicy(accountPolicy, inboxPolicy)
limit := EffectiveLimit(accountPolicy, inboxPolicy)
window := EffectiveWindow(accountPolicy, inboxPolicy)
// Step 5: Reset round-robin queue if members changed
s.roundRobin.SyncQueue(ctx, inboxID, agents)
// Step 6: Assign each conversation
assignedIDs := make([]uint, 0)
for _, conv := range conversations {
agentID, err := s.selectAgent(ctx, inboxID, agents, policy, limit, window)
if err != nil {
applogger.L().Warnf("failed to select agent for conversation %d: %v", conv.ID, err)
continue
}
if agentID == 0 {
// All agents hit rate limit, skip
continue
}
if err := s.assignConversation(ctx, conv.ID, agentID); err != nil {
applogger.L().Warnf("failed to assign conversation %d to agent %d: %v", conv.ID, agentID, err)
continue
}
// Track rate limit
s.rateLimiter.Increment(ctx, inboxID, agentID, window)
assignedIDs = append(assignedIDs, conv.ID)
}
applogger.L().Infof("auto-assigned %d conversations in inbox %d", len(assignedIDs), inboxID)
return assignedIDs, nil
}
// AssignConversation assigns a specific conversation to an agent.
// Reference: Chatwoot AgentAssignmentService.assign_conversation
//
// Steps:
// 1. Get eligible agents for the conversation's inbox
// 2. Select an agent via round-robin + rate limit
// 3. Assign the conversation
func (s *AssignmentService) AssignConversation(ctx context.Context, conversationID uint, inboxID uint, accountID uint) (uint, error) {
// Check if auto-assignment is enabled
inbox, err := s.getInbox(ctx, inboxID)
if err != nil {
return 0, fmt.Errorf("get inbox: %w", err)
}
if !inbox.EnableAutoAssignment {
return 0, nil
}
// Get eligible agents
agents, err := s.getEligibleAgents(ctx, inboxID, accountID)
if err != nil {
return 0, fmt.Errorf("get eligible agents: %w", err)
}
if len(agents) == 0 {
return 0, nil
}
// Get policy and limits
accountPolicy := s.getAccountPolicy(ctx, accountID)
inboxPolicy := s.getInboxPolicy(ctx, inboxID)
policy := EffectivePolicy(accountPolicy, inboxPolicy)
limit := EffectiveLimit(accountPolicy, inboxPolicy)
window := EffectiveWindow(accountPolicy, inboxPolicy)
// Sync round-robin queue
s.roundRobin.SyncQueue(ctx, inboxID, agents)
// Select agent
agentID, err := s.selectAgent(ctx, inboxID, agents, policy, limit, window)
if err != nil {
return 0, fmt.Errorf("select agent: %w", err)
}
if agentID == 0 {
return 0, nil // all agents at rate limit
}
// Assign conversation
if err := s.assignConversation(ctx, conversationID, agentID); err != nil {
return 0, fmt.Errorf("assign conversation: %w", err)
}
// Track rate limit
s.rateLimiter.Increment(ctx, inboxID, agentID, window)
return agentID, nil
}
// selectAgent picks an agent for assignment using the selected policy
// and rate limiting.
func (s *AssignmentService) selectAgent(ctx context.Context, inboxID uint, agents []uint, policy AssignmentPolicyType, limit int, window int) (uint, error) {
switch policy {
case PolicyRoundRobin:
return s.selectRoundRobin(ctx, inboxID, agents, limit, window)
case PolicyLowestLoad:
return s.selectLowestLoad(ctx, inboxID, agents, limit, window)
case PolicyLongestWaiting:
return s.selectLongestWaiting(ctx, inboxID, agents, limit, window)
default:
return s.selectRoundRobin(ctx, inboxID, agents, limit, window)
}
}
// selectRoundRobin selects an agent via round-robin, respecting rate limits.
func (s *AssignmentService) selectRoundRobin(ctx context.Context, inboxID uint, agents []uint, limit int, window int) (uint, error) {
// Try each agent in round-robin order
for i := 0; i < len(agents); i++ {
agentID, err := s.roundRobin.Next(ctx, inboxID)
if err != nil || agentID == 0 {
continue
}
// Check rate limit
if s.rateLimiter.IsAllowed(ctx, inboxID, agentID, limit, window) {
return agentID, nil
}
// Agent is at rate limit, skip and try next
}
// All agents at rate limit
return 0, nil
}
// selectLongestWaiting selects the agent with the longest idle time.
// This is a simplified implementation — a full version would track
// idle time in Redis. For now, we fall back to round-robin.
func (s *AssignmentService) selectLongestWaiting(ctx context.Context, inboxID uint, agents []uint, limit int, window int) (uint, error) {
// TODO: Implement longest-waiting tracking in Redis
// For now, delegate to round-robin with rate limit checking
return s.selectRoundRobin(ctx, inboxID, agents, limit, window)
}
// selectLowestLoad selects the agent with the fewest open conversations,
// respecting rate limits.
func (s *AssignmentService) selectLowestLoad(ctx context.Context, inboxID uint, agents []uint, limit int, window int) (uint, error) {
agentID, err := s.lowestLoad.Select(ctx, inboxID, agents)
if err != nil {
return 0, fmt.Errorf("lowest load select: %w", err)
}
// Check rate limit for the selected agent
if s.rateLimiter.IsAllowed(ctx, inboxID, agentID, limit, window) {
return agentID, nil
}
// Selected agent is at rate limit — fall back to round-robin
applogger.L().Debugf("lowest_load: agent %d at rate limit, falling back to round-robin", agentID)
return s.selectRoundRobin(ctx, inboxID, agents, limit, window)
}
// findUnassignedConversations returns all open conversations in the inbox
// that have no assignee.
func (s *AssignmentService) findUnassignedConversations(ctx context.Context, inboxID uint, accountID uint) ([]model.Conversation, error) {
var conversations []model.Conversation
err := s.db.WithContext(ctx).
Where("inbox_id = ? AND account_id = ? AND status = ? AND assignee_id IS NULL",
inboxID, accountID, model.ConversationStatusOpen).
Find(&conversations).Error
return conversations, err
}
// getEligibleAgents returns agents that are:
// - Members of the inbox (via inbox_members table)
// - Online/available (via user available field)
func (s *AssignmentService) getEligibleAgents(ctx context.Context, inboxID uint, accountID uint) ([]uint, error) {
var agentIDs []uint
err := s.db.WithContext(ctx).
Table("inbox_members").
Select("inbox_members.user_id").
Joins("JOIN users ON users.id = inbox_members.user_id").
Where("inbox_members.inbox_id = ? AND users.available = ? AND users.active = ?",
inboxID, true, true).
Pluck("inbox_members.user_id", &agentIDs).Error
if err != nil {
return nil, err
}
availableIDs := make([]uint, 0, len(agentIDs))
for _, agentID := range agentIDs {
hasCapacity, err := s.agentHasInboxCapacity(ctx, accountID, inboxID, agentID, 0)
if err != nil {
return nil, err
}
if hasCapacity {
availableIDs = append(availableIDs, agentID)
}
}
return availableIDs, nil
}
func (s *AssignmentService) agentHasInboxCapacity(ctx context.Context, accountID, inboxID, agentID, excludeConversationID uint) (bool, error) {
var accountUser model.AccountUser
if err := s.db.WithContext(ctx).
Where("account_id = ? AND user_id = ?", accountID, agentID).
First(&accountUser).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, fmt.Errorf("load account user capacity policy: %w", err)
}
if accountUser.AgentCapacityPolicyID == nil {
return true, nil
}
var limit model.InboxCapacityLimit
if err := s.db.WithContext(ctx).
Where("agent_capacity_policy_id = ? AND inbox_id = ?", *accountUser.AgentCapacityPolicyID, inboxID).
First(&limit).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, nil
}
return false, fmt.Errorf("load inbox capacity limit: %w", err)
}
q := s.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND inbox_id = ? AND assignee_id = ? AND status = ?", accountID, inboxID, agentID, model.ConversationStatusOpen)
if excludeConversationID != 0 {
q = q.Where("id <> ?", excludeConversationID)
}
var assignedOpenCount int64
if err := q.Count(&assignedOpenCount).Error; err != nil {
return false, fmt.Errorf("count assigned open conversations: %w", err)
}
return assignedOpenCount < int64(limit.ConversationLimit), nil
}
// getInbox fetches the inbox by ID.
func (s *AssignmentService) getInbox(ctx context.Context, inboxID uint) (*model.Inbox, error) {
var inbox model.Inbox
if err := s.db.WithContext(ctx).First(&inbox, inboxID).Error; err != nil {
return nil, err
}
return &inbox, nil
}
// getAccountPolicy returns the assignment policy for an account.
// Returns nil if no policy is configured.
func (s *AssignmentService) getAccountPolicy(ctx context.Context, accountID uint) *AssignmentPolicy {
var policy AssignmentPolicy
if err := s.db.WithContext(ctx).
Where("account_id = ? AND active = ?", accountID, true).
First(&policy).Error; err != nil {
return nil // no policy configured
}
return &policy
}
// getInboxPolicy returns the assignment policy override for an inbox.
// Returns nil if no inbox-specific policy is configured.
func (s *AssignmentService) getInboxPolicy(ctx context.Context, inboxID uint) *InboxAssignmentPolicy {
var policy InboxAssignmentPolicy
if err := s.db.WithContext(ctx).
Where("inbox_id = ? AND active = ?", inboxID, true).
First(&policy).Error; err != nil {
return nil // no inbox-specific policy
}
return &policy
}
// assignConversation sets the assignee_id on a conversation.
func (s *AssignmentService) assignConversation(ctx context.Context, conversationID uint, agentID uint) error {
return s.db.WithContext(ctx).
Model(&model.Conversation{}).
Where("id = ?", conversationID).
Update("assignee_id", agentID).Error
}