516 lines
18 KiB
Go
516 lines
18 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"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/auth"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const assignmentBatchLimit = 100
|
|
|
|
// 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: Load the policy associated with this inbox.
|
|
policy, err := s.getInboxPolicy(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get assignment policy: %w", err)
|
|
}
|
|
if policy != nil && !policy.Enabled {
|
|
return nil, nil
|
|
}
|
|
advancedAssignment, err := s.advancedAssignmentEnabled(ctx, accountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get advanced assignment feature: %w", err)
|
|
}
|
|
|
|
// Step 3: Find unassigned open conversations in policy order.
|
|
conversations, err := s.findUnassignedConversations(ctx, inboxID, accountID, policy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find unassigned: %w", err)
|
|
}
|
|
if len(conversations) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Step 4: Resolve selector and rate limits from the linked policy.
|
|
selector := EffectivePolicy(policy, advancedAssignment)
|
|
limit := EffectiveLimit(policy)
|
|
window := EffectiveWindow(policy)
|
|
|
|
// Step 5: Assign each conversation using its team-scoped candidates.
|
|
assignedIDs := make([]uint, 0)
|
|
for _, conv := range conversations {
|
|
agents, err := s.getEligibleAgents(ctx, inboxID, accountID, conv.TeamID, advancedAssignment)
|
|
if err != nil {
|
|
return assignedIDs, fmt.Errorf("get eligible agents for conversation %d: %w", conv.ID, err)
|
|
}
|
|
if advancedAssignment {
|
|
agents, err = s.filterAgentsByCapacityExclusions(ctx, accountID, &conv, agents)
|
|
if err != nil {
|
|
return assignedIDs, fmt.Errorf("apply capacity exclusions for conversation %d: %w", conv.ID, err)
|
|
}
|
|
}
|
|
if len(agents) == 0 {
|
|
continue
|
|
}
|
|
|
|
s.roundRobin.SyncQueue(ctx, inboxID, agents)
|
|
agentID, err := s.selectAgent(ctx, inboxID, agents, selector, 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
|
|
}
|
|
|
|
assigned, err := s.assignConversation(ctx, accountID, inboxID, conv.ID, agentID)
|
|
if err != nil {
|
|
applogger.L().Warnf("failed to assign conversation %d to agent %d: %v", conv.ID, agentID, err)
|
|
continue
|
|
}
|
|
if !assigned {
|
|
continue
|
|
}
|
|
|
|
// Track rate limit
|
|
if err := s.rateLimiter.Increment(ctx, inboxID, agentID, window); err != nil {
|
|
applogger.L().Errorf("failed to track assignment rate for inbox %d agent %d: %v", inboxID, agentID, err)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
policy, err := s.getInboxPolicy(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get assignment policy: %w", err)
|
|
}
|
|
if policy != nil && !policy.Enabled {
|
|
return 0, nil
|
|
}
|
|
advancedAssignment, err := s.advancedAssignmentEnabled(ctx, accountID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get advanced assignment feature: %w", err)
|
|
}
|
|
|
|
var conversation model.Conversation
|
|
if err := s.db.WithContext(ctx).
|
|
Select("id", "team_id", "last_activity_at").
|
|
Where("id = ? AND inbox_id = ? AND account_id = ?", conversationID, inboxID, accountID).
|
|
First(&conversation).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return 0, nil
|
|
}
|
|
return 0, fmt.Errorf("get conversation team: %w", err)
|
|
}
|
|
|
|
// Get eligible agents
|
|
agents, err := s.getEligibleAgents(ctx, inboxID, accountID, conversation.TeamID, advancedAssignment)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get eligible agents: %w", err)
|
|
}
|
|
if advancedAssignment {
|
|
agents, err = s.filterAgentsByCapacityExclusions(ctx, accountID, &conversation, agents)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("apply capacity exclusions: %w", err)
|
|
}
|
|
}
|
|
if len(agents) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
// Get policy and limits
|
|
selector := EffectivePolicy(policy, advancedAssignment)
|
|
limit := EffectiveLimit(policy)
|
|
window := EffectiveWindow(policy)
|
|
|
|
// Sync round-robin queue
|
|
s.roundRobin.SyncQueue(ctx, inboxID, agents)
|
|
|
|
// Select agent
|
|
agentID, err := s.selectAgent(ctx, inboxID, agents, selector, 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
|
|
assigned, err := s.assignConversation(ctx, accountID, inboxID, conversationID, agentID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("assign conversation: %w", err)
|
|
}
|
|
if !assigned {
|
|
return 0, nil
|
|
}
|
|
|
|
// Track rate limit
|
|
if err := s.rateLimiter.Increment(ctx, inboxID, agentID, window); err != nil {
|
|
applogger.L().Errorf("failed to track assignment rate for inbox %d agent %d: %v", inboxID, agentID, err)
|
|
}
|
|
|
|
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)
|
|
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
|
|
}
|
|
|
|
// 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 one bounded batch of open conversations
|
|
// in the inbox that have no assignee.
|
|
func (s *AssignmentService) findUnassignedConversations(ctx context.Context, inboxID uint, accountID uint, policy *model.AssignmentPolicy) ([]model.Conversation, error) {
|
|
var conversations []model.Conversation
|
|
query := s.db.WithContext(ctx).
|
|
Where("inbox_id = ? AND account_id = ? AND status = ? AND assignee_id IS NULL",
|
|
inboxID, accountID, model.ConversationStatusOpen)
|
|
if policy != nil && policy.ExcludeOlderThanHours != nil && *policy.ExcludeOlderThanHours > 0 {
|
|
cutoff := time.Now().Add(-time.Duration(*policy.ExcludeOlderThanHours) * time.Hour).Unix()
|
|
// GoChat legacy rows may not have an activity timestamp; unknown is not stale.
|
|
query = query.Where("last_activity_at IS NULL OR last_activity_at >= ?", cutoff)
|
|
}
|
|
if policy != nil && policy.ConversationPriority == 1 {
|
|
// Unknown activity is not treated as longest waiting. PostgreSQL already
|
|
// puts NULL last for ASC; the CASE makes SQLite follow the same contract.
|
|
query = query.Order("CASE WHEN last_activity_at IS NULL THEN 1 ELSE 0 END ASC").
|
|
Order("last_activity_at ASC").Order("created_at ASC")
|
|
} else {
|
|
query = query.Order("created_at ASC")
|
|
}
|
|
err := query.Limit(assignmentBatchLimit).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, teamID *uint, advancedAssignment bool) ([]uint, error) {
|
|
var agentIDs []uint
|
|
query := s.db.WithContext(ctx).
|
|
Table("inbox_members").
|
|
Distinct("inbox_members.user_id").
|
|
Joins("JOIN users ON users.id = inbox_members.user_id").
|
|
Joins("JOIN account_users ON account_users.user_id = inbox_members.user_id AND account_users.account_id = ?", accountID).
|
|
Where("inbox_members.inbox_id = ? AND inbox_members.deleted_at IS NULL AND users.deleted_at IS NULL AND account_users.deleted_at IS NULL AND users.available = ? AND users.active = ? AND account_users.role IN ?",
|
|
inboxID, true, true, []string{auth.RoleAgent, auth.RoleAdministrator})
|
|
if teamID != nil {
|
|
query = query.
|
|
Joins("JOIN team_members ON team_members.user_id = inbox_members.user_id AND team_members.team_id = ? AND team_members.deleted_at IS NULL", *teamID).
|
|
Joins("JOIN teams ON teams.id = team_members.team_id AND teams.account_id = ? AND teams.allow_auto_assignment = ? AND teams.deleted_at IS NULL", accountID, true)
|
|
}
|
|
err := query.
|
|
Order("inbox_members.user_id ASC").
|
|
Pluck("inbox_members.user_id", &agentIDs).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
availableIDs := make([]uint, 0, len(agentIDs))
|
|
for _, agentID := range agentIDs {
|
|
if !advancedAssignment {
|
|
availableIDs = append(availableIDs, agentID)
|
|
continue
|
|
}
|
|
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) filterAgentsByCapacityExclusions(ctx context.Context, accountID uint, conversation *model.Conversation, agentIDs []uint) ([]uint, error) {
|
|
eligible := make([]uint, 0, len(agentIDs))
|
|
for _, agentID := range agentIDs {
|
|
excluded, err := s.capacityPolicyExcludesConversation(ctx, accountID, agentID, conversation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !excluded {
|
|
eligible = append(eligible, agentID)
|
|
}
|
|
}
|
|
return eligible, nil
|
|
}
|
|
|
|
func (s *AssignmentService) capacityPolicyExcludesConversation(ctx context.Context, accountID, agentID uint, conversation *model.Conversation) (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 true, nil
|
|
}
|
|
return false, fmt.Errorf("load account user capacity policy: %w", err)
|
|
}
|
|
if accountUser.AgentCapacityPolicyID == nil {
|
|
return false, nil
|
|
}
|
|
|
|
var policy model.AgentCapacityPolicy
|
|
if err := s.db.WithContext(ctx).
|
|
Where("id = ? AND account_id = ?", *accountUser.AgentCapacityPolicyID, accountID).
|
|
First(&policy).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("load agent capacity policy: %w", err)
|
|
}
|
|
var rules struct {
|
|
ExcludedLabels []string `json:"excluded_labels"`
|
|
ExcludeOlderThanHours int `json:"exclude_older_than_hours"`
|
|
}
|
|
if len(policy.ExclusionRules) > 0 {
|
|
if err := json.Unmarshal(policy.ExclusionRules, &rules); err != nil {
|
|
return false, fmt.Errorf("decode capacity exclusion rules: %w", err)
|
|
}
|
|
}
|
|
if rules.ExcludeOlderThanHours > 0 {
|
|
cutoff := time.Now().Add(-time.Duration(rules.ExcludeOlderThanHours) * time.Hour).Unix()
|
|
if conversation.LastActivityAt == nil || *conversation.LastActivityAt < cutoff {
|
|
return true, nil
|
|
}
|
|
}
|
|
if len(rules.ExcludedLabels) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
var count int64
|
|
err := s.db.WithContext(ctx).Table("conversation_labels").
|
|
Joins("JOIN tags ON tags.id = conversation_labels.tag_id AND tags.deleted_at IS NULL").
|
|
Where("conversation_labels.conversation_id = ? AND conversation_labels.account_id = ? AND tags.account_id = ? AND tags.name IN ?",
|
|
conversation.ID, accountID, accountID, rules.ExcludedLabels).
|
|
Count(&count).Error
|
|
if err != nil {
|
|
return false, fmt.Errorf("check capacity exclusion labels: %w", err)
|
|
}
|
|
return count > 0, 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
|
|
}
|
|
|
|
// getInboxPolicy resolves the current CRUD model through its inbox join.
|
|
func (s *AssignmentService) getInboxPolicy(ctx context.Context, accountID, inboxID uint) (*model.AssignmentPolicy, error) {
|
|
policy, err := repository.NewInboxAssignmentPolicyRepo(s.db).FindPolicyByInbox(ctx, accountID, inboxID)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
return policy, err
|
|
}
|
|
|
|
func (s *AssignmentService) advancedAssignmentEnabled(ctx context.Context, accountID uint) (bool, error) {
|
|
var account model.Account
|
|
if err := s.db.WithContext(ctx).Select("feature_flags").First(&account, accountID).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return featureEnabled(account.FeatureFlags, "advanced_assignment"), nil
|
|
}
|
|
|
|
func featureEnabled(raw, flag string) bool {
|
|
values := map[string]bool{}
|
|
if json.Unmarshal([]byte(raw), &values) == nil {
|
|
return values[flag]
|
|
}
|
|
var list []string
|
|
if json.Unmarshal([]byte(raw), &list) == nil {
|
|
for _, value := range list {
|
|
if value == flag {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
for _, value := range strings.Split(raw, ",") {
|
|
if strings.TrimSpace(value) == flag {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// assignConversation sets the assignee_id on a conversation.
|
|
func (s *AssignmentService) assignConversation(ctx context.Context, accountID, inboxID, conversationID, agentID uint) (bool, error) {
|
|
return repository.AutoAssignConversation(ctx, s.db, accountID, inboxID, conversationID, agentID)
|
|
}
|