package v1 import ( "net/http" "strconv" "strings" "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" ) // CopilotHandler handles Copilot REST API endpoints. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/copilot_threads_controller.rb type CopilotHandler struct { svc *service.CopilotService } // NewCopilotHandler creates a new CopilotHandler. func NewCopilotHandler(svc *service.CopilotService) *CopilotHandler { return &CopilotHandler{svc: svc} } // CreateThread creates a new copilot thread. // POST /api/v1/accounts/:account_id/copilot_threads func (h *CopilotHandler) CreateThread(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") return } var req service.CreateThreadRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } thread, err := h.svc.CreateThread(c.Request.Context(), accountID, userID, &req) if err != nil { applogger.L().Errorf("CreateThread: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, copilotThreadPayload(thread)) } // GetThread retrieves a copilot thread by ID. // GET /api/v1/accounts/:account_id/copilot_threads/:id func (h *CopilotHandler) GetThread(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") return } id, err := parseUintAnyParam(c, "copilot_thread_id", "thread_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } thread, err := h.svc.GetThread(c.Request.Context(), accountID, userID, id) if err != nil { applogger.L().Errorf("GetThread: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "thread not found") return } c.JSON(http.StatusOK, copilotThreadPayload(thread)) } // ListThreads retrieves copilot threads for a user. // GET /api/v1/accounts/:account_id/copilot_threads func (h *CopilotHandler) ListThreads(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") return } page, _ := parseIntQueryDefault(c, "page", 1) threads, _, err := h.svc.ListThreads(c.Request.Context(), accountID, userID, (page-1)*5, 5) if err != nil { applogger.L().Errorf("ListThreads: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list threads") return } payload := make([]gin.H, 0, len(threads)) for i := range threads { payload = append(payload, copilotThreadPayload(&threads[i])) } c.JSON(http.StatusOK, gin.H{"payload": payload}) } // SendMessage sends a message in a copilot thread and generates an assistant reply. // POST /api/v1/accounts/:account_id/copilot_threads/:id/messages func (h *CopilotHandler) SendMessage(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") return } threadID, err := parseUintAnyParam(c, "copilot_thread_id", "thread_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid thread id") return } var req service.SendMessageRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } result, err := h.svc.SendMessage(c.Request.Context(), accountID, userID, threadID, &req) if err != nil { applogger.L().Errorf("SendMessage: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, copilotMessagePayload(result.UserMessage)) } // GetSuggestedReplies generates reply suggestions for a conversation. // GET /api/v1/accounts/:account_id/conversations/:conversation_id/suggested_replies func (h *CopilotHandler) GetSuggestedReplies(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } // conversation_id is used to fetch context; here we accept it as a query param // for the conversation context (in production, this would fetch the conversation) conversationContext := c.Query("context") if conversationContext == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "missing conversation context") return } result, err := h.svc.GetSuggestedReplies(c.Request.Context(), accountID, conversationContext) if err != nil { applogger.L().Errorf("GetSuggestedReplies: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate suggested replies") return } response.OK(c, result) } // SummarizeConversation generates a summary of a conversation. // GET /api/v1/accounts/:account_id/conversations/:conversation_id/summary func (h *CopilotHandler) SummarizeConversation(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } conversationContext := c.Query("context") if conversationContext == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "missing conversation context") return } result, err := h.svc.SummarizeConversation(c.Request.Context(), accountID, conversationContext) if err != nil { applogger.L().Errorf("SummarizeConversation: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to summarize conversation") return } response.OK(c, result) } // DeleteThread deletes a copilot thread. // DELETE /api/v1/accounts/:account_id/copilot_threads/:id func (h *CopilotHandler) DeleteThread(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") return } id, err := parseUintAnyParam(c, "copilot_thread_id", "thread_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } if err := h.svc.DeleteThread(c.Request.Context(), accountID, userID, id); err != nil { applogger.L().Errorf("DeleteThread: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "thread not found") return } response.NoContent(c) } // TranslateMessage translates a message to a target language. // POST /api/v1/accounts/:account_id/copilot/translate func (h *CopilotHandler) TranslateMessage(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } var req service.TranslateRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } result, err := h.svc.TranslateMessage(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("TranslateMessage: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to translate message") return } response.OK(c, result) } // --- Copilot Suggestion Messages --- // Reference: Chatwoot enterprise/app/controllers/api/v1/copilot_messages_controller.rb // Conversation-level suggestions (GET/POST /api/v1/accounts/:id/copilot_messages) // ListSuggestionMessages lists copilot suggestion messages for a conversation. // GET /api/v1/accounts/:account_id/copilot_messages func (h *CopilotHandler) ListSuggestionMessages(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } if threadID, err := parseUintAnyParam(c, "copilot_thread_id", "thread_id"); err == nil && threadID != 0 { userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") return } page, _ := parseIntQueryDefault(c, "page", 1) messages, _, err := h.svc.ListThreadMessages(c.Request.Context(), accountID, userID, threadID, page, 1000) if err != nil { applogger.L().Errorf("ListCopilotMessages: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "thread not found") return } payload := make([]gin.H, 0, len(messages)) for i := range messages { payload = append(payload, copilotMessagePayload(&messages[i])) } c.JSON(http.StatusOK, gin.H{"payload": payload}) return } conversationIDStr := c.Query("conversation_id") conversationID, err := parseUintString(conversationIDStr) if err != nil || conversationID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "conversation_id is required") return } page, _ := parseIntQueryDefault(c, "page", 1) result, err := h.svc.GetCopilotSuggestions(c.Request.Context(), accountID, conversationID, page, 25) if err != nil { applogger.L().Errorf("ListSuggestionMessages: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list suggestion messages") return } response.OKWithMeta(c, result.Messages, result.Page, result.PerPage, result.TotalCount) } // CreateSuggestionMessage creates a copilot suggestion message. // POST /api/v1/accounts/:account_id/copilot_messages func (h *CopilotHandler) CreateSuggestionMessage(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } var req service.CreateSuggestionRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } if req.ConversationID == 0 || strings.TrimSpace(req.Content) == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "conversation_id and content are required") return } msg, err := h.svc.CreateCopilotSuggestion(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("CreateSuggestionMessage: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create suggestion message") return } response.Created(c, msg) } func parseUintString(raw string) (uint, error) { if raw == "" { return 0, http.ErrMissingFile } n, err := strconv.ParseUint(raw, 10, 32) return uint(n), err } func copilotThreadPayload(thread *model.CopilotThread) gin.H { return gin.H{ "id": thread.ID, "title": thread.Title, "created_at": thread.CreatedAt.Unix(), "user": copilotUserPayload(&thread.User, thread.UserID, thread.AccountID), "assistant": copilotAssistantPushPayload(&thread.Assistant, thread.AssistantID), "account_id": thread.AccountID, } } func copilotThreadPushPayload(thread *model.CopilotThread) gin.H { return gin.H{ "id": thread.ID, "title": thread.Title, "created_at": thread.CreatedAt.Unix(), "user": copilotUserPayload(&thread.User, thread.UserID, thread.AccountID), "account_id": thread.AccountID, } } func copilotMessagePayload(message *model.CopilotMessage) gin.H { return gin.H{ "id": message.ID, "message": rawJSONValue(message.Message), "message_type": message.MessageType, "created_at": message.CreatedAt.Unix(), "copilot_thread": copilotThreadPushPayload(&message.CopilotThread), "account_id": message.AccountID, } } func copilotMessagePushPayload(message *model.CopilotMessage) gin.H { return gin.H{ "id": message.ID, "message": rawJSONValue(message.Message), "message_type": message.MessageType, "created_at": message.CreatedAt.Unix(), "copilot_thread": copilotThreadPushPayload(&message.CopilotThread), } } func copilotUserPayload(user *model.User, fallbackID, accountID uint) gin.H { if user == nil || user.ID == 0 { return gin.H{"id": fallbackID, "account_id": accountID, "type": "user"} } return gin.H{ "id": user.ID, "name": user.Name, "available_name": nonEmpty(user.DisplayName, user.Name), "avatar_url": user.AvatarURL, "type": "user", "availability_status": availabilityStatus(user.Available), "thumbnail": user.AvatarURL, } } func copilotAssistantPushPayload(assistant *model.CaptainAssistant, fallbackID *uint) gin.H { if assistant == nil || assistant.ID == 0 { if fallbackID == nil { return gin.H{} } return gin.H{"id": *fallbackID, "type": "captain_assistant"} } return gin.H{ "id": assistant.ID, "name": assistant.Name, "avatar_url": "", "description": assistant.Description, "created_at": assistant.CreatedAt.Unix(), "type": "captain_assistant", } }