// ==UserScript== // @name Chatwoot Quote Generator // @namespace http://tampermonkey.net/ // @version 0.14 // @description Generate a formatted quote from Chatwoot conversation // @author You // @match https://chatwoot.abawo.de/app/* // @grant GM_setValue // @grant GM_getValue // ==/UserScript== (function() { 'use strict'; // Configuration with default values let config = { maxMessages: 5, language: 'en', showExclusionIndicator: true, autoOpenParticipants: true }; // Separator to identify the end of quotes const QUOTE_SEPARATOR = "----------------- "; // Month translations for German date formatting const monthTranslations = { 'Jan': 'Jan', 'Feb': 'Feb', 'Mar': 'Mär', 'Apr': 'Apr', 'May': 'Mai', 'Jun': 'Jun', 'Jul': 'Jul', 'Aug': 'Aug', 'Sep': 'Sep', 'Oct': 'Okt', 'Nov': 'Nov', 'Dec': 'Dez' }; // Translations const translations = { en: { wrote_at: 'wrote at', from_abawo: 'from abawo', max_messages: 'Maximum messages to quote:', language: 'Language:', save: 'Save', cancel: 'Cancel', quote_config: 'Quote Configuration', english: 'English', german: 'German', show_exclusion: 'Show "..." for excluded messages:', excluded_messages: '... (older messages hidden)', auto_open: 'Auto-open participants section:' }, de: { wrote_at: 'schrieb am', from_abawo: 'von abawo', max_messages: 'Maximale Anzahl zu zitierender Nachrichten:', language: 'Sprache:', save: 'Speichern', cancel: 'Abbrechen', quote_config: 'Zitat-Konfiguration', english: 'Englisch', german: 'Deutsch', show_exclusion: 'Zeige "..." für ausgeschlossene Nachrichten:', excluded_messages: '... (ältere Nachrichten versteckt)', auto_open: 'Teilnehmerbereich automatisch öffnen:' } }; // Helper function to get translation function t(key) { return translations[config.language][key] || translations.en[key]; } // Function to format date in German style function formatDateGerman(dateStr) { // Common format is "Jun 17, 2:33 PM" try { // Extract components from the date string const parts = dateStr.match(/(\w+)\s+(\d+),\s+(\d+):(\d+)\s+(\w+)/); if (!parts) return dateStr; // Return original if no match const month = monthTranslations[parts[1]] || parts[1]; const day = parts[2]; let hour = parseInt(parts[3]); const minute = parts[4]; const ampm = parts[5]; // Convert to 24-hour format if (ampm === 'PM' && hour < 12) hour += 12; if (ampm === 'AM' && hour === 12) hour = 0; // Format as "17. Jun um 14:33" return `${day}. ${month} um ${hour.toString().padStart(2, '0')}:${minute}`; } catch (e) { console.error('Error formatting date:', e); return dateStr; // Return original on error } } // Load saved configuration if available try { const savedConfig = GM_getValue('chatwootQuoteConfig'); if (savedConfig) { config = JSON.parse(savedConfig); } } catch (e) { console.error('Failed to load configuration:', e); } // Save configuration function saveConfig() { GM_setValue('chatwootQuoteConfig', JSON.stringify(config)); } // Function to wait for an element to be available in the DOM function waitForElement(selector, callback, maxWaitTime = 10000) { const startTime = Date.now(); const checkExist = setInterval(function() { const element = document.querySelector(selector); if (element) { clearInterval(checkExist); callback(element); } else if (Date.now() - startTime > maxWaitTime) { clearInterval(checkExist); console.log(`Element ${selector} not found after ${maxWaitTime}ms`); } }, 100); } // IMPROVED: Function to extract message text excluding blockquotes and HR elements function extractTextExcludingQuotes(textContentElement) { if (!textContentElement) return ''; // Check if there are blockquotes const blockquotes = textContentElement.querySelectorAll('blockquote'); const hrElements = textContentElement.querySelectorAll('hr'); // If there are no blockquotes or hr elements, return the full text if (blockquotes.length === 0 && hrElements.length === 0) { // Customer email messages often have a different structure if (textContentElement.querySelector('div[dir="auto"]')) { return textContentElement.querySelector('div[dir="auto"]').textContent.trim(); } // Messages with paragraphs else if (textContentElement.querySelectorAll('p').length > 0) { const paragraphs = textContentElement.querySelectorAll('p'); return Array.from(paragraphs) .map(p => p.textContent.trim()) .filter(t => t) .join('\n'); } // Fallback for any other format else { return textContentElement.textContent.trim(); } } // Clone the node to avoid modifying the original const clonedNode = textContentElement.cloneNode(true); // Remove all blockquotes blockquotes.forEach((blockquote, index) => { const correspondingBlockquote = clonedNode.querySelectorAll('blockquote')[index]; if (correspondingBlockquote) { correspondingBlockquote.remove(); } }); // Remove all hr elements hrElements.forEach((hr, index) => { const correspondingHr = clonedNode.querySelectorAll('hr')[index]; if (correspondingHr) { correspondingHr.remove(); } }); // IMPROVED: Extract text with better line break handling let text = ''; // Process paragraph elements if they exist if (clonedNode.querySelectorAll('p').length > 0) { const paragraphs = clonedNode.querySelectorAll('p'); text = Array.from(paragraphs) .map(p => p.textContent.trim()) .filter(t => t) .join('\n'); } else { // Get the text content text = clonedNode.textContent.trim(); } // IMPROVED: Clean up problematic formatting // Replace double >> with single > text = text.replace(/>>\s*>/g, '> '); // Fix missing spaces after commas in quoted content text = text.replace(/,(?=\S)/g, ', '); // Ensure proper line breaks for quoted content // Look for patterns like "> text> more text" and fix them text = text.replace(/>\s*([^>]+?)>\s*/g, "> $1\n> "); // Fix cases where quote separator is in the middle of text if (text.includes(QUOTE_SEPARATOR)) { const parts = text.split(QUOTE_SEPARATOR); text = parts[0].trim(); } // Normalize multiple consecutive line breaks text = text.replace(/\n{3,}/g, '\n\n'); return text; } // Helper functions for auto-opening UI elements function isSidebarOpen() { // Check if sidebar is open by looking for the caret direction const sidebarToggle = document.querySelector('button[data-v-2defdc7c=""] .i-ph-caret-right-fill'); return !!sidebarToggle; // If caret-right-fill exists, sidebar is open } function isParticipantsSectionExpanded() { // Check if participants section is expanded by looking for the minus icon const participantsSection = document.querySelector('[data-v-70238fc0=""] button'); if (!participantsSection) return false; // Look for minus icon (expanded state) const minusIcon = participantsSection.querySelector('svg path[d*="M3.997 13H20"]'); return !!minusIcon; } function hasParticipantsLoaded() { // Check if participants have loaded (not showing "No one is participating") const participantsText = document.querySelector('[data-v-d343fa58=""] .total-watchers'); if (!participantsText) return false; const text = participantsText.textContent.trim(); return !text.includes('No one is participating') && text.includes('participating'); } function clickSidebarToggle() { const sidebarToggle = document.querySelector('button[data-v-2defdc7c=""]'); if (sidebarToggle) { sidebarToggle.click(); return true; } return false; } function clickParticipantsToggle() { const participantsToggle = document.querySelector('[data-v-70238fc0=""] button'); if (participantsToggle) { participantsToggle.click(); return true; } return false; } // Function to wait with timeout function waitFor(conditionFn, timeout = 1000) { return new Promise((resolve) => { const startTime = Date.now(); const checkCondition = () => { if (conditionFn()) { resolve(true); } else if (Date.now() - startTime > timeout) { resolve(false); } else { setTimeout(checkCondition, 50); } }; checkCondition(); }); } // Function to attempt auto-opening sidebar and participants section async function ensureParticipantsSectionAccessible() { if (!config.autoOpenParticipants) { return false; } let opened = false; // Step 1: Open sidebar if closed if (!isSidebarOpen()) { console.log('Opening sidebar...'); if (clickSidebarToggle()) { opened = true; await waitFor(isSidebarOpen, 200); } } // Step 2: Expand participants section if collapsed if (!isParticipantsSectionExpanded()) { console.log('Expanding participants section...'); if (clickParticipantsToggle()) { opened = true; await waitFor(isParticipantsSectionExpanded, 200); } } // Step 3: Wait for participants to load if (opened) { console.log('Waiting for participants to load...'); await waitFor(hasParticipantsLoaded, 600); } return opened; } // Function to build lookup table from conversation participants function buildAgentLookupTable() { const lookupTable = {}; // Try to find the conversation participants section const participantsSection = document.querySelector('[data-v-d343fa58=""][inbox-id]'); if (!participantsSection) { return lookupTable; } // Look for participant entries in overlapping thumbnails section const thumbnails = participantsSection.querySelectorAll('.overlapping-thumbnails .user-thumbnail-box'); thumbnails.forEach(thumbnail => { const nameAttr = thumbnail.getAttribute('data-original-title'); const avatarContainer = thumbnail.querySelector('.avatar-container'); if (nameAttr && avatarContainer) { const initials = avatarContainer.textContent.trim(); if (initials && nameAttr) { lookupTable[initials] = nameAttr; } } }); // Also look in the dropdown participant list if available const dropdownItems = participantsSection.querySelectorAll('.dropdown-menu__item'); dropdownItems.forEach(item => { const nameSpan = item.querySelector('span[title]'); const avatarContainer = item.querySelector('.avatar-container'); if (nameSpan && avatarContainer) { const fullName = nameSpan.getAttribute('title') || nameSpan.textContent.trim(); const initials = avatarContainer.textContent.trim(); if (initials && fullName) { lookupTable[initials] = fullName; } } }); return lookupTable; } // Function to extract agent initials from avatar and get full name if available function extractAgentInitials(messageElement, agentLookup = {}) { // Look for avatar container within the message const avatarContainer = messageElement.querySelector('.sender--info .avatar-container'); let initials = null; if (avatarContainer) { initials = avatarContainer.textContent.trim(); } else { // Fallback: look for other possible avatar selectors const altAvatarContainer = messageElement.querySelector('.user-thumbnail-box .avatar-container'); if (altAvatarContainer) { initials = altAvatarContainer.textContent.trim(); } } if (initials && initials.length >= 1) { // Try to get full name from lookup table const fullName = agentLookup[initials]; if (fullName) { return fullName; } return initials; } // If no initials found, return generic fallback return "Agent"; } // Function to extract message data async function extractMessages() { // Build agent lookup table from conversation participants let agentLookup = buildAgentLookupTable(); // If we don't have enough data and auto-open is enabled, try to access participants section if (Object.keys(agentLookup).length === 0 && config.autoOpenParticipants) { const opened = await ensureParticipantsSectionAccessible(); if (opened) { // Try building lookup table again after opening UI elements agentLookup = buildAgentLookupTable(); } } // Get customer name const customerNameElement = document.querySelector('.conversation-details-wrap .text-base.font-medium.truncate.leading-tight.text-n-slate-12'); const customerName = customerNameElement ? customerNameElement.textContent.trim() : "Customer"; // Get all message items const messages = []; const messageItems = document.querySelectorAll('.conversation-panel > li'); messageItems.forEach(li => { // Skip system messages (center) if (li.classList.contains('center')) return; const bubble = li.querySelector('.bubble'); if (!bubble) return; // Skip private notes if (bubble.classList.contains('is-private')) return; const isCustomer = li.classList.contains('left'); // Determine sender name let senderName; if (isCustomer) { senderName = customerName; } else { // Extract agent name/initials from the avatar within this specific message const agentName = extractAgentInitials(li, agentLookup); senderName = `${agentName} ${t('from_abawo')}`; } // Extract timestamp const timeElement = li.querySelector('.time'); const time = timeElement ? timeElement.textContent.trim() : ""; // Format the time according to the selected language const formattedTime = config.language === 'de' ? formatDateGerman(time) : time; // Extract message text const textWrap = bubble.querySelector('.message-text__wrap'); if (!textWrap) return; const textContentElement = textWrap.querySelector('.text-content'); // Extract text while excluding blockquotes let text = extractTextExcludingQuotes(textContentElement); if (text) { messages.push({ isCustomer, name: senderName, time: formattedTime, text }); } }); // Keep track of whether messages were excluded const messagesExcluded = messages.length > config.maxMessages; // Limit the number of messages based on configuration if (messagesExcluded) { messages.splice(0, messages.length - config.maxMessages); } return { messages, messagesExcluded }; } // Function to format messages as a quote function formatQuote(data) { let quote = ""; // Add indicator for excluded messages if enabled and messages were excluded if (config.showExclusionIndicator && data.messagesExcluded) { quote += `> ${t('excluded_messages')}\n>\n`; } data.messages.forEach((message, index) => { if (index > 0) { quote += "\n>\n"; // Empty line with ">" between messages } quote += `> ${message.name} ${t('wrote_at')} ${message.time}:\n> ${message.text.replace(/\n/g, '\n> ')}`; }); // Add separator to mark the end of quotes if (quote) { quote += `\n\n${QUOTE_SEPARATOR}\n\n`; } return quote; } // Function to add the quote to the text area function addQuoteToTextArea(quote) { const textArea = document.querySelector('.ProseMirror'); if (!textArea) return; // Focus the editor textArea.focus(); // Clear the editor if it's empty if (textArea.querySelector('.empty-node')) { textArea.innerHTML = '

'; } // Try multiple methods to insert text try { // Try using execCommand document.execCommand('insertText', false, quote); } catch (e) { console.log("execCommand method failed:", e); // Fallback to creating a text node try { const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); const textNode = document.createTextNode(quote); range.insertNode(textNode); } else { // If no selection, add to the beginning const p = textArea.querySelector('p') || textArea; if (p.firstChild) { p.insertBefore(document.createTextNode(quote), p.firstChild); } else { p.appendChild(document.createTextNode(quote)); } } } catch (err) { console.error("Failed to insert text:", err); } } } // Function to create and show configuration popup function showConfigPopup(x, y) { // Remove any existing popup const existingPopup = document.getElementById('chatwoot-quote-config'); if (existingPopup) { existingPopup.remove(); } // Create popup container const popup = document.createElement('div'); popup.id = 'chatwoot-quote-config'; popup.style.position = 'fixed'; popup.style.zIndex = '9999'; popup.style.padding = '12px'; popup.style.backgroundColor = '#1e293b'; // Dark slate background popup.style.border = '1px solid #475569'; // Lighter border popup.style.borderRadius = '8px'; popup.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.3)'; popup.style.width = '280px'; popup.style.color = '#f1f5f9'; // Light text // Add title const title = document.createElement('h3'); title.textContent = t('quote_config'); title.style.margin = '0 0 12px 0'; title.style.fontSize = '16px'; title.style.fontWeight = 'bold'; title.style.color = '#f8fafc'; // Very light text for title popup.appendChild(title); // Add max messages configuration const maxMessagesLabel = document.createElement('label'); maxMessagesLabel.textContent = t('max_messages') + ' '; maxMessagesLabel.style.display = 'block'; maxMessagesLabel.style.marginBottom = '6px'; maxMessagesLabel.style.fontSize = '13px'; maxMessagesLabel.style.color = '#cbd5e1'; // Light gray text popup.appendChild(maxMessagesLabel); const maxMessagesInput = document.createElement('input'); maxMessagesInput.type = 'number'; maxMessagesInput.min = '1'; maxMessagesInput.max = '20'; maxMessagesInput.value = config.maxMessages; maxMessagesInput.style.width = '100%'; maxMessagesInput.style.marginBottom = '12px'; maxMessagesInput.style.padding = '6px 8px'; maxMessagesInput.style.boxSizing = 'border-box'; maxMessagesInput.style.backgroundColor = '#334155'; // Dark input background maxMessagesInput.style.border = '1px solid #475569'; maxMessagesInput.style.borderRadius = '4px'; maxMessagesInput.style.color = '#f1f5f9'; // Light input text popup.appendChild(maxMessagesInput); // Add language selector const languageLabel = document.createElement('label'); languageLabel.textContent = t('language') + ' '; languageLabel.style.display = 'block'; languageLabel.style.marginBottom = '6px'; languageLabel.style.fontSize = '13px'; languageLabel.style.color = '#cbd5e1'; popup.appendChild(languageLabel); const languageSelect = document.createElement('select'); languageSelect.style.width = '100%'; languageSelect.style.marginBottom = '12px'; languageSelect.style.padding = '6px 8px'; languageSelect.style.boxSizing = 'border-box'; languageSelect.style.backgroundColor = '#334155'; languageSelect.style.border = '1px solid #475569'; languageSelect.style.borderRadius = '4px'; languageSelect.style.color = '#f1f5f9'; const languages = [ { value: 'en', label: t('english') }, { value: 'de', label: t('german') } ]; languages.forEach(lang => { const option = document.createElement('option'); option.value = lang.value; option.textContent = lang.label; if (config.language === lang.value) { option.selected = true; } languageSelect.appendChild(option); }); popup.appendChild(languageSelect); // Add show exclusion indicator checkbox const showExclusionLabel = document.createElement('label'); showExclusionLabel.textContent = t('show_exclusion') + ' '; showExclusionLabel.style.display = 'block'; showExclusionLabel.style.marginBottom = '6px'; showExclusionLabel.style.fontSize = '13px'; showExclusionLabel.style.color = '#cbd5e1'; popup.appendChild(showExclusionLabel); const showExclusionCheckbox = document.createElement('input'); showExclusionCheckbox.type = 'checkbox'; showExclusionCheckbox.checked = config.showExclusionIndicator; showExclusionCheckbox.style.marginBottom = '12px'; showExclusionCheckbox.style.marginRight = '6px'; showExclusionCheckbox.style.accentColor = '#3b82f6'; // Blue accent for checkbox popup.appendChild(showExclusionCheckbox); // Add a line break after the checkbox popup.appendChild(document.createElement('br')); // Add auto-open participants checkbox const autoOpenLabel = document.createElement('label'); autoOpenLabel.textContent = t('auto_open') + ' '; autoOpenLabel.style.display = 'block'; autoOpenLabel.style.marginBottom = '6px'; autoOpenLabel.style.fontSize = '13px'; autoOpenLabel.style.color = '#cbd5e1'; popup.appendChild(autoOpenLabel); const autoOpenCheckbox = document.createElement('input'); autoOpenCheckbox.type = 'checkbox'; autoOpenCheckbox.checked = config.autoOpenParticipants; autoOpenCheckbox.style.marginBottom = '12px'; autoOpenCheckbox.style.marginRight = '6px'; autoOpenCheckbox.style.accentColor = '#3b82f6'; popup.appendChild(autoOpenCheckbox); // Add a line break after the checkbox popup.appendChild(document.createElement('br')); // Add save button const saveButton = document.createElement('button'); saveButton.textContent = t('save'); saveButton.style.padding = '8px 16px'; saveButton.style.backgroundColor = '#3b82f6'; // Blue background saveButton.style.color = 'white'; saveButton.style.border = 'none'; saveButton.style.borderRadius = '6px'; saveButton.style.cursor = 'pointer'; saveButton.style.marginRight = '8px'; saveButton.style.fontSize = '13px'; saveButton.style.fontWeight = '500'; saveButton.addEventListener('click', function() { config.maxMessages = parseInt(maxMessagesInput.value, 10) || 5; config.language = languageSelect.value; config.showExclusionIndicator = showExclusionCheckbox.checked; config.autoOpenParticipants = autoOpenCheckbox.checked; saveConfig(); popup.remove(); }); popup.appendChild(saveButton); // Add cancel button const cancelButton = document.createElement('button'); cancelButton.textContent = t('cancel'); cancelButton.style.padding = '8px 16px'; cancelButton.style.backgroundColor = '#475569'; // Dark gray background cancelButton.style.color = '#f1f5f9'; // Light text cancelButton.style.border = '1px solid #64748b'; cancelButton.style.borderRadius = '6px'; cancelButton.style.cursor = 'pointer'; cancelButton.style.fontSize = '13px'; cancelButton.style.fontWeight = '500'; cancelButton.addEventListener('click', function() { popup.remove(); }); popup.appendChild(cancelButton); // Calculate popup dimensions and position it with bottom-right at click position // Need to temporarily add it to the DOM to get the dimensions popup.style.visibility = 'hidden'; document.body.appendChild(popup); const popupWidth = popup.offsetWidth; const popupHeight = popup.offsetHeight; document.body.removeChild(popup); popup.style.visibility = 'visible'; // Position popup so bottom-right corner is at click position const leftPos = Math.max(x - popupWidth, 10); // Ensure it doesn't go off the left edge const topPos = Math.max(y - popupHeight, 10); // Ensure it doesn't go off the top edge popup.style.left = `${leftPos}px`; popup.style.top = `${topPos}px`; // Close popup when clicking outside document.addEventListener('click', function closePopup(e) { if (!popup.contains(e.target) && e.target.id !== 'chatwoot-quote-btn') { popup.remove(); document.removeEventListener('click', closePopup); } }); document.body.appendChild(popup); } // Function to create and add the quote button function addQuoteButton() { waitForElement('[data-v-b5303981] .right-wrap', (rightWrap) => { // Check if button already exists if (document.getElementById('chatwoot-quote-btn')) return; // Create the button const quoteButton = document.createElement('button'); quoteButton.id = 'chatwoot-quote-btn'; quoteButton.className = 'flex-shrink-0 inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50 bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent h-8 px-3 text-sm justify-center'; quoteButton.innerHTML = '|> Quote'; quoteButton.style.marginRight = '8px'; // Add click event quoteButton.addEventListener('click', async function(e) { const data = await extractMessages(); const quote = formatQuote(data); addQuoteToTextArea(quote); }); // Add right-click event for configuration quoteButton.addEventListener('contextmenu', function(e) { e.preventDefault(); showConfigPopup(e.clientX, e.clientY); return false; }); // Insert the button before the send button const sendButton = rightWrap.querySelector('button[type="submit"]'); if (sendButton) { rightWrap.insertBefore(quoteButton, sendButton); } }); } // Initialize the script function init() { // Add button on page load window.addEventListener('load', function() { setTimeout(addQuoteButton, 1000); }); // Try adding button immediately addQuoteButton(); // Also check periodically for dynamic page changes setInterval(addQuoteButton, 3000); // Add CSS for the button and popup const style = document.createElement('style'); style.textContent = ` #chatwoot-quote-btn:hover { background-color: rgba(0, 0, 0, 0.1) !important; } #chatwoot-quote-config { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; } #chatwoot-quote-config input:focus, #chatwoot-quote-config select:focus { outline: 2px solid #3b82f6; outline-offset: 1px; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); } #chatwoot-quote-config button:hover { opacity: 0.9; transform: translateY(-1px); transition: all 0.2s ease; } #chatwoot-quote-config button:active { transform: translateY(0); } #chatwoot-quote-config input[type="number"]::-webkit-outer-spin-button, #chatwoot-quote-config input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } #chatwoot-quote-config input[type="number"] { -moz-appearance: textfield; } `; document.head.appendChild(style); } init(); })();