H-43: fix WEB Captain takeover E2E flow (#8)
* test(shangwutong): cover CID rename reliability * H-43: fix WEB Captain takeover flow * H-48: preserve compatible provider model * H-49: make Captain takeover atomic * H-50: prevent duplicate widget initialization --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -48,6 +48,8 @@ func setupCaptainAssistantHandlerTestWithSummaryProvider(t *testing.T, provider
|
||||
&model.CaptainDocument{},
|
||||
&model.CaptainAssistantResponse{},
|
||||
&model.CaptainMessageReport{},
|
||||
&model.AgentBot{},
|
||||
&model.AgentBotInbox{},
|
||||
))
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
|
||||
@@ -268,7 +268,12 @@ func resolveFeatureModel(ctx context.Context, resolver AccountModelResolver, fal
|
||||
}
|
||||
|
||||
func applyRuntimeChatConfig(ctx context.Context, req ChatRequest, cfg RuntimeProviderConfig, resolver AccountModelResolver) ChatRequest {
|
||||
req.Model = resolveFeatureModel(ctx, resolver, cfg.ChatModel)
|
||||
req.Model = cfg.ChatModel
|
||||
// Account overrides store only a model name. Keep provider/model pairs intact
|
||||
// for compatible endpoints, where an OpenAI model may not exist.
|
||||
if cfg.ChatProvider == "openai" {
|
||||
req.Model = resolveFeatureModel(ctx, resolver, cfg.ChatModel)
|
||||
}
|
||||
req.Temperature = cfg.Temperature
|
||||
if override, ok := ctx.Value(generationOverrideContextKey{}).(generationOverrideContext); ok && override.Temperature != nil {
|
||||
req.Temperature = *override.Temperature
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestProviderManagerUsesAccountFeatureModelAndGenerationSettings(t *testing.
|
||||
return "account-editor-model", nil
|
||||
})
|
||||
require.NoError(t, manager.Configure(RuntimeProviderConfig{
|
||||
ChatProvider: "openai_compatible",
|
||||
ChatProvider: "openai",
|
||||
ChatBaseURL: server.URL,
|
||||
ChatAPIKey: "test-key",
|
||||
ChatModel: "platform-model",
|
||||
@@ -51,6 +51,45 @@ func TestProviderManagerUsesAccountFeatureModelAndGenerationSettings(t *testing.
|
||||
assert.Equal(t, 777, request.MaxTokens)
|
||||
}
|
||||
|
||||
func TestProviderManagerCompatibleKeepsConfiguredModelAndAPIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request ChatRequest
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||
assert.Equal(t, "provider-model", request.Model)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if request.Messages[0].Content == "fail" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"rejected","type":"invalid_request_error","code":"model_not_found"}}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"ok"}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager := NewProviderManager()
|
||||
manager.SetAccountModelResolver(func(context.Context, uint, string) (string, error) {
|
||||
return "stale-openai-model", nil
|
||||
})
|
||||
require.NoError(t, manager.Configure(RuntimeProviderConfig{
|
||||
ChatProvider: "openai_compatible",
|
||||
ChatBaseURL: server.URL,
|
||||
ChatAPIKey: "test-key",
|
||||
ChatModel: "provider-model",
|
||||
EmbeddingMode: EmbeddingModeReuseChat,
|
||||
}))
|
||||
|
||||
ctx := WithAccountFeature(context.Background(), 42, "assistant")
|
||||
_, err := manager.ChatCompletion(ctx, ChatRequest{Messages: []ChatMessage{{Role: "user", Content: "ok"}}})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = manager.ChatCompletion(ctx, ChatRequest{Messages: []ChatMessage{{Role: "user", Content: "fail"}}})
|
||||
var apiErr *APIError
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
|
||||
assert.Equal(t, "invalid_request_error", apiErr.Type)
|
||||
assert.Equal(t, "model_not_found", apiErr.Code)
|
||||
}
|
||||
|
||||
func TestProviderManagerPreservesExplicitAssistantTemperature(t *testing.T) {
|
||||
var request ChatRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -333,8 +333,8 @@ func (r *ConversationRepo) AssignAgentBot(ctx context.Context, id, agentBotID ui
|
||||
Updates(map[string]any{"assignee_id": nil, "assignee_agent_bot_id": value, "status": status}).Error
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) StartAITakeover(ctx context.Context, id, agentBotID uint) error {
|
||||
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
|
||||
func (r *ConversationRepo) StartAITakeover(ctx context.Context, tx *gorm.DB, id, agentBotID uint) error {
|
||||
return tx.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"assignee_id": nil, "assignee_agent_bot_id": agentBotID,
|
||||
"status": model.ConversationStatusPending,
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func configureInboxAI(t *testing.T, svc *ConversationService, accountID, inboxID uint) (*model.AgentBot, *model.CaptainAssistant) {
|
||||
@@ -83,6 +85,59 @@ func TestConversationServiceAITakeoverRejectsMissingOrAmbiguousAI(t *testing.T)
|
||||
assert.ErrorContains(t, err, "exactly one active AI")
|
||||
}
|
||||
|
||||
func TestConversationServiceAITakeoverRejectsConflictWithoutCreatingBinding(t *testing.T) {
|
||||
svc, db := setupConversationService(t)
|
||||
account := createConversationServiceTestAccount(t, db)
|
||||
inbox := createConversationServiceTestInbox(t, db, account.ID)
|
||||
contact := createConversationServiceTestContact(t, db, account.ID)
|
||||
conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, string(model.ConversationStatusOpen))
|
||||
require.NoError(t, db.AutoMigrate(&model.AgentBot{}, &model.AgentBotInbox{}, &model.CaptainAssistant{}, &model.CaptainInbox{}))
|
||||
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Captain", Status: model.AssistantStatusActive}
|
||||
require.NoError(t, db.Create(assistant).Error)
|
||||
require.NoError(t, db.Create(&model.CaptainInbox{AccountID: account.ID, InboxID: inbox.ID, AssistantID: assistant.ID}).Error)
|
||||
conflictingBot := &model.AgentBot{AccountID: &account.ID, Name: "Webhook", BotType: "webhook"}
|
||||
require.NoError(t, db.Create(conflictingBot).Error)
|
||||
require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: conflictingBot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error)
|
||||
|
||||
_, err := svc.StartAITakeover(context.Background(), account.ID, conversation.ID)
|
||||
require.ErrorContains(t, err, "exactly one active AI")
|
||||
var captainBotCount int64
|
||||
require.NoError(t, db.Model(&model.AgentBot{}).Where("bot_type = ?", "captain").Count(&captainBotCount).Error)
|
||||
assert.Zero(t, captainBotCount)
|
||||
var bindingCount int64
|
||||
require.NoError(t, db.Model(&model.AgentBotInbox{}).Count(&bindingCount).Error)
|
||||
assert.Equal(t, int64(1), bindingCount)
|
||||
require.NoError(t, db.First(conversation, conversation.ID).Error)
|
||||
assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status)
|
||||
assert.Nil(t, conversation.AssigneeAgentBotID)
|
||||
assert.Zero(t, conversation.AITakeoverVersion)
|
||||
}
|
||||
|
||||
func TestConversationServiceAITakeoverRollsBackBindingWhenTakeoverFails(t *testing.T) {
|
||||
svc, db := setupConversationService(t)
|
||||
account := createConversationServiceTestAccount(t, db)
|
||||
inbox := createConversationServiceTestInbox(t, db, account.ID)
|
||||
contact := createConversationServiceTestContact(t, db, account.ID)
|
||||
conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, string(model.ConversationStatusOpen))
|
||||
require.NoError(t, db.AutoMigrate(&model.AgentBot{}, &model.AgentBotInbox{}, &model.CaptainAssistant{}, &model.CaptainInbox{}))
|
||||
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Captain", Status: model.AssistantStatusActive}
|
||||
require.NoError(t, db.Create(assistant).Error)
|
||||
require.NoError(t, db.Create(&model.CaptainInbox{AccountID: account.ID, InboxID: inbox.ID, AssistantID: assistant.ID}).Error)
|
||||
require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:fail_takeover", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "conversations" {
|
||||
tx.AddError(errors.New("takeover failed"))
|
||||
}
|
||||
}))
|
||||
|
||||
_, err := svc.StartAITakeover(context.Background(), account.ID, conversation.ID)
|
||||
require.ErrorContains(t, err, "takeover failed")
|
||||
var botCount, bindingCount int64
|
||||
require.NoError(t, db.Model(&model.AgentBot{}).Count(&botCount).Error)
|
||||
require.NoError(t, db.Model(&model.AgentBotInbox{}).Count(&bindingCount).Error)
|
||||
assert.Zero(t, botCount)
|
||||
assert.Zero(t, bindingCount)
|
||||
}
|
||||
|
||||
func TestMessageServiceHumanOutgoingExitsAITakeoverAtomically(t *testing.T) {
|
||||
db, _, dispatcher, svc := setupMessageServiceWithDefaultLLM(t)
|
||||
capture := &captureConversationEventsListener{}
|
||||
|
||||
@@ -559,7 +559,8 @@ func (s *CaptainAssistantService) SetConfig(ctx context.Context, id uint, cfg *m
|
||||
|
||||
// AssociateInbox binds an assistant to an inbox.
|
||||
func (s *CaptainAssistantService) AssociateInbox(ctx context.Context, assistantID, inboxID, accountID uint) (*model.Inbox, error) {
|
||||
if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil {
|
||||
assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assistant not found: %w", err)
|
||||
}
|
||||
inbox, err := s.inboxRepo.FindAccountInbox(ctx, accountID, inboxID)
|
||||
@@ -567,12 +568,17 @@ func (s *CaptainAssistantService) AssociateInbox(ctx context.Context, assistantI
|
||||
return nil, fmt.Errorf("inbox not found: %w", err)
|
||||
}
|
||||
|
||||
ci := &model.CaptainInbox{
|
||||
AssistantID: assistantID,
|
||||
InboxID: inboxID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
if err := s.inboxRepo.Create(ctx, ci); err != nil {
|
||||
db := s.assistantRepo.DB().WithContext(ctx)
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
ci := &model.CaptainInbox{AssistantID: assistantID, InboxID: inboxID, AccountID: accountID}
|
||||
if err := tx.Where("captain_assistant_id = ? AND inbox_id = ?", assistantID, inboxID).FirstOrCreate(ci).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := ensureCaptainAgentBotBinding(ctx, tx, assistant, inboxID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
applogger.L().Errorf("AssociateInbox: %v", err)
|
||||
return nil, fmt.Errorf("associate inbox: %w", err)
|
||||
}
|
||||
@@ -593,9 +599,53 @@ func (s *CaptainAssistantService) DissociateInbox(ctx context.Context, accountID
|
||||
applogger.L().Errorf("DissociateInbox: %v", err)
|
||||
return fmt.Errorf("dissociate inbox: %w", err)
|
||||
}
|
||||
var bots []model.AgentBot
|
||||
if err := s.assistantRepo.DB().WithContext(ctx).Where("account_id = ? AND bot_type = ?", accountID, "captain").Find(&bots).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, bot := range bots {
|
||||
if extractAssistantIDFromBotConfig(bot.Config) == assistantID {
|
||||
if err := s.assistantRepo.DB().WithContext(ctx).Where("inbox_id = ? AND agent_bot_id = ?", inboxID, bot.ID).Delete(&model.AgentBotInbox{}).Error; err != nil {
|
||||
return fmt.Errorf("dissociate inbox bot: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureCaptainAgentBotBinding(ctx context.Context, db *gorm.DB, assistant *model.CaptainAssistant, inboxID uint) (*model.AgentBot, error) {
|
||||
var bots []model.AgentBot
|
||||
if err := db.WithContext(ctx).Where("account_id = ? AND bot_type = ?", assistant.AccountID, "captain").Find(&bots).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bot *model.AgentBot
|
||||
for i := range bots {
|
||||
if extractAssistantIDFromBotConfig(bots[i].Config) == assistant.ID {
|
||||
bot = &bots[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if bot == nil {
|
||||
token, err := generateBotAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := generateBotSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bot = &model.AgentBot{AccountID: &assistant.AccountID, Name: fmt.Sprintf("Captain Assistant #%d", assistant.ID), Description: assistant.Name, BotType: "captain", AccessToken: token, Secret: secret, Config: json.RawMessage(fmt.Sprintf(`{"assistant_id":%d}`, assistant.ID))}
|
||||
if err := db.WithContext(ctx).Create(bot).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
binding := &model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inboxID, AccountID: &assistant.AccountID, Status: model.AgentBotInboxActive}
|
||||
if err := db.WithContext(ctx).Where("agent_bot_id = ? AND inbox_id = ?", bot.ID, inboxID).Assign("status", model.AgentBotInboxActive).FirstOrCreate(binding).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
func (s *CaptainAssistantService) ListInboxes(ctx context.Context, accountID, assistantID uint) ([]model.Inbox, error) {
|
||||
if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil {
|
||||
return nil, fmt.Errorf("assistant not found: %w", err)
|
||||
|
||||
@@ -545,36 +545,42 @@ func (s *ConversationService) StartAITakeover(ctx context.Context, accountID, id
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var captainInbox model.CaptainInbox
|
||||
if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND inbox_id = ?", accountID, conversation.InboxID).First(&captainInbox).Error; err != nil {
|
||||
return nil, errors.New("invalid inbox AI configuration: expected exactly one active AI")
|
||||
}
|
||||
var configuredAssistant model.CaptainAssistant
|
||||
if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND id = ? AND status = ?", accountID, captainInbox.AssistantID, model.AssistantStatusActive).First(&configuredAssistant).Error; err != nil {
|
||||
return nil, errors.New("invalid inbox AI configuration: assistant is disabled or not bound to this inbox")
|
||||
}
|
||||
var bindings []model.AgentBotInbox
|
||||
if err := s.repo.DB().WithContext(ctx).
|
||||
db := s.repo.DB().WithContext(ctx)
|
||||
if err := db.
|
||||
Where("inbox_id = ? AND status = ?", conversation.InboxID, model.AgentBotInboxActive).
|
||||
Find(&bindings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bindings) != 1 {
|
||||
if len(bindings) > 1 {
|
||||
return nil, errors.New("invalid inbox AI configuration: expected exactly one active AI")
|
||||
}
|
||||
var bot model.AgentBot
|
||||
if err := s.repo.DB().WithContext(ctx).
|
||||
Where("id = ? AND bot_type = ? AND (account_id IS NULL OR account_id = ?)", bindings[0].AgentBotID, "captain", accountID).
|
||||
First(&bot).Error; err != nil {
|
||||
return nil, errors.New("invalid inbox AI configuration: active AI is not a Captain bot")
|
||||
if len(bindings) == 1 {
|
||||
var activeBot model.AgentBot
|
||||
if err := db.Where("id = ? AND bot_type = ? AND (account_id IS NULL OR account_id = ?)", bindings[0].AgentBotID, "captain", accountID).First(&activeBot).Error; err != nil || extractAssistantIDFromBotConfig(activeBot.Config) != configuredAssistant.ID {
|
||||
return nil, errors.New("invalid inbox AI configuration: expected exactly one active AI")
|
||||
}
|
||||
if conversation.AssigneeAgentBotID != nil && *conversation.AssigneeAgentBotID == activeBot.ID && conversation.Status == string(model.ConversationStatusPending) {
|
||||
return conversation, nil
|
||||
}
|
||||
}
|
||||
assistantID := extractAssistantIDFromBotConfig(bot.Config)
|
||||
if assistantID == 0 {
|
||||
return nil, errors.New("active inbox AI has invalid assistant configuration")
|
||||
}
|
||||
var assistant model.CaptainAssistant
|
||||
if err := s.repo.DB().WithContext(ctx).
|
||||
Joins("JOIN captain_inboxes ON captain_inboxes.captain_assistant_id = captain_assistants.id").
|
||||
Where("captain_assistants.id = ? AND captain_assistants.account_id = ? AND captain_assistants.status = ? AND captain_inboxes.inbox_id = ?", assistantID, accountID, model.AssistantStatusActive, conversation.InboxID).
|
||||
First(&assistant).Error; err != nil {
|
||||
return nil, errors.New("invalid inbox AI configuration: assistant is disabled or not bound to this inbox")
|
||||
}
|
||||
if conversation.AssigneeAgentBotID != nil && *conversation.AssigneeAgentBotID == bot.ID && conversation.Status == string(model.ConversationStatusPending) {
|
||||
return conversation, nil
|
||||
}
|
||||
if err := s.repo.StartAITakeover(ctx, conversation.ID, bot.ID); err != nil {
|
||||
var bot *model.AgentBot
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
bot, err = ensureCaptainAgentBotBinding(ctx, tx, &configuredAssistant, conversation.InboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.StartAITakeover(ctx, tx, conversation.ID, bot.ID)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conversation.AssigneeID = nil
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestCopilotFeatureModelsReachProviderRequests(t *testing.T) {
|
||||
return models[feature], nil
|
||||
})
|
||||
require.NoError(t, manager.Configure(llm.RuntimeProviderConfig{
|
||||
ChatProvider: "openai_compatible",
|
||||
ChatProvider: "openai",
|
||||
ChatBaseURL: server.URL,
|
||||
ChatAPIKey: "test-key",
|
||||
ChatModel: "platform-model",
|
||||
|
||||
@@ -46,6 +46,8 @@ func newCov7TestDB(t *testing.T, extra ...interface{}) *gorm.DB {
|
||||
&model.InboxAssignmentPolicy{},
|
||||
&model.UserSession{},
|
||||
&model.BackgroundJob{},
|
||||
&model.AgentBot{},
|
||||
&model.AgentBotInbox{},
|
||||
}, extra...)...)
|
||||
return db
|
||||
}
|
||||
@@ -496,6 +498,12 @@ func TestCaptainAssistant_AssociateInbox_Cov7(t *testing.T) {
|
||||
assocInbox, err := svc.AssociateInbox(context.Background(), created.ID, inbox.ID, account.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, inbox.ID, assocInbox.ID)
|
||||
var bot model.AgentBot
|
||||
require.NoError(t, db.Where("account_id = ? AND bot_type = ?", account.ID, "captain").First(&bot).Error)
|
||||
assert.Equal(t, created.ID, extractAssistantIDFromBotConfig(bot.Config))
|
||||
var binding model.AgentBotInbox
|
||||
require.NoError(t, db.Where("agent_bot_id = ? AND inbox_id = ?", bot.ID, inbox.ID).First(&binding).Error)
|
||||
assert.True(t, binding.IsActive())
|
||||
}
|
||||
|
||||
func TestCaptainAssistant_AssociateInbox_AssistantNotFound_Cov7(t *testing.T) {
|
||||
@@ -539,6 +547,9 @@ func TestCaptainAssistant_DissociateInbox_Cov7(t *testing.T) {
|
||||
|
||||
err = svc.DissociateInbox(context.Background(), account.ID, created.ID, inbox.ID)
|
||||
require.NoError(t, err)
|
||||
var bindingCount int64
|
||||
require.NoError(t, db.Model(&model.AgentBotInbox{}).Where("inbox_id = ?", inbox.ID).Count(&bindingCount).Error)
|
||||
assert.Zero(t, bindingCount)
|
||||
}
|
||||
|
||||
func TestCaptainAssistant_DissociateInbox_NotFound_Cov7(t *testing.T) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
ws "github.com/gochat/gochat/internal/ws"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -418,6 +419,9 @@ func (s *WidgetService) SendMessage(ctx context.Context, req WidgetSendMessageRe
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := EnqueueCaptainConversationResponseForMessage(ctx, s.worker, s.messageRepo.DB(), msg.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Dispatch message.created event to trigger auto-reply listener
|
||||
if s.dispatcher != nil {
|
||||
@@ -1012,6 +1016,9 @@ func (s *WidgetService) PublicCreateMessage(ctx context.Context, inboxIdentifier
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if _, err := EnqueueCaptainConversationResponseForMessage(ctx, s.worker, s.messageRepo.DB(), message.ID); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Dispatch message.created event to trigger auto-reply listener
|
||||
if s.dispatcher != nil {
|
||||
@@ -1713,16 +1720,51 @@ func (s *WidgetService) createWidgetConversation(ctx context.Context, contactInb
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status := string(model.ConversationStatusOpen)
|
||||
var agentBotID *uint
|
||||
var takeoverVersion uint
|
||||
db := s.conversationRepo.DB().WithContext(ctx)
|
||||
if db.Migrator().HasTable(&model.CaptainPreference{}) && db.Migrator().HasTable(&model.CaptainInbox{}) {
|
||||
var autoReplyCount int64
|
||||
if err := db.Model(&model.CaptainPreference{}).
|
||||
Where("account_id = ? AND auto_reply_enabled = ?", inbox.AccountID, true).Count(&autoReplyCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if autoReplyCount > 0 {
|
||||
var captainInbox model.CaptainInbox
|
||||
if err := db.Where("account_id = ? AND inbox_id = ?", inbox.AccountID, inbox.ID).First(&captainInbox).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
goto create
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var assistant model.CaptainAssistant
|
||||
if err := db.Where("account_id = ? AND id = ? AND status = ?", inbox.AccountID, captainInbox.AssistantID, model.AssistantStatusActive).First(&assistant).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bot, err := ensureCaptainAgentBotBinding(ctx, db, &assistant, inbox.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status = string(model.ConversationStatusPending)
|
||||
agentBotID = &bot.ID
|
||||
takeoverVersion = 1
|
||||
}
|
||||
}
|
||||
|
||||
create:
|
||||
conversation := model.Conversation{
|
||||
AccountID: inbox.AccountID,
|
||||
InboxID: inbox.ID,
|
||||
ContactID: contactInbox.ContactID,
|
||||
ContactInboxID: &contactInbox.ID,
|
||||
Status: "open",
|
||||
ChannelType: inbox.ChannelType,
|
||||
Channel: inbox.ChannelType,
|
||||
CustomAttributes: mustJSON(customAttributes),
|
||||
Labels: strings.Join(s.validWidgetLabels(ctx, inbox.AccountID, labels), ","),
|
||||
AccountID: inbox.AccountID,
|
||||
InboxID: inbox.ID,
|
||||
ContactID: contactInbox.ContactID,
|
||||
ContactInboxID: &contactInbox.ID,
|
||||
Status: status,
|
||||
AssigneeAgentBotID: agentBotID,
|
||||
AITakeoverVersion: takeoverVersion,
|
||||
ChannelType: inbox.ChannelType,
|
||||
Channel: inbox.ChannelType,
|
||||
CustomAttributes: mustJSON(customAttributes),
|
||||
Labels: strings.Join(s.validWidgetLabels(ctx, inbox.AccountID, labels), ","),
|
||||
}
|
||||
|
||||
if err := s.conversationRepo.Create(ctx, &conversation); err != nil {
|
||||
|
||||
@@ -53,6 +53,11 @@ func setupWidgetServiceTest(t *testing.T) (*gorm.DB, *WidgetService) {
|
||||
&model.WidgetFileUpload{},
|
||||
&model.WidgetOfflineMessage{},
|
||||
&model.BackgroundJob{},
|
||||
&model.CaptainAssistant{},
|
||||
&model.CaptainInbox{},
|
||||
&model.CaptainPreference{},
|
||||
&model.AgentBot{},
|
||||
&model.AgentBotInbox{},
|
||||
), "failed to auto-migrate models")
|
||||
|
||||
t.Cleanup(func() {
|
||||
@@ -296,6 +301,61 @@ func TestWidgetService_SendMessage_NewConversation(t *testing.T) {
|
||||
assert.Equal(t, "incoming", sendResp.Message.MessageType)
|
||||
}
|
||||
|
||||
func TestWidgetService_SendMessage_AutomaticCaptainTakeover(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
account, inbox := seedWidgetInbox(t, db)
|
||||
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Auto", Status: model.AssistantStatusActive, Config: json.RawMessage(`{}`)}
|
||||
require.NoError(t, db.Create(assistant).Error)
|
||||
require.NoError(t, db.Create(&model.CaptainInbox{AccountID: account.ID, InboxID: inbox.ID, AssistantID: assistant.ID}).Error)
|
||||
bot := &model.AgentBot{AccountID: &account.ID, Name: "Auto", BotType: "captain", Config: json.RawMessage(fmt.Sprintf(`{"assistant_id":%d}`, assistant.ID))}
|
||||
require.NoError(t, db.Create(bot).Error)
|
||||
require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error)
|
||||
require.NoError(t, db.Create(&model.CaptainPreference{AccountID: account.ID, AutoReplyEnabled: true}).Error)
|
||||
svc.SetWorkerPool(worker.NewWorkerPool(db))
|
||||
|
||||
initResp, err := svc.Init(context.Background(), WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
|
||||
require.NoError(t, err)
|
||||
response, err := svc.SendMessage(context.Background(), WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "automatic takeover"})
|
||||
require.NoError(t, err)
|
||||
|
||||
var conversation model.Conversation
|
||||
require.NoError(t, db.First(&conversation, response.ConversationID).Error)
|
||||
assert.Equal(t, string(model.ConversationStatusPending), conversation.Status)
|
||||
require.NotNil(t, conversation.AssigneeAgentBotID)
|
||||
assert.Equal(t, bot.ID, *conversation.AssigneeAgentBotID)
|
||||
assert.Equal(t, uint(1), conversation.AITakeoverVersion)
|
||||
var job model.BackgroundJob
|
||||
require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainConversationResponseBuilder).First(&job).Error)
|
||||
}
|
||||
|
||||
func TestWidgetService_SendMessage_AutomaticCaptainTakeoverDoesNotClaimExistingOpenConversation(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
account, inbox := seedWidgetInbox(t, db)
|
||||
initResp, err := svc.Init(context.Background(), WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
|
||||
require.NoError(t, err)
|
||||
first, err := svc.SendMessage(context.Background(), WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "before enabling"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Auto", Status: model.AssistantStatusActive, Config: json.RawMessage(`{}`)}
|
||||
require.NoError(t, db.Create(assistant).Error)
|
||||
require.NoError(t, db.Create(&model.CaptainInbox{AccountID: account.ID, InboxID: inbox.ID, AssistantID: assistant.ID}).Error)
|
||||
bot := &model.AgentBot{AccountID: &account.ID, Name: "Auto", BotType: "captain", Config: json.RawMessage(fmt.Sprintf(`{"assistant_id":%d}`, assistant.ID))}
|
||||
require.NoError(t, db.Create(bot).Error)
|
||||
require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error)
|
||||
require.NoError(t, db.Create(&model.CaptainPreference{AccountID: account.ID, AutoReplyEnabled: true}).Error)
|
||||
svc.SetWorkerPool(worker.NewWorkerPool(db))
|
||||
|
||||
_, err = svc.SendMessage(context.Background(), WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, ConversationID: &first.ConversationID, Content: "after enabling"})
|
||||
require.NoError(t, err)
|
||||
var conversation model.Conversation
|
||||
require.NoError(t, db.First(&conversation, first.ConversationID).Error)
|
||||
assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status)
|
||||
assert.Nil(t, conversation.AssigneeAgentBotID)
|
||||
var jobCount int64
|
||||
require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ?", TaskTypeCaptainConversationResponseBuilder).Count(&jobCount).Error)
|
||||
assert.Zero(t, jobCount)
|
||||
}
|
||||
|
||||
func TestWidgetService_SendMessage_ExistingConversation(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ const isHMACVerified = computed(() => {
|
||||
});
|
||||
|
||||
const currentContact = computed(() =>
|
||||
store.getters['contacts/getContact'](props.chat.meta.sender.id)
|
||||
store.getters['contacts/getContact'](props.chat?.meta?.sender?.id)
|
||||
);
|
||||
|
||||
const isSnoozed = computed(
|
||||
|
||||
@@ -245,7 +245,8 @@ export const mutations = {
|
||||
if (!conversation?.id) return;
|
||||
|
||||
const { allConversations } = _state;
|
||||
const index = allConversations.findIndex(c => c.id === conversation.id);
|
||||
const normalizedId = Number(conversation.id);
|
||||
const index = allConversations.findIndex(c => Number(c.id) === normalizedId);
|
||||
|
||||
if (index > -1) {
|
||||
const selectedConversation = allConversations[index];
|
||||
@@ -256,8 +257,12 @@ export const mutations = {
|
||||
}
|
||||
|
||||
const { messages, ...updates } = conversation;
|
||||
allConversations[index] = { ...selectedConversation, ...updates };
|
||||
if (_state.selectedChatId === conversation.id) {
|
||||
allConversations[index] = {
|
||||
...selectedConversation,
|
||||
...updates,
|
||||
id: selectedConversation.id,
|
||||
};
|
||||
if (Number(_state.selectedChatId) === normalizedId) {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -802,6 +802,28 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should update when route and API ids use different primitive types', () => {
|
||||
const state = {
|
||||
allConversations: [{ id: '3', status: 'pending', updated_at: 100 }],
|
||||
selectedChatId: '3',
|
||||
};
|
||||
|
||||
mutations[types.UPDATE_CONVERSATION](state, {
|
||||
id: 3,
|
||||
status: 'open',
|
||||
ai_takeover_active: false,
|
||||
updated_at: 200,
|
||||
});
|
||||
|
||||
expect(state.allConversations).toHaveLength(1);
|
||||
expect(state.allConversations[0]).toMatchObject({
|
||||
id: '3',
|
||||
status: 'open',
|
||||
ai_takeover_active: false,
|
||||
});
|
||||
expect(emitter.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add conversation if not found on normal view', () => {
|
||||
const state = {
|
||||
allConversations: [],
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { app, ActionCableConnector } = vi.hoisted(() => ({
|
||||
app: {
|
||||
use: vi.fn(),
|
||||
directive: vi.fn(),
|
||||
mount: vi.fn(() => ({ $store: {} })),
|
||||
},
|
||||
ActionCableConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('vue', () => ({ createApp: () => app }));
|
||||
vi.mock('vue-i18n', () => ({ createI18n: vi.fn() }));
|
||||
vi.mock('vue-dompurify-html', () => ({ default: {} }));
|
||||
vi.mock('../../widget/store', () => ({ default: {} }));
|
||||
vi.mock('../../widget/App.vue', () => ({ default: {} }));
|
||||
vi.mock('../../widget/helpers/actionCable', () => ({
|
||||
default: ActionCableConnector,
|
||||
}));
|
||||
vi.mock('../../widget/i18n', () => ({ default: {} }));
|
||||
vi.mock('../../widget/router', () => ({ default: {} }));
|
||||
vi.mock('vue3-click-away', () => ({ directive: {} }));
|
||||
vi.mock('../../shared/helpers/HTMLSanitizer', () => ({
|
||||
domPurifyConfig: {},
|
||||
}));
|
||||
vi.mock('@formkit/vue', () => ({
|
||||
plugin: {},
|
||||
defaultConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock('shared/helpers/Validators', () => ({
|
||||
startsWithPlus: vi.fn(),
|
||||
isPhoneNumberValidWithDialCode: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('widget entrypoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
delete window.WOOT_WIDGET;
|
||||
delete window.actionCable;
|
||||
window.chatwootPubsubToken = 'pubsub-token';
|
||||
});
|
||||
|
||||
it('mounts and connects only once when load fires repeatedly', async () => {
|
||||
vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading');
|
||||
|
||||
await import('../widget');
|
||||
window.dispatchEvent(new Event('load'));
|
||||
window.dispatchEvent(new Event('load'));
|
||||
|
||||
expect(app.mount).toHaveBeenCalledOnce();
|
||||
expect(ActionCableConnector).toHaveBeenCalledOnce();
|
||||
expect(ActionCableConnector).toHaveBeenCalledWith(
|
||||
window.WOOT_WIDGET,
|
||||
'pubsub-token'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -45,10 +45,18 @@ app.use(
|
||||
|
||||
// Vue.config.productionTip = false;
|
||||
|
||||
window.onload = () => {
|
||||
export const initWidget = () => {
|
||||
if (window.WOOT_WIDGET) return;
|
||||
|
||||
window.WOOT_WIDGET = app.mount('#app');
|
||||
window.actionCable = new ActionCableConnector(
|
||||
window.WOOT_WIDGET,
|
||||
window.chatwootPubsubToken
|
||||
);
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
window.addEventListener('load', initWidget, { once: true });
|
||||
} else {
|
||||
initWidget();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export default {
|
||||
computed: {
|
||||
messageContentAttributes() {
|
||||
const { content_attributes: attribute = {} } = this.message;
|
||||
return attribute;
|
||||
return attribute || {};
|
||||
},
|
||||
hasAttachments() {
|
||||
return !!(
|
||||
|
||||
@@ -34,4 +34,11 @@ describe('messageMixin', () => {
|
||||
expect(wrapper.vm.messageContentAttributes).toEqual({});
|
||||
expect(wrapper.vm.hasAttachments).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes null content attributes', () => {
|
||||
const wrapper = shallowMount(Component, {
|
||||
data: () => ({ message: { content_attributes: null } }),
|
||||
});
|
||||
expect(wrapper.vm.messageContentAttributes).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
+12
-14
@@ -49,6 +49,7 @@
|
||||
(function() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var websiteToken = params.get('website_token');
|
||||
var widgetToken = params.get('cw_conversation') || localStorage.getItem('cw_conversation');
|
||||
if (!websiteToken) return;
|
||||
window.chatwootWebChannel = {
|
||||
websiteToken: websiteToken,
|
||||
@@ -75,22 +76,20 @@
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
var config = JSON.parse(xhr.responseText);
|
||||
if (config.widgetColor) window.chatwootWebChannel.widgetColor = config.widgetColor;
|
||||
if (config.enabledLanguages) window.chatwootWebChannel.enabledLanguages = config.enabledLanguages;
|
||||
if (config.workingHours) window.chatwootWebChannel.workingHours = config.workingHours;
|
||||
if (config.workingHoursEnabled !== undefined) window.chatwootWebChannel.workingHoursEnabled = config.workingHoursEnabled;
|
||||
if (config.replyTime) window.chatwootWebChannel.replyTime = config.replyTime;
|
||||
if (config.preChatFormEnabled !== undefined) window.chatwootWebChannel.preChatFormEnabled = config.preChatFormEnabled;
|
||||
if (config.businessHoursEnabled !== undefined) window.chatwootWebChannel.businessHoursEnabled = config.businessHoursEnabled;
|
||||
if (config.offlineMessageEnabled !== undefined) window.chatwootWebChannel.offlineMessageEnabled = config.offlineMessageEnabled;
|
||||
if (config.portal) window.chatwootWebChannel.portal = config.portal;
|
||||
if (config.enabledFeatures) window.chatwootWebChannel.enabledFeatures = config.enabledFeatures;
|
||||
if (config.allowMessageAfterResolved !== undefined) window.chatwootWebChannel.allowMessageAfterResolved = config.allowMessageAfterResolved;
|
||||
if (config.hmacEnabled !== undefined) window.chatwootWebChannel.hmacEnabled = config.hmacEnabled;
|
||||
var channel = config.website_channel_config || {};
|
||||
window.authToken = channel.auth_token;
|
||||
localStorage.setItem('cw_conversation', window.authToken);
|
||||
window.chatwootPubsubToken = config.contact && config.contact.pubsub_token;
|
||||
channel.widgetColor = channel.widget_color || window.chatwootWebChannel.widgetColor;
|
||||
channel.websiteName = channel.website_name;
|
||||
channel.welcomeTitle = channel.welcome_title;
|
||||
channel.welcomeTagline = channel.welcome_tagline;
|
||||
window.chatwootWebChannel = Object.assign(window.chatwootWebChannel, channel);
|
||||
import('/app/javascript/entrypoints/widget.js');
|
||||
} catch(e) {}
|
||||
}
|
||||
};
|
||||
xhr.send('{"website_token":"' + websiteToken + '"}');
|
||||
xhr.send(JSON.stringify({ website_token: websiteToken, widget_token: widgetToken }));
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
@@ -98,6 +97,5 @@
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<noscript id="noscript">This app works best with JavaScript enabled.</noscript>
|
||||
<script type="module" src="/app/javascript/entrypoints/widget.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user