Use JWT.
\nDéconnectez-vous.
Answer.
\n" { + t.Fatalf("uncited reply retained a marker: %q", got) + } + link := articleCitationHTML(`[Article](https://example.com "[[cite:12]]")`, []int{12}) + if strings.Contains(link, "ld-cite") || strings.Contains(link, "[[cite:") { + t.Fatalf("citation inserted inside an HTML attribute: %q", link) + } + if got := numberedCitationText("JWT.[[cite:12]] Logout.[[cite:34]][[cite:999]]", []int{12, 34}); got != "JWT.(1) Logout.(2)" { + t.Fatalf("unexpected preview: %q", got) + } +} diff --git a/internal/conversation/citations.go b/internal/conversation/citations.go new file mode 100644 index 00000000..0f58feef --- /dev/null +++ b/internal/conversation/citations.go @@ -0,0 +1,206 @@ +package conversation + +import ( + "encoding/json" + "fmt" + "html" + "regexp" + "strconv" + "strings" + + "github.com/abhinavxd/libredesk/internal/conversation/models" + hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models" + nethtml "golang.org/x/net/html" +) + +var articleCitationRegexp = regexp.MustCompile(``) + +func (m *Manager) SetArticleReferenceStore(store articleReferenceStore) { + m.articleReferenceStore = store +} + +func (m *Manager) GetArticleReferences(ids []int) ([]hcmodels.ArticleReference, error) { + if m.articleReferenceStore == nil || len(ids) == 0 { + return nil, nil + } + references, err := m.articleReferenceStore.GetArticleReferences(ids) + if err != nil { + return nil, err + } + rootURL, err := m.settingsStore.GetAppRootURL() + if err != nil { + return nil, err + } + resolved := make([]hcmodels.ArticleReference, 0, len(references)) + for _, reference := range references { + reference.URL = reference.PublicURL(rootURL) + if reference.URL != "" { + resolved = append(resolved, reference) + } + } + return resolved, nil +} + +func (m *Manager) RenderArticleReferences(message *models.Message) { + message.Content = chatArticleReferencesHTML(message.Content, m.lookupArticleReferences(message.Meta)[0]) +} + +func (m *Manager) RenderMessagesArticleReferences(messages []models.Message) { + for i, references := range m.lookupArticleReferences(messageMetas(messages)...) { + messages[i].Content = chatArticleReferencesHTML(messages[i].Content, references) + } +} + +func (m *Manager) emailArticleReferences(message *models.Message) string { + return emailArticleReferencesHTML(m.lookupArticleReferences(message.Meta)[0]) +} + +// lookupArticleReferences returns each message's references, resolved with one query for all of them. +func (m *Manager) lookupArticleReferences(metas ...json.RawMessage) [][]hcmodels.ArticleReference { + out := make([][]hcmodels.ArticleReference, len(metas)) + idsByMessage := make([][]int, len(metas)) + var ids []int + for i, meta := range metas { + idsByMessage[i] = messageArticleIDs(meta) + ids = append(ids, idsByMessage[i]...) + } + if len(ids) == 0 { + return out + } + references, err := m.GetArticleReferences(ids) + if err != nil { + m.lo.Error("error rendering article references", "error", err) + return out + } + byID := make(map[int]hcmodels.ArticleReference, len(references)) + for _, reference := range references { + byID[reference.ID] = reference + } + for i, messageIDs := range idsByMessage { + for _, id := range messageIDs { + if reference, ok := byID[id]; ok { + out[i] = append(out[i], reference) + } + } + } + return out +} + +func messageMetas(messages []models.Message) []json.RawMessage { + metas := make([]json.RawMessage, len(messages)) + for i := range messages { + metas[i] = messages[i].Meta + } + return metas +} + +func messageArticleIDs(meta json.RawMessage) []int { + var data struct { + AssistantID int `json:"ai_assistant_id"` + ArticleIDs []int `json:"ai_article_ids"` + } + if json.Unmarshal(meta, &data) != nil || data.AssistantID <= 0 { + return nil + } + ids := make([]int, 0, len(data.ArticleIDs)) + seen := map[int]bool{} + for _, id := range data.ArticleIDs { + if id > 0 && !seen[id] { + ids = append(ids, id) + seen[id] = true + } + } + return ids +} + +func chatArticleReferencesHTML(content string, references []hcmodels.ArticleReference) string { + byID := make(map[int]int, len(references)) + for i, reference := range references { + byID[reference.ID] = i + } + used := map[int]bool{} + if articleCitationRegexp.MatchString(content) { + paragraph := map[int]bool{} + tokens := nethtml.NewTokenizer(strings.NewReader(content)) + var rendered strings.Builder + for { + kind := tokens.Next() + if kind == nethtml.ErrorToken { + break + } + raw := string(tokens.Raw()) + if kind == nethtml.StartTagToken || kind == nethtml.EndTagToken { + name, _ := tokens.TagName() + if string(name) == "p" || string(name) == "li" { + clear(paragraph) + } + } + if kind == nethtml.CommentToken { + raw = articleCitationRegexp.ReplaceAllStringFunc(raw, func(marker string) string { + id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(marker, "")) + i, ok := byID[id] + if !ok || paragraph[id] { + return "" + } + used[id] = true + paragraph[id] = true + return articleReferenceLinkHTML(references[i], i+1) + }) + } + rendered.WriteString(raw) + } + content = rendered.String() + } + var remaining strings.Builder + for i, reference := range references { + if !used[reference.ID] { + if remaining.Len() > 0 { + remaining.WriteByte(' ') + } + remaining.WriteString(articleReferenceLinkHTML(reference, i+1)) + } + } + if remaining.Len() > 0 { + content += "" + remaining.String() + "
" + } + return content +} + +func articleReferenceLinkHTML(reference hcmodels.ArticleReference, number int) string { + return fmt.Sprintf(`(%d)`, html.EscapeString(reference.URL), html.EscapeString(reference.Title), html.EscapeString(reference.Title), number) +} + +func articleReferencesHTML(references []hcmodels.ArticleReference, label string) string { + if len(references) == 0 { + return "" + } + var b strings.Builder + b.WriteString("") + 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..9aaf8cb6 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) @@ -574,6 +581,7 @@ func (c *Manager) GetContactUnreadPreviewMessages(contactID, inboxID, limit int) CreatedAt: message.CreatedAt, Content: message.Content, TextContent: message.TextContent, + ContentType: message.ContentType, Author: author, Attachments: message.Attachments, Meta: message.Meta, @@ -2201,6 +2209,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) @@ -2222,6 +2231,7 @@ func (m *Manager) BuildWidgetConversationResponse(conversation models.Conversati CreatedAt: msg.CreatedAt, Content: msg.Content, TextContent: msg.TextContent, + ContentType: msg.ContentType, ConversationUUID: msg.ConversationUUID, Meta: msg.Meta, Author: author, diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 720e2670..0e0b3147 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,27 @@ func (m *Manager) RenderMessageInTemplate(channel string, message *models.Messag return nil } +func (m *Manager) emailTemplateContent(message *models.Message, data map[string]any) string { + content := articleCitationRegexp.ReplaceAllString(message.Content, "") + 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"] = content + return "{{ .ContinuityContent }}" + } + if references := m.emailArticleReferences(message); references != "" { + data["ArticleReferences"] = references + return content + "{{ .ArticleReferences }}" + } + return 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 ( @@ -1680,6 +1695,7 @@ func (m *Manager) broadcastMessageToWidgetClients(message *models.Message) { CreatedAt: message.CreatedAt, Content: message.Content, TextContent: message.TextContent, + ContentType: message.ContentType, Author: message.Author, Attachments: message.Attachments, Meta: message.Meta, diff --git a/internal/conversation/message_test.go b/internal/conversation/message_test.go index 1ec6a25c..edda6201 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,328 @@ 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