Files
gochat/internal/handler/api/v1/captain_assistant_handler.go
T

399 lines
13 KiB
Go

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))
}
// 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]))
}
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/:id/generate_response
func (h *CaptainAssistantHandler) GenerateResponse(c *gin.Context) {
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"`
} `json:"assistant"`
MessageContent string `json:"message_content"`
Query string `json:"query"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
query := req.Query
if query == "" {
query = req.MessageContent
}
if query == "" {
query = req.Assistant.Query
}
if query == "" {
query = req.Assistant.MessageContent
}
if query == "" {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "message_content is required")
return
}
result, err := h.svc.GenerateResponse(c.Request.Context(), id, query)
if err != nil {
applogger.L().Errorf("GenerateResponse: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate response")
return
}
c.JSON(http.StatusOK, gin.H{"response": 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
}