feat(conversations): complete manual AI takeover
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('#AssignableAgentsAPI', () => {
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
|
||||
params: {
|
||||
inbox_ids: [1],
|
||||
include_agent_bots: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
+3
-1
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -93,6 +93,7 @@ export default {
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
agentId,
|
||||
assigneeType: agent?.assignee_type,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user