package v1 import ( "encoding/json" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/search" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" "gorm.io/datatypes" ) // MessageHandler handles message-related API endpoints. // Reference: Chatwoot app/controllers/api/v1/messages_controller.rb type MessageHandler struct { svc *service.MessageService } // NewMessageHandler creates a new MessageHandler. func NewMessageHandler(svc *service.MessageService) *MessageHandler { return &MessageHandler{svc: svc} } // @Summary List messages in a conversation // @Description Retrieves all messages for a conversation with pagination // @Tags Messages // @Accept json // @Produce json // @Param account_id path uint true "Account ID" // @Param conversation_id path uint true "Conversation ID" // @Param page query int false "Page number" default(1) // @Param page_size query int false "Items per page" default(25) // @Success 200 {object} []model.Message // @Failure 400 {object} model.ErrorResponse // @Failure 401 {object} model.ErrorResponse // @Failure 404 {object} model.ErrorResponse // @Failure 500 {object} model.ErrorResponse // @Security ApiKeyAuth // @Router /api/v1/accounts/{account_id}/conversations/{conversation_id}/messages [get] // List retrieves all messages for a conversation with pagination. // GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages // Reference: Chatwoot conversations#messages (index) func (h *MessageHandler) List(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, svcErr := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, conversationID) if svcErr != nil { handleServiceError(c, svcErr) return } after, _ := strconv.ParseUint(c.Query("after"), 10, 64) before, _ := strconv.ParseUint(c.Query("before"), 10, 64) filterInternal := c.Query("filter_internal_messages") != "" messages, _, svcErr := h.svc.ListByConversationFinder(c.Request.Context(), conversation.ID, uint(after), uint(before), filterInternal) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, serializeMessageIndex(c.Request.Context(), h.svc.DB(), conversation, messages)) } // @Summary Create a message in a conversation // @Description Creates a new message in an existing conversation // @Tags Messages // @Accept json // @Produce json // @Param account_id path uint true "Account ID" // @Param conversation_id path uint true "Conversation ID" // @Param body body service.CreateMessageRequest true "Message creation payload" // @Success 201 {object} model.Message // @Failure 400 {object} model.ErrorResponse // @Failure 401 {object} model.ErrorResponse // @Failure 404 {object} model.ErrorResponse // @Failure 500 {object} model.ErrorResponse // @Security ApiKeyAuth // @Router /api/v1/accounts/{account_id}/conversations/{conversation_id}/messages [post] // Create creates a new message in a conversation. // POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages // Reference: Chatwoot conversations#messages (create) func (h *MessageHandler) Create(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated") return } var req service.CreateMessageRequest if err := bindCreateMessageRequest(c, &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } // If conversation_id is not in the request body, use the URL param if req.ConversationID == 0 { conversationID, urlErr := parseUintParam(c, "conversation_id") if urlErr != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "conversation_id is required") return } conversation, resolveErr := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, conversationID) if resolveErr != nil { handleServiceError(c, resolveErr) return } req.ConversationID = conversation.ID } message, svcErr := h.svc.Create(c.Request.Context(), accountID, userID, req) if svcErr != nil { handleServiceError(c, svcErr) return } conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, req.ConversationID) c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation)) } // @Summary Get a single message // @Description Retrieves detailed information about a specific message // @Tags Messages // @Accept json // @Produce json // @Param account_id path uint true "Account ID" // @Param conversation_id path uint true "Conversation ID" // @Param id path uint true "Message ID" // @Success 200 {object} model.Message // @Failure 400 {object} model.ErrorResponse // @Failure 401 {object} model.ErrorResponse // @Failure 404 {object} model.ErrorResponse // @Failure 500 {object} model.ErrorResponse // @Security ApiKeyAuth // @Router /api/v1/accounts/{account_id}/conversations/{conversation_id}/messages/{id} [get] // Get retrieves a single message. // GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:id // Reference: Chatwoot messages#show func (h *MessageHandler) Get(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } messageID, err := parseUintParam(c, "message_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid message id") return } message, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, messageID) if svcErr != nil { handleServiceError(c, svcErr) return } conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, message.ConversationID) c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation)) } // Update updates a message's content. // PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:id func (h *MessageHandler) Update(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } messageID, err := parseUintParam(c, "message_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid message id") return } var req service.UpdateMessageRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } message, svcErr := h.svc.Update(c.Request.Context(), accountID, messageID, req) if svcErr != nil { handleServiceError(c, svcErr) return } conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, message.ConversationID) c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation)) } // @Summary Delete a message // @Description Soft-deletes a message from a conversation // @Tags Messages // @Accept json // @Produce json // @Param account_id path uint true "Account ID" // @Param conversation_id path uint true "Conversation ID" // @Param id path uint true "Message ID" // @Success 204 {object} object // @Failure 400 {object} model.ErrorResponse // @Failure 401 {object} model.ErrorResponse // @Failure 404 {object} model.ErrorResponse // @Failure 500 {object} model.ErrorResponse // @Security ApiKeyAuth // @Router /api/v1/accounts/{account_id}/conversations/{conversation_id}/messages/{id} [delete] // Delete soft-deletes a message. // DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:id func (h *MessageHandler) Delete(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } messageID, err := parseUintParam(c, "message_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid message id") return } message, svcErr := h.svc.Delete(c.Request.Context(), accountID, messageID) if svcErr != nil { handleServiceError(c, svcErr) return } conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, message.ConversationID) c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation)) } // Search searches messages by content within an account. // GET /api/v1/accounts/:account_id/messages/search?q=... // Reference: Chatwoot messages#search func (h *MessageHandler) Search(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } q := c.Query("q") if q == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "search query 'q' is required") return } p := pagination.Parse(c) searchMode := search.ParseSearchMode(c.DefaultQuery("search_mode", "")) messages, total, svcErr := h.svc.Search(c.Request.Context(), accountID, q, p.Offset, p.PerPage, searchMode) if svcErr != nil { handleServiceError(c, svcErr) return } response.OKWithMeta(c, toInterfaceSlice(messages), p.Page, p.PerPage, total) } // Retry retries a failed message. // POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:id/retry func (h *MessageHandler) Retry(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } messageID, err := parseUintParam(c, "message_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid message id") return } message, svcErr := h.svc.Retry(c.Request.Context(), accountID, messageID) if svcErr != nil { handleServiceError(c, svcErr) return } conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, message.ConversationID) c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation)) } // Translate translates a message's content to a target language using LLM. // POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:id/translate // Reference: Chatwoot messages#translate func (h *MessageHandler) Translate(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } messageID, err := parseUintParam(c, "message_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid message id") return } var req service.TranslateMessageRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } result, svcErr := h.svc.Translate(c.Request.Context(), accountID, messageID, req) if svcErr != nil { lower := strings.ToLower(svcErr.Error()) if strings.Contains(lower, "not found") { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, svcErr.Error()) return } if strings.Contains(lower, "required") || strings.Contains(lower, "invalid") { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, svcErr.Error()) return } response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, svcErr.Error()) return } response.OK(c, result) } func bindCreateMessageRequest(c *gin.Context, req *service.CreateMessageRequest) error { if strings.HasPrefix(c.ContentType(), "multipart/form-data") { if err := c.Request.ParseMultipartForm(32 << 20); err != nil { return err } req.Content = c.PostForm("content") req.MessageType = c.PostForm("message_type") req.ContentType = c.PostForm("content_type") req.SourceID = c.PostForm("source_id") req.EchoID = c.PostForm("echo_id") req.Private = strings.EqualFold(c.PostForm("private"), "true") || c.PostForm("private") == "1" if raw := c.PostForm("content_attributes"); raw != "" { req.ContentAttributes = []byte(raw) } if c.Request.MultipartForm != nil { for _, key := range []string{"attachments[]", "attachments"} { for _, file := range c.Request.MultipartForm.File[key] { req.Attachments = append(req.Attachments, service.MessageAttachmentInput{ FileName: file.Filename, FileSize: int(file.Size), ContentType: file.Header.Get("Content-Type"), }) } } } return nil } var raw map[string]json.RawMessage if err := c.ShouldBindJSON(&raw); err != nil { return err } bytes, _ := json.Marshal(raw) if err := json.Unmarshal(bytes, req); err != nil { return err } if value, ok := raw["content_attributes"]; ok && string(value) != "null" { req.ContentAttributes = datatypes.JSON(value) } return nil }