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:
Rogee
2026-08-12 22:57:06 +08:00
committed by GitHub
co-authored by rogee
parent 4bf6ba6b20
commit 83a8ab315a
31 changed files with 604 additions and 201 deletions
@@ -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,
}
}