Phase 2: AutoReplyRule integration + AgentBot Captain type
AutoReplyRule complete integration: - bootstrap.go: instantiate AutoReplyRuleService + AutoReplyListener, register listener on channel dispatcher for message.created events - router.go: register /captain/auto_reply_rules CRUD + /evaluate routes - auto_reply_rule_handler.go: fix c.Param(id) → c.Param(account_id), override evalCtx.AccountID from path param - auto_reply_rule_service.go: add JSON tags to AutoReplyEvaluationContext for correct request body binding - auto_reply_listener.go (new): EventListener that triggers on incoming messages, evaluates active rules, composes reply (static/LLM/mixed), respects DelaySeconds and OneTimeOnly flags, sends via MessageService - migration 000048: create captain_auto_reply_rules table AgentBot + Captain integration: - agent_bot_listener.go: add captainConvSvc field + SetCaptainConversationService method. In HandleEvent loop, check bot.BotType == captain and route to CaptainConversationService.BuildConversationResponseByAccount instead of webhook push. Extract assistant_id from bot.Config JSONB, extract conversation_id from event data. - bootstrap.go: inject captainConversationService into agentBotListener CaptainConversationService improvement: - generateConversationResponse: use assistant config for system prompt, model name, and temperature instead of hardcoded values Verified: - go build ./... passes - go vet passes on all internal packages - go test passes (service + repository + llm, SQLite mode) - Auto-reply CRUD: create/get/update/delete all work - Auto-reply evaluate: correctly matches hello → should_reply=true, correctly rejects non-matching message - Existing routes unaffected (assistants, RAG, conversation respond) - Migration 000048 creates captain_auto_reply_rules table successfully
This commit is contained in:
@@ -210,6 +210,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
captainCustomToolRepo := repository.NewCaptainCustomToolRepo(db)
|
||||
captainAssistantResponseRepo := repository.NewCaptainAssistantResponseRepo(db)
|
||||
captainPreferenceRepo := repository.NewCaptainPreferenceRepo(db)
|
||||
captainAutoReplyRuleRepo := repository.NewCaptainAutoReplyRuleRepo(db)
|
||||
copilotThreadRepo := repository.NewCopilotThreadRepo(db)
|
||||
copilotMessageRepo := repository.NewCopilotMessageRepo(db)
|
||||
copilotSuggestionRepo := repository.NewCopilotSuggestionRepo(db)
|
||||
@@ -601,9 +602,11 @@ func Bootstrap(env string) (*App, error) {
|
||||
captainBulkActionService.SetCaptainResourceRepos(captainAssistantResponseRepo, captainDocumentRepo)
|
||||
|
||||
// RAG service — knowledge base Q&A (embedding search + LLM generation)
|
||||
// Reference: M12 PRD §Captain AI — RAG Knowledge Base Q&A
|
||||
ragService := service.NewRAGService(captainAssistantResponseRepo, captainAssistantRepo, llmProvider)
|
||||
|
||||
// Auto-reply rule service — CRUD + condition matching + LLM reply composition
|
||||
autoReplyRuleService := service.NewAutoReplyRuleService(captainAutoReplyRuleRepo, captainAssistantRepo, conversationRepo, llmProvider)
|
||||
|
||||
// Analytics services (P11 — Reports/Analytics)
|
||||
analyticsService := service.NewAnalyticsService(reportingEventRepo, reportingEventsRollupRepo)
|
||||
analyticsService.SetWorkerPool(workerPool)
|
||||
@@ -616,8 +619,13 @@ func Bootstrap(env string) (*App, error) {
|
||||
agentBotService := service.NewAgentBotService(agentBotRepo)
|
||||
agentBotInboxService := service.NewAgentBotInboxService(agentBotInboxRepo, agentBotRepo)
|
||||
agentBotListener := service.NewAgentBotListener(agentBotInboxRepo, agentBotRepo, conversationRepo, messageRepo)
|
||||
agentBotListener.SetCaptainConversationService(captainConversationService)
|
||||
channelDispatcher.Register(agentBotListener)
|
||||
|
||||
// Auto-reply listener — evaluates rules on incoming messages and sends auto-replies
|
||||
autoReplyListener := service.NewAutoReplyListener(captainAutoReplyRuleRepo, autoReplyRuleService, conversationRepo, messageRepo, messageService)
|
||||
channelDispatcher.Register(autoReplyListener)
|
||||
|
||||
// Platform: InstallationConfig service (global key-value config for super-admin)
|
||||
installationConfigService := service.NewInstallationConfigService(installationConfigRepo)
|
||||
|
||||
@@ -796,6 +804,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
CaptainBulkAction: v1.NewCaptainBulkActionHandler(captainBulkActionService),
|
||||
RAG: v1.NewRAGHandler(ragService),
|
||||
CaptainConversation: v1.NewCaptainConversationHandler(captainConversationService),
|
||||
AutoReplyRule: v1.NewAutoReplyRuleHandler(autoReplyRuleService),
|
||||
Copilot: v1.NewCopilotHandler(copilotService),
|
||||
Analytics: v1.NewAnalyticsHandler(analyticsService),
|
||||
LiveReport: v1.NewLiveReportHandler(analyticsService),
|
||||
|
||||
@@ -25,7 +25,7 @@ func NewAutoReplyRuleHandler(svc *service.AutoReplyRuleService) *AutoReplyRuleHa
|
||||
// Create creates a new auto-reply rule for an assistant.
|
||||
// POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/auto_reply_rules
|
||||
func (h *AutoReplyRuleHandler) Create(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, "invalid_account_id", "Invalid account ID")
|
||||
return
|
||||
@@ -50,7 +50,7 @@ func (h *AutoReplyRuleHandler) Create(c *gin.Context) {
|
||||
// Get retrieves a single auto-reply rule.
|
||||
// GET /api/v1/accounts/:account_id/captain/auto_reply_rules/:rule_id
|
||||
func (h *AutoReplyRuleHandler) Get(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, "invalid_account_id", "Invalid account ID")
|
||||
return
|
||||
@@ -75,7 +75,7 @@ func (h *AutoReplyRuleHandler) Get(c *gin.Context) {
|
||||
// Update updates an existing auto-reply rule.
|
||||
// PUT /api/v1/accounts/:account_id/captain/auto_reply_rules/:rule_id
|
||||
func (h *AutoReplyRuleHandler) Update(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, "invalid_account_id", "Invalid account ID")
|
||||
return
|
||||
@@ -106,7 +106,7 @@ func (h *AutoReplyRuleHandler) Update(c *gin.Context) {
|
||||
// Delete removes an auto-reply rule.
|
||||
// DELETE /api/v1/accounts/:account_id/captain/auto_reply_rules/:rule_id
|
||||
func (h *AutoReplyRuleHandler) Delete(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, "invalid_account_id", "Invalid account ID")
|
||||
return
|
||||
@@ -130,7 +130,7 @@ func (h *AutoReplyRuleHandler) Delete(c *gin.Context) {
|
||||
// List returns auto-reply rules for an account, optionally filtered by assistant.
|
||||
// GET /api/v1/accounts/:account_id/captain/auto_reply_rules
|
||||
func (h *AutoReplyRuleHandler) List(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, "invalid_account_id", "Invalid account ID")
|
||||
return
|
||||
@@ -150,7 +150,7 @@ func (h *AutoReplyRuleHandler) List(c *gin.Context) {
|
||||
// Evaluate checks rules against a conversation context and returns the best match.
|
||||
// POST /api/v1/accounts/:account_id/captain/auto_reply_rules/evaluate
|
||||
func (h *AutoReplyRuleHandler) Evaluate(c *gin.Context) {
|
||||
accountID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
accountID, _ := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
_ = uint(accountID) // scoped by account_id path param; will be used when EvaluateRules gains account-scoping
|
||||
|
||||
var evalCtx service.AutoReplyEvaluationContext
|
||||
@@ -159,6 +159,9 @@ func (h *AutoReplyRuleHandler) Evaluate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Override account_id from path param (authoritative source)
|
||||
evalCtx.AccountID = uint(accountID)
|
||||
|
||||
result, err := h.svc.EvaluateRules(c.Request.Context(), &evalCtx)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("AutoReplyRuleHandler.Evaluate failed: %v", err)
|
||||
|
||||
@@ -63,6 +63,7 @@ type Handlers struct {
|
||||
CaptainBulkAction *v1.CaptainBulkActionHandler
|
||||
RAG *v1.RAGHandler
|
||||
CaptainConversation *v1.CaptainConversationHandler
|
||||
AutoReplyRule *v1.AutoReplyRuleHandler
|
||||
BulkAction *v1.BulkActionHandler
|
||||
Copilot *v1.CopilotHandler
|
||||
WebWidget *v1.WebWidgetHandler
|
||||
@@ -1501,6 +1502,20 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
// Captain Conversation auto-response (handoff mode)
|
||||
// POST /captain/conversations/:conversation_id/respond
|
||||
captain.POST("/conversations/:conversation_id/respond", h.CaptainConversation.BuildResponse)
|
||||
|
||||
// Auto-reply rules CRUD + evaluate
|
||||
// Reference: M12 PRD §Captain AI — Auto-Reply Rules
|
||||
autoReplyRules := captain.Group("/auto_reply_rules")
|
||||
{
|
||||
autoReplyRules.GET("", h.AutoReplyRule.List)
|
||||
autoReplyRules.GET("/", h.AutoReplyRule.List)
|
||||
autoReplyRules.POST("", h.AutoReplyRule.Create)
|
||||
autoReplyRules.POST("/", h.AutoReplyRule.Create)
|
||||
autoReplyRules.GET("/:rule_id", h.AutoReplyRule.Get)
|
||||
autoReplyRules.PUT("/:rule_id", h.AutoReplyRule.Update)
|
||||
autoReplyRules.DELETE("/:rule_id", h.AutoReplyRule.Delete)
|
||||
autoReplyRules.POST("/evaluate", h.AutoReplyRule.Evaluate)
|
||||
}
|
||||
}
|
||||
|
||||
// Draft messages — account-scoped search & count
|
||||
|
||||
@@ -43,6 +43,9 @@ type AgentBotListener struct {
|
||||
conversationRepo *repository.ConversationRepo
|
||||
messageRepo *repository.MessageRepo
|
||||
httpClient *http.Client
|
||||
// captainConvSvc enables "captain" bot type — AI auto-response via CaptainConversationService.
|
||||
// nil = captain bot type not supported (backward compatible).
|
||||
captainConvSvc *CaptainConversationService
|
||||
}
|
||||
|
||||
// NewAgentBotListener creates a new AgentBotListener.
|
||||
@@ -56,6 +59,12 @@ func NewAgentBotListener(botInboxRepo *repository.AgentBotInboxRepo, botRepo *re
|
||||
}
|
||||
}
|
||||
|
||||
// SetCaptainConversationService injects the CaptainConversationService,
|
||||
// enabling "captain" bot type support for AI auto-response.
|
||||
func (l *AgentBotListener) SetCaptainConversationService(svc *CaptainConversationService) {
|
||||
l.captainConvSvc = svc
|
||||
}
|
||||
|
||||
// Name returns the dispatcher listener identifier.
|
||||
func (l *AgentBotListener) Name() string { return "agent_bot" }
|
||||
|
||||
@@ -161,6 +170,26 @@ func (l *AgentBotListener) HandleEvent(ctx context.Context, eventType string, ac
|
||||
|
||||
// Push to each bound bot
|
||||
for _, binding := range bindings {
|
||||
// Fetch bot to check type
|
||||
bot, botErr := l.botRepo.FindByID(ctx, binding.AgentBotID)
|
||||
if botErr != nil {
|
||||
applogger.L().Errorf("AgentBotListener: find bot %d failed: %v", binding.AgentBotID, botErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Captain bot type — route to CaptainConversationService instead of webhook
|
||||
if bot.BotType == "captain" {
|
||||
if l.captainConvSvc == nil {
|
||||
applogger.L().Warnf("AgentBotListener: captain bot %d but CaptainConversationService not configured", bot.ID)
|
||||
continue
|
||||
}
|
||||
if err := l.handleCaptainBot(ctx, bot, eventType, accountID, inboxID, data); err != nil {
|
||||
applogger.L().Errorf("AgentBotListener: captain bot %d response failed: %v", bot.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Webhook bot type — push payload to outgoing_url
|
||||
pushErr := l.pushToBot(ctx, binding, payloadBytes)
|
||||
if pushErr != nil {
|
||||
applogger.L().Errorf("AgentBotListener: push to bot %d failed: %v", binding.AgentBotID, pushErr)
|
||||
@@ -433,3 +462,96 @@ func isRetryableAgentBotError(err error) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleCaptainBot processes a "captain" type AgentBot by routing the
|
||||
// conversation to the CaptainConversationService for AI auto-response.
|
||||
//
|
||||
// The bot's Config JSONB must contain an "assistant_id" field specifying
|
||||
// which Captain Assistant to use. The conversation must be in "pending"
|
||||
// status for AI response to be generated.
|
||||
//
|
||||
// Reference: Chatwoot AgentBot + Captain integration — bot.bot_type == "captain"
|
||||
// routes to Captain::ConversationService instead of webhook push.
|
||||
func (l *AgentBotListener) handleCaptainBot(ctx context.Context, bot *model.AgentBot, eventType string, accountID, inboxID uint, data map[string]interface{}) error {
|
||||
// Only respond to new message events
|
||||
if eventType != "message_created" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only respond to incoming (contact) messages
|
||||
msgType := ""
|
||||
if msg, ok := data["message"].(map[string]interface{}); ok {
|
||||
msgType, _ = msg["message_type"].(string)
|
||||
} else if msg, ok := data["message"].(*model.Message); ok {
|
||||
msgType = msg.MessageType
|
||||
} else if msg, ok := data["message"].(model.Message); ok {
|
||||
msgType = msg.MessageType
|
||||
}
|
||||
if msgType != "incoming" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract assistant_id from bot config
|
||||
assistantID := extractAssistantIDFromBotConfig(bot.Config)
|
||||
if assistantID == 0 {
|
||||
applogger.L().Warnf("AgentBotListener: captain bot %d has no assistant_id in config", bot.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract conversation ID from event data
|
||||
conversationID := extractConversationID(data)
|
||||
if conversationID == 0 {
|
||||
applogger.L().Warnf("AgentBotListener: captain bot %d could not extract conversation_id from event", bot.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trigger AI response generation
|
||||
// CaptainConversationService checks conversation status (must be pending)
|
||||
// and generates a response or handoff
|
||||
_, err := l.captainConvSvc.BuildConversationResponseByAccount(ctx, accountID, conversationID, assistantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("captain conversation response: %w", err)
|
||||
}
|
||||
|
||||
applogger.L().Infof("AgentBotListener: captain bot %d triggered AI response for conversation %d",
|
||||
bot.ID, conversationID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractAssistantIDFromBotConfig parses the assistant_id from the bot's Config JSONB.
|
||||
func extractAssistantIDFromBotConfig(config json.RawMessage) uint {
|
||||
if len(config) == 0 {
|
||||
return 0
|
||||
}
|
||||
var cfg struct {
|
||||
AssistantID uint `json:"assistant_id"`
|
||||
}
|
||||
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||
return 0
|
||||
}
|
||||
return cfg.AssistantID
|
||||
}
|
||||
|
||||
// extractConversationID extracts the conversation_id from event data.
|
||||
func extractConversationID(data map[string]interface{}) uint {
|
||||
if id := extractUintFromMap(data, "conversation_id"); id != 0 {
|
||||
return id
|
||||
}
|
||||
if conv, ok := data["conversation"].(map[string]interface{}); ok {
|
||||
if id := extractUintFromMap(conv, "id"); id != 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
if msg, ok := data["message"].(*model.Message); ok {
|
||||
return msg.ConversationID
|
||||
}
|
||||
if msg, ok := data["message"].(model.Message); ok {
|
||||
return msg.ConversationID
|
||||
}
|
||||
if msg, ok := data["message"].(map[string]interface{}); ok {
|
||||
if id := extractUintFromMap(msg, "conversation_id"); id != 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
)
|
||||
|
||||
// AutoReplyListener is the event listener that triggers auto-reply rule
|
||||
// evaluation when a new incoming message is created.
|
||||
//
|
||||
// Reference: Chatwoot Captain::AutoReplyListener — subscribes to message.created
|
||||
// events and evaluates auto-reply rules for the conversation's inbox.
|
||||
//
|
||||
// Event flow:
|
||||
// 1. Incoming message persisted → EventMessageCreated published
|
||||
// 2. AutoReplyListener receives event
|
||||
// 3. Evaluates active auto-reply rules for the account/inbox
|
||||
// 4. If a rule matches, composes reply (static/LLM/mixed) and sends via MessageService
|
||||
// 5. Respects DelaySeconds and OneTimeOnly flags
|
||||
type AutoReplyListener struct {
|
||||
ruleRepo *repository.CaptainAutoReplyRuleRepo
|
||||
autoReplySvc *AutoReplyRuleService
|
||||
conversationRepo *repository.ConversationRepo
|
||||
messageRepo *repository.MessageRepo
|
||||
messageSvc *MessageService
|
||||
}
|
||||
|
||||
// NewAutoReplyListener creates a new AutoReplyListener.
|
||||
func NewAutoReplyListener(
|
||||
ruleRepo *repository.CaptainAutoReplyRuleRepo,
|
||||
autoReplySvc *AutoReplyRuleService,
|
||||
conversationRepo *repository.ConversationRepo,
|
||||
messageRepo *repository.MessageRepo,
|
||||
messageSvc *MessageService,
|
||||
) *AutoReplyListener {
|
||||
return &AutoReplyListener{
|
||||
ruleRepo: ruleRepo,
|
||||
autoReplySvc: autoReplySvc,
|
||||
conversationRepo: conversationRepo,
|
||||
messageRepo: messageRepo,
|
||||
messageSvc: messageSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the dispatcher listener identifier.
|
||||
func (l *AutoReplyListener) Name() string { return "auto_reply" }
|
||||
|
||||
// OnEvent processes channel events, triggering auto-reply on message.created.
|
||||
func (l *AutoReplyListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only trigger on new incoming messages
|
||||
if event.Type != channel.EventMessageCreated && event.Type != channel.EventMessageIncoming {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only process incoming (contact) messages, not agent/bot replies
|
||||
senderType, _ := event.Data["sender_type"].(string)
|
||||
if senderType != "Contact" && senderType != "contact" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract message content and conversation info
|
||||
content, _ := event.Data["content"].(string)
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
conversationID := event.ConversationID
|
||||
if conversationID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load conversation to get full context
|
||||
conversation, err := l.conversationRepo.FindByAccountAndID(ctx, event.AccountID, conversationID)
|
||||
if err != nil {
|
||||
applogger.L().Warnf("AutoReplyListener: conversation %d not found: %v", conversationID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip if conversation is already resolved/bot-resolved
|
||||
if conversation.Status == string(model.ConversationStatusResolved) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch recent messages for context (last 10)
|
||||
prevMessages, _ := l.fetchRecentMessages(ctx, conversationID, 10)
|
||||
|
||||
// Build evaluation context
|
||||
evalCtx := &AutoReplyEvaluationContext{
|
||||
AccountID: event.AccountID,
|
||||
InboxID: event.InboxID,
|
||||
ConversationID: conversationID,
|
||||
MessageContent: content,
|
||||
SenderType: "contact",
|
||||
Language: "", // language detection could be added later
|
||||
ConversationStatus: conversation.Status,
|
||||
PreviousMessages: prevMessages,
|
||||
}
|
||||
|
||||
// Evaluate rules
|
||||
result, err := l.autoReplySvc.EvaluateRules(ctx, evalCtx)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("AutoReplyListener: evaluate rules: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !result.ShouldReply || result.Rule == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check OneTimeOnly: if the rule already fired for this conversation, skip
|
||||
if result.Rule.OneTimeOnly {
|
||||
if l.hasAutoReplyBeenSent(ctx, conversationID, result.Rule.ID) {
|
||||
applogger.L().Infof("AutoReplyListener: rule %d already fired for conversation %d, skipping",
|
||||
result.Rule.ID, conversationID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Apply delay if configured
|
||||
if result.Rule.DelaySeconds > 0 {
|
||||
go func() {
|
||||
time.Sleep(time.Duration(result.Rule.DelaySeconds) * time.Second)
|
||||
l.sendAutoReply(context.Background(), event, conversation, result)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
return l.sendAutoReply(ctx, event, conversation, result)
|
||||
}
|
||||
|
||||
// sendAutoReply sends the composed reply as an outgoing message.
|
||||
func (l *AutoReplyListener) sendAutoReply(ctx context.Context, event *channel.ChannelEvent, conversation *model.Conversation, result *AutoReplyMatchResult) error {
|
||||
if l.messageSvc == nil {
|
||||
applogger.L().Warnf("AutoReplyListener: message service not available, cannot send auto-reply")
|
||||
return nil
|
||||
}
|
||||
|
||||
replyContent := result.ReplyContent
|
||||
if strings.TrimSpace(replyContent) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send as a bot/outgoing message
|
||||
// Use the assistant's ID as the sender if available
|
||||
senderType := "agent_bot"
|
||||
var senderID uint
|
||||
if result.Rule.AssistantID > 0 {
|
||||
senderID = 0 // agent_bot messages use bot_id, not user_id
|
||||
}
|
||||
|
||||
// Create the outgoing message via MessageService
|
||||
_, err := l.messageSvc.Create(ctx, event.AccountID, senderID, CreateMessageRequest{
|
||||
ConversationID: conversation.ID,
|
||||
Content: replyContent,
|
||||
ContentType: "text",
|
||||
MessageType: "outgoing",
|
||||
SenderType: senderType,
|
||||
Private: false,
|
||||
})
|
||||
if err != nil {
|
||||
applogger.L().Errorf("AutoReplyListener: send auto-reply: %v", err)
|
||||
return fmt.Errorf("send auto-reply: %w", err)
|
||||
}
|
||||
|
||||
applogger.L().Infof("AutoReplyListener: auto-reply sent for conversation %d (rule %d, mode %s)",
|
||||
conversation.ID, result.Rule.ID, result.ReplyMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchRecentMessages returns the last N message contents for context.
|
||||
func (l *AutoReplyListener) fetchRecentMessages(ctx context.Context, conversationID uint, limit int) ([]string, error) {
|
||||
messages, _, err := l.messageRepo.FindByConversation(ctx, conversationID, 0, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []string
|
||||
for _, m := range messages {
|
||||
role := "contact"
|
||||
if m.SenderType == "User" || m.SenderType == "user" || m.SenderType == "agent_bot" {
|
||||
role = "agent"
|
||||
}
|
||||
result = append(result, fmt.Sprintf("[%s]: %s", role, m.Content))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// hasAutoReplyBeenSent checks if an auto-reply with the given rule already
|
||||
// exists in the conversation. This is a simple heuristic: we check if any
|
||||
// agent_bot message in the conversation contains the rule's response text.
|
||||
// A more robust implementation would store rule_id in message additional_attributes.
|
||||
func (l *AutoReplyListener) hasAutoReplyBeenSent(ctx context.Context, conversationID, ruleID uint) bool {
|
||||
// Simple check: if there are any agent_bot messages in the conversation,
|
||||
// assume auto-reply already fired. This prevents spamming.
|
||||
// TODO: Store rule_id in message additional_attributes for precise dedup.
|
||||
messages, count, err := l.messageRepo.FindByConversation(ctx, conversationID, 0, 50)
|
||||
if err != nil || count == 0 {
|
||||
return false
|
||||
}
|
||||
for _, m := range messages {
|
||||
if m.SenderType == "agent_bot" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
_ = messages
|
||||
return false
|
||||
}
|
||||
@@ -73,14 +73,14 @@ type AutoReplyRuleResult struct {
|
||||
// --- 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
|
||||
AccountID uint `json:"account_id"`
|
||||
InboxID uint `json:"inbox_id"`
|
||||
ConversationID uint `json:"conversation_id"`
|
||||
MessageContent string `json:"message_content"`
|
||||
SenderType string `json:"sender_type"`
|
||||
Language string `json:"language"`
|
||||
ConversationStatus string `json:"conversation_status"`
|
||||
PreviousMessages []string `json:"previous_messages"`
|
||||
}
|
||||
|
||||
type AutoReplyMatchResult struct {
|
||||
|
||||
@@ -123,14 +123,36 @@ func (s *CaptainConversationService) generateConversationResponse(ctx context.Co
|
||||
if s.llmProvider == nil {
|
||||
return nil, fmt.Errorf("captain conversation response generation disabled")
|
||||
}
|
||||
messages := []llm.ChatMessage{{Role: "system", Content: "You are Captain, a customer support assistant. Reply to the latest customer message. Return only the reply text."}}
|
||||
|
||||
// Build system prompt from assistant config (not hardcoded)
|
||||
cfg, _ := assistant.GetConfig()
|
||||
systemPrompt := fmt.Sprintf("You are %s, a customer support assistant.", assistant.Name)
|
||||
if cfg.ProductName != "" {
|
||||
systemPrompt += fmt.Sprintf(" You represent the product: %s.", cfg.ProductName)
|
||||
}
|
||||
if cfg.Instructions != "" {
|
||||
systemPrompt += "\nInstructions: " + cfg.Instructions
|
||||
}
|
||||
systemPrompt += "\nReply to the latest customer message. Return only the reply text."
|
||||
|
||||
messages := []llm.ChatMessage{{Role: "system", Content: systemPrompt}}
|
||||
for _, msg := range history {
|
||||
if strings.TrimSpace(msg.Content) == "" {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, llm.ChatMessage{Role: msg.Role, Content: msg.Content})
|
||||
}
|
||||
resp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{Model: "gpt-4", Messages: messages, Temperature: 0.7, MaxTokens: 1024})
|
||||
|
||||
modelName := cfg.Model
|
||||
if modelName == "" {
|
||||
modelName = "gpt-4"
|
||||
}
|
||||
temperature := cfg.Temperature
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
}
|
||||
|
||||
resp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{Model: modelName, Messages: messages, Temperature: temperature, MaxTokens: 1024})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate captain conversation response: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 000048_add_captain_auto_reply_rules.down.sql
|
||||
-- Drop Captain AI auto-reply rules table
|
||||
|
||||
DROP TABLE IF EXISTS captain_auto_reply_rules;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 000048_add_captain_auto_reply_rules.up.sql
|
||||
-- Captain AI — Auto-Reply Rules table
|
||||
-- Reference: M12 PRD §Captain AI — Auto-Reply Rules
|
||||
-- Stores rules for automated replies when incoming messages match conditions.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS captain_auto_reply_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL,
|
||||
assistant_id BIGINT NOT NULL,
|
||||
inbox_id BIGINT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'draft',
|
||||
mode VARCHAR(50) NOT NULL DEFAULT 'static',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
conditions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
response_text TEXT DEFAULT '',
|
||||
llm_prompt_override TEXT DEFAULT '',
|
||||
delay_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
one_time_only BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_captain_auto_reply_rules_account_id ON captain_auto_reply_rules(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_captain_auto_reply_rules_assistant_id ON captain_auto_reply_rules(assistant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_captain_auto_reply_rules_inbox_id ON captain_auto_reply_rules(inbox_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_captain_auto_reply_rules_status ON captain_auto_reply_rules(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_captain_auto_reply_rules_deleted_at ON captain_auto_reply_rules(deleted_at);
|
||||
Reference in New Issue
Block a user