feat(conversations): complete manual AI takeover

This commit is contained in:
2026-08-12 21:16:49 +08:00
committed by Rogee
parent 4be52e05c7
commit 4bf6ba6b20
22 changed files with 266 additions and 54 deletions
+1
View File
@@ -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)
}
}
@@ -1061,6 +1061,7 @@ func (h *ConversationHandler) AssignTeam(c *gin.Context) {
var req struct {
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 {
@@ -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)
@@ -773,6 +773,7 @@ func serializeAgentBotSlim(bot *model.AgentBot) map[string]any {
"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.
+31 -14
View File
@@ -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
}
seen := make(map[uint]bool, len(bindings)+1)
for _, binding := range bindings {
seen[binding.AgentBotID] = true
if active {
return []model.AgentBotInbox{{AgentBotID: assignedBotID, InboxID: inboxID, Status: model.AgentBotInboxActive}}, nil
}
if seen[assignedBotID] {
return bindings, nil
}
bindings = append(bindings, model.AgentBotInbox{AgentBotID: assignedBotID, InboxID: inboxID, Status: model.AgentBotInboxActive})
return bindings, nil
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
}
status, _ := data["status"].(string)
if conversation, ok := data["conversation"].(map[string]interface{}); ok {
if value, ok := conversation["status"].(string); ok {
status = value
}
}
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)
}
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 ===
+13 -1
View File
@@ -248,11 +248,23 @@ 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 {
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 = "Captain::Assistant"
}
}
externalSourceIDs := messageContentAttributes(req.ExternalSourceIDs)
additionalAttributes := messageContentAttributes(req.AdditionalAttributes)
@@ -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',
}
);
});
@@ -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,6 +59,17 @@ export function useBulkActions() {
// Same method used in context menu, conversationId being passed from there.
async function onAssignAgent(agent, conversationId = null) {
try {
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,
@@ -66,6 +77,7 @@ export function useBulkActions() {
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,