package v1 import ( "encoding/json" "errors" "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" "gorm.io/gorm" ) // CaptainAssistantHandler handles CaptainAssistant REST API endpoints. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/assistants_controller.rb type CaptainAssistantHandler struct { svc *service.CaptainAssistantService } // NewCaptainAssistantHandler creates a new CaptainAssistantHandler. func NewCaptainAssistantHandler(svc *service.CaptainAssistantService) *CaptainAssistantHandler { return &CaptainAssistantHandler{svc: svc} } // Create creates a new captain assistant. // POST /api/v1/accounts/:account_id/captain_assistants func (h *CaptainAssistantHandler) Create(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } var req service.CreateAssistantRequest if err := bindCaptainAssistantPayload(c, &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } assistant, err := h.svc.Create(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("Create captain assistant: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, captainAssistantPayload(assistant)) } // Get retrieves a captain assistant by ID. // GET /api/v1/accounts/:account_id/captain_assistants/:id func (h *CaptainAssistantHandler) Get(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } id, err := parseUintParam(c, "assistant_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } assistant, err := h.svc.Get(c.Request.Context(), accountID, id) if err != nil { applogger.L().Errorf("Get captain assistant: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "assistant not found") return } c.JSON(http.StatusOK, captainAssistantPayload(assistant)) } // Update updates an existing captain assistant. // PUT /api/v1/accounts/:account_id/captain_assistants/:id func (h *CaptainAssistantHandler) Update(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } id, err := parseUintParam(c, "assistant_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } var req service.UpdateAssistantRequest if err := bindCaptainAssistantPayload(c, &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } assistant, err := h.svc.Update(c.Request.Context(), accountID, id, &req) if err != nil { applogger.L().Errorf("Update captain assistant: %v", err) if captainAssistantErrorStatus(err) == http.StatusNotFound { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "assistant not found") return } response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update assistant") return } c.JSON(http.StatusOK, captainAssistantPayload(assistant)) } // Delete deletes a captain assistant. // DELETE /api/v1/accounts/:account_id/captain_assistants/:id func (h *CaptainAssistantHandler) Delete(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } id, err := parseUintParam(c, "assistant_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } if err := h.svc.Delete(c.Request.Context(), accountID, id); err != nil { applogger.L().Errorf("Delete captain assistant: %v", err) if captainAssistantErrorStatus(err) == http.StatusNotFound { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "assistant not found") return } response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete assistant") return } response.NoContent(c) } // List retrieves captain assistants for an account. // GET /api/v1/accounts/:account_id/captain_assistants func (h *CaptainAssistantHandler) List(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } assistants, count, err := h.svc.List(c.Request.Context(), accountID, 0, 1000) if err != nil { applogger.L().Errorf("List captain assistants: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list assistants") return } payload := make([]gin.H, 0, len(assistants)) for i := range assistants { payload = append(payload, captainAssistantPayload(&assistants[i])) } c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": count, "page": 1}}) } // GetConfig retrieves the assistant's JSONB config. // GET /api/v1/accounts/:account_id/captain_assistants/:id/config func (h *CaptainAssistantHandler) GetConfig(c *gin.Context) { id, err := parseUintAnyParam(c, "assistant_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } cfg, err := h.svc.GetConfig(c.Request.Context(), id) if err != nil { applogger.L().Errorf("GetConfig captain assistant: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "assistant not found") return } response.OK(c, cfg) } // SetConfig updates the assistant's JSONB config. // PUT /api/v1/accounts/:account_id/captain_assistants/:id/config func (h *CaptainAssistantHandler) SetConfig(c *gin.Context) { id, err := parseUintAnyParam(c, "assistant_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } var cfg model.AssistantConfig if err := c.ShouldBindJSON(&cfg); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } if err := h.svc.SetConfig(c.Request.Context(), id, &cfg); err != nil { applogger.L().Errorf("SetConfig captain assistant: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update config") return } response.OK(c, cfg) } // AssociateInbox binds an assistant to an inbox. // POST /api/v1/accounts/:account_id/captain_assistants/:id/inboxes func (h *CaptainAssistantHandler) AssociateInbox(c *gin.Context) { assistantID, err := parseUintParam(c, "assistant_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant id") return } accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } var req struct { Inbox struct { InboxID uint `json:"inbox_id"` } `json:"inbox"` InboxID uint `json:"inbox_id"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } inboxID := req.InboxID if inboxID == 0 { inboxID = req.Inbox.InboxID } inbox, err := h.svc.AssociateInbox(c.Request.Context(), assistantID, inboxID, accountID) if err != nil { applogger.L().Errorf("AssociateInbox: %v", err) if captainAssistantErrorStatus(err) == http.StatusNotFound { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "captain inbox not found") return } response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to associate inbox") return } c.JSON(http.StatusOK, serializeInbox(inbox, nil, inboxSerializationAdmin(c))) } // DissociateInbox unbinds an assistant from an inbox. // DELETE /api/v1/accounts/:account_id/captain_assistants/:id/inboxes/:inbox_id func (h *CaptainAssistantHandler) DissociateInbox(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } assistantID, err := parseUintParam(c, "assistant_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant id") return } inboxID, err := parseUintParam(c, "inbox_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid inbox_id") return } if err := h.svc.DissociateInbox(c.Request.Context(), accountID, assistantID, inboxID); err != nil { applogger.L().Errorf("DissociateInbox: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "captain inbox not found") return } c.Status(http.StatusNoContent) } func (h *CaptainAssistantHandler) ListInboxes(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } assistantID, err := parseUintParam(c, "assistant_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant id") return } inboxes, err := h.svc.ListInboxes(c.Request.Context(), accountID, assistantID) if err != nil { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "assistant not found") return } payload := make([]gin.H, 0, len(inboxes)) for i := range inboxes { payload = append(payload, serializeInbox(&inboxes[i], nil, inboxSerializationAdmin(c))) } c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": len(payload), "page": 1}}) } func (h *CaptainAssistantHandler) Tools(c *gin.Context) { accountID := parseAccountIDParam(c) c.JSON(http.StatusOK, h.svc.AvailableTools(c.Request.Context(), accountID)) } // GenerateResponse generates an AI response via RAG. // POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/playground func (h *CaptainAssistantHandler) GenerateResponse(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } id, err := parseUintAnyParam(c, "assistant_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } var req struct { Assistant struct { MessageContent string `json:"message_content"` Query string `json:"query"` MessageHistory []service.PlaygroundMessage `json:"message_history"` } `json:"assistant"` MessageContent string `json:"message_content"` Query string `json:"query"` MessageHistory []service.PlaygroundMessage `json:"message_history"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } messageContent := req.MessageContent if messageContent == "" { messageContent = req.Query } if messageContent == "" { messageContent = req.Assistant.MessageContent } if messageContent == "" { messageContent = req.Assistant.Query } if messageContent == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "message_content is required") return } messageHistory := req.MessageHistory if len(messageHistory) == 0 && len(req.Assistant.MessageHistory) > 0 { messageHistory = req.Assistant.MessageHistory } result, err := h.svc.GeneratePlaygroundResponse(c.Request.Context(), accountID, id, service.PlaygroundRequest{ MessageContent: messageContent, MessageHistory: messageHistory, }) if err != nil { applogger.L().Errorf("GenerateResponse: %v", err) if captainAssistantErrorStatus(err) == http.StatusNotFound { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "assistant not found") return } response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate response") return } c.JSON(http.StatusOK, result) } func bindCaptainAssistantPayload(c *gin.Context, dst any) error { return bindNestedJSONPayload(c, "assistant", dst) } func bindNestedJSONPayload(c *gin.Context, key string, dst any) error { var raw map[string]json.RawMessage if err := c.ShouldBindJSON(&raw); err != nil { return err } if nested, ok := raw[key]; ok { return json.Unmarshal(nested, dst) } body, err := json.Marshal(raw) if err != nil { return err } return json.Unmarshal(body, dst) } func captainAssistantPayload(assistant *model.CaptainAssistant) gin.H { return gin.H{ "account_id": assistant.AccountID, "config": rawJSONValue(assistant.Config), "created_at": assistant.CreatedAt.Unix(), "description": assistant.Description, "guardrails": rawJSONValue(assistant.Guardrails), "id": assistant.ID, "name": assistant.Name, "response_guidelines": rawJSONValue(assistant.ResponseGuidelines), "updated_at": assistant.UpdatedAt.Unix(), } } func rawJSONValue(raw json.RawMessage) any { if len(raw) == 0 || string(raw) == "null" { return nil } var value any if err := json.Unmarshal(raw, &value); err != nil { return fmt.Sprintf("%s", raw) } return value } func captainAssistantErrorStatus(err error) int { if errors.Is(err, gorm.ErrRecordNotFound) { return http.StatusNotFound } return http.StatusInternalServerError }