feat(conversations): finish message mutation parity

This commit is contained in:
2026-06-05 02:10:42 +08:00
parent 5c44ca0c50
commit a465bbe09d
10 changed files with 420 additions and 42 deletions
@@ -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)
}
@@ -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() {
@@ -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
+22 -6
View File
@@ -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
}
+129 -5
View File
@@ -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() {
+44 -1
View File
@@ -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
}
}
+139 -16
View File
@@ -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)
+9 -8
View File
@@ -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)
})
}
}
@@ -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)
+2 -1
View File
@@ -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.)")
}
}