package service import ( "context" "fmt" "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" ) // 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) 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) } // 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" validate:"required,min=1"` MessageType string `json:"message_type" validate:"required,oneof=outgoing incoming activity template private_note"` ContentType string `json:"content_type,omitempty" validate:"omitempty,oneof=text input_text input_email input_phone select card private_note"` Private bool `json:"private,omitempty"` SourceID string `json:"source_id,omitempty"` // Chatwoot: source_id for message origin (user/agent/bot) } // Create creates a new message. func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint, req CreateMessageRequest) (*model.Message, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } message := &model.Message{ AccountID: accountID, ConversationID: req.ConversationID, Content: req.Content, MessageType: req.MessageType, ContentType: req.ContentType, SenderID: &userID, SenderType: "user", Private: req.Private, SourceID: req.SourceID, } if req.ContentType == "" { message.ContentType = "text" } // 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.Create(ctx, message); 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 } // UpdateMessageRequest is the DTO for updating a message. type UpdateMessageRequest struct { Content string `json:"content,omitempty" validate:"omitempty,min=1"` } // 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 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 } // Delete soft-deletes a message and dispatches EventMessageDeleted. func (s *MessageService) Delete(ctx context.Context, accountID, id uint) error { message, err := s.repo.FindByAccountAndID(ctx, accountID, id) if err != nil { return err } if err := s.repo.Delete(ctx, message.ID); err != nil { return err } // Dispatch EventMessageDeleted s.dispatchMessageEvent(ctx, channel.EventMessageDeleted, message) s.deleteMessageIndex(ctx, accountID, message.ID) return 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 } // 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 }