H-16: align takeover with channel AI workflow (#2)
* feat(conversations): complete manual AI takeover * fix(conversations): align AI takeover flow with channel AI * fix(conversations): close takeover review gaps --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -80,23 +80,7 @@ func (h *AssignableAgentHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
includeAgentBots := c.Query("include_agent_bots") != ""
|
||||
payload := serializeAssignableAgents(accountID, agents, includeAgentBots)
|
||||
if includeAgentBots {
|
||||
bots, err := h.svc.GetAssignableAgentBots(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to list assignable agent bots")
|
||||
return
|
||||
}
|
||||
for i := range bots {
|
||||
bot := serializeAgentBotSlim(&bots[i])
|
||||
bot["assignee_type"] = "AgentBot"
|
||||
bot["icon"] = "i-lucide-bot"
|
||||
bot["availability_status"] = "offline"
|
||||
bot["confirmed"] = true
|
||||
payload = append(payload, bot)
|
||||
}
|
||||
}
|
||||
payload := serializeAssignableAgents(accountID, agents)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": payload})
|
||||
}
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_StandaloneResourceUsesFronten
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AssignableAgentHandlerTestSuite) TestList_IncludeAgentBotsAddsTypedOwners() {
|
||||
func (s *AssignableAgentHandlerTestSuite) TestList_IncludeAgentBotsDoesNotExposeBots() {
|
||||
bot := &model.AgentBot{AccountID: &s.account.ID, Name: "Triage bot", AvatarURL: "https://example.test/bot.png", BotType: "webhook"}
|
||||
s.Require().NoError(s.db.Create(bot).Error)
|
||||
s.T().Cleanup(func() { s.db.Delete(bot) })
|
||||
@@ -315,18 +315,10 @@ func (s *AssignableAgentHandlerTestSuite) TestList_IncludeAgentBotsAddsTypedOwne
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
||||
payload := s.decodeAssignablePayload(w)
|
||||
foundBot := false
|
||||
for _, raw := range payload {
|
||||
owner := raw.(map[string]any)
|
||||
s.Contains(owner, "assignee_type")
|
||||
if owner["assignee_type"] == "AgentBot" {
|
||||
foundBot = true
|
||||
s.Equal(float64(bot.ID), owner["id"])
|
||||
s.Equal("i-lucide-bot", owner["icon"])
|
||||
s.Equal("offline", owner["availability_status"])
|
||||
}
|
||||
s.NotEqual("AgentBot", owner["assignee_type"])
|
||||
}
|
||||
s.True(foundBot)
|
||||
}
|
||||
|
||||
func (s *AssignableAgentHandlerTestSuite) TestList_MultipleInboxIDsQueryParams_NoIntersection() {
|
||||
|
||||
@@ -276,16 +276,7 @@ func (h *ConversationHandler) AssignAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if req.AssigneeType == "AgentBot" {
|
||||
_, bot, svcErr := h.conversationSvc.AssignAgentBot(c.Request.Context(), accountID, conversation.ID, req.AssigneeID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if bot == nil {
|
||||
c.JSON(http.StatusOK, nil)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, serializeAgentBotSlim(bot))
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "AgentBot assignment is not supported; use AI takeover")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1059,10 +1050,9 @@ 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"`
|
||||
AgentID *uint `json:"agent_id"`
|
||||
AssigneeID *uint `json:"assignee_id"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
@@ -1077,23 +1067,6 @@ 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)
|
||||
@@ -1111,6 +1084,75 @@ func (h *ConversationHandler) AssignTeam(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation))
|
||||
}
|
||||
|
||||
func (h *ConversationHandler) StartAITakeover(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
conversationID, err := parseUintParam(c, "conversation_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
|
||||
return
|
||||
}
|
||||
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.canManageConversationInbox(c.Request.Context(), accountID, conversation.InboxID, currentUserID(c)) {
|
||||
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "user does not have access to this inbox")
|
||||
return
|
||||
}
|
||||
conversation, svcErr := h.conversationSvc.StartAITakeover(c.Request.Context(), accountID, conversation.ID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation))
|
||||
}
|
||||
|
||||
func (h *ConversationHandler) ExitAITakeover(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
conversationID, err := parseUintParam(c, "conversation_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
|
||||
return
|
||||
}
|
||||
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.canManageConversationInbox(c.Request.Context(), accountID, conversation.InboxID, currentUserID(c)) {
|
||||
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "user does not have access to this inbox")
|
||||
return
|
||||
}
|
||||
conversation, svcErr := h.conversationSvc.ExitAITakeover(c.Request.Context(), accountID, conversation.ID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation))
|
||||
}
|
||||
|
||||
func (h *ConversationHandler) canManageConversationInbox(ctx context.Context, accountID, inboxID, userID uint) bool {
|
||||
if userID == 0 || h.conversationSvc == nil || h.conversationSvc.DB() == nil {
|
||||
return false
|
||||
}
|
||||
var accountUser model.AccountUser
|
||||
if err := h.conversationSvc.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
if accountUser.Role == "administrator" {
|
||||
return true
|
||||
}
|
||||
var count int64
|
||||
return h.conversationSvc.DB().WithContext(ctx).Model(&model.InboxMember{}).Where("inbox_id = ? AND user_id = ?", inboxID, userID).Count(&count).Error == nil && count == 1
|
||||
}
|
||||
|
||||
// handleServiceError maps service-layer errors to appropriate HTTP responses.
|
||||
// GORM record-not-found → 404, validation/invalid/required → 400, other → 500.
|
||||
func handleServiceError(c *gin.Context, err error) {
|
||||
|
||||
@@ -727,62 +727,19 @@ func (s *ConversationCrudTestSuite) TestAssignAgent_Success() {
|
||||
assert.Equal(s.T(), float64(user.ID), resp["id"])
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestAssignAgentBot_MutuallyExclusiveAndAccountScoped() {
|
||||
func (s *ConversationCrudTestSuite) TestAssignAgentBot_IsRejectedByHumanAssignmentEndpoint() {
|
||||
bot := &model.AgentBot{AccountID: &s.testAccount.ID, Name: "Triage bot", BotType: "webhook", AccessToken: "triage-token", Secret: "triage-secret"}
|
||||
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)+"/assignments", bytes.NewReader(body))
|
||||
req := httptest.NewRequest(http.MethodPost, s.convURL(s.testConv.ID)+"/assign", 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.Equal(http.StatusBadRequest, w.Code, w.Body.String())
|
||||
var assigned model.Conversation
|
||||
s.Require().NoError(s.db.First(&assigned, s.testConv.ID).Error)
|
||||
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)
|
||||
s.Require().NoError(s.db.Create(&model.InboxMember{InboxID: s.testInbox.ID, UserID: user.ID}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.testAccount.ID, UserID: user.ID, Role: "agent"}).Error)
|
||||
body, _ = json.Marshal(map[string]any{"assignee_id": user.ID, "assignee_type": "User"})
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, s.convURL(s.testConv.ID)+"/assign", 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.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)
|
||||
foreignBot := &model.AgentBot{AccountID: &other.ID, Name: "Foreign", BotType: "webhook", AccessToken: "foreign-token", Secret: "foreign-secret"}
|
||||
s.Require().NoError(s.db.Create(foreignBot).Error)
|
||||
body, _ = json.Marshal(map[string]any{"assignee_id": foreignBot.ID, "assignee_type": "AgentBot"})
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, s.convURL(s.testConv.ID)+"/assign", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestAssignAgent_InvalidAccountID() {
|
||||
|
||||
@@ -48,6 +48,7 @@ type chatwootConversationPayload struct {
|
||||
Muted bool `json:"muted"`
|
||||
SnoozedUntil *int64 `json:"snoozed_until"`
|
||||
Status string `json:"status"`
|
||||
AITakeoverActive bool `json:"ai_takeover_active"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt float64 `json:"updated_at"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
@@ -132,6 +133,7 @@ type chatwootMessagePayload struct {
|
||||
ExternalSourceIDs map[string]any `json:"external_source_ids"`
|
||||
AdditionalAttrs map[string]any `json:"additional_attributes"`
|
||||
IdempotentReplay bool `json:"idempotent_replay,omitempty"`
|
||||
AITakeoverExited bool `json:"ai_takeover_exited,omitempty"`
|
||||
Sender map[string]any `json:"sender,omitempty"`
|
||||
Attachments []any `json:"attachments,omitempty"`
|
||||
Call map[string]any `json:"call,omitempty"`
|
||||
@@ -260,6 +262,7 @@ func serializeConversation(ctx context.Context, db *gorm.DB, conversation *model
|
||||
Muted: conversationMuted(ctx, db, conversation),
|
||||
SnoozedUntil: conversation.SnoozedUntil,
|
||||
Status: conversation.Status,
|
||||
AITakeoverActive: conversation.AssigneeAgentBotID != nil && conversation.Status == string(model.ConversationStatusPending),
|
||||
CreatedAt: conversation.CreatedAt.Unix(),
|
||||
UpdatedAt: float64(conversation.UpdatedAt.UnixNano()) / float64(time.Second),
|
||||
Timestamp: int64Value(conversation.LastActivityAt),
|
||||
@@ -446,6 +449,7 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message,
|
||||
ExternalSourceIDs: jsonObject(message.ExternalSourceIDs),
|
||||
AdditionalAttrs: jsonObject(message.AdditionalAttributes),
|
||||
IdempotentReplay: message.IdempotentReplay,
|
||||
AITakeoverExited: message.AITakeoverExited,
|
||||
}
|
||||
if db != nil && message.SenderID != nil && *message.SenderID != 0 {
|
||||
senderType := normalizedSenderType(message.SenderType)
|
||||
@@ -767,13 +771,12 @@ 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,
|
||||
"assignee_type": "AgentBot",
|
||||
"id": bot.ID,
|
||||
"name": bot.Name,
|
||||
"description": bot.Description,
|
||||
"thumbnail": bot.AvatarURL,
|
||||
"outgoing_url": bot.OutgoingURL,
|
||||
"bot_type": bot.BotType,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user