package service import ( "context" "encoding/json" "fmt" "strings" "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" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" "gorm.io/datatypes" "gorm.io/gorm" ) // 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 } // 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 } 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) { 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 applogger.L().Infof("dispatching event %s for message %d", eventType, message.ID) if err := s.dispatcher.Dispatch(ctx, event); err != nil { applogger.L().Errorf("failed to dispatch event %s for message %d: %v", eventType, message.ID, err) } } // 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) } func (s *MessageService) ResolveConversationForRoute(ctx context.Context, accountID, routeID uint) (*model.Conversation, error) { var conversation model.Conversation db := s.repo.DB().WithContext(ctx) if err := db.Where("account_id = ? AND display_id = ?", accountID, routeID).First(&conversation).Error; err == nil { return &conversation, nil } if err := db.Where("account_id = ? AND id = ?", accountID, routeID).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) } // 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"` 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. 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 { 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 } message := &model.Message{ AccountID: accountID, ConversationID: req.ConversationID, InboxID: conversation.InboxID, Content: req.Content, MessageType: req.MessageType, ContentType: req.ContentType, SenderID: &userID, SenderType: "user", Private: req.Private, SourceID: req.SourceID, EchoID: req.EchoID, Status: "sent", ContentAttributes: req.ContentAttributes, } // 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" } } 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 } // Dispatch EventMessageCreated s.dispatchMessageEvent(ctx, channel.EventMessageCreated, message) s.indexMessage(ctx, message) // Dispatch additional event based on message type if req.MessageType == "incoming" { s.dispatchMessageEvent(ctx, channel.EventMessageIncoming, message) } else if req.MessageType == "outgoing" { s.dispatchMessageEvent(ctx, channel.EventMessageOutgoing, message) } return message, 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_email", "input_phone", "select", "card", "private_note", "input_csat", "file", "image", "audio", "video", "voice_call": return true default: return false } } // UpdateMessageRequest is the DTO for updating a message. type UpdateMessageRequest struct { 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. func (s *MessageService) Update(ctx context.Context, accountID, id uint, req UpdateMessageRequest) (*model.Message, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } message, err := s.repo.FindByAccountAndID(ctx, accountID, id) if err != nil { return nil, err } 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 } // Dispatch EventMessageUpdated s.dispatchMessageEvent(ctx, channel.EventMessageUpdated, message) 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 } 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 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 s.dispatchMessageEvent(ctx, channel.EventMessageDeleted, message) 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 } // Dispatch EventMessageStatusUpdated 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) } s.indexMessage(ctx, message) 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) if err != nil { return nil, err } // Reset status to indicate retry message.Status = "retrying" if err := s.repo.Update(ctx, message); err != nil { return nil, err } // Dispatch EventMessageStatusUpdated with "retrying" status 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"] = "retrying" 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 } // 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"` } // 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) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } message, err := s.repo.FindByAccountAndID(ctx, accountID, id) if err != nil { return nil, err } 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 } return &TranslateMessageResult{ ID: message.ID, OriginalContent: message.Content, TranslatedContent: translated, TargetLanguage: req.TargetLanguage, }, nil } // 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() err := db.WithContext(ctx). Where("account_id = ? AND message_id IN (SELECT id FROM messages WHERE conversation_id = ?)", accountID, conversationID). Order("id ASC"). Find(&attachments).Error if err != nil { return nil, 0, err } total = int64(len(attachments)) start := offset if start > len(attachments) { start = len(attachments) } end := start + limit if end > len(attachments) { end = len(attachments) } return attachments[start:end], total, nil }