mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 13:28:57 +00:00
12bb634b0f
# Conflicts: # frontend/apps/main/src/features/conversation/ReplyBox.vue # frontend/apps/main/src/stores/macro.js # frontend/apps/main/src/stores/macro.test.js # internal/image/image.go
1553 lines
57 KiB
Go
1553 lines
57 KiB
Go
package conversation
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/abhinavxd/libredesk/internal/attachment"
|
|
amodels "github.com/abhinavxd/libredesk/internal/automation/models"
|
|
"github.com/abhinavxd/libredesk/internal/conversation/models"
|
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
|
"github.com/abhinavxd/libredesk/internal/image"
|
|
"github.com/abhinavxd/libredesk/internal/inbox"
|
|
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
|
|
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
|
|
"github.com/abhinavxd/libredesk/internal/sla"
|
|
"github.com/abhinavxd/libredesk/internal/stringutil"
|
|
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
|
wmodels "github.com/abhinavxd/libredesk/internal/webhook/models"
|
|
"github.com/lib/pq"
|
|
"github.com/volatiletech/null/v9"
|
|
)
|
|
|
|
const (
|
|
maxMessagesPerPage = 500
|
|
// Only allow visitor-to-contact upgrade within this window after the last continuity email.
|
|
upgradeWindowTTL = 7 * 24 * time.Hour
|
|
)
|
|
|
|
// Matches <img ... src="URL"> and captures the URL for downstream parsing.
|
|
var imgSrcPattern = regexp.MustCompile(`(?i)<img\b[^>]*?\bsrc=["']([^"']*)["']`)
|
|
|
|
// fromNameVars is the template context for an inbox's from-name template.
|
|
type fromNameVars struct {
|
|
Agent fromNameAgent
|
|
Inbox fromNameInbox
|
|
}
|
|
|
|
type fromNameAgent struct{ FirstName, LastName, FullName string }
|
|
|
|
type fromNameInbox struct{ Name string }
|
|
|
|
// Run starts a pool of worker goroutines to handle message dispatching via inbox's channel and processes incoming messages. It scans for
|
|
// pending outgoing messages at the specified read interval and pushes them to the outgoing queue to be sent.
|
|
func (m *Manager) Run(ctx context.Context, incomingQWorkers, outgoingQWorkers, scanInterval time.Duration) {
|
|
dbScanner := time.NewTicker(scanInterval)
|
|
defer dbScanner.Stop()
|
|
|
|
for range outgoingQWorkers {
|
|
m.wg.Add(1)
|
|
go func() {
|
|
defer m.wg.Done()
|
|
m.MessageSenderWorker(ctx)
|
|
}()
|
|
}
|
|
for range incomingQWorkers {
|
|
m.wg.Add(1)
|
|
go func() {
|
|
defer m.wg.Done()
|
|
m.IncomingMessageWorker(ctx)
|
|
}()
|
|
}
|
|
|
|
// Scan pending outgoing messages and send them.
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-dbScanner.C:
|
|
var (
|
|
pendingMessages = []models.Message{}
|
|
messageIDs = m.getOutgoingProcessingMessageIDs()
|
|
)
|
|
|
|
// Get pending outgoing messages and skip the currently processing message ids.
|
|
if err := m.q.GetOutgoingPendingMessages.Select(&pendingMessages, pq.Array(messageIDs)); err != nil {
|
|
m.lo.Error("error fetching pending messages from db", "error", err)
|
|
continue
|
|
}
|
|
|
|
// Prepare and push the message to the outgoing queue.
|
|
for _, message := range pendingMessages {
|
|
// Put the message ID in the processing map.
|
|
m.outgoingProcessingMessages.Store(message.ID, message.ID)
|
|
|
|
// Push the message to the outgoing message queue.
|
|
m.outgoingMessageQueue <- message
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close signals the Manager to stop processing messages, closes channels,
|
|
// and waits for all worker goroutines to finish processing.
|
|
func (m *Manager) Close() {
|
|
m.closedMu.Lock()
|
|
defer m.closedMu.Unlock()
|
|
m.closed = true
|
|
close(m.outgoingMessageQueue)
|
|
close(m.incomingMessageQueue)
|
|
m.wg.Wait()
|
|
}
|
|
|
|
// IncomingMessageWorker processes incoming messages from the incoming message queue.
|
|
func (m *Manager) IncomingMessageWorker(ctx context.Context) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case msg, ok := <-m.incomingMessageQueue:
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := m.ProcessIncomingMessage(msg); err != nil {
|
|
m.lo.Error("error processing incoming msg", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MessageSenderWorker sends outgoing pending messages.
|
|
func (m *Manager) MessageSenderWorker(ctx context.Context) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case message, ok := <-m.outgoingMessageQueue:
|
|
if !ok {
|
|
return
|
|
}
|
|
m.sendOutgoingMessage(message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendOutgoingMessage sends an outgoing message.
|
|
func (m *Manager) sendOutgoingMessage(message models.Message) {
|
|
defer m.outgoingProcessingMessages.Delete(message.ID)
|
|
|
|
// Helper function to handle errors
|
|
handleError := func(err error, errorMsg string) bool {
|
|
if err != nil {
|
|
m.lo.Error(errorMsg, "error", err, "message_id", message.ID)
|
|
m.UpdateMessageStatus(message.UUID, models.MessageStatusFailed)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Get inbox
|
|
inb, err := m.inboxStore.Get(message.InboxID)
|
|
if handleError(err, "error fetching inbox") {
|
|
return
|
|
}
|
|
|
|
// Render content in template
|
|
if err := m.RenderMessageInTemplate(inb.Channel(), &message); err != nil {
|
|
handleError(err, "error rendering content in template")
|
|
return
|
|
}
|
|
|
|
// Attach attachments to the message
|
|
if err := m.attachAttachmentsToMessage(&message); err != nil {
|
|
handleError(err, "error attaching attachments to message")
|
|
return
|
|
}
|
|
|
|
// Convert to OutboundMessage for transport
|
|
outbound := message.ToOutbound()
|
|
|
|
if inb.Channel() == inbox.ChannelEmail {
|
|
outbound.From = m.emailFromAddress(inb, message)
|
|
|
|
// Set "In-Reply-To" and "References" headers for email threading.
|
|
outbound.References, outbound.InReplyTo = m.BuildEmailThreadingHeaders(message.ConversationID, outbound.SourceID)
|
|
}
|
|
|
|
// Send message
|
|
err = inb.Send(outbound)
|
|
if err != nil && err != livechat.ErrClientNotConnected {
|
|
handleError(err, "error sending message")
|
|
return
|
|
}
|
|
|
|
// Update status as sent.
|
|
m.UpdateMessageStatus(message.UUID, models.MessageStatusSent)
|
|
|
|
// Skip system user replies since we only update timestamps and SLA for human replies.
|
|
systemUser, err := m.userStore.GetSystemUser()
|
|
if err != nil {
|
|
m.lo.Error("error fetching system user", "error", err)
|
|
return
|
|
}
|
|
if message.SenderID != systemUser.ID {
|
|
conversation, err := m.GetConversation(message.ConversationID, "", "")
|
|
if err != nil {
|
|
m.lo.Error("error fetching conversation", "conversation_id", message.ConversationID, "error", err)
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
nowStr := now.Format(time.RFC3339)
|
|
wsData := map[string]any{"last_reply_at": nowStr, "waiting_since": nil}
|
|
|
|
var isFirstReply bool
|
|
if err := m.q.UpdateConversationReplyTimestamps.QueryRow(message.ConversationID, now).Scan(&isFirstReply); err != nil {
|
|
m.lo.Error("error updating conversation reply timestamps", "error", err)
|
|
} else if isFirstReply {
|
|
wsData["first_reply_at"] = nowStr
|
|
// Stamp the first-response SLA immediately.
|
|
if err := m.slaStore.EvaluateConversationSLA(message.ConversationID); err != nil {
|
|
m.lo.Error("error evaluating SLA after first reply", "conversation_id", message.ConversationID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Mark latest SLA event for next response metric as met.
|
|
metAt, err := m.slaStore.SetLatestSLAEventMetAt(conversation.AppliedSLAID.Int, sla.MetricNextResponse)
|
|
if err != nil && !errors.Is(err, sla.ErrLatestSLAEventNotFound) {
|
|
m.lo.Error("error setting next response SLA event `met_at`", "conversation_id", conversation.ID, "metric", sla.MetricNextResponse, "applied_sla_id", conversation.AppliedSLAID.Int, "error", err)
|
|
} else if !metAt.IsZero() {
|
|
wsData["next_response_met_at"] = metAt.Format(time.RFC3339)
|
|
}
|
|
|
|
m.BroadcastConversationUpdate(message.ConversationUUID, wsData)
|
|
|
|
if message.ShouldEvaluateAutomation(systemUser.ID) {
|
|
m.automation.EvaluateConversationUpdateRulesByID(message.ConversationID, "", amodels.EventConversationMessageOutgoing, umodels.User{ID: message.SenderID})
|
|
}
|
|
}
|
|
}
|
|
|
|
// BuildTemplateData builds the common template data map for rendering message content variables.
|
|
func (m *Manager) BuildTemplateData(conversationUUID string, senderID int) (map[string]any, error) {
|
|
conversation, err := m.GetConversation(0, conversationUUID, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching conversation: %w", err)
|
|
}
|
|
|
|
sender, err := m.userStore.GetAgent(senderID, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching message sender user: %w", err)
|
|
}
|
|
|
|
data := map[string]any{
|
|
"Conversation": map[string]any{
|
|
"ReferenceNumber": conversation.ReferenceNumber,
|
|
"Subject": conversation.Subject.String,
|
|
"Priority": conversation.Priority.String,
|
|
"UUID": conversation.UUID,
|
|
},
|
|
"Contact": map[string]any{
|
|
"FirstName": conversation.Contact.FirstName,
|
|
"LastName": conversation.Contact.LastName,
|
|
"FullName": conversation.Contact.FullName(),
|
|
"Email": conversation.Contact.Email.String,
|
|
},
|
|
"Recipient": map[string]any{
|
|
"FirstName": conversation.Contact.FirstName,
|
|
"LastName": conversation.Contact.LastName,
|
|
"FullName": conversation.Contact.FullName(),
|
|
"Email": conversation.Contact.Email.String,
|
|
},
|
|
"Author": map[string]any{
|
|
"FirstName": sender.FirstName,
|
|
"LastName": sender.LastName,
|
|
"FullName": sender.FullName(),
|
|
"Email": sender.Email.String,
|
|
},
|
|
// Lets templates disclose AI-composed replies, e.g. {{ if .IsAIComposed }}Composed by AI{{ end }}.
|
|
"IsAIComposed": sender.Type == umodels.UserTypeAIAssistant,
|
|
}
|
|
|
|
// For automated replies set author fields to empty strings as the recipients will see name as System.
|
|
if sender.IsSystemUser() {
|
|
data["Author"] = map[string]any{
|
|
"FirstName": "",
|
|
"LastName": "",
|
|
"FullName": "",
|
|
"Email": "",
|
|
}
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// RenderMessageInTemplate renders message content in the email base template for sending.
|
|
func (m *Manager) RenderMessageInTemplate(channel string, message *models.Message) error {
|
|
switch channel {
|
|
case inbox.ChannelEmail:
|
|
data, err := m.BuildTemplateData(message.ConversationUUID, message.SenderID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Expose message meta flags to the template.
|
|
var (
|
|
isContinuity bool
|
|
meta map[string]any
|
|
)
|
|
if len(message.Meta) > 0 && json.Unmarshal(message.Meta, &meta) == nil {
|
|
isContinuity, _ = meta["continuity_email"].(bool)
|
|
}
|
|
data["IsContinuityEmail"] = isContinuity
|
|
|
|
message.Content, err = m.template.RenderEmailWithTemplate(data, message.Content)
|
|
if err != nil {
|
|
m.lo.Error("could not render email content using template", "id", message.ID, "error", err)
|
|
return fmt.Errorf("could not render email content using template: %w", err)
|
|
}
|
|
case inbox.ChannelLiveChat:
|
|
// Live chat doesn't use templates for rendering messages.
|
|
return nil
|
|
default:
|
|
m.lo.Warn("unknown message channel", "channel", channel)
|
|
return fmt.Errorf("unknown message channel: %s", channel)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetConversationMessages retrieves messages for a specific conversation.
|
|
func (m *Manager) GetConversationMessages(conversationUUID string, page, pageSize int, private *bool, msgTypes []string) ([]models.Message, int, error) {
|
|
var (
|
|
messages = make([]models.Message, 0)
|
|
qArgs []any
|
|
)
|
|
|
|
// Convert msgTypes slice to pq.StringArray for PostgreSQL
|
|
var typesArg any
|
|
if len(msgTypes) > 0 {
|
|
typesArg = pq.StringArray(msgTypes)
|
|
}
|
|
|
|
qArgs = append(qArgs, conversationUUID, private, typesArg)
|
|
query, pageSize, qArgs, err := m.generateMessagesQuery(m.q.GetMessages, qArgs, page, pageSize)
|
|
if err != nil {
|
|
m.lo.Error("error generating messages query", "error", err)
|
|
return messages, pageSize, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
|
ReadOnly: true,
|
|
})
|
|
defer tx.Rollback()
|
|
if err != nil {
|
|
m.lo.Error("error preparing get messages query", "error", err)
|
|
return messages, pageSize, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
if err := tx.Select(&messages, query, qArgs...); err != nil {
|
|
m.lo.Error("error fetching conversations", "error", err)
|
|
return messages, pageSize, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
return messages, pageSize, nil
|
|
}
|
|
|
|
// GetAllConversationMessages returns the newest messages in a conversation in chronological order, capped at limit; a non-positive limit returns them all.
|
|
func (m *Manager) GetAllConversationMessages(conversationUUID string, private *bool, msgTypes []string, limit int) ([]models.Message, error) {
|
|
var all []models.Message
|
|
pageSize := maxMessagesPerPage
|
|
if limit > 0 && limit < pageSize {
|
|
pageSize = limit
|
|
}
|
|
for page := 1; ; page++ {
|
|
messages, _, err := m.GetConversationMessages(conversationUUID, page, pageSize, private, msgTypes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, messages...)
|
|
if len(messages) == 0 || len(all) >= messages[0].Total || (limit > 0 && len(all) >= limit) {
|
|
break
|
|
}
|
|
}
|
|
if limit > 0 && len(all) > limit {
|
|
all = all[:limit]
|
|
}
|
|
slices.Reverse(all)
|
|
return all, nil
|
|
}
|
|
|
|
// GetMessage retrieves a message by UUID.
|
|
func (m *Manager) GetMessage(uuid string) (models.Message, error) {
|
|
var message models.Message
|
|
if err := m.q.GetMessage.Get(&message, uuid); err != nil {
|
|
m.lo.Error("error fetching message", "uuid", uuid, "error", err)
|
|
return message, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Generate signed URLs for attachments.
|
|
m.SignAttachmentURLs(message.Attachments)
|
|
|
|
return message, nil
|
|
}
|
|
|
|
// SignAttachmentURLs adds access URLs for the original image and its thumbnail.
|
|
func (m *Manager) SignAttachmentURLs(attachments attachment.Attachments) {
|
|
for i := range attachments {
|
|
attachments[i].URL = m.mediaStore.GetURL(attachments[i].UUID, attachments[i].ContentType, attachments[i].Name)
|
|
if strings.HasPrefix(attachments[i].ContentType, "image/") {
|
|
attachments[i].ThumbnailURL = m.mediaStore.GetThumbnailURL(attachments[i].UUID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// UpdateMessageStatus updates the status of a message.
|
|
func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
|
|
if _, err := m.q.UpdateMessageStatus.Exec(status, messageUUID); err != nil {
|
|
m.lo.Error("error updating message status", "message_uuid", messageUUID, "error", err)
|
|
return err
|
|
}
|
|
|
|
// Broadcast message status update to all conversation subscribers.
|
|
conversationUUID, _ := m.getConversationUUIDFromMessageUUID(messageUUID)
|
|
m.BroadcastMessageUpdate(conversationUUID, messageUUID, map[string]any{"status": status})
|
|
|
|
// Trigger webhook for message update.
|
|
if message, err := m.GetMessage(messageUUID); err != nil {
|
|
m.lo.Error("error fetching message for webhook event", "uuid", messageUUID, "error", err)
|
|
} else {
|
|
m.webhookStore.TriggerEvent(wmodels.EventMessageUpdated, message)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarkMessageAsPending updates message status to `Pending`, enqueuing it for sending.
|
|
func (m *Manager) MarkMessageAsPending(uuid string) error {
|
|
if err := m.UpdateMessageStatus(uuid, models.MessageStatusPending); err != nil {
|
|
m.lo.Error("error marking message as pending", "uuid", uuid, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.errorSendingMessage"), nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SendPrivateNote inserts a private message in a conversation.
|
|
func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversationUUID, content string, mentions []models.MentionInput) (models.Message, error) {
|
|
// Best-effort render template variables before saving.
|
|
if data, err := m.BuildTemplateData(conversationUUID, senderID); err == nil {
|
|
content = m.template.RenderString(data, content)
|
|
}
|
|
|
|
message := models.Message{
|
|
ConversationUUID: conversationUUID,
|
|
SenderID: senderID,
|
|
Type: models.MessageOutgoing,
|
|
SenderType: models.SenderTypeAgent,
|
|
Status: models.MessageStatusSent,
|
|
Content: content,
|
|
ContentType: models.ContentTypeHTML,
|
|
Private: true,
|
|
Media: media,
|
|
}
|
|
if err := m.InsertMessage(&message); err != nil {
|
|
return models.Message{}, err
|
|
}
|
|
|
|
// Insert mentions if any.
|
|
if len(mentions) > 0 {
|
|
if err := m.InsertMentions(message.ConversationID, message.ID, senderID, mentions); err != nil {
|
|
m.lo.Error("error inserting mentions", "error", err)
|
|
}
|
|
go m.NotifyMention(conversationUUID, message, mentions, senderID)
|
|
}
|
|
|
|
return message, nil
|
|
}
|
|
|
|
// CreateContactMessage creates a contact message in a conversation.
|
|
// sourceID is the bare RFC 5322 Message-ID of the inbound message; it is normalized and stored on the message so replies thread on it, mirroring the IMAP ingestion path. Empty leaves the column NULL.
|
|
func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, conversationUUID, content, contentType string, isNewConversation bool, sourceID string) (models.Message, error) {
|
|
sourceID = stringutil.NormalizeMessageID(sourceID)
|
|
message := models.Message{
|
|
ConversationUUID: conversationUUID,
|
|
SenderID: contactID,
|
|
Type: models.MessageIncoming,
|
|
SenderType: models.SenderTypeContact,
|
|
Status: models.MessageStatusReceived,
|
|
Content: content,
|
|
ContentType: contentType,
|
|
Private: false,
|
|
Media: media,
|
|
SourceID: null.NewString(sourceID, sourceID != ""),
|
|
}
|
|
if err := m.InsertMessage(&message); err != nil {
|
|
return models.Message{}, err
|
|
}
|
|
|
|
// Process post-message hooks (reopen, waiting since, automation, SLA).
|
|
if err := m.ProcessIncomingMessageHooks(conversationUUID, isNewConversation); err != nil {
|
|
m.lo.Error("error processing incoming message hooks", "conversation_uuid", conversationUUID, "error", err)
|
|
}
|
|
|
|
return message, nil
|
|
}
|
|
|
|
// QueueReply queues a reply message in a conversation.
|
|
func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID, contactID int, conversationUUID, content string, to, cc, bcc []string, metaMap map[string]interface{}) (models.Message, error) {
|
|
var (
|
|
message = models.Message{}
|
|
)
|
|
|
|
inboxRecord, err := m.inboxStore.GetDBRecord(inboxID)
|
|
if err != nil {
|
|
m.lo.Error("error fetching inbox record", "inbox_id", inboxID, "error", err)
|
|
return models.Message{}, err
|
|
}
|
|
|
|
if !inboxRecord.Enabled {
|
|
return models.Message{}, envelope.NewError(envelope.InputError, m.i18n.T("status.disabledInbox"), nil)
|
|
}
|
|
|
|
var sourceID string
|
|
switch inboxRecord.Channel {
|
|
case inbox.ChannelEmail:
|
|
// Add `to`, `cc`, and `bcc` recipients to meta map.
|
|
to = stringutil.RemoveEmpty(to)
|
|
cc = stringutil.RemoveEmpty(cc)
|
|
bcc = stringutil.RemoveEmpty(bcc)
|
|
if len(to) > 0 {
|
|
metaMap["to"] = to
|
|
}
|
|
if len(cc) > 0 {
|
|
metaMap["cc"] = cc
|
|
}
|
|
if len(bcc) > 0 {
|
|
metaMap["bcc"] = bcc
|
|
}
|
|
if len(to) == 0 {
|
|
return message, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.empty", "name", "`to`"), nil)
|
|
}
|
|
sourceID, err = stringutil.GenerateEmailMessageID(conversationUUID, inboxRecord.From)
|
|
if err != nil {
|
|
m.lo.Error("error generating source message id", "error", err)
|
|
return models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
}
|
|
|
|
// Marshal meta.
|
|
metaJSON, err := json.Marshal(metaMap)
|
|
if err != nil {
|
|
m.lo.Error("error marshalling message meta map to JSON", "error", err)
|
|
return models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Best-effort render template variables before saving so agents see rendered content immediately.
|
|
if data, err := m.BuildTemplateData(conversationUUID, senderID); err == nil {
|
|
content = m.template.RenderString(data, content)
|
|
}
|
|
|
|
// Insert the message into the database
|
|
message = models.Message{
|
|
ConversationUUID: conversationUUID,
|
|
SenderID: senderID,
|
|
Type: models.MessageOutgoing,
|
|
SenderType: models.SenderTypeAgent,
|
|
Status: models.MessageStatusPending,
|
|
Content: content,
|
|
ContentType: models.ContentTypeHTML,
|
|
Private: false,
|
|
Media: media,
|
|
SourceID: null.StringFrom(sourceID),
|
|
MessageReceiverID: contactID,
|
|
Meta: metaJSON,
|
|
}
|
|
if err := m.InsertMessage(&message); err != nil {
|
|
return models.Message{}, err
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
// InsertMessage inserts a message and attaches the media to the message.
|
|
func (m *Manager) InsertMessage(message *models.Message) error {
|
|
if message.Private {
|
|
message.Status = models.MessageStatusSent
|
|
}
|
|
if len(message.Meta) == 0 || string(message.Meta) == "null" {
|
|
message.Meta = json.RawMessage(`{}`)
|
|
}
|
|
|
|
// Handle empty content type enum, default to text.
|
|
if message.ContentType == "" {
|
|
message.ContentType = models.ContentTypeText
|
|
}
|
|
|
|
inlineUUIDs := extractInlineImageUUIDs(message.Content)
|
|
|
|
// Rewrite inline image URLs to cid:ldsk-<uuid>. The read API resolves them back to signed URLs.
|
|
message.Content = rewriteInlineImagesToCID(message.Content)
|
|
|
|
// Convert content to plain text for search.
|
|
if message.ContentType == models.ContentTypeText {
|
|
message.TextContent = message.Content
|
|
} else {
|
|
message.TextContent = stringutil.HTML2Text(message.Content)
|
|
}
|
|
|
|
tx, err := m.db.Beginx()
|
|
if err != nil {
|
|
m.lo.Error("error beginning message insert transaction", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if err := tx.Stmtx(m.q.InsertMessage).Get(message, message.Type, message.Status, message.ConversationID, message.ConversationUUID, message.Content, message.TextContent, message.SenderID, message.SenderType,
|
|
message.Private, message.ContentType, message.SourceID, message.Meta); err != nil {
|
|
m.lo.Error("error inserting message in db", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
if err := m.mediaStore.LinkMessageMediaTx(tx, message.ID, message.Media, inlineUUIDs); err != nil {
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
m.lo.Error("error committing message insert transaction", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Add this user as a participant if not already present.
|
|
m.addConversationParticipant(message.SenderID, message.ConversationUUID)
|
|
|
|
// Skip updating last_message and broadcasting for continuity emails.
|
|
if !message.IsContinuityMessage() {
|
|
// Hide CSAT message content as it contains a public link to the survey.
|
|
lastMessage := message.TextContent
|
|
if message.HasCSAT() {
|
|
lastMessage = "Please rate your experience with us"
|
|
}
|
|
|
|
// HTML2Text drops <img> tags, so image-only messages have empty text. Fall back to a media-type preview.
|
|
if strings.TrimSpace(lastMessage) == "" {
|
|
switch {
|
|
case len(message.Media) > 0:
|
|
lastMessage = m.getMediaPreview(message.Media[0])
|
|
case len(inlineUUIDs) > 0:
|
|
lastMessage = m.i18n.T("globals.terms.image")
|
|
}
|
|
}
|
|
|
|
// Update conversation last message details (also conditionally updates last_interaction if not activity/private).
|
|
m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.SenderType, message.Type, message.Private, message.CreatedAt, message.SenderID)
|
|
|
|
var convItem *models.ConversationListItem
|
|
if item, err := m.GetConversationListItem(message.ConversationUUID); err == nil {
|
|
convItem = &item
|
|
} else {
|
|
m.lo.Error("error fetching conversation list item for broadcast", "uuid", message.ConversationUUID, "error", err)
|
|
}
|
|
m.BroadcastNewMessage(message, convItem, lastMessage)
|
|
}
|
|
|
|
// Refetch the message to get all fields populated (e.g., author, media URLs).
|
|
refetchedMessage, err := m.GetMessage(message.UUID)
|
|
if err != nil {
|
|
m.lo.Error("error fetching message after insert", "error", err)
|
|
} else {
|
|
*message = refetchedMessage
|
|
}
|
|
|
|
// Trigger webhook for new message created.
|
|
m.webhookStore.TriggerEvent(wmodels.EventMessageCreated, message)
|
|
|
|
return nil
|
|
}
|
|
|
|
// RecordAssigneeUserChange records an activity for a user assignee change.
|
|
func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID int, actor umodels.User) error {
|
|
// Self assignment.
|
|
if assigneeID == actor.ID {
|
|
return m.InsertConversationActivity(models.ActivitySelfAssign, conversationUUID, actor.FullName(), actor)
|
|
}
|
|
|
|
// Assignment to another user.
|
|
assignee, err := m.userStore.GetAgent(assigneeID, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return m.InsertConversationActivity(models.ActivityAssignedUserChange, conversationUUID, assignee.FullName(), actor)
|
|
}
|
|
|
|
// RecordAssigneeTeamChange records an activity for a team assignee change.
|
|
func (m *Manager) RecordAssigneeTeamChange(conversationUUID string, teamID int, actor umodels.User) error {
|
|
team, err := m.teamStore.Get(teamID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return m.InsertConversationActivity(models.ActivityAssignedTeamChange, conversationUUID, team.Name, actor)
|
|
}
|
|
|
|
// RecordPriorityChange records an activity for a priority change.
|
|
func (m *Manager) RecordPriorityChange(priority, conversationUUID string, actor umodels.User) error {
|
|
return m.InsertConversationActivity(models.ActivityPriorityChange, conversationUUID, priority, actor)
|
|
}
|
|
|
|
// RecordStatusChange records an activity for a status change.
|
|
func (m *Manager) RecordStatusChange(status, conversationUUID string, actor umodels.User) error {
|
|
return m.InsertConversationActivity(models.ActivityStatusChange, conversationUUID, status, actor)
|
|
}
|
|
|
|
// RecordSLASet records an activity for an SLA set.
|
|
func (m *Manager) RecordSLASet(conversationUUID string, slaName string, actor umodels.User) error {
|
|
return m.InsertConversationActivity(models.ActivitySLASet, conversationUUID, slaName, actor)
|
|
}
|
|
|
|
// RecordTagAddition records an activity for a tag addition.
|
|
func (m *Manager) RecordTagAddition(conversationUUID string, tag string, actor umodels.User) error {
|
|
return m.InsertConversationActivity(models.ActivityTagAdded, conversationUUID, tag, actor)
|
|
}
|
|
|
|
// RecordTagRemoval records an activity for a tag removal.
|
|
func (m *Manager) RecordTagRemoval(conversationUUID string, tag string, actor umodels.User) error {
|
|
return m.InsertConversationActivity(models.ActivityTagRemoved, conversationUUID, tag, actor)
|
|
}
|
|
|
|
// InsertConversationActivity inserts an activity message.
|
|
func (m *Manager) InsertConversationActivity(activityType, conversationUUID, newValue string, actor umodels.User) error {
|
|
content, err := m.getMessageActivityContent(activityType, newValue, actor.FullName())
|
|
if err != nil {
|
|
m.lo.Error("error could not generate activity content", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Store the activity type structurally so callers can filter activities without parsing i18n content.
|
|
meta, _ := json.Marshal(map[string]string{"activity_type": activityType})
|
|
|
|
message := models.Message{
|
|
Type: models.MessageActivity,
|
|
Status: models.MessageStatusSent,
|
|
Content: content,
|
|
ContentType: models.ContentTypeText,
|
|
ConversationUUID: conversationUUID,
|
|
Private: true,
|
|
SenderID: actor.ID,
|
|
SenderType: models.SenderTypeAgent,
|
|
Meta: meta,
|
|
}
|
|
|
|
if err := m.InsertMessage(&message); err != nil {
|
|
m.lo.Error("error inserting activity message", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getConversationUUIDFromMessageUUID returns conversation UUID from message UUID.
|
|
func (m *Manager) getConversationUUIDFromMessageUUID(uuid string) (string, error) {
|
|
var conversationUUID string
|
|
if err := m.q.GetConversationUUIDFromMessageUUID.Get(&conversationUUID, uuid); err != nil {
|
|
m.lo.Error("error fetching conversation uuid from message uuid", "uuid", uuid, "error", err)
|
|
return conversationUUID, err
|
|
}
|
|
return conversationUUID, nil
|
|
}
|
|
|
|
// getMessageActivityContent generates activity content based on the activity type.
|
|
func (m *Manager) getMessageActivityContent(activityType, newValue, actorName string) (string, error) {
|
|
var content = ""
|
|
switch activityType {
|
|
case models.ActivityAssignedUserChange:
|
|
content = fmt.Sprintf("Assigned to %s by %s", newValue, actorName)
|
|
case models.ActivityAssignedTeamChange:
|
|
content = fmt.Sprintf("Assigned to %s team by %s", newValue, actorName)
|
|
case models.ActivitySelfAssign:
|
|
content = fmt.Sprintf("%s self-assigned this conversation", actorName)
|
|
case models.ActivityPriorityChange:
|
|
content = fmt.Sprintf("%s set priority to %s", actorName, newValue)
|
|
case models.ActivityStatusChange:
|
|
content = fmt.Sprintf("%s marked the conversation as %s", actorName, newValue)
|
|
case models.ActivityTagAdded:
|
|
content = fmt.Sprintf("%s added tag %s", actorName, newValue)
|
|
case models.ActivityTagRemoved:
|
|
content = fmt.Sprintf("%s removed tag %s", actorName, newValue)
|
|
case models.ActivitySLASet:
|
|
content = fmt.Sprintf("%s set %s SLA policy", actorName, newValue)
|
|
case models.ActivityParticipantAdded:
|
|
content = fmt.Sprintf("%s joined the conversation", newValue)
|
|
default:
|
|
return "", fmt.Errorf("invalid activity type %s", activityType)
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
// ProcessIncomingMessage handles the insertion of an incoming message and
|
|
// associated contact. It finds or creates the contact, checks for existing
|
|
// conversations, and creates a new conversation if necessary. It also
|
|
// inserts the message, uploads any attachments, and queues the conversation evaluation of automation rules.
|
|
func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Message, error) {
|
|
// Return early if this message already exists (same source ID).
|
|
dupConvID, err := m.messageExistsBySourceID([]string{in.SourceID.String})
|
|
if err != nil && err != errConversationNotFound {
|
|
return models.Message{}, err
|
|
}
|
|
if dupConvID > 0 {
|
|
return models.Message{}, nil
|
|
}
|
|
|
|
// Resolve sender and conversation from plus addressing.
|
|
senderID, conversationID, conversationUUID, err := m.resolveSender(&in)
|
|
if err != nil {
|
|
return models.Message{}, err
|
|
}
|
|
|
|
// Find or create contact.
|
|
if senderID == 0 {
|
|
user := umodels.User{
|
|
FirstName: in.Contact.FirstName,
|
|
LastName: in.Contact.LastName,
|
|
Email: in.Contact.Email,
|
|
Type: umodels.UserTypeContact,
|
|
}
|
|
if err := m.userStore.ResolveContact(&user, umodels.ContactSync); err != nil {
|
|
m.lo.Error("error creating contact for incoming message", "message_source_id", in.SourceID.String, "error", err)
|
|
return models.Message{}, fmt.Errorf("creating contact: %w", err)
|
|
}
|
|
senderID = user.ID
|
|
in.Contact.ID = senderID
|
|
}
|
|
|
|
// Match conversation if not already matched by plus-addressing.
|
|
var isNewConversation bool
|
|
if conversationID == 0 {
|
|
conversationID, conversationUUID, isNewConversation, err = m.findOrCreateConversation(in)
|
|
if err != nil {
|
|
m.lo.Error("error finding or creating conversation for incoming message", "message_source_id", in.SourceID.String, "error", err)
|
|
return models.Message{}, err
|
|
}
|
|
}
|
|
|
|
// For existing conversations, override sender with the conversation's contact when emails match.
|
|
if !isNewConversation && conversationID > 0 {
|
|
conversation, convErr := m.GetConversation(conversationID, "", "")
|
|
if convErr == nil && strings.EqualFold(conversation.Contact.Email.String, in.Contact.Email.String) {
|
|
senderID = conversation.ContactID
|
|
in.Contact.ID = senderID
|
|
}
|
|
}
|
|
|
|
// Convert to Message for attachment upload and insertion.
|
|
msg := in.ToMessage(senderID, conversationID, conversationUUID)
|
|
|
|
// Upload message attachments. On failure, delete the conversation if it was just created for this message.
|
|
if upErr := m.uploadMessageAttachments(&msg); upErr != nil {
|
|
m.lo.Error("error uploading message attachments", "message_source_id", in.SourceID.String, "error", upErr)
|
|
if isNewConversation && conversationUUID != "" {
|
|
m.lo.Info("deleting conversation as message attachment upload failed", "conversation_uuid", conversationUUID, "message_source_id", in.SourceID.String)
|
|
if err := m.DeleteConversation(conversationUUID); err != nil {
|
|
return models.Message{}, fmt.Errorf("deleting conversation after message attachment upload failure: %w", err)
|
|
}
|
|
}
|
|
return models.Message{}, fmt.Errorf("uploading message attachments: %w", upErr)
|
|
}
|
|
|
|
// Insert message. On failure, delete the conversation if it was just created for this message.
|
|
if err = m.InsertMessage(&msg); err != nil {
|
|
m.lo.Error("error inserting incoming message", "message_source_id", in.SourceID.String, "conversation_uuid", conversationUUID, "is_new", isNewConversation, "error", err)
|
|
if isNewConversation && conversationUUID != "" {
|
|
if delErr := m.DeleteConversation(conversationUUID); delErr != nil {
|
|
return models.Message{}, fmt.Errorf("deleting conversation after message insert failure: %w", delErr)
|
|
}
|
|
}
|
|
return models.Message{}, fmt.Errorf("inserting message: %w", err)
|
|
}
|
|
|
|
// When a customer replies to a continuity emailsync the message to their live chat widget via WebSocket.
|
|
// No-op if the conversation's inbox isn't livechat.
|
|
m.broadcastMessageToWidgetClients(&msg)
|
|
|
|
// Process post-message hooks (automation rules, webhooks, SLA, etc.).
|
|
if err := m.ProcessIncomingMessageHooks(msg.ConversationUUID, isNewConversation); err != nil {
|
|
m.lo.Error("error processing incoming message hooks", "conversation_uuid", msg.ConversationUUID, "error", err)
|
|
return models.Message{}, fmt.Errorf("processing incoming message hooks: %w", err)
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
// resolveSender resolves the sender for an incoming message via plus-addressing.
|
|
// Returns senderID, and optionally conversationID/UUID if matched.
|
|
// If sender is not resolved here, ProcessIncomingMessage handles it with conversation context.
|
|
func (m *Manager) resolveSender(in *models.IncomingMessage) (senderID, conversationID int, conversationUUID string, err error) {
|
|
if in.ConversationUUIDFromReplyTo != "" {
|
|
senderID, conversationID, conversationUUID, err = m.resolveByPlusAddress(in)
|
|
if err != nil {
|
|
return 0, 0, "", err
|
|
}
|
|
if senderID > 0 {
|
|
in.Contact.ID = senderID
|
|
}
|
|
}
|
|
return senderID, conversationID, conversationUUID, nil
|
|
}
|
|
|
|
// resolveByPlusAddress attempts to match a conversation via plus-addressed Reply-To
|
|
// (e.g., inbox+conv-{uuid}@domain). If the conversation contact is a visitor, it upgrades
|
|
// them to a contact (proving email ownership). Returns senderID > 0 if resolved.
|
|
func (m *Manager) resolveByPlusAddress(in *models.IncomingMessage) (senderID, conversationID int, conversationUUID string, err error) {
|
|
conversation, err := m.GetConversation(0, in.ConversationUUIDFromReplyTo, "")
|
|
if err != nil {
|
|
// Not found return with no error.
|
|
if envErr, ok := err.(envelope.Error); ok && envErr.ErrorType == envelope.NotFoundError {
|
|
return 0, 0, "", nil
|
|
}
|
|
// Other errors.
|
|
return 0, 0, "", fmt.Errorf("fetching conversation: %w", err)
|
|
}
|
|
|
|
m.lo.Debug("matched conversation by plus-addressed Reply-To", "conversation_uuid", conversation.UUID, "contact_email", in.Contact.Email.String)
|
|
|
|
conversationID = conversation.ID
|
|
conversationUUID = conversation.UUID
|
|
senderID = conversation.Contact.ID
|
|
|
|
// Already a contact - if same email, return as sender. If different email, let contact resolution find the actual sender.
|
|
if conversation.Contact.Type == umodels.UserTypeContact {
|
|
if !strings.EqualFold(conversation.Contact.Email.String, in.Contact.Email.String) {
|
|
return 0, conversationID, conversationUUID, nil
|
|
}
|
|
return senderID, conversationID, conversationUUID, nil
|
|
}
|
|
|
|
// Visitor upgrade requires email match - proves email ownership.
|
|
// If a different email replied, thread the message but don't upgrade.
|
|
if !strings.EqualFold(conversation.Contact.Email.String, in.Contact.Email.String) {
|
|
return 0, conversationID, conversationUUID, nil
|
|
}
|
|
|
|
user, contactErr := m.userStore.Get(0, in.Contact.Email.String, []string{umodels.UserTypeContact})
|
|
if contactErr == nil {
|
|
m.lo.Debug("a contact already exists with the same email as visitor; not upgrading visitor", "conversation_uuid", conversation.UUID, "contact_email", in.Contact.Email.String, "contact_user_id", user.ID)
|
|
// A contact with this email already exists; don't upgrade visitor.
|
|
// Let contact resolution find the correct sender ID.
|
|
return 0, conversationID, conversationUUID, nil
|
|
}
|
|
|
|
if envErr, ok := contactErr.(envelope.Error); !ok || envErr.ErrorType != envelope.NotFoundError {
|
|
return 0, 0, "", fmt.Errorf("fetching contact by email: %w", contactErr)
|
|
}
|
|
|
|
// Block upgrade if continuity email TTL expired.
|
|
if !m.isVisitorUpgradeSafe(conversation) {
|
|
return 0, conversationID, conversationUUID, nil
|
|
}
|
|
|
|
// Upgrade visitor as no contact exist with this email.
|
|
if err := m.userStore.UpgradeVisitorToContact(conversation.Contact.ID); err != nil {
|
|
return 0, 0, "", fmt.Errorf("upgrading visitor to contact: %w", err)
|
|
}
|
|
|
|
m.lo.Debug("upgraded visitor to contact", "conversation_uuid", conversation.UUID, "contact_id", conversation.Contact.ID)
|
|
|
|
// Notify conversation subscribers that the contact type has changed.
|
|
m.BroadcastContactUpdate(conversation.ContactID, map[string]any{"type": umodels.UserTypeContact})
|
|
|
|
return senderID, conversationID, conversationUUID, nil
|
|
}
|
|
|
|
// isVisitorUpgradeSafe checks whether a visitor-to-contact upgrade should proceed.
|
|
// Blocks upgrade if the continuity email TTL has expired.
|
|
func (m *Manager) isVisitorUpgradeSafe(conversation models.Conversation) bool {
|
|
if conversation.LastContinuityEmailSentAt.Valid {
|
|
if time.Since(conversation.LastContinuityEmailSentAt.Time) > upgradeWindowTTL {
|
|
m.lo.Info("visitor upgrade blocked: continuity email TTL expired",
|
|
"conversation_uuid", conversation.UUID,
|
|
"last_sent", conversation.LastContinuityEmailSentAt.Time,
|
|
"age", time.Since(conversation.LastContinuityEmailSentAt.Time).String(),
|
|
"max_ttl", upgradeWindowTTL.String())
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ProcessIncomingLiveChatMessage handles incoming live chat messages.
|
|
func (m *Manager) ProcessIncomingLiveChatMessage(msg models.Message) (models.Message, error) {
|
|
// Upload message attachments.
|
|
if err := m.uploadMessageAttachments(&msg); err != nil {
|
|
return models.Message{}, fmt.Errorf("uploading message attachments: %w", err)
|
|
}
|
|
|
|
// Insert message.
|
|
if err := m.InsertMessage(&msg); err != nil {
|
|
return models.Message{}, err
|
|
}
|
|
|
|
// Advance contact_last_seen_at.
|
|
if err := m.UpdateConversationContactLastSeen(msg.ConversationUUID); err != nil {
|
|
m.lo.Error("error updating contact last seen after livechat message", "conversation_uuid", msg.ConversationUUID, "error", err)
|
|
}
|
|
|
|
// Process post-message hooks (automation rules, webhooks, SLA, etc.).
|
|
// isNewConversation = false since conversation always exists for live chat.
|
|
if err := m.ProcessIncomingMessageHooks(msg.ConversationUUID, false); err != nil {
|
|
m.lo.Error("error processing incoming message hooks", "conversation_uuid", msg.ConversationUUID, "error", err)
|
|
}
|
|
|
|
return msg, nil
|
|
}
|
|
|
|
// MessageExists checks if a message with the given messageID exists.
|
|
func (m *Manager) MessageExists(messageID string) (bool, error) {
|
|
_, err := m.messageExistsBySourceID([]string{messageID})
|
|
if err != nil {
|
|
if errors.Is(err, errConversationNotFound) {
|
|
return false, nil
|
|
}
|
|
m.lo.Error("error fetching message from db", "error", err)
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// EnqueueIncoming enqueues an incoming message for inserting in db.
|
|
func (m *Manager) EnqueueIncoming(message models.IncomingMessage) error {
|
|
m.closedMu.Lock()
|
|
defer m.closedMu.Unlock()
|
|
if m.closed {
|
|
return errors.New("incoming message queue is closed")
|
|
}
|
|
|
|
select {
|
|
case m.incomingMessageQueue <- message:
|
|
return nil
|
|
default:
|
|
m.lo.Warn("WARNING: incoming message queue is full")
|
|
return errors.New("incoming message queue is full")
|
|
}
|
|
}
|
|
|
|
// GetConversationByMessageID returns conversation by message id.
|
|
func (m *Manager) GetConversationByMessageID(id int) (models.Conversation, error) {
|
|
var conversation = models.Conversation{}
|
|
if err := m.q.GetConversationByMessageID.Get(&conversation, id); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return conversation, envelope.NewError(envelope.NotFoundError, m.i18n.T("validation.notFoundConversation"), nil)
|
|
}
|
|
m.lo.Error("error fetching message from DB", "error", err)
|
|
return conversation, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
// generateMessagesQuery generates the SQL query for fetching messages in a conversation.
|
|
func (c *Manager) generateMessagesQuery(baseQuery string, qArgs []interface{}, page, pageSize int) (string, int, []interface{}, error) {
|
|
if pageSize > maxMessagesPerPage {
|
|
pageSize = maxMessagesPerPage
|
|
}
|
|
|
|
// Calculate the offset
|
|
offset := (page - 1) * pageSize
|
|
|
|
// Append LIMIT and OFFSET to query arguments
|
|
qArgs = append(qArgs, pageSize, offset)
|
|
|
|
// Include LIMIT and OFFSET in the SQL query
|
|
sqlQuery := fmt.Sprintf(baseQuery, fmt.Sprintf("LIMIT $%d OFFSET $%d", len(qArgs)-1, len(qArgs)))
|
|
return sqlQuery, pageSize, qArgs, nil
|
|
}
|
|
|
|
// extractInlineImageUUIDs returns unique media UUIDs from <img src="..."> URLs in order of first appearance, skipping the cid: form.
|
|
func extractInlineImageUUIDs(content string) []string {
|
|
matches := imgSrcPattern.FindAllStringSubmatch(content, -1)
|
|
seen := make(map[string]bool, len(matches))
|
|
out := make([]string, 0, len(matches))
|
|
for _, m := range matches {
|
|
url := m[1]
|
|
if strings.HasPrefix(url, "cid:") {
|
|
continue
|
|
}
|
|
u := stringutil.ExtractUUID(url)
|
|
if u == "" || seen[u] {
|
|
continue
|
|
}
|
|
seen[u] = true
|
|
out = append(out, u)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extractInlineContentIDs returns unique content_ids referenced via <img src="cid:..."> in the body.
|
|
func extractInlineContentIDs(content string) []string {
|
|
matches := imgSrcPattern.FindAllStringSubmatch(content, -1)
|
|
seen := make(map[string]bool, len(matches))
|
|
out := make([]string, 0, len(matches))
|
|
for _, m := range matches {
|
|
url := m[1]
|
|
if !strings.HasPrefix(url, "cid:") {
|
|
continue
|
|
}
|
|
cid := strings.TrimPrefix(url, "cid:")
|
|
if cid == "" || seen[cid] {
|
|
continue
|
|
}
|
|
seen[cid] = true
|
|
out = append(out, cid)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rewriteInlineImagesToCID rewrites every <img src="...<uuid>..."> to <img src="cid:ldsk-<uuid>">. Already-cid form is left alone.
|
|
func rewriteInlineImagesToCID(content string) string {
|
|
return imgSrcPattern.ReplaceAllStringFunc(content, func(match string) string {
|
|
sub := imgSrcPattern.FindStringSubmatch(match)
|
|
url := sub[1]
|
|
if strings.HasPrefix(url, "cid:") {
|
|
return match
|
|
}
|
|
u := stringutil.ExtractUUID(url)
|
|
if u == "" {
|
|
return match
|
|
}
|
|
return strings.Replace(match, url, "cid:"+inlineContentID(u), 1)
|
|
})
|
|
}
|
|
|
|
// uploadMessageAttachments uploads all attachments for a message.
|
|
func (m *Manager) uploadMessageAttachments(message *models.Message) error {
|
|
if len(message.Attachments) == 0 {
|
|
return nil
|
|
}
|
|
|
|
for _, attachment := range message.Attachments {
|
|
contentID := attachment.ContentID
|
|
if contentID != "" {
|
|
storedCID, exists, mediaUUID := m.findExistingMedia(contentID, message.ConversationUUID)
|
|
|
|
// Make body's cid match the stored content_id so the read path can find it.
|
|
if storedCID != contentID {
|
|
message.Content = strings.ReplaceAll(message.Content, fmt.Sprintf("cid:%s", contentID), fmt.Sprintf("cid:%s", storedCID))
|
|
}
|
|
|
|
if exists {
|
|
m.lo.Debug("inline attachment exists, reusing", "content_id", storedCID, "media_uuid", mediaUUID)
|
|
continue
|
|
}
|
|
contentID = storedCID
|
|
}
|
|
|
|
attachment.Name = stringutil.SanitizeFilename(attachment.Name)
|
|
|
|
if len(attachment.Content) == 0 {
|
|
m.lo.Warn("skipping empty attachment", "name", attachment.Name, "content_id", contentID, "content_type", attachment.ContentType, "disposition", attachment.Disposition, "message_source_id", message.SourceID.String, "conversation_uuid", message.ConversationUUID)
|
|
continue
|
|
}
|
|
|
|
m.lo.Debug("uploading message attachment", "name", attachment.Name, "content_id", contentID, "size", attachment.Size, "content_type", attachment.ContentType, "disposition", attachment.Disposition)
|
|
|
|
// Upload and insert entry in media table.
|
|
attachReader := bytes.NewReader(attachment.Content)
|
|
media, err := m.mediaStore.UploadAndInsert(
|
|
attachment.Name,
|
|
attachment.ContentType,
|
|
contentID,
|
|
/** Linking media to message happens later **/
|
|
null.String{}, /** modelType */
|
|
null.Int{}, /** modelID **/
|
|
attachReader,
|
|
attachment.Size,
|
|
null.StringFrom(attachment.Disposition),
|
|
[]byte("{}"), /** meta **/
|
|
true, /** private **/
|
|
)
|
|
if err != nil {
|
|
m.lo.Error("failed to upload attachment", "name", attachment.Name, "content_type", attachment.ContentType, "size", attachment.Size, "content_id", contentID, "disposition", attachment.Disposition, "conversation_uuid", message.ConversationUUID, "message_source_id", message.SourceID.String, "error", err)
|
|
return fmt.Errorf("failed to upload media %s: %w", attachment.Name, err)
|
|
}
|
|
|
|
// If the attachment is an image, generate and upload a thumbnail. Log any errors and continue.
|
|
attachmentExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(attachment.Name)), ".")
|
|
if slices.Contains(image.Exts, attachmentExt) && image.IsImageByContent(bytes.NewReader(attachment.Content)) {
|
|
if err := m.uploadThumbnailForMedia(media, attachment.Content); err != nil {
|
|
m.lo.Error("error uploading thumbnail", "error", err)
|
|
}
|
|
}
|
|
|
|
message.Media = append(message.Media, media)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findOrCreateConversation finds or creates a conversation for the given incoming message.
|
|
func (m *Manager) findOrCreateConversation(in models.IncomingMessage) (int, string, bool, error) {
|
|
var (
|
|
conversationID int
|
|
conversationUUID string
|
|
err error
|
|
)
|
|
|
|
// Search for existing conversation using the in-reply-to and references.
|
|
m.lo.Debug("searching conversation using in-reply-to and references", "in_reply_to", in.InReplyTo, "references", in.References)
|
|
|
|
sourceIDs := append([]string{in.InReplyTo}, in.References...)
|
|
conversationID, err = m.messageExistsBySourceID(sourceIDs)
|
|
if err != nil && err != errConversationNotFound {
|
|
return 0, "", false, err
|
|
}
|
|
|
|
// Conversation not found, create one.
|
|
if conversationID == 0 {
|
|
m.lo.Debug("no conversation found with in-reply-to and references, creating new conversation", "in_reply_to", in.InReplyTo, "references", in.References)
|
|
lastMessage := stringutil.HTML2Text(in.Content)
|
|
lastMessageAt := time.Now()
|
|
conversationID, conversationUUID, err = m.CreateConversation(in.Contact.ID,
|
|
in.InboxID,
|
|
lastMessage,
|
|
lastMessageAt,
|
|
in.Subject,
|
|
false, /**append reference number to subject**/
|
|
nil, /** meta **/
|
|
nil, /** customer attributes **/
|
|
0, /** max conversation **/
|
|
0, /** rate limit window **/
|
|
)
|
|
if err != nil || conversationID == 0 {
|
|
return 0, "", false, err
|
|
}
|
|
return conversationID, conversationUUID, true, nil
|
|
}
|
|
|
|
// Get UUID for the found conversation ID.
|
|
conversationUUID, err = m.GetConversationUUID(conversationID)
|
|
if err != nil {
|
|
return 0, "", false, err
|
|
}
|
|
return conversationID, conversationUUID, false, nil
|
|
}
|
|
|
|
// messageExistsBySourceID returns conversation ID if a message with any of the given source IDs exists.
|
|
func (m *Manager) messageExistsBySourceID(messageSourceIDs []string) (int, error) {
|
|
messageSourceIDs = stringutil.RemoveEmpty(messageSourceIDs)
|
|
if len(messageSourceIDs) == 0 {
|
|
return 0, errConversationNotFound
|
|
}
|
|
var conversationID int
|
|
if err := m.q.MessageExistsBySourceID.QueryRow(pq.Array(messageSourceIDs)).Scan(&conversationID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return conversationID, errConversationNotFound
|
|
}
|
|
m.lo.Error("error fetching msg from DB", "error", err)
|
|
return conversationID, err
|
|
}
|
|
return conversationID, nil
|
|
}
|
|
|
|
// GetInlineMediaRefs returns media referenced via cid: in the body but linked to other messages (quoted history).
|
|
func (m *Manager) GetInlineMediaRefs(message *models.Message) ([]mmodels.Media, error) {
|
|
cids := extractInlineContentIDs(message.Content)
|
|
if len(cids) == 0 {
|
|
return nil, nil
|
|
}
|
|
existing := make(map[string]bool, len(message.Attachments))
|
|
for _, a := range message.Attachments {
|
|
if a.ContentID != "" {
|
|
existing[a.ContentID] = true
|
|
}
|
|
}
|
|
missing := make([]string, 0, len(cids))
|
|
for _, cid := range cids {
|
|
if !existing[cid] {
|
|
missing = append(missing, cid)
|
|
}
|
|
}
|
|
if len(missing) == 0 {
|
|
return nil, nil
|
|
}
|
|
return m.mediaStore.GetByContentIDs(missing, message.ConversationUUID)
|
|
}
|
|
|
|
// fetchMessageAttachments fetches attachments (also inline images) for a single message ID.
|
|
func (m *Manager) fetchMessageAttachments(messageID int) (attachment.Attachments, error) {
|
|
var attachments attachment.Attachments
|
|
|
|
// Get all media for this message.
|
|
medias, err := m.mediaStore.GetByModel(messageID, mmodels.ModelMessages)
|
|
if err != nil {
|
|
return attachments, fmt.Errorf("error fetching message attachments: %w", err)
|
|
}
|
|
|
|
// Fetch blobs for each media item.
|
|
for _, media := range medias {
|
|
blob, err := m.mediaStore.GetBlob(media.UUID)
|
|
if err != nil {
|
|
return attachments, fmt.Errorf("error fetching media blob: %w", err)
|
|
}
|
|
|
|
contentID := media.ContentID
|
|
if contentID == "" {
|
|
contentID = media.UUID
|
|
}
|
|
|
|
attachment := attachment.Attachment{
|
|
Name: media.Filename,
|
|
UUID: media.UUID,
|
|
ContentType: media.ContentType,
|
|
ContentID: contentID,
|
|
Content: blob,
|
|
Size: media.Size,
|
|
Header: attachment.MakeHeader(media.ContentType, contentID, media.Filename, "base64", media.Disposition.String),
|
|
URL: m.mediaStore.GetURL(media.UUID, media.ContentType, media.Filename),
|
|
}
|
|
if strings.HasPrefix(media.ContentType, "image/") {
|
|
attachment.ThumbnailURL = m.mediaStore.GetThumbnailURL(media.UUID)
|
|
}
|
|
attachments = append(attachments, attachment)
|
|
}
|
|
|
|
return attachments, nil
|
|
}
|
|
|
|
// attachAttachmentsToMessage attaches attachment blobs to message.
|
|
func (m *Manager) attachAttachmentsToMessage(message *models.Message) error {
|
|
attachments, err := m.fetchMessageAttachments(message.ID)
|
|
if err != nil {
|
|
m.lo.Error("error fetching message attachments", "error", err)
|
|
return err
|
|
}
|
|
|
|
// Attach attachments.
|
|
message.Attachments = attachments
|
|
|
|
return nil
|
|
}
|
|
|
|
// getOutgoingProcessingMessageIDs returns the IDs of outgoing messages currently being processed.
|
|
func (m *Manager) getOutgoingProcessingMessageIDs() []int {
|
|
var out = make([]int, 0)
|
|
m.outgoingProcessingMessages.Range(func(key, _ any) bool {
|
|
if k, ok := key.(int); ok {
|
|
out = append(out, k)
|
|
}
|
|
return true
|
|
})
|
|
return out
|
|
}
|
|
|
|
// uploadThumbnailForMedia prepares and uploads a thumbnail for an image attachment.
|
|
func (m *Manager) uploadThumbnailForMedia(media mmodels.Media, content []byte) error {
|
|
// Create a reader from the content
|
|
file := bytes.NewReader(content)
|
|
|
|
// Seek to the beginning of the file
|
|
file.Seek(0, 0)
|
|
|
|
// Create the thumbnail
|
|
thumbFile, err := image.CreateThumb(image.DefThumbSize, file)
|
|
if err != nil {
|
|
return fmt.Errorf("error creating thumbnail: %w", err)
|
|
}
|
|
|
|
// Generate thumbnail name
|
|
thumbName := fmt.Sprintf("thumb_%s", media.UUID)
|
|
|
|
// Upload the thumbnail
|
|
if _, _, err := m.mediaStore.Upload(thumbName, media.ContentType, thumbFile); err != nil {
|
|
m.lo.Error("error uploading thumbnail", "error", err)
|
|
return fmt.Errorf("error uploading thumbnail: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ProcessIncomingMessageHooks handles automation rules, webhooks, SLA events, and other post-processing
|
|
// for incoming messages. This allows other channels to insert messages first and then call this
|
|
// function to trigger the necessary hooks.
|
|
func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConversation bool) error {
|
|
// Start waiting since clock, cleared when agent replies.
|
|
m.StartConversationWaitingSince(conversationUUID, time.Now())
|
|
|
|
// Handle new conversation events.
|
|
if isNewConversation {
|
|
conversation, err := m.GetConversation(0, conversationUUID, "")
|
|
if err == nil {
|
|
m.webhookStore.TriggerEvent(wmodels.EventConversationCreated, conversation)
|
|
m.automation.EvaluateNewConversationRules(conversation)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Snapshot before reopening so previous_* filters see the pre-reopen state.
|
|
var previousValues map[string]string
|
|
if preReopen, err := m.GetConversation(0, conversationUUID, ""); err == nil {
|
|
previousValues = amodels.PreviousValues(preReopen)
|
|
}
|
|
|
|
// Reopen conversation if it's not Open.
|
|
systemUser, err := m.userStore.GetSystemUser()
|
|
if err != nil {
|
|
m.lo.Error("error fetching system user", "error", err)
|
|
} else {
|
|
if err := m.ReOpenConversation(conversationUUID, systemUser); err != nil {
|
|
m.lo.Error("error reopening conversation", "error", err)
|
|
}
|
|
}
|
|
|
|
// Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met.
|
|
// This cycle continues for next response time SLA metric.
|
|
conversation, err := m.GetConversation(0, conversationUUID, "")
|
|
if err != nil {
|
|
m.lo.Error("error fetching conversation for incoming message hooks", "conversation_uuid", conversationUUID, "error", err)
|
|
} else {
|
|
// Trigger automations on incoming message event.
|
|
m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming, previousValues, umodels.User{ID: conversation.ContactID})
|
|
|
|
// If assigned to an AI assistant, let it respond to this inbound customer message.
|
|
if m.aiAgent != nil && conversation.AssignedUserID.Valid {
|
|
m.aiAgent.HandleConversationEvent(conversation.ID, conversation.AssignedUserID.Int)
|
|
}
|
|
|
|
if conversation.SLAPolicyID.Int == 0 {
|
|
m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation")
|
|
return nil
|
|
}
|
|
if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil && !errors.Is(err, sla.ErrUnmetSLAEventAlreadyExists) {
|
|
m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err)
|
|
} else if !deadline.IsZero() {
|
|
m.lo.Info("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int)
|
|
m.BroadcastConversationUpdate(conversationUUID, map[string]any{
|
|
"next_response_deadline_at": deadline.Format(time.RFC3339),
|
|
"next_response_met_at": nil,
|
|
})
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// broadcastMessageToWidgetClients sends a message to widget clients if the conversation belongs to a livechat inbox.
|
|
func (m *Manager) broadcastMessageToWidgetClients(message *models.Message) {
|
|
conversation, err := m.GetConversation(0, message.ConversationUUID, "")
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
inboxInstance, err := m.inboxStore.Get(conversation.InboxID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
liveChatInbox, ok := inboxInstance.(*livechat.LiveChat)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
m.SignAttachmentURLs(message.Attachments)
|
|
m.SignAvatarURL(&message.Author.AvatarURL)
|
|
liveChatInbox.BroadcastMessageToClients(message.ConversationUUID, conversation.ContactID, models.ChatMessage{
|
|
UUID: message.UUID,
|
|
Status: message.Status,
|
|
ConversationUUID: message.ConversationUUID,
|
|
CreatedAt: message.CreatedAt,
|
|
Content: message.Content,
|
|
TextContent: message.TextContent,
|
|
Author: message.Author,
|
|
Attachments: message.Attachments,
|
|
Meta: message.Meta,
|
|
})
|
|
}
|
|
|
|
// getMediaPreview returns a localized preview string based on attachment type.
|
|
func (m *Manager) getMediaPreview(media mmodels.Media) string {
|
|
contentType := media.ContentType
|
|
switch {
|
|
case strings.HasPrefix(contentType, "image/"):
|
|
return m.i18n.T("globals.terms.image")
|
|
case strings.HasPrefix(contentType, "video/"):
|
|
return m.i18n.T("globals.terms.video")
|
|
case strings.HasPrefix(contentType, "audio/"):
|
|
return m.i18n.T("globals.terms.audio")
|
|
default:
|
|
return m.i18n.T("globals.terms.file")
|
|
}
|
|
}
|
|
|
|
// inlineContentID lowercases the uuid to match the content_id the DB stamps from uuid::TEXT.
|
|
func inlineContentID(uuid string) string {
|
|
return "ldsk-" + strings.ToLower(uuid)
|
|
}
|
|
|
|
// findExistingMedia resolves an inbound cid to its stored form: ldsk-* is left as-is, others are namespaced by conversation to avoid cross-conversation collisions.
|
|
func (m *Manager) findExistingMedia(rawContentID, conversationUUID string) (string, bool, string) {
|
|
storedCID := rawContentID
|
|
if !strings.HasPrefix(rawContentID, "ldsk-") {
|
|
storedCID = conversationUUID + "_" + rawContentID
|
|
}
|
|
exists, mediaUUID, err := m.mediaStore.ContentIDExists(storedCID, conversationUUID)
|
|
if err != nil {
|
|
m.lo.Error("error checking media existence by content ID", "content_id", storedCID, "error", err)
|
|
}
|
|
return storedCID, exists, mediaUUID
|
|
}
|
|
|
|
// emailFromAddress returns the From header, applying the inbox from-name template for agent senders
|
|
// Falls back to the inbox's default from address if the template is empty, the sender is not an agent, or any errors occur.
|
|
func (m *Manager) emailFromAddress(inb inbox.Inbox, message models.Message) string {
|
|
from := inb.FromAddress()
|
|
|
|
tpl := inb.FromNameTemplate()
|
|
if tpl == "" || message.SenderType != models.SenderTypeAgent {
|
|
return from
|
|
}
|
|
|
|
agent, err := m.userStore.GetAgentCachedOrLoad(message.SenderID)
|
|
if err != nil {
|
|
m.lo.Error("error fetching agent for from name template", "error", err, "sender_id", message.SenderID)
|
|
return from
|
|
}
|
|
if agent.IsSystemUser() {
|
|
return from
|
|
}
|
|
|
|
addr, err := mail.ParseAddress(from)
|
|
if err != nil {
|
|
m.lo.Error("error parsing inbox from address for name template", "error", err, "from", from)
|
|
return from
|
|
}
|
|
|
|
firstName := strings.TrimSpace(agent.FirstName)
|
|
lastName := strings.TrimSpace(agent.LastName)
|
|
data := fromNameVars{
|
|
Agent: fromNameAgent{
|
|
FirstName: firstName,
|
|
LastName: lastName,
|
|
FullName: strings.TrimSpace(firstName + " " + lastName),
|
|
},
|
|
Inbox: fromNameInbox{Name: inb.Name()},
|
|
}
|
|
|
|
t, err := template.New("from").Parse(tpl)
|
|
if err != nil {
|
|
m.lo.Error("error parsing from name template", "error", err, "template", tpl)
|
|
return from
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := t.Execute(&buf, data); err != nil {
|
|
m.lo.Error("error executing from name template", "error", err, "template", tpl)
|
|
return from
|
|
}
|
|
|
|
name := strings.TrimSpace(buf.String())
|
|
if name == "" {
|
|
return from
|
|
}
|
|
addr.Name = name
|
|
return addr.String()
|
|
}
|