package service import ( "context" "encoding/json" "fmt" "strconv" "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" "github.com/gochat/gochat/internal/worker" 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 worker *worker.WorkerPool } // 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) 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) } // 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"` ContentAttributes datatypes.JSON `json:"content_attributes,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:"-"` } 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 && !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") } } 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) } } senderID := userID senderType := "user" if 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 { senderID = bot.ID senderType = string(model.SenderTypeAgentBot) } } 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: "sent", ContentAttributes: contentAttributes, AdditionalAttributes: messageAdditionalAttributes(map[string]any{ "campaign_id": req.CampaignID, "template_params": req.TemplateParams, }), } // 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 { 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}` } 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) 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" { if s.worker != nil { if _, err := EnqueueSendReply(ctx, s.worker, message.ID); err != nil { return message, err } } else { s.dispatchMessageEvent(ctx, channel.EventMessageOutgoing, message) } } return message, nil } func messageCreateAllowedIncoming(channelType string) bool { switch strings.TrimSpace(channelType) { case "api", string(model.InboxChannelTypeAPI): return true default: return false } } 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 messageAdditionalAttributes(values map[string]any) datatypes.JSON { attrs := map[string]any{} for key, value := range values { switch v := value.(type) { case nil: continue case string: if strings.TrimSpace(v) == "" { continue } attrs[key] = v case datatypes.JSON: if len(v) == 0 || string(v) == "null" { continue } var parsed any if err := json.Unmarshal(v, &parsed); err == nil { attrs[key] = parsed } default: attrs[key] = v } } if len(attrs) == 0 { return nil } encoded, err := json.Marshal(attrs) if err != nil { return nil } return datatypes.JSON(encoded) } 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 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) { 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 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. // 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 } 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) 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) { 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 }