From 4bf6ba6b203999ee6cd32a3de8d0cdda7717cde5 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 12 Aug 2026 19:18:11 +0800 Subject: [PATCH] feat(conversations): complete manual AI takeover --- backend/internal/app/bootstrap.go | 1 + .../api/v1/assignable_agent_handler.go | 1 + .../handler/api/v1/conversation_handler.go | 24 +++++++-- .../api/v1/conversation_handler_crud_test.go | 18 ++++++- .../handler/api/v1/conversation_serializer.go | 13 ++--- .../internal/repository/conversation_repo.go | 10 +++- .../internal/service/agent_bot_listener.go | 45 +++++++++++------ .../service/agent_bot_listener_test.go | 49 ++++++++++++++----- .../service/captain_conversation_service.go | 15 ++++++ .../captain_conversation_worker_test.go | 40 ++++++++++++++- .../internal/service/conversation_service.go | 17 +++++++ backend/internal/service/message_service.go | 18 +++++-- .../internal/service/message_service_test.go | 17 +++++++ .../dashboard/api/assignableAgents.js | 2 +- .../dashboard/api/inbox/conversation.js | 3 +- .../api/specs/assignableAgents.spec.js | 1 + .../api/specs/inbox/conversation.spec.js | 7 ++- .../BulkAgentActions.vue | 4 +- .../composables/chatlist/useBulkActions.js | 26 +++++++--- .../dashboard/helper/agentHelper.js | 2 +- .../conversation/ConversationAction.vue | 1 + .../store/modules/conversations/actions.js | 6 ++- 22 files changed, 266 insertions(+), 54 deletions(-) diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index fa427368..d72dff24 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -595,6 +595,7 @@ func Bootstrap(env string) (*App, error) { copilotService.SetWorkerPool(workerPool) captainConversationService := service.NewCaptainConversationService(db, llmProvider) captainConversationService.SetWorkerPool(workerPool) + captainConversationService.SetMessageService(messageService) // Tool execution service — LLM function calling (tool_call loop) toolExecutionService := service.NewToolExecutionService(captainCustomToolRepo, llmProvider) diff --git a/backend/internal/handler/api/v1/assignable_agent_handler.go b/backend/internal/handler/api/v1/assignable_agent_handler.go index 01215398..e99f10ea 100644 --- a/backend/internal/handler/api/v1/assignable_agent_handler.go +++ b/backend/internal/handler/api/v1/assignable_agent_handler.go @@ -93,6 +93,7 @@ func (h *AssignableAgentHandler) List(c *gin.Context) { bot["assignee_type"] = "AgentBot" bot["icon"] = "i-lucide-bot" bot["availability_status"] = "offline" + bot["confirmed"] = true payload = append(payload, bot) } } diff --git a/backend/internal/handler/api/v1/conversation_handler.go b/backend/internal/handler/api/v1/conversation_handler.go index 341d6d42..0f87ddc8 100644 --- a/backend/internal/handler/api/v1/conversation_handler.go +++ b/backend/internal/handler/api/v1/conversation_handler.go @@ -1059,9 +1059,10 @@ func (h *ConversationHandler) AssignTeam(c *gin.Context) { } var req struct { - AgentID *uint `json:"agent_id"` - AssigneeID *uint `json:"assignee_id"` - TeamID *uint `json:"team_id"` + AgentID *uint `json:"agent_id"` + AssigneeID *uint `json:"assignee_id"` + AssigneeType string `json:"assignee_type"` + TeamID *uint `json:"team_id"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) @@ -1076,6 +1077,23 @@ func (h *ConversationHandler) AssignTeam(c *gin.Context) { if !ok { return } + if req.AssigneeType == "AgentBot" { + if agentID == nil { + agentID = new(uint) + } + conversation, bot, svcErr := h.conversationSvc.AssignAgentBot(c.Request.Context(), accountID, conversation.ID, *agentID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + recordAuditMutation(c, h.auditSvc, auditMutation{AccountID: accountID, AuditableType: "Conversation", AuditableID: conversation.ID, Action: "update", AuditedChanges: gin.H{"assignee_agent_bot_id": conversation.AssigneeAgentBotID}}) + if bot == nil { + c.JSON(http.StatusOK, nil) + return + } + c.JSON(http.StatusOK, serializeAgentBotSlim(bot)) + return + } conversation, svcErr := h.conversationSvc.AssignTeam(c.Request.Context(), accountID, conversation.ID, agentID, req.TeamID) if svcErr != nil { handleServiceError(c, svcErr) diff --git a/backend/internal/handler/api/v1/conversation_handler_crud_test.go b/backend/internal/handler/api/v1/conversation_handler_crud_test.go index 0b066e00..b891ef82 100644 --- a/backend/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/backend/internal/handler/api/v1/conversation_handler_crud_test.go @@ -732,7 +732,7 @@ func (s *ConversationCrudTestSuite) TestAssignAgentBot_MutuallyExclusiveAndAccou s.Require().NoError(s.db.Create(bot).Error) body, _ := json.Marshal(map[string]any{"assignee_id": bot.ID, "assignee_type": "AgentBot"}) w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, s.convURL(s.testConv.ID)+"/assign", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, s.convURL(s.testConv.ID)+"/assignments", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code, w.Body.String()) @@ -741,6 +741,21 @@ func (s *ConversationCrudTestSuite) TestAssignAgentBot_MutuallyExclusiveAndAccou s.Nil(assigned.AssigneeID) s.Require().NotNil(assigned.AssigneeAgentBotID) s.Equal(bot.ID, *assigned.AssigneeAgentBotID) + s.Equal(string(model.ConversationStatusPending), assigned.Status) + var assignmentResponse map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &assignmentResponse)) + s.Equal("AgentBot", assignmentResponse["assignee_type"]) + + // Repeating takeover is idempotent. + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, s.convURL(s.testConv.ID)+"/assignments", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + s.Require().NoError(s.db.First(&assigned, s.testConv.ID).Error) + s.Require().NotNil(assigned.AssigneeAgentBotID) + s.Equal(bot.ID, *assigned.AssigneeAgentBotID) + s.Equal(string(model.ConversationStatusPending), assigned.Status) user := &model.User{Name: "Agent", Email: "bot-switch@example.com"} s.Require().NoError(s.db.Create(user).Error) @@ -756,6 +771,7 @@ func (s *ConversationCrudTestSuite) TestAssignAgentBot_MutuallyExclusiveAndAccou s.Require().NotNil(assigned.AssigneeID) s.Equal(user.ID, *assigned.AssigneeID) s.Nil(assigned.AssigneeAgentBotID) + s.Equal(string(model.ConversationStatusOpen), assigned.Status) other := &model.Account{Name: "Other"} s.Require().NoError(s.db.Create(other).Error) diff --git a/backend/internal/handler/api/v1/conversation_serializer.go b/backend/internal/handler/api/v1/conversation_serializer.go index 7b1e78b9..66212f7c 100644 --- a/backend/internal/handler/api/v1/conversation_serializer.go +++ b/backend/internal/handler/api/v1/conversation_serializer.go @@ -767,12 +767,13 @@ func serializeCaptainAssistantSender(assistant *model.CaptainAssistant) map[stri func serializeAgentBotSlim(bot *model.AgentBot) map[string]any { return map[string]any{ - "id": bot.ID, - "name": bot.Name, - "description": bot.Description, - "thumbnail": bot.AvatarURL, - "outgoing_url": bot.OutgoingURL, - "bot_type": bot.BotType, + "id": bot.ID, + "name": bot.Name, + "description": bot.Description, + "thumbnail": bot.AvatarURL, + "outgoing_url": bot.OutgoingURL, + "bot_type": bot.BotType, + "assignee_type": "AgentBot", } } diff --git a/backend/internal/repository/conversation_repo.go b/backend/internal/repository/conversation_repo.go index 42d59d8f..d42be9ce 100644 --- a/backend/internal/repository/conversation_repo.go +++ b/backend/internal/repository/conversation_repo.go @@ -315,16 +315,22 @@ func (r *ConversationRepo) AssignAgent(ctx context.Context, id, assigneeID uint) value = assigneeID } return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id). - Updates(map[string]any{"assignee_id": value, "assignee_agent_bot_id": nil}).Error + Updates(map[string]any{ + "assignee_id": value, "assignee_agent_bot_id": nil, + "status": gorm.Expr("CASE WHEN assignee_agent_bot_id IS NOT NULL AND status = ? THEN ? ELSE status END", model.ConversationStatusPending, model.ConversationStatusOpen), + }).Error } func (r *ConversationRepo) AssignAgentBot(ctx context.Context, id, agentBotID uint) error { var value any + status := any(model.ConversationStatusPending) if agentBotID != 0 { value = agentBotID + } else { + status = gorm.Expr("CASE WHEN assignee_agent_bot_id IS NOT NULL AND status = ? THEN ? ELSE status END", model.ConversationStatusPending, model.ConversationStatusOpen) } return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id). - Updates(map[string]any{"assignee_id": nil, "assignee_agent_bot_id": value}).Error + Updates(map[string]any{"assignee_id": nil, "assignee_agent_bot_id": value, "status": status}).Error } // ToggleStatus toggles conversation between open/resolved. diff --git a/backend/internal/service/agent_bot_listener.go b/backend/internal/service/agent_bot_listener.go index 6c0077f4..f8c01501 100644 --- a/backend/internal/service/agent_bot_listener.go +++ b/backend/internal/service/agent_bot_listener.go @@ -212,26 +212,43 @@ func (l *AgentBotListener) HandleEvent(ctx context.Context, eventType string, ac } func (l *AgentBotListener) agentBotBindingsForEvent(ctx context.Context, inboxID uint, data map[string]interface{}) ([]model.AgentBotInbox, error) { - bindings, err := l.botInboxRepo.FindActiveByInboxID(ctx, inboxID) - if err != nil { - return nil, err - } assignedBotID, err := l.assignedAgentBotIDFromEvent(ctx, data) if err != nil { return nil, err } - if assignedBotID == 0 { - return bindings, nil + if assignedBotID != 0 { + active, err := l.assignedAgentBotActive(ctx, data) + if err != nil { + return nil, err + } + if active { + return []model.AgentBotInbox{{AgentBotID: assignedBotID, InboxID: inboxID, Status: model.AgentBotInboxActive}}, nil + } } - seen := make(map[uint]bool, len(bindings)+1) - for _, binding := range bindings { - seen[binding.AgentBotID] = true + return l.botInboxRepo.FindActiveByInboxID(ctx, inboxID) +} + +func (l *AgentBotListener) assignedAgentBotActive(ctx context.Context, data map[string]interface{}) (bool, error) { + if data == nil { + return false, nil } - if seen[assignedBotID] { - return bindings, nil + status, _ := data["status"].(string) + if conversation, ok := data["conversation"].(map[string]interface{}); ok { + if value, ok := conversation["status"].(string); ok { + status = value + } } - bindings = append(bindings, model.AgentBotInbox{AgentBotID: assignedBotID, InboxID: inboxID, Status: model.AgentBotInboxActive}) - return bindings, nil + if status == "" && l.conversationRepo != nil { + conversationID := extractConversationID(data) + if conversationID != 0 { + conversation, err := l.conversationRepo.FindByID(ctx, conversationID) + if err != nil { + return false, err + } + status = conversation.Status + } + } + return status == string(model.ConversationStatusPending), nil } func (l *AgentBotListener) assignedAgentBotIDFromEvent(ctx context.Context, data map[string]interface{}) (uint, error) { @@ -249,7 +266,7 @@ func (l *AgentBotListener) assignedAgentBotIDFromEvent(ctx context.Context, data if l.conversationRepo == nil { return 0, nil } - conversationID := conversationIDFromEventData(data) + conversationID := extractConversationID(data) if conversationID == 0 { return 0, nil } diff --git a/backend/internal/service/agent_bot_listener_test.go b/backend/internal/service/agent_bot_listener_test.go index 8ea638d3..2df047d8 100644 --- a/backend/internal/service/agent_bot_listener_test.go +++ b/backend/internal/service/agent_bot_listener_test.go @@ -59,7 +59,7 @@ func TestAgentBotListenerReopensPendingConversationOnWebhookFailure(t *testing.T listener := NewAgentBotListener(botInboxRepo, botRepo, convRepo, msgRepo) err := listener.HandleEvent(context.Background(), "message_created", account.ID, inbox.ID, map[string]interface{}{ - "conversation": map[string]interface{}{"id": float64(conversation.ID)}, + "conversation": map[string]interface{}{"id": float64(conversation.ID), "status": "pending"}, "message": map[string]interface{}{"id": float64(1)}, }) require.NoError(t, err) @@ -131,9 +131,9 @@ func TestAgentBotListenerSendsChatwootStyleTopLevelPayload(t *testing.T) { assert.Equal(t, "sha256="+hex.EncodeToString(mac.Sum(nil)), receivedSignature) } -func TestAgentBotListenerSendsToInboxAndAssignedAgentBots(t *testing.T) { +func TestAgentBotListenerSendsOnlyToAssignedAgentBot(t *testing.T) { db := newAgentBotListenerTestDB(t) - deliveries := make(chan map[string]interface{}, 2) + deliveries := make(chan map[string]interface{}, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload map[string]interface{} body, err := io.ReadAll(r.Body) @@ -153,7 +153,7 @@ func TestAgentBotListenerSendsToInboxAndAssignedAgentBots(t *testing.T) { assignedBot := &model.AgentBot{AccountID: &account.ID, Name: "Assigned Bot", BotType: "default", OutgoingURL: server.URL, Secret: "assigned-secret", AccessToken: "assigned-token"} require.NoError(t, db.Create(assignedBot).Error) require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: inboxBot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) - conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: "api", AssigneeAgentBotID: &assignedBot.ID} + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusPending), ChannelType: "api", AssigneeAgentBotID: &assignedBot.ID} require.NoError(t, db.Create(conversation).Error) listener := NewAgentBotListener(repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), repository.NewConversationRepo(db), nil) @@ -162,13 +162,11 @@ func TestAgentBotListenerSendsToInboxAndAssignedAgentBots(t *testing.T) { }) require.NoError(t, err) - for i := 0; i < 2; i++ { - select { - case payload := <-deliveries: - assert.Equal(t, "message_created", payload["event"]) - case <-time.After(time.Second): - t.Fatalf("expected delivery %d", i+1) - } + select { + case payload := <-deliveries: + assert.Equal(t, "message_created", payload["event"]) + case <-time.After(time.Second): + t.Fatal("expected assigned bot delivery") } assert.Empty(t, deliveries) } @@ -200,6 +198,35 @@ func TestAgentBotListenerDoesNotDuplicateAssignedInboxBot(t *testing.T) { assert.Equal(t, 1, deliveryCount) } +func TestAgentBotListenerFindsAssignedBotFromMessage(t *testing.T) { + db := newAgentBotListenerTestDB(t) + deliveryCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deliveryCount++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + account := &model.Account{Name: "Message assignment", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusPending), ChannelType: "api", AssigneeAgentBotID: &bot.ID} + require.NoError(t, db.Create(conversation).Error) + message := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: string(model.MessageTypeIncoming)} + + listener := NewAgentBotListener(repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), repository.NewConversationRepo(db), nil) + require.NoError(t, listener.OnEvent(context.Background(), &channel.ChannelEvent{ + Type: channel.EventMessageCreated, + AccountID: account.ID, + InboxID: inbox.ID, + Data: map[string]interface{}{"message": message, "status": "pending"}, + })) + assert.Equal(t, 1, deliveryCount) +} + func TestAgentBotListenerOnEventSkipsActivityMessages(t *testing.T) { db := newAgentBotListenerTestDB(t) deliveryCount := 0 diff --git a/backend/internal/service/captain_conversation_service.go b/backend/internal/service/captain_conversation_service.go index 0206da65..f7b57efc 100644 --- a/backend/internal/service/captain_conversation_service.go +++ b/backend/internal/service/captain_conversation_service.go @@ -47,10 +47,15 @@ type CaptainConversationService struct { llmProvider llm.Provider backend CaptainConversationResponseBackend worker *worker.WorkerPool + messageSvc *MessageService // toolExecSvc enables LLM function calling (tool_call loop). nil = tools disabled. toolExecSvc *ToolExecutionService } +func (s *CaptainConversationService) SetMessageService(messageSvc *MessageService) { + s.messageSvc = messageSvc +} + func NewCaptainConversationService(db *gorm.DB, llmProvider llm.Provider) *CaptainConversationService { return &CaptainConversationService{db: db, llmProvider: llmProvider} } @@ -187,6 +192,13 @@ func (s *CaptainConversationService) createCaptainOutgoingMessage(ctx context.Co raw, _ := json.Marshal(map[string]any{"agent_name": strings.TrimSpace(agentName)}) attrs = datatypes.JSON(raw) } + if s.messageSvc != nil { + return s.messageSvc.Create(ctx, conversation.AccountID, assistant.ID, CreateMessageRequest{ + ConversationID: conversation.ID, Content: content, + ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), + SenderID: assistant.ID, SenderType: "Captain::Assistant", AdditionalAttributes: attrs, + }) + } message := &model.Message{ AccountID: conversation.AccountID, ConversationID: conversation.ID, @@ -290,6 +302,9 @@ func EnqueueCaptainConversationResponseForMessage(ctx context.Context, wp *worke if conversation.Status != string(model.ConversationStatusPending) { return nil, nil } + if conversation.AssigneeAgentBotID != nil { + return nil, nil + } var ci model.CaptainInbox if err := db.WithContext(ctx).Where("account_id = ? AND inbox_id = ?", conversation.AccountID, conversation.InboxID).First(&ci).Error; err != nil { if err == gorm.ErrRecordNotFound { diff --git a/backend/internal/service/captain_conversation_worker_test.go b/backend/internal/service/captain_conversation_worker_test.go index bef27f9e..1fe48e07 100644 --- a/backend/internal/service/captain_conversation_worker_test.go +++ b/backend/internal/service/captain_conversation_worker_test.go @@ -22,7 +22,7 @@ func setupCaptainConversationWorkerTest(t *testing.T) (*gorm.DB, *CaptainConvers dbName := fmt.Sprintf("file:%s?mode=memory&cache=private", t.Name()) db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.Attachment{}, &model.CaptainAssistant{}, &model.CaptainInbox{}, &model.BackgroundJob{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.Attachment{}, &model.AgentBot{}, &model.CaptainAssistant{}, &model.CaptainInbox{}, &model.BackgroundJob{})) t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() @@ -72,6 +72,29 @@ func TestCaptainConversationResponseJobQueuesFromIncomingMessage(t *testing.T) { assert.Equal(t, int64(1), count) } +func TestCaptainConversationResponseUsesShangwutongDelivery(t *testing.T) { + db, conversationSvc, messageSvc, account, inbox, conversation, _ := setupCaptainConversationWorkerTest(t) + require.NoError(t, db.Model(&model.Inbox{}).Where("id = ?", inbox.ID).Update("channel_type", "shangwutong").Error) + require.NoError(t, db.Model(&model.Conversation{}).Where("id = ?", conversation.ID).Updates(map[string]any{"channel_type": "shangwutong", "channel": "shangwutong"}).Error) + conversationSvc.SetResponseBackend(&fakeCaptainConversationBackend{response: &CaptainConversationResponse{Content: "AI reply"}}) + wp := worker.NewWorkerPool(db) + conversationSvc.SetWorkerPool(wp) + messageSvc.SetWorkerPool(wp) + conversationSvc.SetMessageService(messageSvc) + + var bot model.AgentBot + require.NoError(t, db.Where("account_id = ?", account.ID).FirstOrCreate(&bot, model.AgentBot{AccountID: &account.ID, Name: "Captain", BotType: "captain", Config: []byte(`{"assistant_id":1}`)}).Error) + require.NoError(t, db.Model(&model.Conversation{}).Where("id = ?", conversation.ID).Update("assignee_agent_bot_id", bot.ID).Error) + message, err := conversationSvc.BuildConversationResponseByAccount(context.Background(), account.ID, conversation.ID, 1) + require.NoError(t, err) + require.NotNil(t, message) + assert.Equal(t, string(model.MessageStatusProgress), message.Status) + + var count int64 + require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Count(&count).Error) + assert.Equal(t, int64(1), count) +} + func TestCaptainConversationResponseJobHandoffOpensConversation(t *testing.T) { db, conversationSvc, _, account, _, conversation, _ := setupCaptainConversationWorkerTest(t) conversationSvc.SetResponseBackend(&fakeCaptainConversationBackend{response: &CaptainConversationResponse{Action: "handoff"}}) @@ -124,6 +147,21 @@ func TestCaptainConversationResponseSkipsNonPendingConversation(t *testing.T) { assert.Equal(t, int64(0), count) } +func TestCaptainInboxAutoResponseSkipsManuallyAssignedBot(t *testing.T) { + db, _, messageSvc, account, _, conversation, _ := setupCaptainConversationWorkerTest(t) + botID := uint(42) + require.NoError(t, db.Model(&model.Conversation{}).Where("id = ?", conversation.ID).Update("assignee_agent_bot_id", botID).Error) + wp := worker.NewWorkerPool(db) + messageSvc.SetWorkerPool(wp) + + _, err := messageSvc.Create(context.Background(), account.ID, 99, CreateMessageRequest{ConversationID: conversation.ID, Content: "Hello", MessageType: string(model.MessageTypeIncoming), ContentType: string(model.MessageContentTypeText)}) + require.NoError(t, err) + + var count int64 + require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ?", TaskTypeCaptainConversationResponseBuilder).Count(&count).Error) + assert.Zero(t, count) +} + type fakeCaptainConversationBackend struct { response *CaptainConversationResponse err error diff --git a/backend/internal/service/conversation_service.go b/backend/internal/service/conversation_service.go index 7cd2d493..475877a7 100644 --- a/backend/internal/service/conversation_service.go +++ b/backend/internal/service/conversation_service.go @@ -427,6 +427,7 @@ func (s *ConversationService) AssignAgent(ctx context.Context, accountID, id uin if err != nil { return nil, err } + hadAgentBot := conversation.AssigneeAgentBotID != nil if assigneeID == 0 { // Unassign: dispatch EventConversationUnassigned @@ -434,6 +435,10 @@ func (s *ConversationService) AssignAgent(ctx context.Context, accountID, id uin return nil, err } conversation.AssigneeID = nil + conversation.AssigneeAgentBotID = nil + if hadAgentBot && conversation.Status == string(model.ConversationStatusPending) { + conversation.Status = string(model.ConversationStatusOpen) + } event := channel.NewChannelEvent(channel.EventConversationUnassigned, channel.ChannelType(conversation.ChannelType), conversation.AccountID, conversation.InboxID) event.ConversationID = conversation.ID event.ContactID = conversation.ContactID @@ -474,6 +479,9 @@ func (s *ConversationService) AssignAgent(ctx context.Context, accountID, id uin } conversation.AssigneeID = &assigneeID + if hadAgentBot && conversation.Status == string(model.ConversationStatusPending) { + conversation.Status = string(model.ConversationStatusOpen) + } conversation.AssigneeAgentBotID = nil // Dispatch EventConversationAssigned @@ -504,6 +512,9 @@ func (s *ConversationService) AssignAgentBot(ctx context.Context, accountID, id, } conversation.AssigneeID = nil conversation.AssigneeAgentBotID = nil + if conversation.Status == string(model.ConversationStatusPending) { + conversation.Status = string(model.ConversationStatusOpen) + } s.dispatchConversationEvent(ctx, channel.EventConversationUnassigned, conversation) s.indexConversation(ctx, conversation) return conversation, nil, nil @@ -520,6 +531,7 @@ func (s *ConversationService) AssignAgentBot(ctx context.Context, accountID, id, } conversation.AssigneeID = nil conversation.AssigneeAgentBotID = &bot.ID + conversation.Status = string(model.ConversationStatusPending) s.dispatchConversationEvent(ctx, channel.EventConversationAssigned, conversation) s.indexConversation(ctx, conversation) return conversation, &bot, nil @@ -1952,6 +1964,7 @@ func (s *ConversationService) AssignTeam(ctx context.Context, accountID, convers if err != nil { return nil, err } + hadAgentBot := conversation.AssigneeAgentBotID != nil // === Team validation === // Reference: Chatwoot AssignmentsController#set_team — validates team belongs to account @@ -1990,6 +2003,10 @@ func (s *ConversationService) AssignTeam(ctx context.Context, accountID, convers return nil, err } conversation.AssigneeID = agentID + conversation.AssigneeAgentBotID = nil + if hadAgentBot && conversation.Status == string(model.ConversationStatusPending) { + conversation.Status = string(model.ConversationStatusOpen) + } } // === Team overflow logic === diff --git a/backend/internal/service/message_service.go b/backend/internal/service/message_service.go index b96da7fa..650cc9e8 100644 --- a/backend/internal/service/message_service.go +++ b/backend/internal/service/message_service.go @@ -248,10 +248,22 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint var bot model.AgentBot if err := s.repo.DB().WithContext(ctx). Where("id = ? AND (account_id IS NULL OR account_id = ?)", req.SenderID, accountID). - First(&bot).Error; err == nil { - id := bot.ID + First(&bot).Error; err != nil { + return nil, errors.New("agent bot not found") + } + if conversation.AssigneeAgentBotID == nil || *conversation.AssigneeAgentBotID != bot.ID { + return nil, errors.New("agent bot is not assigned to this conversation") + } + id := bot.ID + senderID = &id + senderType = string(model.SenderTypeAgentBot) + } + if !req.External && strings.TrimSpace(req.SenderType) == "Captain::Assistant" && req.SenderID != 0 { + var assistant model.CaptainAssistant + if err := s.repo.DB().WithContext(ctx).Where("id = ? AND account_id = ?", req.SenderID, accountID).First(&assistant).Error; err == nil { + id := assistant.ID senderID = &id - senderType = string(model.SenderTypeAgentBot) + senderType = "Captain::Assistant" } } externalSourceIDs := messageContentAttributes(req.ExternalSourceIDs) diff --git a/backend/internal/service/message_service_test.go b/backend/internal/service/message_service_test.go index a64fcede..274821e2 100644 --- a/backend/internal/service/message_service_test.go +++ b/backend/internal/service/message_service_test.go @@ -646,6 +646,23 @@ func TestMessageService_ShangwutongOutboundResultAndRetryStayDurable(t *testing. require.JSONEq(t, `{"shangwutong":["98766","98767"]}`, string(updated.ExternalSourceIDs)) } +func TestMessageServiceRejectsUnassignedAgentBotReply(t *testing.T) { + db, _, _, svc := setupMessageServiceWithDefaultLLM(t) + require.NoError(t, db.AutoMigrate(&model.AgentBot{})) + account := createTestAccount(t, db) + inbox := createTestInbox(t, db, account.ID, "api") + contact := createTestContact(t, db, account.ID) + conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "webhook"} + require.NoError(t, db.Create(bot).Error) + + _, err := svc.Create(context.Background(), account.ID, bot.ID, CreateMessageRequest{ + ConversationID: conversation.ID, MessageType: "outgoing", ContentType: "text", Content: "late reply", + SenderType: string(model.SenderTypeAgentBot), SenderID: bot.ID, + }) + require.EqualError(t, err, "agent bot is not assigned to this conversation") +} + func TestMessageService_ConversationScopedMessageActions(t *testing.T) { db, _, _, svc := setupMessageServiceWithDefaultLLM(t) ctx := context.Background() diff --git a/frontend/app/javascript/dashboard/api/assignableAgents.js b/frontend/app/javascript/dashboard/api/assignableAgents.js index 5b999fac..cad7afdd 100644 --- a/frontend/app/javascript/dashboard/api/assignableAgents.js +++ b/frontend/app/javascript/dashboard/api/assignableAgents.js @@ -8,7 +8,7 @@ class AssignableAgents extends ApiClient { get(inboxIds) { return axios.get(this.url, { - params: { inbox_ids: inboxIds }, + params: { inbox_ids: inboxIds, include_agent_bots: true }, }); } } diff --git a/frontend/app/javascript/dashboard/api/inbox/conversation.js b/frontend/app/javascript/dashboard/api/inbox/conversation.js index f94fca45..08820aac 100644 --- a/frontend/app/javascript/dashboard/api/inbox/conversation.js +++ b/frontend/app/javascript/dashboard/api/inbox/conversation.js @@ -62,9 +62,10 @@ class ConversationApi extends ApiClient { }); } - assignAgent({ conversationId, agentId }) { + assignAgent({ conversationId, agentId, assigneeType }) { return axios.post(`${this.url}/${conversationId}/assignments`, { assignee_id: agentId, + assignee_type: assigneeType, }); } diff --git a/frontend/app/javascript/dashboard/api/specs/assignableAgents.spec.js b/frontend/app/javascript/dashboard/api/specs/assignableAgents.spec.js index d553d55c..a9411597 100644 --- a/frontend/app/javascript/dashboard/api/specs/assignableAgents.spec.js +++ b/frontend/app/javascript/dashboard/api/specs/assignableAgents.spec.js @@ -23,6 +23,7 @@ describe('#AssignableAgentsAPI', () => { expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', { params: { inbox_ids: [1], + include_agent_bots: true, }, }); }); diff --git a/frontend/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/frontend/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index de0d7a7d..ea0ef3e7 100644 --- a/frontend/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/frontend/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -90,11 +90,16 @@ describe('#ConversationAPI', () => { }); it('#assignAgent', () => { - conversationAPI.assignAgent({ conversationId: 12, agentId: 34 }); + conversationAPI.assignAgent({ + conversationId: 12, + agentId: 34, + assigneeType: 'AgentBot', + }); expect(axiosMock.post).toHaveBeenCalledWith( `/api/v1/conversations/12/assignments`, { assignee_id: 34, + assignee_type: 'AgentBot', } ); }); diff --git a/frontend/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkAgentActions.vue b/frontend/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkAgentActions.vue index d776118d..d5aa3b07 100644 --- a/frontend/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkAgentActions.vue +++ b/frontend/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkAgentActions.vue @@ -41,7 +41,9 @@ const assignableAgentsList = useMapGetter( 'inboxAssignableAgents/getAssignableAgents' ); const assignableAgents = computed(() => - assignableAgentsList.value(props.selectedInboxes.join(',')) + assignableAgentsList + .value(props.selectedInboxes.join(',')) + .filter(agent => agent.assignee_type !== 'AgentBot') ); const agentMenuItems = computed(() => { diff --git a/frontend/app/javascript/dashboard/composables/chatlist/useBulkActions.js b/frontend/app/javascript/dashboard/composables/chatlist/useBulkActions.js index 772c6e7d..622deee0 100644 --- a/frontend/app/javascript/dashboard/composables/chatlist/useBulkActions.js +++ b/frontend/app/javascript/dashboard/composables/chatlist/useBulkActions.js @@ -59,13 +59,25 @@ export function useBulkActions() { // Same method used in context menu, conversationId being passed from there. async function onAssignAgent(agent, conversationId = null) { try { - await store.dispatch('bulkActions/process', { - type: 'Conversation', - ids: conversationId || selectedConversations.value, - fields: { - assignee_id: agent.id, - }, - }); + if (conversationId && agent.assignee_type === 'AgentBot') { + await Promise.all( + conversationId.map(id => + store.dispatch('assignAgent', { + conversationId: id, + agentId: agent.id, + assigneeType: agent.assignee_type, + }) + ) + ); + } else { + await store.dispatch('bulkActions/process', { + type: 'Conversation', + ids: conversationId || selectedConversations.value, + fields: { + assignee_id: agent.id, + }, + }); + } store.dispatch('bulkActions/clearSelectedConversationIds'); if (conversationId) { useAlert( diff --git a/frontend/app/javascript/dashboard/helper/agentHelper.js b/frontend/app/javascript/dashboard/helper/agentHelper.js index d521e724..fbc1ff1a 100644 --- a/frontend/app/javascript/dashboard/helper/agentHelper.js +++ b/frontend/app/javascript/dashboard/helper/agentHelper.js @@ -38,7 +38,7 @@ export const getAgentsByUpdatedPresence = ( currentAccountId ) => { const agentsWithDynamicPresenceUpdate = agents.map(item => - item.id === currentUser.id + item.assignee_type !== 'AgentBot' && item.id === currentUser.id ? { ...item, availability_status: currentUser.accounts.find( diff --git a/frontend/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue b/frontend/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue index 8543968f..cd2a5b0e 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue @@ -93,6 +93,7 @@ export default { .dispatch('assignAgent', { conversationId: this.currentChat.id, agentId, + assigneeType: agent?.assignee_type, }) .then(() => { useAlert(this.$t('CONVERSATION.CHANGE_AGENT')); diff --git a/frontend/app/javascript/dashboard/store/modules/conversations/actions.js b/frontend/app/javascript/dashboard/store/modules/conversations/actions.js index 943b2444..b8f57629 100644 --- a/frontend/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/frontend/app/javascript/dashboard/store/modules/conversations/actions.js @@ -219,11 +219,15 @@ const actions = { } }, - assignAgent: async ({ dispatch }, { conversationId, agentId }) => { + assignAgent: async ( + { dispatch }, + { conversationId, agentId, assigneeType } + ) => { try { const response = await ConversationApi.assignAgent({ conversationId, agentId, + assigneeType, }); dispatch('setCurrentChatAssignee', { conversationId,