package service import ( "context" "encoding/json" "fmt" "regexp" "strings" "time" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // AutoReplyRuleService manages Captain auto-reply rules: CRUD + rule matching + auto-reply execution. // Reference: M12 PRD §Captain AI — Auto-Reply Rules // // Auto-reply workflow: // 1. When a new incoming message arrives, check active rules for the inbox/account // 2. Evaluate rule conditions against the message content and context // 3. If matched, compose reply (static text or LLM-generated) and send // --- CRUD DTOs --- type CreateAutoReplyRuleRequest struct { AssistantID uint `json:"assistant_id" validate:"required"` InboxID *uint `json:"inbox_id,omitempty"` Name string `json:"name" validate:"required,min=1"` Description string `json:"description,omitempty"` Mode string `json:"mode" validate:"required,oneof=static llm mixed"` // static, llm, mixed Priority int `json:"priority,omitempty"` Conditions []model.AutoReplyCondition `json:"conditions,omitempty"` ResponseText string `json:"response_text,omitempty"` LLMPromptOverride string `json:"llm_prompt_override,omitempty"` DelaySeconds int `json:"delay_seconds,omitempty"` OneTimeOnly *bool `json:"one_time_only,omitempty"` } type UpdateAutoReplyRuleRequest struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` Status *model.AutoReplyRuleStatus `json:"status,omitempty"` Mode *model.AutoReplyRuleMode `json:"mode,omitempty"` Priority *int `json:"priority,omitempty"` Conditions []model.AutoReplyCondition `json:"conditions,omitempty"` ResponseText *string `json:"response_text,omitempty"` LLMPromptOverride *string `json:"llm_prompt_override,omitempty"` DelaySeconds *int `json:"delay_seconds,omitempty"` OneTimeOnly *bool `json:"one_time_only,omitempty"` } type AutoReplyRuleResult struct { ID uint `json:"id"` AccountID uint `json:"account_id"` AssistantID uint `json:"assistant_id"` InboxID *uint `json:"inbox_id,omitempty"` Name string `json:"name"` Description string `json:"description,omitempty"` Status model.AutoReplyRuleStatus `json:"status"` Mode model.AutoReplyRuleMode `json:"mode"` Priority int `json:"priority"` Conditions []model.AutoReplyCondition `json:"conditions,omitempty"` ResponseText string `json:"response_text,omitempty"` LLMPromptOverride string `json:"llm_prompt_override,omitempty"` DelaySeconds int `json:"delay_seconds"` OneTimeOnly bool `json:"one_time_only"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // --- Auto-Reply Execution DTOs --- type AutoReplyEvaluationContext struct { AccountID uint InboxID uint ConversationID uint MessageContent string SenderType string // "contact", "agent", "bot" Language string // detected language, e.g. "en", "zh" ConversationStatus string // "open", "pending", etc. PreviousMessages []string // last N messages for context } type AutoReplyMatchResult struct { Rule *model.CaptainAutoReplyRule `json:"rule"` ReplyContent string `json:"reply_content"` ReplyMode model.AutoReplyRuleMode `json:"reply_mode"` ShouldReply bool `json:"should_reply"` } // AutoReplyRuleService provides CRUD + evaluation + execution for auto-reply rules. type AutoReplyRuleService struct { ruleRepo *repository.CaptainAutoReplyRuleRepo assistantRepo *repository.CaptainAssistantRepo conversationRepo *repository.ConversationRepo llmProvider llm.Provider promptBuilder *SystemPromptBuilder } // NewAutoReplyRuleService creates a new AutoReplyRuleService. func NewAutoReplyRuleService( ruleRepo *repository.CaptainAutoReplyRuleRepo, assistantRepo *repository.CaptainAssistantRepo, conversationRepo *repository.ConversationRepo, llmProvider llm.Provider, ) *AutoReplyRuleService { return &AutoReplyRuleService{ ruleRepo: ruleRepo, assistantRepo: assistantRepo, conversationRepo: conversationRepo, llmProvider: llmProvider, promptBuilder: NewSystemPromptBuilder(), } } // --- CRUD Operations --- // CreateRule creates a new auto-reply rule. func (s *AutoReplyRuleService) CreateRule(ctx context.Context, accountID uint, req *CreateAutoReplyRuleRequest) (*AutoReplyRuleResult, error) { // Validate assistant exists assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID) if err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } if assistant.AccountID != accountID { return nil, fmt.Errorf("assistant does not belong to account %d", accountID) } // Validate mode-specific fields mode := model.AutoReplyRuleMode(req.Mode) switch mode { case model.AutoReplyRuleModeStatic, model.AutoReplyRuleModeMixed: if req.ResponseText == "" { return nil, fmt.Errorf("response_text is required for %s mode", mode) } case model.AutoReplyRuleModeLLM: // LLM mode can work without static text, just needs the assistant } // Marshal conditions to JSON conditionsJSON, err := json.Marshal(req.Conditions) if err != nil { return nil, fmt.Errorf("invalid conditions format: %w", err) } oneTimeOnly := true if req.OneTimeOnly != nil { oneTimeOnly = *req.OneTimeOnly } rule := &model.CaptainAutoReplyRule{ AccountID: accountID, AssistantID: req.AssistantID, InboxID: req.InboxID, Name: req.Name, Description: req.Description, Status: model.AutoReplyRuleStatusDraft, // new rules start as draft Mode: mode, Priority: req.Priority, Conditions: conditionsJSON, ResponseText: req.ResponseText, LLMPromptOverride: req.LLMPromptOverride, DelaySeconds: req.DelaySeconds, OneTimeOnly: oneTimeOnly, } if err := s.ruleRepo.Create(ctx, rule); err != nil { applogger.L().Errorf("CreateRule: %v", err) return nil, fmt.Errorf("create rule failed: %w", err) } return ruleToResult(rule), nil } // GetRule retrieves a rule by ID. func (s *AutoReplyRuleService) GetRule(ctx context.Context, accountID, ruleID uint) (*AutoReplyRuleResult, error) { rule, err := s.ruleRepo.GetByID(ctx, ruleID) if err != nil { return nil, fmt.Errorf("rule not found: %w", err) } if rule.AccountID != accountID { return nil, fmt.Errorf("rule does not belong to account %d", accountID) } return ruleToResult(rule), nil } // UpdateRule updates an existing rule. func (s *AutoReplyRuleService) UpdateRule(ctx context.Context, accountID, ruleID uint, req *UpdateAutoReplyRuleRequest) (*AutoReplyRuleResult, error) { rule, err := s.ruleRepo.GetByID(ctx, ruleID) if err != nil { return nil, fmt.Errorf("rule not found: %w", err) } if rule.AccountID != accountID { return nil, fmt.Errorf("rule does not belong to account %d", accountID) } // Apply partial updates if req.Name != nil { rule.Name = *req.Name } if req.Description != nil { rule.Description = *req.Description } if req.Status != nil { rule.Status = *req.Status } if req.Mode != nil { rule.Mode = *req.Mode } if req.Priority != nil { rule.Priority = *req.Priority } if req.Conditions != nil { conditionsJSON, err := json.Marshal(req.Conditions) if err != nil { return nil, fmt.Errorf("invalid conditions format: %w", err) } rule.Conditions = conditionsJSON } if req.ResponseText != nil { rule.ResponseText = *req.ResponseText } if req.LLMPromptOverride != nil { rule.LLMPromptOverride = *req.LLMPromptOverride } if req.DelaySeconds != nil { rule.DelaySeconds = *req.DelaySeconds } if req.OneTimeOnly != nil { rule.OneTimeOnly = *req.OneTimeOnly } if err := s.ruleRepo.Update(ctx, rule); err != nil { applogger.L().Errorf("UpdateRule: %v", err) return nil, fmt.Errorf("update rule failed: %w", err) } return ruleToResult(rule), nil } // DeleteRule deletes a rule. func (s *AutoReplyRuleService) DeleteRule(ctx context.Context, accountID, ruleID uint) error { rule, err := s.ruleRepo.GetByID(ctx, ruleID) if err != nil { return fmt.Errorf("rule not found: %w", err) } if rule.AccountID != accountID { return fmt.Errorf("rule does not belong to account %d", accountID) } return s.ruleRepo.Delete(ctx, ruleID) } // ListRules lists all rules for an account. func (s *AutoReplyRuleService) ListRules(ctx context.Context, accountID uint, offset, limit int) ([]AutoReplyRuleResult, int64, error) { rules, count, err := s.ruleRepo.ListByAccount(ctx, accountID, offset, limit) if err != nil { return nil, 0, fmt.Errorf("list rules failed: %w", err) } results := make([]AutoReplyRuleResult, len(rules)) for i, r := range rules { results[i] = *ruleToResult(&r) } return results, count, nil } // --- Auto-Reply Evaluation & Execution --- // Reference: M12 PRD §Captain AI — Auto-Reply Processing // EvaluateRules checks if any active auto-reply rules match the incoming message context. // Returns the highest-priority matching rule and its composed reply content. func (s *AutoReplyRuleService) EvaluateRules(ctx context.Context, evalCtx *AutoReplyEvaluationContext) (*AutoReplyMatchResult, error) { // Find active rules for this inbox/account rules, err := s.ruleRepo.FindActiveByInbox(ctx, evalCtx.AccountID, evalCtx.InboxID) if err != nil { applogger.L().Errorf("EvaluateRules find rules: %v", err) return nil, fmt.Errorf("find active rules: %w", err) } if len(rules) == 0 { return &AutoReplyMatchResult{ShouldReply: false}, nil } // Evaluate rules in priority order (already sorted) for _, rule := range rules { matched, err := s.matchConditions(ctx, &rule, evalCtx) if err != nil { applogger.L().Warnf("EvaluateRules match rule %d: %v", rule.ID, err) continue } if matched { // Compose reply based on rule mode replyContent, err := s.composeReply(ctx, &rule, evalCtx) if err != nil { applogger.L().Errorf("EvaluateRules compose reply: %v", err) continue } return &AutoReplyMatchResult{ Rule: &rule, ReplyContent: replyContent, ReplyMode: rule.Mode, ShouldReply: true, }, nil } } return &AutoReplyMatchResult{ShouldReply: false}, nil } // matchConditions evaluates all conditions of a rule against the evaluation context. // All conditions must match (AND logic). func (s *AutoReplyRuleService) matchConditions(ctx context.Context, rule *model.CaptainAutoReplyRule, evalCtx *AutoReplyEvaluationContext) (bool, error) { conditions, err := rule.GetConditions() if err != nil { return false, fmt.Errorf("parse conditions: %w", err) } // If no conditions, the rule matches everything (catch-all) if len(conditions) == 0 { return true, nil } for _, cond := range conditions { matched, err := matchSingleCondition(cond, evalCtx) if err != nil { return false, err } if !matched { return false, nil // AND logic: one failure = rule doesn't match } } return true, nil } // matchSingleCondition evaluates one condition against the evaluation context. func matchSingleCondition(cond model.AutoReplyCondition, evalCtx *AutoReplyEvaluationContext) (bool, error) { // Get the field value from context var fieldValue string switch cond.Field { case "message_content": fieldValue = evalCtx.MessageContent case "sender_type": fieldValue = evalCtx.SenderType case "conversation_status": fieldValue = evalCtx.ConversationStatus case "language": fieldValue = evalCtx.Language case "keywords": // keywords condition: check if any of the keywords (comma-separated in value) appear keywords := strings.Split(cond.Value, ",") for _, kw := range keywords { if strings.Contains(strings.ToLower(evalCtx.MessageContent), strings.TrimSpace(strings.ToLower(kw))) { return true, nil } } return false, nil default: return false, fmt.Errorf("unknown condition field: %s", cond.Field) } // Apply operator switch cond.Operator { case "contains": return strings.Contains(strings.ToLower(fieldValue), strings.ToLower(cond.Value)), nil case "equals": return strings.EqualFold(fieldValue, cond.Value), nil case "starts_with": return strings.HasPrefix(strings.ToLower(fieldValue), strings.ToLower(cond.Value)), nil case "regex": re, err := regexp.Compile(cond.Value) if err != nil { return false, fmt.Errorf("invalid regex: %w", err) } return re.MatchString(fieldValue), nil case "language_is": return strings.EqualFold(evalCtx.Language, cond.Value), nil default: return false, fmt.Errorf("unknown condition operator: %s", cond.Operator) } } // composeReply generates the auto-reply content based on the rule mode. func (s *AutoReplyRuleService) composeReply(ctx context.Context, rule *model.CaptainAutoReplyRule, evalCtx *AutoReplyEvaluationContext) (string, error) { switch rule.Mode { case model.AutoReplyRuleModeStatic: return rule.ResponseText, nil case model.AutoReplyRuleModeLLM: return s.composeLLMReply(ctx, rule, evalCtx) case model.AutoReplyRuleModeMixed: // Static intro + LLM contextual body llmBody, err := s.composeLLMReply(ctx, rule, evalCtx) if err != nil { // Fallback to static-only if LLM fails applogger.L().Warnf("LLM compose failed for mixed mode rule %d, using static only: %v", rule.ID, err) return rule.ResponseText, nil } return rule.ResponseText + "\n\n" + llmBody, nil default: return "", fmt.Errorf("unknown rule mode: %s", rule.Mode) } } // composeLLMReply uses LLM to generate a contextual reply based on rule and conversation context. func (s *AutoReplyRuleService) composeLLMReply(ctx context.Context, rule *model.CaptainAutoReplyRule, evalCtx *AutoReplyEvaluationContext) (string, error) { // Get assistant for prompt building assistant, err := s.assistantRepo.GetByID(ctx, rule.AssistantID) if err != nil { return "", fmt.Errorf("assistant not found: %w", err) } cfg, _ := assistant.GetConfig() // Build system prompt systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg) if rule.LLMPromptOverride != "" { systemPrompt += "\n\nAdditional Instructions: " + rule.LLMPromptOverride } systemPrompt += "\n\nYou are generating an auto-reply for a customer message. Be helpful, professional, and concise. Do not mention that this is an automated response." // Build conversation context var contextBuilder strings.Builder contextBuilder.WriteString("Current message from customer:\n") contextBuilder.WriteString(evalCtx.MessageContent) contextBuilder.WriteString("\n\nPrevious messages:\n") for _, msg := range evalCtx.PreviousMessages { contextBuilder.WriteString(msg + "\n") } modelName := cfg.Model if modelName == "" { modelName = "gpt-4" } temperature := cfg.Temperature if temperature == 0 { temperature = 0.7 } llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ Model: modelName, Messages: []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: contextBuilder.String()}, }, Temperature: temperature, MaxTokens: 512, }) if err != nil { return "", fmt.Errorf("LLM auto-reply generation failed: %w", err) } if len(llmResp.Choices) == 0 { return "", fmt.Errorf("no LLM response") } return llmResp.Choices[0].Message.Content, nil } // --- Helper --- func ruleToResult(rule *model.CaptainAutoReplyRule) *AutoReplyRuleResult { conditions, _ := rule.GetConditions() return &AutoReplyRuleResult{ ID: rule.ID, AccountID: rule.AccountID, AssistantID: rule.AssistantID, InboxID: rule.InboxID, Name: rule.Name, Description: rule.Description, Status: rule.Status, Mode: rule.Mode, Priority: rule.Priority, Conditions: conditions, ResponseText: rule.ResponseText, LLMPromptOverride: rule.LLMPromptOverride, DelaySeconds: rule.DelaySeconds, OneTimeOnly: rule.OneTimeOnly, CreatedAt: rule.CreatedAt, UpdatedAt: rule.UpdatedAt, } }