const fs = require("fs"); const path = require("path"); const yaml = require("js-yaml"); const { normalizeFontFamily, resolveTypography, } = require("./googleFonts.js"); const TEMPLATE_PATH = path.join(__dirname, "../Configs/transcript-template.yml"); let cachedTemplate = null; function escapeHtml(str = "") { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function parseMarkdown(content = "") { let safe = escapeHtml(content); safe = safe.replace(/```([\s\S]*?)```/g, "
$1
"); safe = safe.replace(/`(.*?)`/g, "$1"); safe = safe.replace(/\|\|(.*?)\|\|/g, '$1'); safe = safe.replace(/\*\*(.*?)\*\*/g, "$1"); safe = safe.replace(/\*(.*?)\*/g, "$1"); safe = safe.replace(/_(.*?)_/g, "$1"); safe = safe.replace(/~~(.*?)~~/g, "$1"); safe = safe.replace(/__(.*?)__/g, "$1"); safe = safe.replace(/<@!?(\d+)>/g, '@User'); safe = safe.replace(/<@&(\d+)>/g, '@Role'); safe = safe.replace( /\[(.*?)\]\((.*?)\)/g, '$1', ); return safe.replace(/\n/g, "
"); } function formatDate(iso) { try { return new Date(iso).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short", }); } catch { return iso; } } function getFileTypeInfo(att) { const fileName = String(att.name || "attachment").toLowerCase(); if (/\.(png|jpg|jpeg|gif|webp|bmp|svg)$/i.test(fileName)) { return "image"; } if (/\.(mp4|webm|mov|m4v)$/i.test(fileName)) { return "video"; } if (/\.(mp3|wav|ogg|m4a|flac)$/i.test(fileName)) { return "audio"; } return "file"; } function renderAttachment(att) { const url = escapeHtml(att.url || ""); const name = escapeHtml(att.name || "attachment"); const type = getFileTypeInfo(att); if (type === "image") { return `
๐Ÿ“Ž ${name}
${name}
`; } if (type === "video") { return `
๐Ÿ“Ž ${name}
`; } if (type === "audio") { return `
๐Ÿ“Ž ${name}
`; } return `
๐Ÿ“Ž ${name}
`; } function renderEmbeds(embeds = []) { return embeds .map((embed) => { const color = embed.color || "#5865F2"; const fields = (embed.fields || []) .map( (field) => `
${escapeHtml(field.name || "Field")}
${parseMarkdown(field.value || "")}
`, ) .join(""); const author = embed.author ? `
${embed.author.iconURL ? `` : ''} ${escapeHtml(embed.author.name || '')}
` : ''; const thumbnail = embed.thumbnail?.url ? ` ` : ''; const image = embed.image?.url ? `
` : ''; const footer = (embed.footer || embed.timestamp) ? ` ` : ''; return `
${author} ${thumbnail} ${embed.title ? `
${parseMarkdown(embed.title)}
` : ""} ${embed.description ? `
${parseMarkdown(embed.description)}
` : ""} ${fields ? `
${fields}
` : ""} ${image} ${footer}
`; }) .join(""); } function deepMerge(base, patch) { const out = structuredClone(base); for (const [key, val] of Object.entries(patch || {})) { if ( val && typeof val === "object" && !Array.isArray(val) && out[key] && typeof out[key] === "object" && !Array.isArray(out[key]) ) { out[key] = deepMerge(out[key], val); } else { out[key] = val; } } return out; } function getDefaultTemplate() { return { Theme: { Background: "#0b0f14", Panel: "#111821", PanelSecondary: "#182230", PanelTertiary: "#1d2938", Border: "#283548", Text: "#e6edf3", Muted: "#9fb0c3", Accent: "#22c55e", AccentSoft: "rgba(34, 197, 94, 0.12)", AccentBorder: "rgba(34, 197, 94, 0.25)", AccentText: "#8df0ad", Link: "#8fd9ff", AttachmentLink: "#8fd9a8", CodeBackground: "#0f1720", }, Typography: { FontFamily: "Inter", }, Header: { Title: "Ticket Transcript", Subtitle: "Exported conversation log for support ticket **{{ticketName}}**", Badge: "Closed Transcript", ShowMetaCards: true, }, Meta: { ChannelLabel: "Channel", TicketIdLabel: "Ticket ID", MessageCountLabel: "Message Count", CloseReasonLabel: "Close Reason", DefaultCloseReason: "No Reason Provided.", }, Messages: { SectionTitle: "Messages", EmptyState: "No messages were found in this ticket.", }, Advanced: { CustomCss: "", }, }; } function normalizeTemplate(raw) { const merged = deepMerge(getDefaultTemplate(), raw || {}); if (merged.Typography) { merged.Typography.FontFamily = normalizeFontFamily( merged.Typography.FontFamily, ); } return merged; } function loadTemplateFromDisk() { try { if (!fs.existsSync(TEMPLATE_PATH)) { const defaults = getDefaultTemplate(); fs.mkdirSync(path.dirname(TEMPLATE_PATH), { recursive: true }); fs.writeFileSync(TEMPLATE_PATH, yaml.dump(defaults), "utf8"); return defaults; } const parsed = yaml.load(fs.readFileSync(TEMPLATE_PATH, "utf8")); return normalizeTemplate(parsed); } catch (err) { console.error("[TranscriptTemplate] Failed to load template:", err.message); return getDefaultTemplate(); } } function getTemplate() { if (!cachedTemplate) { cachedTemplate = loadTemplateFromDisk(); } return structuredClone(cachedTemplate); } function reloadTemplate() { cachedTemplate = loadTemplateFromDisk(); return getTemplate(); } function saveTemplate(data) { const merged = normalizeTemplate(data); fs.mkdirSync(path.dirname(TEMPLATE_PATH), { recursive: true }); fs.writeFileSync(TEMPLATE_PATH, yaml.dump(merged), "utf8"); cachedTemplate = merged; return merged; } function applyPlaceholders(text, ctx) { return String(text || "") .replace(/\{\{ticketName\}\}/g, ctx.name || "") .replace(/\{\{ticketId\}\}/g, ctx.id || "") .replace(/\{\{messageCount\}\}/g, String(ctx.messageCount ?? 0)) .replace(/\{\{reason\}\}/g, ctx.reason || ""); } function formatHeaderText(text, ctx) { const withPlaceholders = applyPlaceholders(text, ctx); const escaped = escapeHtml(withPlaceholders); return escaped.replace(/\*\*(.+?)\*\*/g, "$1"); } function buildStyles(template) { const t = template.Theme; const { fontFamilyCss } = resolveTypography(template.Typography?.FontFamily); const custom = String(template.Advanced?.CustomCss || "").trim(); return ` :root { --bg: ${t.Background}; --panel: ${t.Panel}; --panel-2: ${t.PanelSecondary}; --panel-3: ${t.PanelTertiary}; --border: ${t.Border}; --text: ${t.Text}; --muted: ${t.Muted}; --accent: ${t.Accent}; --accent-soft: ${t.AccentSoft}; --accent-border: ${t.AccentBorder}; --accent-text: ${t.AccentText}; --link: ${t.Link}; --attachment-link: ${t.AttachmentLink}; --code: ${t.CodeBackground}; } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: ${fontFamilyCss}; } body { padding: 32px 20px; } .page { max-width: 1100px; margin: 0 auto; } .header { background: var(--panel); border: 1px solid var(--border); border-radius: 16px; padding: 24px; margin-bottom: 18px; } .header-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; } .title-wrap h1 { margin: 0; font-size: 1.8rem; line-height: 1.2; font-weight: 800; letter-spacing: -0.02em; } .title-wrap p { margin: 8px 0 0; color: var(--muted); font-size: 0.98rem; } .badge { display: inline-flex; align-items: center; gap: 8px; background: var(--accent-soft); color: var(--accent-text); border: 1px solid var(--accent-border); padding: 8px 12px; border-radius: 999px; font-size: 0.9rem; font-weight: 700; white-space: nowrap; } .meta-grid { margin-top: 18px; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .meta-card { background: var(--panel-2); border: 1px solid var(--border); border-radius: 12px; padding: 14px; } .meta-label { color: var(--muted); font-size: 0.82rem; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; } .meta-value { font-size: 0.98rem; font-weight: 600; word-break: break-word; } .messages-wrap { background: var(--panel); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; } .messages-header { padding: 18px 20px; border-bottom: 1px solid var(--border); background: var(--panel-2); font-weight: 800; font-size: 1rem; } .message { display: flex; gap: 14px; padding: 18px 20px; border-bottom: 1px solid var(--border); } .message:last-child { border-bottom: none; } .avatar { width: 42px; height: 42px; min-width: 42px; border-radius: 50%; object-fit: cover; background: var(--panel-3); border: 1px solid var(--border); } .message-body { min-width: 0; flex: 1; } .message-header { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; } .username { font-weight: 800; color: var(--text); font-size: 0.98rem; } .userid { color: var(--muted); font-size: 0.8rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .timestamp { color: var(--muted); font-size: 0.82rem; margin-left: auto; } .content { color: var(--text); font-size: 0.96rem; line-height: 1.6; word-break: break-word; } .content:empty { display: none; } .discord-embed { margin-top: 12px; background: var(--panel-2); border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: 10px; padding: 12px 14px; } .discord-embed-title { font-weight: 800; margin-bottom: 6px; } .discord-embed-description { color: var(--text); line-height: 1.5; } .embed-author { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 0.9em; margin-bottom: 6px; } .embed-author-icon { width: 18px; height: 18px; border-radius: 50%; } .embed-fields-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.06); } .embed-thumbnail { position: absolute; top: 12px; right: 12px; width: 44px; height: 44px; border-radius: 6px; object-fit: cover; } .embed-image img { width: 100%; max-height: 300px; object-fit: cover; border-radius: 8px; margin-top: 10px; } .embed-footer { display: flex; align-items: center; gap: 6px; font-size: 0.8em; color: var(--muted); margin-top: 8px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.04); } .embed-footer-icon { width: 14px; height: 14px; border-radius: 50%; } .mention { background: rgba(88, 101, 242, 0.2); color: #5865F2; padding: 2px 6px; border-radius: 4px; font-weight: 600; font-size: 0.9em; } .spoiler { background: #202225; color: transparent; border-radius: 4px; padding: 0 4px; cursor: pointer; } .spoiler:hover { color: inherit; background: rgba(255,255,255,0.1); } .embed-field { margin-top: 6px; } .embed-field-name { font-weight: 700; margin-bottom: 4px; font-size: 0.85em; text-transform: uppercase; color: var(--muted); } .embed-field-value { color: var(--text); line-height: 1.5; } .attachment { margin-top: 12px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 12px; padding: 12px; } .attachment-file { display: inline-block; color: var(--attachment-link); text-decoration: none; font-weight: 700; word-break: break-word; } .attachment-file:hover { text-decoration: underline; } .attachment-preview { margin-top: 10px; } .attachment-preview img, .attachment-preview video { width: 100%; max-width: 560px; border-radius: 10px; border: 1px solid var(--border); display: block; background: #0a0f15; } .attachment-preview audio { width: 100%; max-width: 560px; display: block; } code { background: var(--code); border: 1px solid var(--border); padding: 2px 6px; border-radius: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; } pre { background: var(--code); border: 1px solid var(--border); padding: 12px; border-radius: 10px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; } pre code { background: transparent; border: none; padding: 0; } a { color: var(--link); } .empty-state { padding: 24px 20px; color: var(--muted); } @media (max-width: 700px) { body { padding: 18px 12px; } .header, .messages-wrap { border-radius: 12px; } .message { padding: 14px; } .timestamp { width: 100%; margin-left: 0; } } ${custom} `; } function getSampleTicket() { return { id: "123456789012345678", name: "ticket-support-demo", messages: [ { username: "SupportBot", userId: "100000000000000001", avatar: "https://cdn.discordapp.com/embed/avatars/0.png", timestamp: new Date().toISOString(), content: "Hello! Thanks for opening a ticket. How can we help?", embeds: [], attachments: [], }, { username: "Member", userId: "100000000000000002", avatar: "https://cdn.discordapp.com/embed/avatars/1.png", timestamp: new Date(Date.now() - 60000).toISOString(), content: "I need help with my purchase โ€” order **#48291**.", embeds: [ { title: "Order details", description: "Payment received ยท awaiting delivery", fields: [{ name: "Status", value: "Pending" }], }, ], attachments: [], }, ], }; } function createTranscriptHTML(ticket, reason, templateInput) { const template = templateInput ? normalizeTemplate(templateInput) : getTemplate(); const header = template.Header; const meta = template.Meta; const messagesCfg = template.Messages; const closeReason = reason || meta.DefaultCloseReason; const ctx = { name: ticket.name, id: ticket.id, messageCount: ticket.messages.length, reason: closeReason, }; const { headLinks } = resolveTypography(template.Typography?.FontFamily); const metaSection = header.ShowMetaCards ? `
${escapeHtml(meta.ChannelLabel)}
${escapeHtml(ticket.name)}
${escapeHtml(meta.TicketIdLabel)}
${escapeHtml(ticket.id)}
${escapeHtml(meta.MessageCountLabel)}
${ticket.messages.length}
${escapeHtml(meta.CloseReasonLabel)}
${escapeHtml(closeReason)}
` : ""; return ` Transcript - ${escapeHtml(ticket.name)} ${headLinks}

${escapeHtml(header.Title)}

${formatHeaderText(header.Subtitle, ctx)}

${escapeHtml(header.Badge)}
${metaSection}
${escapeHtml(messagesCfg.SectionTitle)}
${ ticket.messages.length ? ticket.messages .map( (msg) => `
Avatar
${escapeHtml(msg.username || "Unknown User")} ${escapeHtml(msg.userId || "Unknown ID")} ${formatDate(msg.timestamp)}
${parseMarkdown(msg.content || "")}
${renderEmbeds(msg.embeds || [])} ${(msg.attachments || []).map(renderAttachment).join("")}
`, ) .join("") : `
${escapeHtml(messagesCfg.EmptyState)}
` }
`; } function renderPreview(templateInput) { const sample = getSampleTicket(); return createTranscriptHTML( sample, "Resolved โ€” thank you for contacting support.", templateInput, ); } module.exports = { TEMPLATE_PATH, getDefaultTemplate, getTemplate, reloadTemplate, saveTemplate, normalizeTemplate, getSampleTicket, createTranscriptHTML, renderPreview, };