Files
gochat/internal/service/message_service.go
T

336 lines
12 KiB
Go

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
}