package service import ( "context" "crypto/sha256" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" "github.com/gochat/gochat/internal/worker" "github.com/gochat/gochat/internal/wsevent" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) // MessageService implements business logic for Message operations. // Reference: Chatwoot app/controllers/api/v1/messages_controller.rb type MessageService struct { repo *repository.MessageRepo dispatcher *channel.Dispatcher searchIndexer SearchIndexer llmProvider llm.Provider worker *worker.WorkerPool realtime *wsevent.BridgeListener } // NewMessageService creates a new Message service. func NewMessageService(repo *repository.MessageRepo, dispatcher *channel.Dispatcher, llmProvider llm.Provider) *MessageService { return &MessageService{repo: repo, dispatcher: dispatcher, llmProvider: llmProvider} } func (s *MessageService) SetSearchIndexer(indexer SearchIndexer) { s.searchIndexer = indexer RegisterMessageDeliverySearchIndexer(s.worker, s.repo.DB(), s.dispatcher, indexer) } func (s *MessageService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp RegisterMessageDeliverySearchIndexer(wp, s.repo.DB(), s.dispatcher, s.searchIndexer) } func (s *MessageService) SetRealtimeEventBridge(bridge *wsevent.BridgeListener) { s.realtime = bridge } func (s *MessageService) DB() *gorm.DB { if s == nil || s.repo == nil { return nil } return s.repo.DB() } func (s *MessageService) indexMessage(ctx context.Context, message *model.Message) { if s.searchIndexer != nil { logSearchIndexError("message", message.ID, s.searchIndexer.IndexMessage(ctx, message)) } } func (s *MessageService) deleteMessageIndex(ctx context.Context, accountID uint, id uint) { if s.searchIndexer != nil { logSearchIndexError("message", id, s.searchIndexer.DeleteMessage(ctx, accountID, id)) } } // dispatchMessageEvent is a helper to build and dispatch a message event. func (s *MessageService) dispatchMessageEvent(ctx context.Context, eventType channel.EventType, message *model.Message) error { event, err := s.buildMessageEvent(ctx, s.repo.DB(), eventType, message) if err != nil { return err } return s.dispatchPreparedMessageEvent(ctx, event, message.ID) } func (s *MessageService) buildMessageEvent(ctx context.Context, db *gorm.DB, eventType channel.EventType, message *model.Message) (*channel.ChannelEvent, error) { event := channel.NewChannelEvent(eventType, channel.ChannelAPI, message.AccountID, message.InboxID) event.ConversationID = message.ConversationID if message.SenderID != nil { event.UserID = *message.SenderID } event.Data["message"] = message if eventType == channel.EventMessageCreated || eventType == channel.EventMessageUpdated || eventType == channel.EventMessageDeleted { if err := s.addMessageEventContext(ctx, db, event, message); err != nil { return nil, fmt.Errorf("build %s event for message %d: %w", eventType, message.ID, err) } } return event, nil } func (s *MessageService) dispatchPreparedMessageEvent(ctx context.Context, event *channel.ChannelEvent, messageID uint) error { applogger.L().Infof("dispatching event %s for message %d", event.Type, messageID) if err := s.dispatcher.Dispatch(ctx, event); err != nil { return fmt.Errorf("dispatch event %s for message %d: %w", event.Type, messageID, err) } return nil } func (s *MessageService) addMessageEventContext(ctx context.Context, db *gorm.DB, event *channel.ChannelEvent, message *model.Message) error { var inbox model.Inbox if err := db.WithContext(ctx).Where("id = ? AND account_id = ?", message.InboxID, message.AccountID).First(&inbox).Error; err != nil { return fmt.Errorf("load inbox %d: %w", message.InboxID, err) } event.Data["inbox"] = &inbox isWebWidget := strings.EqualFold(strings.TrimSpace(inbox.ChannelType), string(channel.ChannelWebWidget)) || strings.EqualFold(strings.TrimSpace(inbox.ChannelType), string(model.InboxChannelTypeWebWidget)) if isWebWidget { event.Channel = channel.ChannelWebWidget } var conversation model.Conversation if err := db.WithContext(ctx).Where("id = ? AND account_id = ?", message.ConversationID, message.AccountID).First(&conversation).Error; err != nil { return fmt.Errorf("load conversation %d: %w", message.ConversationID, err) } event.ContactID = conversation.ContactID event.Data["conversation"] = &conversation widgetVisible := isWebWidget && !message.Private && message.MessageType != string(model.MessageTypeActivity) var contact model.Contact if err := db.WithContext(ctx).Where("id = ? AND account_id = ?", conversation.ContactID, message.AccountID).First(&contact).Error; err != nil { return fmt.Errorf("load contact %d: %w", conversation.ContactID, err) } event.Data["contact"] = &contact if message.SenderID != nil { switch strings.ToLower(strings.TrimSpace(message.SenderType)) { case "user": var sender model.User if err := db.WithContext(ctx).First(&sender, *message.SenderID).Error; err != nil { if widgetVisible { return fmt.Errorf("load user sender %d: %w", *message.SenderID, err) } break } event.Data["sender"] = &sender case "agentbot", "agent_bot": var sender model.AgentBot if err := db.WithContext(ctx).Where("id = ? AND (account_id IS NULL OR account_id = ?)", *message.SenderID, message.AccountID).First(&sender).Error; err != nil { return fmt.Errorf("load agent bot sender %d: %w", *message.SenderID, err) } event.Data["sender"] = &sender case "captain::assistant", "captainassistant", "captain_assistant": var sender model.CaptainAssistant if err := db.WithContext(ctx).Where("id = ? AND account_id = ?", *message.SenderID, message.AccountID).First(&sender).Error; err != nil { return fmt.Errorf("load captain sender %d: %w", *message.SenderID, err) } event.Data["sender"] = &sender } } if !widgetVisible { return nil } if conversation.ContactInboxID == nil { return fmt.Errorf("web widget conversation %d has no contact inbox", conversation.ID) } var contactInbox model.ContactInbox if err := db.WithContext(ctx).Select("pubsub_token").Where( "id = ? AND contact_id = ? AND inbox_id = ?", *conversation.ContactInboxID, conversation.ContactID, conversation.InboxID, ).First(&contactInbox).Error; err != nil { return fmt.Errorf("load contact inbox %d: %w", *conversation.ContactInboxID, err) } if strings.TrimSpace(contactInbox.PubsubToken) == "" { return fmt.Errorf("contact inbox %d has no pubsub token", *conversation.ContactInboxID) } event.Data["widget_token"] = contactInbox.PubsubToken return nil } // ListByConversation retrieves all messages for a conversation. func (s *MessageService) ListByConversation(ctx context.Context, conversationID uint, offset, limit int) ([]model.Message, int64, error) { return s.repo.FindByConversation(ctx, conversationID, offset, limit) } // ResolveConversationForRoute resolves Chatwoot message route IDs exclusively as account-scoped display IDs. func (s *MessageService) ResolveConversationForRoute(ctx context.Context, accountID, displayID uint) (*model.Conversation, error) { var conversation model.Conversation if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND display_id = ?", accountID, displayID).First(&conversation).Error; err != nil { return nil, err } return &conversation, nil } // GetByID retrieves a single message. func (s *MessageService) GetByID(ctx context.Context, id uint) (*model.Message, error) { return s.repo.FindByID(ctx, id) } // GetByAccountAndID retrieves a message scoped to an account. func (s *MessageService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.Message, error) { return s.repo.FindByAccountAndID(ctx, accountID, id) } // GetByConversationAndID retrieves a message scoped to a conversation. func (s *MessageService) GetByConversationAndID(ctx context.Context, conversationID, id uint) (*model.Message, error) { return s.repo.FindByConversationAndID(ctx, conversationID, id) } // GetByAccountConversationAndID retrieves a message scoped to an account and conversation. func (s *MessageService) GetByAccountConversationAndID(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) { return s.repo.FindByAccountConversationAndID(ctx, accountID, conversationID, id) } // Search searches messages by content within an account. func (s *MessageService) Search(ctx context.Context, accountID uint, query string, offset, limit int, searchMode search.SearchMode) ([]model.Message, int64, error) { return s.repo.Search(ctx, accountID, query, offset, limit, searchMode) } // 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"` MessageType string `json:"message_type,omitempty"` ContentType string `json:"content_type,omitempty"` Private bool `json:"private,omitempty"` SenderID uint `json:"sender_id,omitempty"` SenderType string `json:"sender_type,omitempty"` SourceID string `json:"source_id,omitempty"` EchoID string `json:"echo_id,omitempty"` ExternalCreatedAt string `json:"external_created_at,omitempty"` External bool `json:"external,omitempty"` ContentAttributes datatypes.JSON `json:"content_attributes,omitempty"` AdditionalAttributes datatypes.JSON `json:"additional_attributes,omitempty"` ExternalSourceIDs datatypes.JSON `json:"external_source_ids,omitempty"` EmailHTMLContent string `json:"email_html_content,omitempty"` CCEmails string `json:"cc_emails,omitempty"` BCCEmails string `json:"bcc_emails,omitempty"` ToEmails string `json:"to_emails,omitempty"` CampaignID any `json:"campaign_id,omitempty"` TemplateParams datatypes.JSON `json:"template_params,omitempty"` IsVoiceMessage bool `json:"is_voice_message,omitempty"` Attachments []MessageAttachmentInput `json:"-"` ExpectedAITakeoverVersion uint `json:"-"` } type MessageAttachmentInput struct { FileName string FileSize int ContentType string SHA256 string } var ErrMessageIdempotencyConflict = errors.New("idempotency key is already associated with a different message payload") // Create creates a new message. func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint, req CreateMessageRequest) (*model.Message, error) { req.MessageType = normalizeMessageType(req.MessageType) if req.ContentType == "" { req.ContentType = "text" } if !validMessageType(req.MessageType) { return nil, fmt.Errorf("invalid message_type") } if !validContentType(req.ContentType) { return nil, fmt.Errorf("invalid content_type") } if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } if strings.TrimSpace(req.Content) == "" && len(req.Attachments) == 0 && !contentTypeAllowsEmptyContent(req.ContentType, req.ContentAttributes) { 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 { return nil, err } var inbox *model.Inbox loadInbox := func() *model.Inbox { if inbox != nil { return inbox } var loaded model.Inbox if err := s.repo.DB().WithContext(ctx).Select("id", "channel_type").Where("id = ?", conversation.InboxID).First(&loaded).Error; err == nil { inbox = &loaded if strings.TrimSpace(conversation.ChannelType) == "" { conversation.ChannelType = loaded.ChannelType } } return inbox } if req.MessageType == "incoming" && !messageCreateAllowedIncoming(conversation.ChannelType) { if loaded := loadInbox(); loaded != nil { conversation.ChannelType = loaded.ChannelType } if !messageCreateAllowedIncoming(conversation.ChannelType) { return nil, fmt.Errorf("Incoming messages are only allowed in Api inboxes") } } loadedInbox := loadInbox() requestHash, err := shangwutongMessageRequestHash(req) if err != nil { return nil, err } if requestHash != "" { if existing, found, err := s.findShangwutongMessageReplay(ctx, conversation.InboxID, req.SourceID, requestHash); err != nil { return nil, err } else if found { return existing, nil } } contentAttributes := messageContentAttributes(req.ContentAttributes) contentAttributes = mergeMessageContentAttributes(contentAttributes, map[string]any{ "external_created_at": strings.TrimSpace(req.ExternalCreatedAt), }) contentAttributes = s.resolveInReplyToContentAttributes(ctx, req.ConversationID, contentAttributes) if messageCreateEmailInbox(conversation.ChannelType) || (loadInbox() != nil && messageCreateEmailInbox(inbox.ChannelType)) { contentAttributes = mergeMessageContentAttributes(contentAttributes, map[string]any{ "cc_emails": parseEmailList(req.CCEmails), "bcc_emails": parseEmailList(req.BCCEmails), "to_emails": parseEmailList(req.ToEmails), }) if !req.Private && strings.TrimSpace(req.Content) != "" { contentAttributes = mergeEmailContentAttributes(contentAttributes, req.Content, req.EmailHTMLContent) } } var senderID *uint senderType := "" if req.External { if req.MessageType == "incoming" && conversation.ContactID != 0 { contactID := conversation.ContactID senderID, senderType = &contactID, string(model.SenderTypeContact) } } else { id := userID senderID, senderType = &id, "user" } if !req.External && strings.TrimSpace(req.SenderType) == string(model.SenderTypeAgentBot) && req.SenderID != 0 { var bot model.AgentBot if err := s.repo.DB().WithContext(ctx). Where("id = ? AND (account_id IS NULL OR account_id = ?)", req.SenderID, accountID). First(&bot).Error; err != nil { return nil, errors.New("agent bot not found") } if conversation.AssigneeAgentBotID == nil || *conversation.AssigneeAgentBotID != bot.ID { return nil, errors.New("agent bot is not assigned to this conversation") } id := bot.ID senderID = &id senderType = string(model.SenderTypeAgentBot) } if !req.External && strings.TrimSpace(req.SenderType) == "Captain::Assistant" && req.SenderID != 0 { var assistant model.CaptainAssistant if err := s.repo.DB().WithContext(ctx).Where("id = ? AND account_id = ?", req.SenderID, accountID).First(&assistant).Error; err == nil { id := assistant.ID senderID = &id senderType = "Captain::Assistant" } } externalSourceIDs := messageContentAttributes(req.ExternalSourceIDs) additionalAttributes := messageContentAttributes(req.AdditionalAttributes) additionalAttributes = mergeMessageContentAttributes(additionalAttributes, map[string]any{ "campaign_id": req.CampaignID, "template_params": req.TemplateParams, }) queueShangwutong := loadedInbox != nil && loadedInbox.ChannelType == "shangwutong" && !req.External && (req.MessageType == "outgoing" || req.MessageType == "template") && !req.Private status := string(model.MessageStatusSent) if queueShangwutong { status = string(model.MessageStatusProgress) } message := &model.Message{ AccountID: accountID, ConversationID: req.ConversationID, InboxID: conversation.InboxID, Content: req.Content, MessageType: req.MessageType, ContentType: req.ContentType, SenderID: senderID, SenderType: senderType, Private: req.Private, SourceID: req.SourceID, EchoID: req.EchoID, Status: status, External: req.External, ContentAttributes: contentAttributes, AdditionalAttributes: additionalAttributes, ExternalSourceIDs: externalSourceIDs, ExternalRequestHash: requestHash, } // Chatwoot: when message_type is "private_note", force Private=true and ContentType="private_note" if req.MessageType == "private_note" { message.Private = true if message.ContentType == "text" { message.ContentType = "private_note" } } var deliveryJob *model.BackgroundJob var deliveryCreated bool var realtimeJobs []*model.BackgroundJob var messageCreatedEvent *channel.ChannelEvent if err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if req.ExpectedAITakeoverVersion != 0 { var active model.Conversation if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ? AND account_id = ? AND status = ? AND assignee_agent_bot_id IS NOT NULL AND ai_takeover_version = ?", message.ConversationID, accountID, model.ConversationStatusPending, req.ExpectedAITakeoverVersion). First(&active).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errAITakeoverEnded } return err } if active.ID == 0 { return errAITakeoverEnded } } humanOutgoing := !req.External && !message.Private && (message.MessageType == string(model.MessageTypeOutgoing) || message.MessageType == string(model.MessageTypeTemplate)) && message.SenderType != string(model.SenderTypeAgentBot) && message.SenderType != "Captain::Assistant" if humanOutgoing { result := tx.Model(&model.Conversation{}). Where("id = ? AND account_id = ? AND assignee_agent_bot_id IS NOT NULL", message.ConversationID, accountID). Updates(map[string]any{ "assignee_agent_bot_id": nil, "status": gorm.Expr("CASE WHEN status = ? THEN ? ELSE status END", model.ConversationStatusPending, model.ConversationStatusOpen), "ai_takeover_version": gorm.Expr("ai_takeover_version + 1"), }) if result.Error != nil { return result.Error } message.AITakeoverExited = result.RowsAffected > 0 } if err := tx.Create(message).Error; err != nil { return err } messageTimestamp := message.CreatedAt.Unix() conversationUpdates := map[string]any{ "last_activity_at": messageTimestamp, "last_message_at": messageTimestamp, } if message.MessageType != "activity" { conversationUpdates["last_non_sys_msg_at"] = messageTimestamp } if err := tx.Model(&model.Conversation{}). Where("id = ? AND account_id = ?", message.ConversationID, accountID). Updates(conversationUpdates).Error; err != nil { return fmt.Errorf("update conversation message timestamps: %w", err) } if message.AITakeoverExited { if err := tx.Where("id = ? AND account_id = ?", message.ConversationID, accountID).First(&conversation).Error; err != nil { return err } } for _, input := range req.Attachments { fileType := attachmentFileType(input.ContentType) attachment := &model.Attachment{ MessageID: message.ID, AccountID: accountID, FileType: fileType, FileURL: attachmentDataURL(message.ID, input.FileName), ThumbURL: attachmentThumbURL(input.ContentType, message.ID, input.FileName), FileSize: input.FileSize, FileName: input.FileName, } if req.IsVoiceMessage && fileType == "audio" { attachment.Metadata = `{"is_voice_message":true}` } createAttachment := tx if attachment.Metadata == "" { createAttachment = tx.Omit("Metadata") } if err := createAttachment.Create(attachment).Error; err != nil { return err } message.Attachments = append(message.Attachments, *attachment) } if queueShangwutong && s.worker != nil { var err error deliveryJob, deliveryCreated, err = s.worker.EnqueueInTransaction( ctx, tx, TaskTypeShangwutongWebhookDelivery, newShangwutongMessageJob("message_created", message, 0), worker.WithMaxAttempts(10), worker.WithIdempotencyKey(fmt.Sprintf("api-inbox-message:%d:created", message.ID)), ) if err != nil { return err } } if s.realtime != nil { messageCreatedEvent, err = s.buildMessageEvent(ctx, tx, channel.EventMessageCreated, message) if err != nil { return err } realtimeJobs, err = s.realtime.EnqueueInTransaction(ctx, tx, messageCreatedEvent) if err != nil { return err } } return nil }); err != nil { if errors.Is(err, errAITakeoverEnded) { return nil, nil } if requestHash != "" { if existing, found, replayErr := s.findShangwutongMessageReplay(ctx, conversation.InboxID, req.SourceID, requestHash); replayErr == nil && found { return existing, nil } } applogger.L().Errorf("Failed to create message: %v", err) return nil, err } if deliveryCreated { s.worker.Publish(ctx, deliveryJob) } if s.realtime != nil { s.realtime.PublishEnqueued(ctx, realtimeJobs) } if message.AITakeoverExited { event := channel.NewChannelEvent(channel.EventConversationUpdated, channel.ChannelType(conversation.ChannelType), conversation.AccountID, conversation.InboxID) event.ConversationID = conversation.ID event.ContactID = conversation.ContactID event.Data["conversation"] = &conversation if err := s.dispatcher.Dispatch(ctx, event); err != nil { applogger.L().Errorf("failed to dispatch event %s for conversation %d: %v", channel.EventConversationUpdated, conversation.ID, err) } } // Dispatch EventMessageCreated var dispatchErr error if messageCreatedEvent != nil { dispatchErr = s.dispatchPreparedMessageEvent(ctx, messageCreatedEvent, message.ID) } else { dispatchErr = s.dispatchMessageEvent(ctx, channel.EventMessageCreated, message) } if dispatchErr != nil { return message, dispatchErr } s.indexMessage(ctx, message) // Dispatch additional event based on message type if req.MessageType == "incoming" { if err := s.dispatchMessageEvent(ctx, channel.EventMessageIncoming, message); err != nil { return message, err } if s.worker != nil { if _, err := EnqueueCaptainConversationResponseForMessage(ctx, s.worker, s.repo.DB(), message.ID); err != nil { return message, err } } } else if req.MessageType == "outgoing" || req.MessageType == "template" { if req.External || queueShangwutong { // Imported messages and Shangwutong messages use their dedicated external flow. } else if s.worker != nil { if _, err := EnqueueSendReply(ctx, s.worker, message.ID); err != nil { return message, err } } else { if err := s.dispatchMessageEvent(ctx, channel.EventMessageOutgoing, message); err != nil { return message, err } } } return message, nil } var errAITakeoverEnded = errors.New("AI takeover ended") func messageCreateAllowedIncoming(channelType string) bool { return channel.IsAPIInboxLike(channelType) } func messageCreateEmailInbox(channelType string) bool { switch strings.TrimSpace(channelType) { case "email", string(model.InboxChannelTypeEmail): return true default: return false } } func messageContentAttributes(raw datatypes.JSON) datatypes.JSON { if len(raw) == 0 || string(raw) == "null" { return nil } attrs := map[string]any{} if err := json.Unmarshal(raw, &attrs); err != nil { var encoded string if stringErr := json.Unmarshal(raw, &encoded); stringErr != nil || strings.TrimSpace(encoded) == "" { return nil } if err := json.Unmarshal([]byte(encoded), &attrs); err != nil { return nil } } if len(attrs) == 0 { return nil } encoded, err := json.Marshal(attrs) if err != nil { return nil } return datatypes.JSON(encoded) } func mergeMessageContentAttributes(raw datatypes.JSON, values map[string]any) datatypes.JSON { attrs := map[string]any{} if len(raw) > 0 && string(raw) != "null" { _ = json.Unmarshal(raw, &attrs) } for key, value := range values { if list, ok := value.([]string); ok && len(list) == 0 { continue } attrs[key] = value } if len(attrs) == 0 { return nil } encoded, err := json.Marshal(attrs) if err != nil { return nil } return datatypes.JSON(encoded) } func mergeEmailContentAttributes(raw datatypes.JSON, content string, htmlContent string) datatypes.JSON { attrs := map[string]any{} if len(raw) > 0 && string(raw) != "null" { _ = json.Unmarshal(raw, &attrs) } emailAttrs := map[string]any{} if existing, ok := attrs["email"].(map[string]any); ok { emailAttrs = existing } textAttrs := map[string]any{} if existing, ok := emailAttrs["text_content"].(map[string]any); ok { textAttrs = existing } textAttrs["full"] = content textAttrs["reply"] = content emailAttrs["text_content"] = textAttrs renderedHTML := strings.TrimSpace(htmlContent) if renderedHTML == "" { renderedHTML = content } htmlAttrs := map[string]any{} if existing, ok := emailAttrs["html_content"].(map[string]any); ok { htmlAttrs = existing } htmlAttrs["full"] = renderedHTML htmlAttrs["reply"] = renderedHTML emailAttrs["html_content"] = htmlAttrs attrs["email"] = emailAttrs encoded, err := json.Marshal(attrs) if err != nil { return raw } return datatypes.JSON(encoded) } func (s *MessageService) resolveInReplyToContentAttributes(ctx context.Context, conversationID uint, raw datatypes.JSON) datatypes.JSON { attrs := map[string]any{} if len(raw) > 0 && string(raw) != "null" { _ = json.Unmarshal(raw, &attrs) } if _, hasReplyID := attrs["in_reply_to"]; !hasReplyID { if _, hasExternalID := attrs["in_reply_to_external_id"]; !hasExternalID { return raw } } var replyMessage model.Message found := false if replyID, ok := uintFromAny(attrs["in_reply_to"]); ok && replyID != 0 { if err := s.repo.DB().WithContext(ctx).Where("conversation_id = ? AND id = ?", conversationID, replyID).First(&replyMessage).Error; err == nil { found = true } } if !found { if externalID, ok := stringFromAny(attrs["in_reply_to_external_id"]); ok && strings.TrimSpace(externalID) != "" { if err := s.repo.DB().WithContext(ctx).Where("conversation_id = ? AND source_id = ?", conversationID, strings.TrimSpace(externalID)).First(&replyMessage).Error; err == nil { found = true } } } if found { attrs["in_reply_to"] = replyMessage.ID attrs["in_reply_to_external_id"] = replyMessage.SourceID } else { attrs["in_reply_to"] = nil attrs["in_reply_to_external_id"] = nil } encoded, err := json.Marshal(attrs) if err != nil { return raw } return datatypes.JSON(encoded) } func uintFromAny(value any) (uint, bool) { switch typed := value.(type) { case float64: if typed > 0 && typed == float64(uint(typed)) { return uint(typed), true } case int: if typed > 0 { return uint(typed), true } case uint: if typed > 0 { return typed, true } case string: parsed, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64) if err == nil && parsed > 0 { return uint(parsed), true } } return 0, false } func stringFromAny(value any) (string, bool) { if value == nil { return "", false } if typed, ok := value.(string); ok { return typed, true } return fmt.Sprint(value), true } func parseEmailList(value string) []string { if strings.TrimSpace(value) == "" { return nil } parts := strings.Split(strings.ReplaceAll(value, " ", ""), ",") emails := make([]string, 0, len(parts)) for _, part := range parts { if part != "" { emails = append(emails, part) } } return emails } func shangwutongMessageRequestHash(req CreateMessageRequest) (string, error) { if !strings.HasPrefix(strings.TrimSpace(req.SourceID), "swt:") { return "", nil } contentAttributes, err := canonicalJSONValue(req.ContentAttributes) if err != nil { return "", fmt.Errorf("invalid content_attributes: %w", err) } additionalAttributes, err := canonicalJSONValue(req.AdditionalAttributes) if err != nil { return "", fmt.Errorf("invalid additional_attributes: %w", err) } externalSourceIDs, err := canonicalJSONValue(req.ExternalSourceIDs) if err != nil { return "", fmt.Errorf("invalid external_source_ids: %w", err) } attachments := make([]map[string]any, 0, len(req.Attachments)) for _, attachment := range req.Attachments { attachments = append(attachments, map[string]any{ "content_type": strings.TrimSpace(attachment.ContentType), "file_name": strings.TrimSpace(attachment.FileName), "file_size": attachment.FileSize, "sha256": strings.ToLower(strings.TrimSpace(attachment.SHA256)), }) } payload := struct { MessageType string `json:"message_type"` ContentType string `json:"content_type"` Content string `json:"content"` Private bool `json:"private"` SourceID string `json:"source_id"` External bool `json:"external"` ExternalCreatedAt string `json:"external_created_at"` ExternalSourceIDs any `json:"external_source_ids"` ContentAttributes any `json:"content_attributes"` AdditionalAttributes any `json:"additional_attributes"` Attachments []map[string]any `json:"attachments"` }{ MessageType: req.MessageType, ContentType: req.ContentType, Content: req.Content, Private: req.Private, SourceID: strings.TrimSpace(req.SourceID), External: req.External, ExternalCreatedAt: strings.TrimSpace(req.ExternalCreatedAt), ExternalSourceIDs: externalSourceIDs, ContentAttributes: contentAttributes, AdditionalAttributes: additionalAttributes, Attachments: attachments, } encoded, err := json.Marshal(payload) if err != nil { return "", err } hash := sha256.Sum256(encoded) return fmt.Sprintf("%x", hash[:]), nil } func canonicalJSONValue(raw datatypes.JSON) (any, error) { if len(raw) == 0 || string(raw) == "null" || strings.TrimSpace(string(raw)) == "" { return nil, nil } var value any if err := json.Unmarshal(raw, &value); err != nil { return nil, err } if encoded, ok := value.(string); ok { if err := json.Unmarshal([]byte(encoded), &value); err != nil { return nil, err } } if value == nil { return nil, nil } if _, ok := value.(map[string]any); !ok { return nil, errors.New("must be a JSON object") } return value, nil } func (s *MessageService) findShangwutongMessageReplay(ctx context.Context, inboxID uint, sourceID, requestHash string) (*model.Message, bool, error) { var existing model.Message err := s.repo.DB().WithContext(ctx).Where("inbox_id = ? AND source_id = ?", inboxID, sourceID).First(&existing).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, nil } if err != nil { return nil, false, err } if existing.ExternalRequestHash == "" || existing.ExternalRequestHash != requestHash { return nil, false, ErrMessageIdempotencyConflict } existing.IdempotentReplay = true return &existing, true, nil } func normalizeMessageType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", "1", "outgoing": return "outgoing" case "0", "incoming": return "incoming" case "2", "activity": return "activity" case "3", "template": return "template" case "private_note": return "private_note" default: return value } } func validMessageType(value string) bool { switch value { case "incoming", "outgoing", "activity", "template", "private_note": return true default: return false } } func validContentType(value string) bool { switch value { case "text", "input_text", "input_textarea", "input_email", "input_select", "cards", "form", "article", "incoming_email", "input_csat", "integrations", "sticker", "voice_call", "input_phone", "select", "card", "private_note", "file", "image", "audio", "video": return true default: return false } } func contentTypeAllowsEmptyContent(contentType string, contentAttributes datatypes.JSON) bool { switch contentType { case "input_text", "input_textarea", "input_email", "input_select", "cards", "form", "article", "input_csat", "integrations", "sticker", "voice_call", "select", "card": return len(messageContentAttributes(contentAttributes)) > 0 default: return false } } // UpdateMessageRequest is the DTO for updating a message. type UpdateMessageRequest struct { Status string `json:"status,omitempty"` ExternalError string `json:"external_error,omitempty"` } // Update modifies an existing message. func (s *MessageService) Update(ctx context.Context, accountID, id uint, req UpdateMessageRequest) (*model.Message, error) { return s.UpdateInConversation(ctx, accountID, 0, id, req) } // UpdateInConversation modifies a message scoped to a conversation route. func (s *MessageService) UpdateInConversation(ctx context.Context, accountID, conversationID, id uint, req UpdateMessageRequest) (*model.Message, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id) if err != nil { return nil, err } if !s.messageInboxIsAPI(ctx, message.InboxID) { return nil, fmt.Errorf("Message status update is only allowed for API inboxes") } if req.Status != "" { if !validMessageStatus(req.Status) { return nil, fmt.Errorf("invalid status") } if !(message.Status == "read" && req.Status == "delivered") { 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 } // Dispatch EventMessageUpdated if err := s.dispatchMessageEvent(ctx, channel.EventMessageUpdated, message); err != nil { return message, err } s.indexMessage(ctx, message) return message, nil } 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 } return channel.IsAPIInboxLike(inbox.ChannelType) } // Delete marks a message deleted using Chatwoot's visible tombstone payload. func (s *MessageService) Delete(ctx context.Context, accountID, id uint) (*model.Message, error) { return s.DeleteInConversation(ctx, accountID, 0, id) } // DeleteInConversation marks a message deleted using Chatwoot's conversation-scoped lookup. func (s *MessageService) DeleteInConversation(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) { message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id) if err != nil { return nil, 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 if err := s.dispatchMessageEvent(ctx, channel.EventMessageDeleted, message); err != nil { return message, err } s.deleteMessageIndex(ctx, accountID, message.ID) return message, nil } // UpdateStatus updates the delivery status of a message and dispatches EventMessageStatusUpdated. func (s *MessageService) UpdateStatus(ctx context.Context, id uint, status string) (*model.Message, error) { message, err := s.repo.FindByID(ctx, id) if err != nil { return nil, err } message.Status = status if err := s.repo.Update(ctx, message); err != nil { return nil, err } s.dispatchMessageStatus(ctx, message, status) s.indexMessage(ctx, message) return message, nil } type ShangwutongMessageResult struct { ResultVersion int64 Status string ExternalID *string ExternalIDs []string ErrorCode *string ErrorMessage *string OccurredAt time.Time } var ( ErrShangwutongMessageResultConflict = errors.New("shangwutong message result conflicts with the stored result") ErrShangwutongMessageNotEligible = errors.New("message is not eligible for shangwutong result updates") ) func (s *MessageService) ApplyShangwutongMessageResult(ctx context.Context, accountID, inboxID, messageID uint, result ShangwutongMessageResult) (*model.Message, bool, error) { hash, err := shangwutongResultHash(result) if err != nil { return nil, false, err } var message model.Message applied := false err = s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where( "id = ? AND account_id = ? AND inbox_id = ?", messageID, accountID, inboxID, ).First(&message).Error; err != nil { return err } if message.External || message.Private || (message.MessageType != "outgoing" && message.MessageType != "template") { return ErrShangwutongMessageNotEligible } attrs := webhookJSONObject(message.ContentAttributes) delivery, _ := attrs["swt_delivery"].(map[string]any) if delivery == nil { delivery = map[string]any{} } currentVersion := int64ValueFromJSON(delivery["result_version"]) if result.ResultVersion < currentVersion { return nil } if result.ResultVersion == currentVersion && currentVersion > 0 { if storedHash, _ := delivery["request_hash"].(string); storedHash != hash { return ErrShangwutongMessageResultConflict } return nil } previousCode, _ := delivery["error_code"].(string) if message.Status == string(model.MessageStatusSent) && result.Status != string(model.MessageStatusSent) { return ErrShangwutongMessageResultConflict } if message.Status == string(model.MessageStatusFailed) && result.Status == string(model.MessageStatusSent) && previousCode != "uncertain_timeout" { return ErrShangwutongMessageResultConflict } delivery = map[string]any{ "result_version": result.ResultVersion, "request_hash": hash, "status": result.Status, "occurred_at": result.OccurredAt.UTC().Format(time.RFC3339Nano), } if result.ErrorCode != nil { delivery["error_code"] = strings.TrimSpace(*result.ErrorCode) } if result.ErrorMessage != nil { delivery["error_message"] = strings.TrimSpace(*result.ErrorMessage) } attrs["swt_delivery"] = delivery switch result.Status { case string(model.MessageStatusSent): if message.Status != string(model.MessageStatusDelivered) && message.Status != string(model.MessageStatusRead) { message.Status = string(model.MessageStatusSent) } delete(attrs, "external_error") delete(attrs, "external_delivery_state") case string(model.MessageStatusFailed): message.Status = string(model.MessageStatusFailed) errorText := "shangwutong delivery failed" if result.ErrorMessage != nil && strings.TrimSpace(*result.ErrorMessage) != "" { errorText = strings.TrimSpace(*result.ErrorMessage) } else if result.ErrorCode != nil && strings.TrimSpace(*result.ErrorCode) != "" { errorText = strings.TrimSpace(*result.ErrorCode) } attrs["external_error"] = errorText delete(attrs, "external_delivery_state") case "uncertain": message.Status = string(model.MessageStatusProgress) attrs["external_delivery_state"] = "uncertain" delete(attrs, "external_error") default: return errors.New("invalid shangwutong result status") } if result.ExternalID != nil { externalIDs := webhookJSONObject(message.ExternalSourceIDs) externalIDs["shangwutong"] = mergeShangwutongExternalID( externalIDs["shangwutong"], strings.TrimSpace(*result.ExternalID), ) message.ExternalSourceIDs, _ = json.Marshal(externalIDs) } if len(result.ExternalIDs) > 0 { externalIDs := webhookJSONObject(message.ExternalSourceIDs) for _, externalID := range result.ExternalIDs { externalIDs["shangwutong"] = mergeShangwutongExternalID( externalIDs["shangwutong"], strings.TrimSpace(externalID), ) } message.ExternalSourceIDs, _ = json.Marshal(externalIDs) } message.ContentAttributes, _ = json.Marshal(attrs) if err := tx.Save(&message).Error; err != nil { return err } applied = true return nil }) if err != nil { return nil, false, err } if applied { s.dispatchMessageStatus(ctx, &message, message.Status) s.indexMessage(ctx, &message) } return &message, applied, nil } func mergeShangwutongExternalID(current any, externalID string) any { if externalID == "" { return current } values := make([]string, 0, 2) appendUnique := func(value string) { value = strings.TrimSpace(value) if value == "" { return } for _, existing := range values { if existing == value { return } } values = append(values, value) } switch typed := current.(type) { case string: appendUnique(typed) case []any: for _, value := range typed { if text, ok := value.(string); ok { appendUnique(text) } } case []string: for _, value := range typed { appendUnique(value) } } appendUnique(externalID) if len(values) == 1 { return values[0] } return values } func shangwutongResultHash(result ShangwutongMessageResult) (string, error) { payload := struct { ResultVersion int64 `json:"result_version"` Status string `json:"status"` ExternalID *string `json:"external_id"` ExternalIDs []string `json:"external_ids,omitempty"` ErrorCode *string `json:"error_code"` ErrorMessage *string `json:"error_message"` OccurredAt string `json:"occurred_at"` }{ ResultVersion: result.ResultVersion, Status: result.Status, ExternalID: result.ExternalID, ExternalIDs: result.ExternalIDs, ErrorCode: result.ErrorCode, ErrorMessage: result.ErrorMessage, OccurredAt: result.OccurredAt.UTC().Format(time.RFC3339Nano), } encoded, err := json.Marshal(payload) if err != nil { return "", err } hash := sha256.Sum256(encoded) return fmt.Sprintf("%x", hash[:]), nil } func (s *MessageService) dispatchMessageStatus(ctx context.Context, message *model.Message, status string) { event := channel.NewChannelEvent(channel.EventMessageStatusUpdated, channel.ChannelAPI, message.AccountID, message.InboxID) event.ConversationID = message.ConversationID if message.SenderID != nil { event.UserID = *message.SenderID } event.Data["message_id"] = message.ID event.Data["status"] = status applogger.L().Infof("dispatching event %s for message %d, status=%s", channel.EventMessageStatusUpdated, message.ID, status) if err := s.dispatcher.Dispatch(ctx, event); err != nil { applogger.L().Errorf("failed to dispatch event %s for message %d: %v", channel.EventMessageStatusUpdated, message.ID, err) } } 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 "progress", "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. // Reference: Chatwoot MessagesController#retry sets status to sent, clears // content_attributes, and queues SendReplyJob. func (s *MessageService) Retry(ctx context.Context, accountID, id uint) (*model.Message, error) { return s.RetryInConversation(ctx, accountID, 0, id) } // RetryInConversation retries a failed message scoped to a conversation route. func (s *MessageService) RetryInConversation(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) { message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id) if err != nil { return nil, err } var inbox model.Inbox if err := s.repo.DB().WithContext(ctx).Select("id", "channel_type").First(&inbox, message.InboxID).Error; err != nil { return nil, err } if strings.EqualFold(inbox.ChannelType, "shangwutong") { return s.retryShangwutongMessage(ctx, message) } message.Status = "sent" message.ContentAttributes = datatypes.JSON([]byte(`{}`)) if err := s.repo.Update(ctx, message); err != nil { return nil, err } if s.worker != nil { if _, err := EnqueueSendReply(ctx, s.worker, message.ID); err != nil { return nil, err } } event := channel.NewChannelEvent(channel.EventMessageStatusUpdated, channel.ChannelAPI, message.AccountID, message.InboxID) event.ConversationID = message.ConversationID if message.SenderID != nil { event.UserID = *message.SenderID } event.Data["message_id"] = message.ID event.Data["status"] = "sent" applogger.L().Infof("dispatching retry event for message %d", message.ID) if err := s.dispatcher.Dispatch(ctx, event); err != nil { applogger.L().Errorf("failed to dispatch retry event for message %d: %v", message.ID, err) } s.indexMessage(ctx, message) return message, nil } func (s *MessageService) retryShangwutongMessage(ctx context.Context, message *model.Message) (*model.Message, error) { if message.Status != string(model.MessageStatusFailed) || message.External || message.Private || (message.MessageType != "outgoing" && message.MessageType != "template") { return nil, errors.New("only failed non-external shangwutong outgoing messages can be retried") } attrs := webhookJSONObject(message.ContentAttributes) retryVersion := int64ValueFromJSON(attrs["external_retry_version"]) + 1 attrs["external_retry_version"] = retryVersion delete(attrs, "external_error") delete(attrs, "external_delivery_state") encoded, _ := json.Marshal(attrs) message.Status = string(model.MessageStatusProgress) message.ContentAttributes = datatypes.JSON(encoded) var job *model.BackgroundJob var created bool err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Save(message).Error; err != nil { return err } if s.worker == nil { return errors.New("shangwutong webhook worker is unavailable") } var err error job, created, err = s.worker.EnqueueInTransaction( ctx, tx, TaskTypeShangwutongWebhookDelivery, newShangwutongMessageJob("message_retry_requested", message, retryVersion), worker.WithMaxAttempts(10), worker.WithIdempotencyKey(fmt.Sprintf("api-inbox-message:%d:retry:%d", message.ID, retryVersion)), ) return err }) if err != nil { return nil, err } if created { s.worker.Publish(ctx, job) } s.dispatchMessageStatus(ctx, message, message.Status) s.indexMessage(ctx, message) return message, nil } func int64ValueFromJSON(value any) int64 { switch typed := value.(type) { case float64: return int64(typed) case int64: return typed case int: return int64(typed) case json.Number: result, _ := typed.Int64() return result default: return 0 } } func (s *MessageService) findMessageForConversationRoute(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) { if conversationID == 0 { return s.repo.FindByAccountAndID(ctx, accountID, id) } return s.repo.FindByAccountConversationAndID(ctx, accountID, conversationID, id) } // CountByConversation returns the total message count in a conversation. func (s *MessageService) CountByConversation(ctx context.Context, conversationID uint) (int64, error) { return s.repo.CountByConversation(ctx, conversationID) } // TranslateMessageRequest is the DTO for translating a message. type TranslateMessageRequest struct { TargetLanguage string `json:"target_language" validate:"required"` } // TranslateMessageResult holds the translated message content. type TranslateMessageResult struct { ID uint `json:"id"` OriginalContent string `json:"original_content"` TranslatedContent string `json:"translated_content"` SourceLanguage string `json:"source_language,omitempty"` TargetLanguage string `json:"target_language"` AlreadyTranslated bool `json:"-"` } // Translate translates a message's content to the target language using LLM. func (s *MessageService) Translate(ctx context.Context, accountID, id uint, req TranslateMessageRequest) (*TranslateMessageResult, error) { return s.TranslateInConversation(ctx, accountID, 0, id, req) } // TranslateInConversation translates a message scoped to a conversation route and caches the result. func (s *MessageService) TranslateInConversation(ctx context.Context, accountID, conversationID, id uint, req TranslateMessageRequest) (*TranslateMessageResult, error) { ctx = llm.WithAccountFeature(ctx, accountID, "copilot") if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id) if err != nil { return nil, err } if translated, ok := messageTranslationForLanguage(message.ContentAttributes, req.TargetLanguage); ok { return &TranslateMessageResult{ ID: message.ID, OriginalContent: message.Content, TranslatedContent: translated, TargetLanguage: req.TargetLanguage, AlreadyTranslated: true, }, nil } llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ Messages: []llm.ChatMessage{ {Role: "system", Content: fmt.Sprintf("You are a translator. Translate the user's message to %s. Return only the translated text, nothing else.", req.TargetLanguage)}, {Role: "user", Content: message.Content}, }, }) if err != nil { applogger.L().Errorf("Translate message LLM: %v", err) return nil, fmt.Errorf("translate message: %w", err) } translated := "" if len(llmResp.Choices) > 0 { translated = llmResp.Choices[0].Message.Content } if strings.TrimSpace(translated) != "" { message.ContentAttributes = setMessageTranslation(message.ContentAttributes, req.TargetLanguage, translated) if err := s.repo.Update(ctx, message); err != nil { return nil, err } s.indexMessage(ctx, message) } return &TranslateMessageResult{ ID: message.ID, OriginalContent: message.Content, TranslatedContent: translated, TargetLanguage: req.TargetLanguage, }, nil } func messageTranslationForLanguage(attrs datatypes.JSON, language string) (string, bool) { language = strings.TrimSpace(language) if language == "" { return "", false } obj := map[string]any{} if len(attrs) == 0 || string(attrs) == "null" { return "", false } if err := json.Unmarshal(attrs, &obj); err != nil { return "", false } translations, ok := obj["translations"].(map[string]any) if !ok { return "", false } value, ok := translations[language].(string) return value, ok && strings.TrimSpace(value) != "" } func setMessageTranslation(attrs datatypes.JSON, language string, translated string) datatypes.JSON { obj := map[string]any{} if len(attrs) > 0 && string(attrs) != "null" { _ = json.Unmarshal(attrs, &obj) } translations := map[string]any{} if existing, ok := obj["translations"].(map[string]any); ok { translations = existing } translations[strings.TrimSpace(language)] = translated obj["translations"] = translations encoded, err := json.Marshal(obj) if err != nil { return attrs } return datatypes.JSON(encoded) } // ListAttachments returns paginated attachments for all messages in a conversation. // Reference: Chatwoot conversations_controller.rb #attachments (member route) func (s *MessageService) ListAttachments(ctx context.Context, accountID, conversationID uint, offset, limit int) ([]model.Attachment, int64, error) { var attachments []model.Attachment var total int64 db := s.repo.DB() scope := db.WithContext(ctx). Where("account_id = ? AND message_id IN (SELECT id FROM messages WHERE conversation_id = ?)", accountID, conversationID) if err := scope.Model(&model.Attachment{}).Count(&total).Error; err != nil { return nil, 0, err } if limit <= 0 { limit = 100 } if err := scope. Preload("Message"). Order("created_at DESC, id DESC"). Offset(offset). Limit(limit). Find(&attachments).Error; err != nil { return nil, 0, err } return attachments, total, nil }