1432 lines
50 KiB
Go
1432 lines
50 KiB
Go
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"
|
|
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
|
|
}
|
|
|
|
// 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
|
|
if eventType == channel.EventMessageCreated || eventType == channel.EventMessageUpdated || eventType == channel.EventMessageDeleted {
|
|
s.addMessageEventContext(ctx, event, 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)
|
|
}
|
|
}
|
|
|
|
func (s *MessageService) addMessageEventContext(ctx context.Context, event *channel.ChannelEvent, message *model.Message) {
|
|
var inbox model.Inbox
|
|
if err := s.repo.DB().WithContext(ctx).First(&inbox, message.InboxID).Error; err != nil {
|
|
return
|
|
}
|
|
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 := s.repo.DB().WithContext(ctx).First(&conversation, message.ConversationID).Error; err != nil {
|
|
return
|
|
}
|
|
event.ContactID = conversation.ContactID
|
|
event.Data["conversation"] = &conversation
|
|
|
|
var contact model.Contact
|
|
if err := s.repo.DB().WithContext(ctx).First(&contact, conversation.ContactID).Error; err == nil {
|
|
event.Data["contact"] = &contact
|
|
}
|
|
if message.SenderID != nil {
|
|
switch strings.ToLower(strings.TrimSpace(message.SenderType)) {
|
|
case "user":
|
|
var sender model.User
|
|
if err := s.repo.DB().WithContext(ctx).First(&sender, *message.SenderID).Error; err == nil {
|
|
event.Data["sender"] = &sender
|
|
}
|
|
case "agentbot", "agent_bot":
|
|
var sender model.AgentBot
|
|
if err := s.repo.DB().WithContext(ctx).First(&sender, *message.SenderID).Error; err == nil {
|
|
event.Data["sender"] = &sender
|
|
}
|
|
case "captain::assistant", "captainassistant", "captain_assistant":
|
|
var sender model.CaptainAssistant
|
|
if err := s.repo.DB().WithContext(ctx).First(&sender, *message.SenderID).Error; err == nil {
|
|
event.Data["sender"] = &sender
|
|
}
|
|
}
|
|
}
|
|
if !isWebWidget || message.Private || message.MessageType == string(model.MessageTypeActivity) || conversation.ContactInboxID == nil {
|
|
return
|
|
}
|
|
var contactInbox model.ContactInbox
|
|
if err := s.repo.DB().WithContext(ctx).Select("pubsub_token").First(&contactInbox, *conversation.ContactInboxID).Error; err == nil {
|
|
event.Data["widget_token"] = contactInbox.PubsubToken
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|
|
}
|
|
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 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
|
|
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" || 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 {
|
|
s.dispatchMessageEvent(ctx, channel.EventMessageOutgoing, message)
|
|
}
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
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
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|