mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 22:25:39 +00:00
dad99bf0ee
Replies from the AI agent are now converted from markdown to HTML with goldmark before queueing, so bold, links, and lists render properly in the widget, agent app, and email. The prompt now allows simple markdown. Raw HTML in model output is escaped by goldmark, and both frontends sanitize on render anyway. Other fixes bundled in: - validate avatar type and size before creating or updating an assistant, and roll back the assistant if the avatar upload fails after create - return 404 from agent update and API key endpoints for AI assistant identity users, and hide assistants from mention and SLA user pickers - reserve the autonomous assistant's built-in tool names so custom tools cannot shadow them - apply resolve after the reply is posted so the CSAT survey follows the answer instead of preceding it - unassign the assistant on handoff even when the fallback team is the same team - count reopens by status category instead of status name, and exclude CSAT messages from the turn cap - split oversized wrapper divs into child blocks when chunking KB HTML instead of truncating them - return 404 when soft-deleting an already-deleted agent, and keep AI assistants (which have no email) visible in the compact users list
270 lines
7.1 KiB
Go
270 lines
7.1 KiB
Go
// Package stringutil provides string utility functions.
|
|
package stringutil
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"fmt"
|
|
"net/mail"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jaytaylor/html2text"
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/extension"
|
|
"github.com/yuin/goldmark/renderer/html"
|
|
)
|
|
|
|
const (
|
|
PasswordDummy = "•"
|
|
)
|
|
|
|
var (
|
|
regexpNonAlNum = regexp.MustCompile(`[^a-zA-Z0-9\-_\.]+`)
|
|
regexpSpaces = regexp.MustCompile(`[\s]+`)
|
|
uuidV4Regex = regexp.MustCompile(`[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}`)
|
|
regexpRefNumber = regexp.MustCompile(`#(\d+)`)
|
|
regexpConvUUID = regexp.MustCompile(`(?i)\+conv-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}@`)
|
|
|
|
// markdownRenderer escapes raw HTML in the input; single newlines render as <br>.
|
|
markdownRenderer = goldmark.New(
|
|
goldmark.WithExtensions(extension.GFM),
|
|
goldmark.WithRendererOptions(html.WithHardWraps()),
|
|
)
|
|
)
|
|
|
|
// SanitizeUTF8 removes NUL bytes and replaces invalid UTF-8 byte sequences with the Unicode replacement character.
|
|
func SanitizeUTF8(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
s = strings.ReplaceAll(s, "\x00", "")
|
|
return strings.ToValidUTF8(s, "�")
|
|
}
|
|
|
|
// HTML2Text converts HTML to text.
|
|
func HTML2Text(html string) string {
|
|
out, err := html2text.FromString(html, html2text.Options{TextOnly: true})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(out)
|
|
}
|
|
|
|
// Markdown2HTML converts markdown to HTML, falling back to the input on error.
|
|
func Markdown2HTML(md string) string {
|
|
var b strings.Builder
|
|
if err := markdownRenderer.Convert([]byte(md), &b); err != nil {
|
|
return md
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// SanitizeFilename sanitizes the provided filename.
|
|
func SanitizeFilename(fName string) string {
|
|
// Trim whitespace.
|
|
name := strings.TrimSpace(fName)
|
|
|
|
// Replace whitespace and "/" with "-"
|
|
name = regexpSpaces.ReplaceAllString(name, "-")
|
|
|
|
// Remove or replace any non-alphanumeric characters
|
|
name = regexpNonAlNum.ReplaceAllString(name, "")
|
|
|
|
// Convert to lowercase
|
|
name = strings.ToLower(name)
|
|
return filepath.Base(name)
|
|
}
|
|
|
|
// RandomAlphanumeric generates a random alphanumeric string of length n.
|
|
func RandomAlphanumeric(n int) (string, error) {
|
|
const dictionary = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
|
|
bytes := make([]byte, n)
|
|
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for k, v := range bytes {
|
|
bytes[k] = dictionary[v%byte(len(dictionary))]
|
|
}
|
|
|
|
return string(bytes), nil
|
|
}
|
|
|
|
// RandomNumeric generates a random numeric string of length n.
|
|
func RandomNumeric(n int) (string, error) {
|
|
const dictionary = "0123456789"
|
|
|
|
bytes := make([]byte, n)
|
|
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for k, v := range bytes {
|
|
bytes[k] = dictionary[v%byte(len(dictionary))]
|
|
}
|
|
|
|
return string(bytes), nil
|
|
}
|
|
|
|
// RemoveEmpty removes empty strings from a slice of strings.
|
|
func RemoveEmpty(s []string) []string {
|
|
var r []string
|
|
for _, str := range s {
|
|
if str != "" {
|
|
r = append(r, str)
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// GenerateEmailMessageID generates an RFC-compliant Message-ID for an email without angle brackets.
|
|
func GenerateEmailMessageID(uuid string, fromAddress string) (string, error) {
|
|
if uuid == "" {
|
|
return "", fmt.Errorf("uuid cannot be empty")
|
|
}
|
|
|
|
// Parse from address
|
|
addr, err := mail.ParseAddress(fromAddress)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid from address: %w", err)
|
|
}
|
|
|
|
// Extract domain with validation
|
|
parts := strings.Split(addr.Address, "@")
|
|
if len(parts) != 2 || parts[1] == "" {
|
|
return "", fmt.Errorf("invalid domain in from address")
|
|
}
|
|
domain := parts[1]
|
|
|
|
// Random component
|
|
randomStr, err := RandomAlphanumeric(11)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate random string: %w", err)
|
|
}
|
|
|
|
return fmt.Sprintf("%s-%d-%s@%s",
|
|
uuid,
|
|
time.Now().UnixNano(),
|
|
randomStr,
|
|
domain,
|
|
), nil
|
|
}
|
|
|
|
// RemoveItemByValue removes all instances of a value from a slice of strings.
|
|
func RemoveItemByValue(slice []string, value string) []string {
|
|
result := []string{}
|
|
for _, v := range slice {
|
|
if v != value {
|
|
result = append(result, v)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// FormatDuration formats a duration as a string.
|
|
func FormatDuration(d time.Duration, includeSeconds bool) string {
|
|
d = d.Round(time.Second)
|
|
h := int64(d.Hours())
|
|
d -= time.Duration(h) * time.Hour
|
|
m := int64(d.Minutes())
|
|
d -= time.Duration(m) * time.Minute
|
|
s := int64(d.Seconds())
|
|
|
|
var parts []string
|
|
if h > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d hours", h))
|
|
}
|
|
if m >= 0 {
|
|
parts = append(parts, fmt.Sprintf("%d minutes", m))
|
|
}
|
|
if s > 0 && includeSeconds {
|
|
parts = append(parts, fmt.Sprintf("%d seconds", s))
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// ValidEmail returns true if it's a valid email else return false.
|
|
func ValidEmail(email string) bool {
|
|
addr, err := mail.ParseAddress(email)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return addr.Name == "" && addr.Address == email
|
|
}
|
|
|
|
// ExtractEmail extracts the email address from a string.
|
|
// E.g. "Name <john@example.com>" -> "john@example.com", "john@example.com" -> "john@example.com".
|
|
func ExtractEmail(s string) (string, error) {
|
|
addr, err := mail.ParseAddress(s)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return addr.Address, nil
|
|
}
|
|
|
|
// DedupAndExcludeString returns a deduplicated []string excluding empty and a specific value.
|
|
func DedupAndExcludeString(list []string, exclude string) []string {
|
|
seen := make(map[string]struct{}, len(list))
|
|
cleaned := make([]string, 0, len(list))
|
|
for _, s := range list {
|
|
if s == "" || s == exclude {
|
|
continue
|
|
}
|
|
if _, ok := seen[s]; !ok {
|
|
seen[s] = struct{}{}
|
|
cleaned = append(cleaned, s)
|
|
}
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
// ExtractConvUUID extracts the conversation UUID from a plus-addressed email.
|
|
// e.g., support+conv-abc12345-1234-4123-1234-123456789abc@domain.com -> abc12345-1234-4123-1234-123456789abc
|
|
// Returns empty string if no valid UUIDv4 found.
|
|
func ExtractConvUUID(email string) string {
|
|
match := regexpConvUUID.FindString(email)
|
|
if match == "" {
|
|
return ""
|
|
}
|
|
// match is "+conv-{uuid}@", extract just the UUID (skip "+conv-" prefix and "@" suffix)
|
|
return match[6 : len(match)-1]
|
|
}
|
|
|
|
// ExtractUUID finds and returns the first valid UUID v4 in the given text.
|
|
// Returns empty string if no valid UUID is found.
|
|
func ExtractUUID(text string) string {
|
|
return uuidV4Regex.FindString(text)
|
|
}
|
|
|
|
// ExtractReferenceNumber extracts the last reference number from a subject line.
|
|
// For example, "RE: Test - #392" returns "392".
|
|
// If multiple numbers exist (e.g., "Order #123 - #392"), returns the last one ("392").
|
|
func ExtractReferenceNumber(subject string) string {
|
|
matches := regexpRefNumber.FindAllStringSubmatch(subject, -1)
|
|
if len(matches) > 0 {
|
|
// Return the last match's captured group.
|
|
lastMatch := matches[len(matches)-1]
|
|
if len(lastMatch) >= 2 {
|
|
return lastMatch[1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SplitName splits a full name; the first word is the first name, the rest is the last name.
|
|
func SplitName(name string) (string, string) {
|
|
fields := strings.Fields(name)
|
|
if len(fields) == 0 {
|
|
return "", ""
|
|
}
|
|
if len(fields) == 1 {
|
|
return fields[0], ""
|
|
}
|
|
return fields[0], strings.Join(fields[1:], " ")
|
|
}
|