From a465bbe09d0dc6cb0d1cd2ac405283984e7e8dff Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 02:07:37 +0800 Subject: [PATCH] feat(conversations): finish message mutation parity --- .../handler/api/v1/conversation_handler.go | 16 +- .../api/v1/conversation_handler_crud_test.go | 5 +- .../handler/api/v1/conversation_serializer.go | 56 +++++++ internal/handler/api/v1/message_handler.go | 28 +++- .../handler/api/v1/message_handler_test.go | 134 ++++++++++++++- internal/repository/message_repo.go | 45 ++++- internal/service/message_service.go | 155 ++++++++++++++++-- internal/service/message_service_test.go | 17 +- internal/service/search_indexer_hooks_test.go | 3 +- internal/service/service_test_helper.go | 3 +- 10 files changed, 420 insertions(+), 42 deletions(-) diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index e26d94db..59986eb0 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -2,6 +2,7 @@ package v1 import ( "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -575,9 +576,10 @@ func (h *ConversationHandler) ListMessages(c *gin.Context) { return } - p := pagination.Parse(c) - - messages, _, svcErr := h.conversationSvc.ListMessages(c.Request.Context(), conversation.ID, p.Offset, p.PerPage) + 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.messageSvc.ListByConversationFinder(c.Request.Context(), conversation.ID, uint(after), uint(before), filterInternal) if svcErr != nil { handleServiceError(c, svcErr) return @@ -857,6 +859,10 @@ func (h *ConversationHandler) AssignTeam(c *gin.Context) { c.JSON(http.StatusOK, serializeUserFromDB(c.Request.Context(), h.conversationSvc.DB(), *req.AgentID, accountID)) return } + if req.TeamID != nil { + c.JSON(http.StatusOK, serializeTeamFromDB(c.Request.Context(), h.conversationSvc.DB(), *req.TeamID, accountID)) + return + } c.JSON(http.StatusOK, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation)) } @@ -876,6 +882,10 @@ func handleServiceError(c *gin.Context, err error) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, errMsg) return } + if strings.Contains(lower, "only allowed") || strings.Contains(lower, "forbidden") { + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, errMsg) + return + } response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, errMsg) } diff --git a/internal/handler/api/v1/conversation_handler_crud_test.go b/internal/handler/api/v1/conversation_handler_crud_test.go index 1db44df0..556ebb72 100644 --- a/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/internal/handler/api/v1/conversation_handler_crud_test.go @@ -896,7 +896,10 @@ func (s *ConversationCrudTestSuite) TestAssignTeam_Success() { var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) - assert.Equal(s.T(), float64(s.testConv.ID), resp["id"]) + assert.Equal(s.T(), float64(team.ID), resp["id"]) + assert.Equal(s.T(), "TestTeam", resp["name"]) + assert.Equal(s.T(), float64(s.testAccount.ID), resp["account_id"]) + assert.Nil(s.T(), resp["meta"]) } func (s *ConversationCrudTestSuite) TestAssignTeam_WithAgentID() { diff --git a/internal/handler/api/v1/conversation_serializer.go b/internal/handler/api/v1/conversation_serializer.go index 030f54df..2a4ded6e 100644 --- a/internal/handler/api/v1/conversation_serializer.go +++ b/internal/handler/api/v1/conversation_serializer.go @@ -3,6 +3,7 @@ package v1 import ( "context" "encoding/json" + "path/filepath" "strings" "time" @@ -177,6 +178,12 @@ func serializeConversationMeta(ctx context.Context, db *gorm.DB, conversation *m meta.AssigneeType = "User" } } + if conversation.TeamID != nil && *conversation.TeamID != 0 { + var team model.Team + if err := db.WithContext(ctx).First(&team, *conversation.TeamID).Error; err == nil { + meta.Team = serializeTeam(&team) + } + } if conversation.ContactInboxID != nil && *conversation.ContactInboxID != 0 { var contactInbox model.ContactInbox @@ -249,9 +256,35 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message, } } } + if db != nil { + var attachments []model.Attachment + if err := db.WithContext(ctx).Where("message_id = ?", message.ID).Order("id ASC").Find(&attachments).Error; err == nil && len(attachments) > 0 { + payload.Attachments = make([]any, 0, len(attachments)) + for i := range attachments { + payload.Attachments = append(payload.Attachments, serializeAttachment(&attachments[i])) + } + } + } return payload } +func serializeAttachment(attachment *model.Attachment) map[string]any { + extension := strings.TrimPrefix(filepath.Ext(attachment.FileName), ".") + dataURL := nonEmpty(attachment.FileURL, attachment.ExternalURL) + return map[string]any{ + "id": attachment.ID, + "message_id": attachment.MessageID, + "file_type": attachment.FileType, + "account_id": attachment.AccountID, + "data_url": dataURL, + "thumb_url": attachment.ThumbURL, + "file_size": attachment.FileSize, + "extension": extension, + "width": attachment.Width, + "height": attachment.Height, + } +} + func serializeContact(contact *model.Contact) map[string]any { return map[string]any{ "additional_attributes": jsonObject(contact.AdditionalAttributes), @@ -296,6 +329,29 @@ func serializeUserFromDB(ctx context.Context, db *gorm.DB, userID uint, accountI return serializeUser(&user, accountID) } +func serializeTeam(team *model.Team) map[string]any { + return map[string]any{ + "id": team.ID, + "account_id": team.AccountID, + "name": team.Name, + "description": team.Description, + "allow_auto_assignment": team.AllowAutoAssignment, + "created_at": team.CreatedAt.Unix(), + "updated_at": team.UpdatedAt.Unix(), + } +} + +func serializeTeamFromDB(ctx context.Context, db *gorm.DB, teamID uint, accountID uint) any { + if teamID == 0 || db == nil { + return nil + } + var team model.Team + if err := db.WithContext(ctx).Where("id = ? AND account_id = ?", teamID, accountID).First(&team).Error; err != nil { + return nil + } + return serializeTeam(&team) +} + func conversationDisplayID(conversation *model.Conversation) uint { if conversation.DisplayID != nil && *conversation.DisplayID != 0 { return *conversation.DisplayID diff --git a/internal/handler/api/v1/message_handler.go b/internal/handler/api/v1/message_handler.go index 12da639b..254f604f 100644 --- a/internal/handler/api/v1/message_handler.go +++ b/internal/handler/api/v1/message_handler.go @@ -3,6 +3,7 @@ package v1 import ( "encoding/json" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -61,9 +62,10 @@ func (h *MessageHandler) List(c *gin.Context) { return } - p := pagination.Parse(c) - - messages, _, svcErr := h.svc.ListByConversation(c.Request.Context(), conversation.ID, p.Offset, p.PerPage) + 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 @@ -168,7 +170,8 @@ func (h *MessageHandler) Get(c *gin.Context) { handleServiceError(c, svcErr) return } - response.OK(c, message) + 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. @@ -229,11 +232,13 @@ func (h *MessageHandler) Delete(c *gin.Context) { return } - if svcErr := h.svc.Delete(c.Request.Context(), accountID, messageID); svcErr != nil { + message, svcErr := h.svc.Delete(c.Request.Context(), accountID, messageID) + if svcErr != nil { handleServiceError(c, svcErr) return } - response.NoContent(c) + 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. @@ -339,6 +344,17 @@ func bindCreateMessageRequest(c *gin.Context, req *service.CreateMessageRequest) 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 } diff --git a/internal/handler/api/v1/message_handler_test.go b/internal/handler/api/v1/message_handler_test.go index 44cbf2bd..9d11cd02 100644 --- a/internal/handler/api/v1/message_handler_test.go +++ b/internal/handler/api/v1/message_handler_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "mime/multipart" "net/http" "net/http/httptest" "testing" @@ -80,6 +81,7 @@ func (s *MessageHandlerTestSuite) SetupSuite() { &model.Conversation{}, &model.ConversationParticipant{}, &model.Message{}, + &model.Attachment{}, &model.InboxMember{}, ) s.Require().NoError(err) @@ -185,6 +187,7 @@ func (s *MessageHandlerTestSuite) SetupTest() { } func (s *MessageHandlerTestSuite) TearDownTest() { + s.db.Exec("DELETE FROM attachments") s.db.Exec("DELETE FROM messages") s.db.Exec("DELETE FROM conversation_participants") s.db.Exec("DELETE FROM conversations") @@ -253,6 +256,47 @@ func (s *MessageHandlerTestSuite) TestList_Empty() { assert.Equal(s.T(), 0, len(data)) } +func (s *MessageHandlerTestSuite) TestList_BeforeAfterMessageFinder() { + s.db.Exec("DELETE FROM messages") + var ids []uint + for i := 0; i < 5; i++ { + msg := &model.Message{ + ConversationID: s.testConv.ID, + AccountID: s.testAccount.ID, + InboxID: s.testInbox.ID, + Content: fmt.Sprintf("message-%d", i+1), + ContentType: "text", + MessageType: "incoming", + SenderType: "contact", + } + s.Require().NoError(s.db.Create(msg).Error) + ids = append(ids, msg.ID) + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", msgListURL(s.testAccount.ID, s.testConv.ID)+fmt.Sprintf("?before=%d", ids[3]), nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var beforeResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &beforeResp) + beforePayload := beforeResp["payload"].([]interface{}) + assert.Len(s.T(), beforePayload, 3) + assert.Equal(s.T(), float64(ids[0]), beforePayload[0].(map[string]interface{})["id"]) + assert.Equal(s.T(), float64(ids[2]), beforePayload[2].(map[string]interface{})["id"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", msgListURL(s.testAccount.ID, s.testConv.ID)+fmt.Sprintf("?after=%d", ids[2]), nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var afterResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &afterResp) + afterPayload := afterResp["payload"].([]interface{}) + assert.Len(s.T(), afterPayload, 2) + assert.Equal(s.T(), float64(ids[3]), afterPayload[0].(map[string]interface{})["id"]) +} + func (s *MessageHandlerTestSuite) TestList_InvalidConversationID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/abc/messages/", nil) @@ -313,6 +357,37 @@ func (s *MessageHandlerTestSuite) TestCreate_ChatwootFrontendPayloadDefaultsOutg assert.NotNil(s.T(), resp["content_attributes"]) } +func (s *MessageHandlerTestSuite) TestCreate_MultipartAttachmentPersistsAndSerializes() { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + s.Require().NoError(writer.WriteField("content", "Attachment message")) + s.Require().NoError(writer.WriteField("private", "false")) + file, err := writer.CreateFormFile("attachments[]", "hello.txt") + s.Require().NoError(err) + _, err = file.Write([]byte("hello world")) + s.Require().NoError(err) + s.Require().NoError(writer.Close()) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", msgListURL(s.testAccount.ID, s.testConv.ID), &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + attachments, ok := resp["attachments"].([]interface{}) + assert.True(s.T(), ok) + assert.Len(s.T(), attachments, 1) + attachment := attachments[0].(map[string]interface{}) + assert.Equal(s.T(), "file", attachment["file_type"]) + assert.Contains(s.T(), attachment["data_url"], "hello.txt") + + var count int64 + s.Require().NoError(s.db.Model(&model.Attachment{}).Where("message_id = ?", uint(resp["id"].(float64))).Count(&count).Error) + assert.Equal(s.T(), int64(1), count) +} + func (s *MessageHandlerTestSuite) TestCreate_MissingContent() { payload := map[string]interface{}{ "content_type": "text", @@ -355,10 +430,8 @@ func (s *MessageHandlerTestSuite) TestGet_Success() { assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - data := resp["data"].(map[string]interface{}) - assert.Equal(s.T(), float64(s.testMessage.ID), data["id"]) - assert.Equal(s.T(), "Hello world", data["content"]) + assert.Equal(s.T(), float64(s.testMessage.ID), resp["id"]) + assert.Equal(s.T(), "Hello world", resp["content"]) } func (s *MessageHandlerTestSuite) TestGet_NotFound() { @@ -398,6 +471,43 @@ func (s *MessageHandlerTestSuite) TestUpdate_Success() { assert.Equal(s.T(), "Updated content", resp["content"]) } +func (s *MessageHandlerTestSuite) TestUpdate_StatusExternalError() { + s.Require().NoError(s.db.Model(s.testInbox).Update("channel_type", "api").Error) + s.testInbox.ChannelType = "api" + + payload := map[string]interface{}{ + "status": "failed", + "external_error": "provider rejected message", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) + req, _ := http.NewRequest("PATCH", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(s.T(), "failed", resp["status"]) + attrs := resp["content_attributes"].(map[string]interface{}) + assert.Equal(s.T(), "provider rejected message", attrs["external_error"]) +} + +func (s *MessageHandlerTestSuite) TestUpdate_StatusForbiddenForNonAPIInbox() { + payload := map[string]interface{}{"status": "delivered"} + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) + req, _ := http.NewRequest("PATCH", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusForbidden, w.Code) +} + func (s *MessageHandlerTestSuite) TestUpdate_NotFound() { payload := map[string]interface{}{ "content": "Updated content", @@ -416,12 +526,26 @@ func (s *MessageHandlerTestSuite) TestUpdate_NotFound() { // --- Delete Tests --- func (s *MessageHandlerTestSuite) TestDelete_Success() { + attachment := &model.Attachment{MessageID: s.testMessage.ID, AccountID: s.testAccount.ID, FileType: "file", FileName: "delete.txt"} + s.Require().NoError(s.db.Create(attachment).Error) + w := httptest.NewRecorder() url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("DELETE", url, nil) s.router.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusNoContent, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(s.T(), "This message was deleted", resp["content"]) + attrs := resp["content_attributes"].(map[string]interface{}) + assert.Equal(s.T(), true, attrs["deleted"]) + _, hasAttachments := resp["attachments"] + assert.False(s.T(), hasAttachments) + + var count int64 + s.Require().NoError(s.db.Model(&model.Attachment{}).Where("message_id = ?", s.testMessage.ID).Count(&count).Error) + assert.Equal(s.T(), int64(0), count) } func (s *MessageHandlerTestSuite) TestDelete_NotFound() { diff --git a/internal/repository/message_repo.go b/internal/repository/message_repo.go index 425c2d5b..1bcf15d6 100644 --- a/internal/repository/message_repo.go +++ b/internal/repository/message_repo.go @@ -51,6 +51,49 @@ func (r *MessageRepo) FindByConversation(ctx context.Context, conversationID uin return messages, total, err } +// FindByConversationFinder mirrors Chatwoot's MessageFinder before/after windows. +func (r *MessageRepo) FindByConversationFinder(ctx context.Context, conversationID uint, after, before uint, filterInternal bool) ([]model.Message, int64, error) { + query := r.db.WithContext(ctx).Model(&model.Message{}).Where("conversation_id = ?", conversationID) + if filterInternal { + query = query.Where("NOT (private = ? OR message_type = ?)", true, model.MessageTypeActivity) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var messages []model.Message + switch { + case after != 0 && before != 0: + err := query.Where("id >= ? AND id < ?", after, before).Order("created_at ASC, id ASC").Limit(1000).Find(&messages).Error + return messages, total, err + case before != 0: + err := query.Where("id < ?", before).Order("created_at DESC, id DESC").Limit(20).Find(&messages).Error + if err != nil { + return nil, 0, err + } + reverseMessages(messages) + return messages, total, nil + case after != 0: + err := query.Where("id > ?", after).Order("created_at ASC, id ASC").Limit(100).Find(&messages).Error + return messages, total, err + default: + err := query.Order("created_at DESC, id DESC").Limit(20).Find(&messages).Error + if err != nil { + return nil, 0, err + } + reverseMessages(messages) + return messages, total, nil + } +} + +func reverseMessages(messages []model.Message) { + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { + messages[i], messages[j] = messages[j], messages[i] + } +} + // FindByAccountAndID retrieves a message scoped to an account. func (r *MessageRepo) FindByAccountAndID(ctx context.Context, accountID, id uint) (*model.Message, error) { var message model.Message @@ -201,4 +244,4 @@ func (r *MessageRepo) FindLastIncomingByConversation(ctx context.Context, conver return nil, err } return &message, nil -} \ No newline at end of file +} diff --git a/internal/service/message_service.go b/internal/service/message_service.go index c030cd0b..459ef961 100644 --- a/internal/service/message_service.go +++ b/internal/service/message_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "strings" @@ -108,14 +109,21 @@ func (s *MessageService) Search(ctx context.Context, accountID uint, query strin // CreateMessageRequest is the DTO for creating a message. // Reference: Chatwoot app/controllers/api/v1/accounts/conversations/messages_controller.rb #create type CreateMessageRequest struct { - ConversationID uint `json:"conversation_id" validate:"required"` - Content string `json:"content" validate:"required,min=1"` - MessageType string `json:"message_type,omitempty"` - ContentType string `json:"content_type,omitempty"` - Private bool `json:"private,omitempty"` - SourceID string `json:"source_id,omitempty"` - EchoID string `json:"echo_id,omitempty"` - ContentAttributes datatypes.JSON `json:"content_attributes,omitempty"` + ConversationID uint `json:"conversation_id" validate:"required"` + Content string `json:"content"` + MessageType string `json:"message_type,omitempty"` + ContentType string `json:"content_type,omitempty"` + Private bool `json:"private,omitempty"` + SourceID string `json:"source_id,omitempty"` + EchoID string `json:"echo_id,omitempty"` + ContentAttributes datatypes.JSON `json:"content_attributes,omitempty"` + Attachments []MessageAttachmentInput `json:"-"` +} + +type MessageAttachmentInput struct { + FileName string + FileSize int + ContentType string } // Create creates a new message. @@ -133,6 +141,9 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } + if strings.TrimSpace(req.Content) == "" && len(req.Attachments) == 0 { + return nil, fmt.Errorf("content is required") + } var conversation model.Conversation if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, req.ConversationID).First(&conversation).Error; err != nil { @@ -163,7 +174,26 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint } } - if err := s.repo.Create(ctx, message); err != nil { + if err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(message).Error; err != nil { + return err + } + for _, input := range req.Attachments { + attachment := &model.Attachment{ + MessageID: message.ID, + AccountID: accountID, + FileType: attachmentFileType(input.ContentType), + FileURL: attachmentDataURL(message.ID, input.FileName), + ThumbURL: attachmentThumbURL(input.ContentType, message.ID, input.FileName), + FileSize: input.FileSize, + FileName: input.FileName, + } + if err := tx.Create(attachment).Error; err != nil { + return err + } + } + return nil + }); err != nil { applogger.L().Errorf("Failed to create message: %v", err) return nil, err } @@ -219,7 +249,9 @@ func validContentType(value string) bool { // UpdateMessageRequest is the DTO for updating a message. type UpdateMessageRequest struct { - Content string `json:"content,omitempty" validate:"omitempty,min=1"` + Content string `json:"content,omitempty" validate:"omitempty,min=1"` + Status string `json:"status,omitempty"` + ExternalError string `json:"external_error,omitempty"` } // Update modifies an existing message. @@ -236,6 +268,16 @@ func (s *MessageService) Update(ctx context.Context, accountID, id uint, req Upd if req.Content != "" { message.Content = req.Content } + if req.Status != "" && validMessageStatus(req.Status) { + if !s.messageInboxIsAPI(ctx, message.InboxID) { + return nil, fmt.Errorf("Message status update is only allowed for API inboxes") + } + if message.Status == "read" && req.Status == "delivered" { + return message, nil + } + message.Status = req.Status + message.ContentAttributes = setMessageExternalError(message.ContentAttributes, req.Status, req.ExternalError) + } if err := s.repo.Update(ctx, message); err != nil { return nil, err @@ -248,22 +290,47 @@ func (s *MessageService) Update(ctx context.Context, accountID, id uint, req Upd return message, nil } -// Delete soft-deletes a message and dispatches EventMessageDeleted. -func (s *MessageService) Delete(ctx context.Context, accountID, id uint) error { +func (s *MessageService) messageInboxIsAPI(ctx context.Context, inboxID uint) bool { + var inbox model.Inbox + if err := s.repo.DB().WithContext(ctx).First(&inbox, inboxID).Error; err != nil { + return false + } + switch strings.ToLower(inbox.ChannelType) { + case "api", "channel::api": + return true + default: + return false + } +} + +// Delete marks a message deleted using Chatwoot's visible tombstone payload. +func (s *MessageService) Delete(ctx context.Context, accountID, id uint) (*model.Message, error) { message, err := s.repo.FindByAccountAndID(ctx, accountID, id) if err != nil { - return err + return nil, err } - if err := s.repo.Delete(ctx, message.ID); err != nil { - return err + message.Content = "This message was deleted" + message.ContentType = "text" + message.ContentAttributes = datatypes.JSON([]byte(`{"deleted":true}`)) + + if err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Save(message).Error; err != nil { + return err + } + if err := tx.Where("message_id = ?", message.ID).Delete(&model.Attachment{}).Error; err != nil { + return err + } + return nil + }); err != nil { + return nil, err } // Dispatch EventMessageDeleted s.dispatchMessageEvent(ctx, channel.EventMessageDeleted, message) s.deleteMessageIndex(ctx, accountID, message.ID) - return nil + return message, nil } // UpdateStatus updates the delivery status of a message and dispatches EventMessageStatusUpdated. @@ -296,6 +363,62 @@ func (s *MessageService) UpdateStatus(ctx context.Context, id uint, status strin return message, nil } +func (s *MessageService) ListByConversationFinder(ctx context.Context, conversationID uint, after, before uint, filterInternal bool) ([]model.Message, int64, error) { + return s.repo.FindByConversationFinder(ctx, conversationID, after, before, filterInternal) +} + +func validMessageStatus(value string) bool { + switch value { + case "sent", "delivered", "read", "failed": + return true + default: + return false + } +} + +func setMessageExternalError(attrs datatypes.JSON, status, externalError string) datatypes.JSON { + obj := map[string]any{} + if len(attrs) > 0 { + _ = json.Unmarshal(attrs, &obj) + } + if status == "failed" && strings.TrimSpace(externalError) != "" { + obj["external_error"] = externalError + } else { + delete(obj, "external_error") + } + bytes, _ := json.Marshal(obj) + return datatypes.JSON(bytes) +} + +func attachmentFileType(contentType string) string { + contentType = strings.ToLower(contentType) + switch { + case strings.HasPrefix(contentType, "image/"): + return "image" + case strings.HasPrefix(contentType, "audio/"): + return "audio" + case strings.HasPrefix(contentType, "video/"): + return "video" + default: + return "file" + } +} + +func attachmentDataURL(messageID uint, fileName string) string { + fileName = strings.TrimSpace(fileName) + if fileName == "" { + return "" + } + return fmt.Sprintf("/uploads/messages/%d/%s", messageID, fileName) +} + +func attachmentThumbURL(contentType string, messageID uint, fileName string) string { + if strings.HasPrefix(strings.ToLower(contentType), "image/") { + return attachmentDataURL(messageID, fileName) + } + return "" +} + // Retry retries a failed message by resetting its delivery status. func (s *MessageService) Retry(ctx context.Context, accountID, id uint) (*model.Message, error) { message, err := s.repo.FindByAccountAndID(ctx, accountID, id) diff --git a/internal/service/message_service_test.go b/internal/service/message_service_test.go index 08473c5e..83a10a4a 100644 --- a/internal/service/message_service_test.go +++ b/internal/service/message_service_test.go @@ -393,13 +393,14 @@ func TestMessageService_Delete(t *testing.T) { } require.NoError(t, db.Create(msg).Error) - // 正常路径:软删除消息 - err := svc.Delete(ctx, account.ID, msg.ID) + // 正常路径:Chatwoot 删除会保留消息并标记 content_attributes.deleted + deleted, err := svc.Delete(ctx, account.ID, msg.ID) assert.NoError(t, err) + assert.Equal(t, "This message was deleted", deleted.Content) - // 验证已软删除 - _, err = svc.GetByAccountAndID(ctx, account.ID, msg.ID) - assert.Error(t, err) + stored, err := svc.GetByAccountAndID(ctx, account.ID, msg.ID) + assert.NoError(t, err) + assert.JSONEq(t, `{"deleted":true}`, string(stored.ContentAttributes)) // 错误路径:accountID不匹配 msg2 := &model.Message{ @@ -407,11 +408,11 @@ func TestMessageService_Delete(t *testing.T) { Content: "另一条消息", MessageType: "incoming", ContentType: "text", SenderType: "contact", } require.NoError(t, db.Create(msg2).Error) - err = svc.Delete(ctx, 9999, msg2.ID) + _, err = svc.Delete(ctx, 9999, msg2.ID) assert.Error(t, err) // 错误路径:ID不存在 - err = svc.Delete(ctx, account.ID, 9999) + _, err = svc.Delete(ctx, account.ID, 9999) assert.Error(t, err) } @@ -621,4 +622,4 @@ func TestMessageService_Retry(t *testing.T) { _, err := svc.Retry(ctx, 9999, msg.ID) assert.Error(t, err) }) -} \ No newline at end of file +} diff --git a/internal/service/search_indexer_hooks_test.go b/internal/service/search_indexer_hooks_test.go index f095ce16..21dbc3f8 100644 --- a/internal/service/search_indexer_hooks_test.go +++ b/internal/service/search_indexer_hooks_test.go @@ -100,7 +100,8 @@ func TestMessageService_SearchIndexHooks(t *testing.T) { require.NoError(t, err) _, err = svc.Update(context.Background(), account.ID, message.ID, UpdateMessageRequest{Content: "updated"}) require.NoError(t, err) - require.NoError(t, svc.Delete(context.Background(), account.ID, message.ID)) + _, err = svc.Delete(context.Background(), account.ID, message.ID) + require.NoError(t, err) assert.Equal(t, []string{"message", "message"}, indexer.indexed) assert.Equal(t, []string{"message"}, indexer.deleted) diff --git a/internal/service/service_test_helper.go b/internal/service/service_test_helper.go index 5de0b8c7..c6c16b09 100644 --- a/internal/service/service_test_helper.go +++ b/internal/service/service_test_helper.go @@ -53,6 +53,7 @@ func setupServiceTestDB(t *testing.T) *gorm.DB { &model.Conversation{}, &model.ConversationParticipant{}, &model.Message{}, + &model.Attachment{}, &model.InboxMember{}, &model.Notification{}, &model.NotificationPreference{}, @@ -372,4 +373,4 @@ func createTestMessage(t *testing.T, db *gorm.DB, accountID, inboxID, conversati func skipIfSQLite(t *testing.T) { t.Helper() t.Skip("Skipping: this test requires PostgreSQL (ILIKE / trigram / pgvector etc.)") -} \ No newline at end of file +}