") + b.WriteString(html.EscapeString(label)) + b.WriteString("
") + for i, reference := range references { + if i > 0 { + b.WriteByte(' ') + } + fmt.Fprintf(&b, `(%d)`, html.EscapeString(reference.URL), i+1) + } + b.WriteString("
") + return b.String() +} diff --git a/internal/conversation/continuity.go b/internal/conversation/continuity.go index 0230c349..eb4837a5 100644 --- a/internal/conversation/continuity.go +++ b/internal/conversation/continuity.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "html" + "slices" "strings" "time" @@ -202,7 +203,8 @@ func (m *Manager) sendContinuityEmail(conv models.ContinuityConversation, maxMes // Build References and In-Reply-To headers for email threading. references, inReplyTo := m.BuildEmailThreadingHeaders(conv.ID, sourceID) - // Render message template + content, hasReferences := m.renderContinuityEmailContent(unreadMessages, websiteURL) + message.Content = content if err := m.RenderMessageInTemplate(linkedEmailInbox.Channel(), &message); err != nil { // Clean up the inserted message on failure cleanUp = true @@ -241,6 +243,9 @@ func (m *Manager) sendContinuityEmail(conv models.ContinuityConversation, maxMes Meta: message.Meta, CreatedAt: message.CreatedAt, } + if hasReferences { + outbound.AltContent = stringutil.HTML2TextMarkdownLinks(message.Content) + } // Send the email if err := linkedEmailInbox.Send(outbound); err != nil { @@ -270,7 +275,22 @@ func (m *Manager) sendContinuityEmail(conv models.ContinuityConversation, maxMes return nil } -// buildContinuityEmailContent creates email content with conversation summary and unread messages +func (m *Manager) renderContinuityEmailContent(unreadMessages []models.ContinuityUnreadMessage, websiteURL string) (string, bool) { + messages := slices.Clone(unreadMessages) + metas := make([]json.RawMessage, len(messages)) + for i := range messages { + metas[i] = messages[i].Meta + } + hasReferences := false + for i, references := range m.lookupArticleReferences(metas...) { + if len(references) > 0 { + messages[i].Content += emailArticleReferencesHTML(references) + hasReferences = true + } + } + return m.buildContinuityEmailContent(messages, websiteURL), hasReferences +} + func (m *Manager) buildContinuityEmailContent(unreadMessages []models.ContinuityUnreadMessage, websiteURL string) string { var content strings.Builder diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 8524f44c..15ebd06f 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -29,6 +29,7 @@ import ( csatModels "github.com/abhinavxd/libredesk/internal/csat/models" "github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/envelope" + hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models" "github.com/abhinavxd/libredesk/internal/inbox" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" mmodels "github.com/abhinavxd/libredesk/internal/media/models" @@ -91,6 +92,10 @@ type notificationDispatcher interface { Send(nmodels.Notification) ([]nmodels.DeliveryResult, error) } +type articleReferenceStore interface { + GetArticleReferences([]int) ([]hcmodels.ArticleReference, error) +} + // Manager handles the operations related to conversations type Manager struct { q queries @@ -101,6 +106,7 @@ type Manager struct { statusStore statusStore priorityStore priorityStore slaStore slaStore + articleReferenceStore articleReferenceStore settingsStore settingsStore csatStore csatStore webhookStore webhookStore @@ -556,6 +562,7 @@ func (c *Manager) GetContactUnreadPreviewMessages(contactID, inboxID, limit int) return nil, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } + c.RenderMessagesArticleReferences(messages) previews := make([]models.ChatMessage, 0, len(messages)) for _, message := range messages { c.SignAvatarURL(&message.Author.AvatarURL) @@ -2201,6 +2208,7 @@ func (m *Manager) BuildWidgetConversationResponse(conversation models.Conversati m.ProcessCSATStatus(messages) // Generate signed URLs for all attachments. + m.RenderMessagesArticleReferences(messages) chatMessages := make([]models.ChatMessage, 0, len(messages)) for _, msg := range messages { m.SignAvatarURL(&msg.Author.AvatarURL) diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 720e2670..ee5c8e6c 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -188,6 +188,9 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { return } outbound.From = m.emailFromAddress(inb, message, selected) + if len(messageArticleIDs(message.Meta)) > 0 { + outbound.AltContent = stringutil.HTML2TextMarkdownLinks(message.Content) + } // Set "In-Reply-To" and "References" headers for email threading. outbound.References, outbound.InReplyTo = m.BuildEmailThreadingHeaders(message.ConversationID, outbound.SourceID) @@ -304,7 +307,6 @@ func (m *Manager) BuildTemplateData(conversationUUID string, senderID int) (map[ 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: @@ -313,22 +315,14 @@ func (m *Manager) RenderMessageInTemplate(channel string, message *models.Messag 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) + content := m.emailTemplateContent(message, data) + message.Content, err = m.template.RenderEmailWithTemplate(data, 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, inbox.ChannelWhatsApp: + m.RenderArticleReferences(message) return nil default: m.lo.Warn("unknown message channel", "channel", channel) @@ -337,6 +331,26 @@ func (m *Manager) RenderMessageInTemplate(channel string, message *models.Messag return nil } +func (m *Manager) emailTemplateContent(message *models.Message, data map[string]any) string { + 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 + if isContinuity { + data["ContinuityContent"] = message.Content + return "{{ .ContinuityContent }}" + } + if references := m.emailArticleReferences(message); references != "" { + data["ArticleReferences"] = references + return message.Content + "{{ .ArticleReferences }}" + } + return message.Content +} + // 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 ( diff --git a/internal/conversation/message_test.go b/internal/conversation/message_test.go index 1ec6a25c..3e6fb16c 100644 --- a/internal/conversation/message_test.go +++ b/internal/conversation/message_test.go @@ -1,11 +1,20 @@ package conversation import ( + "encoding/json" + "errors" + "reflect" "strings" "testing" + "text/template" + "time" "github.com/abhinavxd/libredesk/internal/conversation/models" + hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models" "github.com/abhinavxd/libredesk/internal/inbox" + "github.com/abhinavxd/libredesk/internal/stringutil" + "github.com/abhinavxd/libredesk/internal/testutil" + "github.com/zerodha/logf" ) const testUUID = "d0355103-455f-4c7d-b9c7-86e9254fe119" @@ -21,6 +30,32 @@ func (f fromTestInbox) FromAddress() string { return f.from } func (f fromTestInbox) PrimaryAddress() string { return f.primary } func (f fromTestInbox) FromNameTemplate() string { return "" } +type citationArticleStore struct { + references []hcmodels.ArticleReference + err error + lookups *int +} + +type citationSettingsStore struct { + settingsStore + rootURL string + lookups *int +} + +func (s citationArticleStore) GetArticleReferences(ids []int) ([]hcmodels.ArticleReference, error) { + if s.lookups != nil { + (*s.lookups)++ + } + return s.references, s.err +} + +func (s citationSettingsStore) GetAppRootURL() (string, error) { + if s.lookups != nil { + (*s.lookups)++ + } + return s.rootURL, nil +} + func TestImgSrcUploadsPattern(t *testing.T) { tests := []struct { name string @@ -630,3 +665,265 @@ func TestEmailFromAddress(t *testing.T) { }) } } + +func TestMessageArticleIDs(t *testing.T) { + tests := []struct { + meta string + want []int + }{ + {`{"ai_assistant_id":1,"ai_article_ids":[12,12,0,-1,34]}`, []int{12, 34}}, + {`{"ai_assistant_id":1,"ai_article_ids":[]}`, []int{}}, + {`{"ai_article_ids":[12]}`, nil}, + {`{"ai_assistant_id":0,"ai_article_ids":[12]}`, nil}, + {`{"ai_assistant_id":1,"ai_article_ids":["12"]}`, nil}, + {`not-json`, nil}, + } + for _, tt := range tests { + if got := messageArticleIDs(json.RawMessage(tt.meta)); !reflect.DeepEqual(got, tt.want) { + t.Errorf("messageArticleIDs(%s) = %v, want %v", tt.meta, got, tt.want) + } + } +} + +func TestRenderArticleReferences(t *testing.T) { + lo := logf.New(logf.Opts{}) + m := Manager{ + articleReferenceStore: citationArticleStore{references: []hcmodels.ArticleReference{ + {ID: 12, Title: `Refunds