package services import ( "context" "fmt" "log/slog" "sort" "strings" "sync" "code.tczkiot.com/wlw/ai-agent/contract" "code.tczkiot.com/wlw/ai-agent/internal/models" "code.tczkiot.com/wlw/ai-agent/internal/pkg/enums" "code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx" "code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity" "github.com/mlogclub/simple/common/strs" ) var CustomerQuickActionService = &customerQuickActionService{} type customerQuickActionService struct { mu sync.RWMutex actions map[string]contract.CustomerQuickAction } func SetCustomerQuickActions(actions []contract.CustomerQuickAction) error { registered := make(map[string]contract.CustomerQuickAction, len(actions)) for _, action := range actions { action.Code = strings.TrimSpace(action.Code) action.Title = strings.TrimSpace(action.Title) action.Description = strings.TrimSpace(action.Description) action.Message = strings.TrimSpace(action.Message) if action.Code == "" || action.Title == "" || action.Message == "" { return fmt.Errorf("ai-agent: customer quick action code, title and message are required") } if action.Execute == nil { return fmt.Errorf("ai-agent: customer quick action executor is required: %s", action.Code) } if _, exists := registered[action.Code]; exists { return fmt.Errorf("ai-agent: duplicate customer quick action code: %s", action.Code) } registered[action.Code] = action } CustomerQuickActionService.mu.Lock() CustomerQuickActionService.actions = registered CustomerQuickActionService.mu.Unlock() return nil } func (s *customerQuickActionService) ListForConversation(ctx context.Context, conversation *models.Conversation) ([]contract.CustomerQuickAction, error) { if conversation == nil { return nil, nil } conversation = resolveQuickActionConversation(ctx, conversation) s.mu.RLock() actions := make([]contract.CustomerQuickAction, 0, len(s.actions)) for _, action := range s.actions { if quickActionSupportsCustomerType(action, conversation.CustomerType) { actions = append(actions, action) } } s.mu.RUnlock() businessContext := quickActionBusinessContext(ctx, conversation) ret := make([]contract.CustomerQuickAction, 0, len(actions)) for _, action := range actions { if action.Available != nil { available, err := action.Available(ctx, businessContext) if err != nil { slog.Warn("check customer quick action availability failed", "code", action.Code, "conversation_id", conversation.ID, "error", err) continue } if !available { continue } } ret = append(ret, action) } sort.Slice(ret, func(i, j int) bool { if ret[i].Sort == ret[j].Sort { return ret[i].Code < ret[j].Code } return ret[i].Sort < ret[j].Sort }) return ret, nil } func (s *customerQuickActionService) ExecuteAndRecord( ctx context.Context, conversationID int64, code string, clientMsgID string, external openidentity.ExternalUser, requestID string, ) (*models.Message, *models.Message, error) { conversation := ConversationService.Get(conversationID) if conversation == nil { return nil, nil, errorsx.InvalidParamI18n("error.e0116") } if !ConversationService.IsCustomerConversationOwner(conversation, external) { return nil, nil, errorsx.ForbiddenI18n("error.e0222") } conversation = resolveQuickActionConversation(ctx, conversation) action, ok := s.resolve(code, conversation.CustomerType) if !ok { return nil, nil, errorsx.InvalidParam("customer quick action is unavailable") } businessContext := quickActionBusinessContext(ctx, conversation) if action.Available != nil { available, err := action.Available(ctx, businessContext) if err != nil { return nil, nil, err } if !available { return nil, nil, errorsx.InvalidParam("customer quick action is currently unavailable") } } if action.TriggerAI { customerMessage, err := MessageService.SendCustomerMessageWithContextAndRequestID(ctx, conversation.ID, clientMsgID, enums.IMMessageTypeText, action.Message, "", external, requestID, ) return customerMessage, nil, err } reply, err := action.Execute(ctx, businessContext) if err != nil { return nil, nil, err } reply = strings.TrimSpace(reply) if reply == "" { return nil, nil, errorsx.InvalidParam("customer quick action returned an empty reply") } customerMessage, err := MessageService.SendCustomerMessageWithoutAIReplyWithContextAndRequestID(ctx, conversation.ID, clientMsgID, enums.IMMessageTypeText, action.Message, "", external, requestID, ) if err != nil { return nil, nil, err } replyClientMsgID := strs.UUID() if value := strings.TrimSpace(clientMsgID); value != "" { if len(value) > 96 { value = value[:96] } replyClientMsgID = value + "_auto_reply" } replyMessage, err := MessageService.SendAutomaticServiceMessageWithRequestID( conversation.ID, replyClientMsgID, reply, requestID, ) if err != nil { return customerMessage, nil, err } return customerMessage, replyMessage, nil } // ExecuteMatchedReply executes a deterministic quick action for an already // recorded customer message. It is used by the AI reply pipeline so explicit // read commands do not depend on a model deciding whether to call a tool. func (s *customerQuickActionService) ExecuteMatchedReply( ctx context.Context, conversation *models.Conversation, content string, requestID string, sourceMessageID int64, ) (bool, error) { if conversation == nil || strings.TrimSpace(content) == "" { return false, nil } actions := s.matchingDeterministicActions(content, conversation.CustomerType) if len(actions) == 0 { return false, nil } return s.executeDeterministicReplies(ctx, conversation, actions, requestID, sourceMessageID) } func (s *customerQuickActionService) ExecuteSelectedReply( ctx context.Context, conversation *models.Conversation, selection int, requestID string, sourceMessageID int64, ) (matched bool, aiMessage string, err error) { if conversation == nil || selection <= 0 { return false, "", nil } actions, err := s.ListForConversation(ctx, conversation) if err != nil { return false, "", err } if selection > len(actions) { return false, "", nil } action := actions[selection-1] return s.executeActionReply(ctx, conversation, action, requestID, sourceMessageID) } // ExecuteActionReply executes a registered quick action by code for an already // recorded customer message. It is used for deterministic conversational // choices whose display order is not the main quick-action menu order. func (s *customerQuickActionService) ExecuteActionReply( ctx context.Context, conversation *models.Conversation, code string, requestID string, sourceMessageID int64, ) (matched bool, aiMessage string, err error) { if conversation == nil || strings.TrimSpace(code) == "" { return false, "", nil } actions, err := s.ListForConversation(ctx, conversation) if err != nil { return false, "", err } for _, action := range actions { if action.Code == strings.TrimSpace(code) { return s.executeActionReply(ctx, conversation, action, requestID, sourceMessageID) } } return false, "", nil } func (s *customerQuickActionService) executeActionReply( ctx context.Context, conversation *models.Conversation, action contract.CustomerQuickAction, requestID string, sourceMessageID int64, ) (matched bool, aiMessage string, err error) { if action.TriggerAI { return true, action.Message, nil } matched, err = s.executeDeterministicReplies( ctx, conversation, []contract.CustomerQuickAction{action}, requestID, sourceMessageID, ) return matched, "", err } func (s *customerQuickActionService) executeDeterministicReplies( ctx context.Context, conversation *models.Conversation, actions []contract.CustomerQuickAction, requestID string, sourceMessageID int64, ) (bool, error) { businessContext := quickActionBusinessContext(ctx, conversation) replies := make([]string, 0, len(actions)) for _, action := range actions { if action.Available != nil { available, err := action.Available(ctx, businessContext) if err != nil { return true, err } if !available { continue } } reply, err := action.Execute(ctx, businessContext) if err != nil { return true, err } reply = strings.TrimSpace(reply) if reply == "" { return true, errorsx.InvalidParam("customer quick action returned an empty reply") } replies = append(replies, reply) } if len(replies) == 0 { return false, nil } clientMsgID := fmt.Sprintf("matched_action_%d", sourceMessageID) _, err := MessageService.SendAutomaticServiceMessageWithRequestID( conversation.ID, clientMsgID, strings.Join(replies, "\n\n"), requestID, ) return true, err } func (s *customerQuickActionService) matchingDeterministicActions(content, customerType string) []contract.CustomerQuickAction { s.mu.RLock() defer s.mu.RUnlock() matched := make([]contract.CustomerQuickAction, 0, 1) for _, action := range s.actions { if action.TriggerAI || action.MatchIntent == nil || !quickActionSupportsCustomerType(action, customerType) { continue } if action.MatchIntent(content) { matched = append(matched, action) } } // Free text must only bypass the Agent when it is one short, unambiguous // lookup. Negations, explanations and compound requests need conversational // reasoning; returning one or more keyword templates here would silently // discard the customer's actual intent. Menu selections and explicit action // codes use separate deterministic entry points and are unaffected. if len(matched) != 1 || !isHighConfidenceDeterministicQuickActionMessage(content) { return nil } sort.Slice(matched, func(i, j int) bool { if matched[i].Sort == matched[j].Sort { return matched[i].Code < matched[j].Code } return matched[i].Sort < matched[j].Sort }) return matched } func isHighConfidenceDeterministicQuickActionMessage(content string) bool { text := strings.TrimSpace(content) if text == "" || len([]rune(text)) > 28 { return false } for _, marker := range []string{ "不是", "而是", "不要", "别", "搞错", "说错", "为什么", "怎么", "如何", "能否", "可以吗", "咨询", "原因", "另外", "还有", "顺便", "同时", "并且", "而且", "以及", "\n", ";", ";", } { if strings.Contains(text, marker) { return false } } return true } func quickActionBusinessContext(ctx context.Context, conversation *models.Conversation) contract.BusinessReadContext { if conversation == nil { return contract.BusinessReadContext{} } businessContext := contract.BusinessReadContext{ ConversationID: conversation.ID, CustomerType: conversation.CustomerType, CustomerID: conversation.CustomerID, CustomerExternalID: conversation.CustomerExternalID, CustomerName: conversation.CustomerName, } if proof, ok := contract.CustomerAccessProofFromContext(ctx); ok { businessContext.AccessProof = &proof businessContext.RequestMessageID = proof.MessageID businessContext.RequestID = proof.RequestID } return businessContext } func (s *customerQuickActionService) resolve(code, customerType string) (contract.CustomerQuickAction, bool) { s.mu.RLock() defer s.mu.RUnlock() action, ok := s.actions[strings.TrimSpace(code)] if !ok || !quickActionSupportsCustomerType(action, customerType) { return contract.CustomerQuickAction{}, false } return action, true } func quickActionSupportsCustomerType(action contract.CustomerQuickAction, customerType string) bool { if len(action.CustomerTypes) == 0 { return true } for _, candidate := range action.CustomerTypes { if strings.EqualFold(strings.TrimSpace(candidate), strings.TrimSpace(customerType)) { return true } } return false }