gpt_chrome_autodrafter_v3/background.js
2025-07-01 15:46:34 -07:00

652 lines
23 KiB
JavaScript

// background.js
// Import email parser functions
importScripts('email_parser.js');
// Enhanced logging function
function logWithStyle(message, type = 'info') {
const styles = {
info: 'color: #1a4d2e; font-weight: bold;',
success: 'color: #1a4d2e; font-weight: bold; background: #e6ffe6; padding: 2px 5px; border-radius: 3px;',
error: 'color: #cc0000; font-weight: bold; background: #ffe6e6; padding: 2px 5px; border-radius: 3px;',
warning: 'color: #e6b800; font-weight: bold; background: #fff9e6; padding: 2px 5px; border-radius: 3px;'
};
console.log(`%c${message}`, styles[type]);
}
// Initialize with enhanced logging
logWithStyle('=== GPT Auto-Draft Background Script Initializing ===', 'info');
logWithStyle('Background script loaded successfully', 'success');
logWithStyle('Email parser imported successfully', 'success');
console.log('Background script loaded, email_parser.js imported');
// Add a cached token so we don't hit the OAuth flow on every request
let cachedOAuthToken = null;
let tokenExpiryTime = null;
// Enhanced token handling
let tokenRefreshInProgress = false;
let tokenRefreshPromise = null;
// Rate limiting and API usage tracking
const API_RATE_LIMITS = {
gmail: {
maxRequestsPerMinute: 60,
maxRequestsPerHour: 1000
},
openai: {
maxRequestsPerMinute: 20,
maxRequestsPerHour: 200
}
};
const apiUsage = {
gmail: {
requests: [],
lastReset: Date.now()
},
openai: {
requests: [],
lastReset: Date.now()
}
};
// Rate limiting helper
function checkRateLimit(api) {
const now = Date.now();
const limits = API_RATE_LIMITS[api];
const usage = apiUsage[api];
// Clean old requests
usage.requests = usage.requests.filter(time => now - time < 3600000); // Keep last hour
// Check minute limit
const minuteRequests = usage.requests.filter(time => now - time < 60000);
if (minuteRequests.length >= limits.maxRequestsPerMinute) {
throw new Error(`Rate limit exceeded: ${limits.maxRequestsPerMinute} requests per minute for ${api}`);
}
// Check hour limit
if (usage.requests.length >= limits.maxRequestsPerHour) {
throw new Error(`Rate limit exceeded: ${limits.maxRequestsPerHour} requests per hour for ${api}`);
}
// Add new request
usage.requests.push(now);
}
async function getValidToken(forceRefresh = false) {
// Check if we need to refresh based on expiry time
const now = Date.now();
const shouldRefresh = forceRefresh ||
!cachedOAuthToken ||
(tokenExpiryTime && now >= tokenExpiryTime - 60000); // Refresh 1 minute before expiry
if (!shouldRefresh && cachedOAuthToken) {
return cachedOAuthToken;
}
// If a refresh is already in progress, wait for it
if (tokenRefreshInProgress && tokenRefreshPromise) {
return tokenRefreshPromise;
}
// Start a new refresh
tokenRefreshInProgress = true;
tokenRefreshPromise = new Promise((resolve, reject) => {
chrome.identity.getAuthToken({ interactive: false }, async (token) => {
if (chrome.runtime.lastError || !token) {
console.warn('Silent token refresh failed, trying interactive...');
// Clear cached token on failure - FIX: Check if token exists before trying to remove
if (cachedOAuthToken) {
chrome.identity.removeCachedAuthToken({ token: cachedOAuthToken }, () => {
chrome.identity.getAuthToken({ interactive: true }, (interactiveToken) => {
if (chrome.runtime.lastError || !interactiveToken) {
console.error('Token refresh failed:', chrome.runtime.lastError);
cachedOAuthToken = null;
tokenExpiryTime = null;
tokenRefreshInProgress = false;
tokenRefreshPromise = null;
reject(chrome.runtime.lastError || new Error('Failed to get token'));
} else {
cachedOAuthToken = interactiveToken;
// Set expiry to 50 minutes from now (tokens typically last 1 hour)
tokenExpiryTime = Date.now() + (50 * 60 * 1000);
tokenRefreshInProgress = false;
tokenRefreshPromise = null;
logWithStyle('Token refreshed successfully (interactive)', 'success');
resolve(interactiveToken);
}
});
});
} else {
// No cached token to remove, go straight to interactive auth
chrome.identity.getAuthToken({ interactive: true }, (interactiveToken) => {
if (chrome.runtime.lastError || !interactiveToken) {
console.error('Token refresh failed:', chrome.runtime.lastError);
cachedOAuthToken = null;
tokenExpiryTime = null;
tokenRefreshInProgress = false;
tokenRefreshPromise = null;
reject(chrome.runtime.lastError || new Error('Failed to get token'));
} else {
cachedOAuthToken = interactiveToken;
// Set expiry to 50 minutes from now
tokenExpiryTime = Date.now() + (50 * 60 * 1000);
tokenRefreshInProgress = false;
tokenRefreshPromise = null;
logWithStyle('Token refreshed successfully (interactive)', 'success');
resolve(interactiveToken);
}
});
}
} else {
cachedOAuthToken = token;
// Set expiry to 50 minutes from now
tokenExpiryTime = Date.now() + (50 * 60 * 1000);
tokenRefreshInProgress = false;
tokenRefreshPromise = null;
logWithStyle('Token refreshed successfully (silent)', 'success');
resolve(token);
}
});
});
return tokenRefreshPromise;
}
// Enhanced API request wrapper with rate limiting
async function makeGmailApiRequest(url, options = {}) {
try {
checkRateLimit('gmail');
let token = await getValidToken();
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});
// If unauthorized, try refreshing token once
if (response.status === 401) {
console.log('Token expired (401), refreshing...');
// Remove the expired token from cache - FIX: Check if token exists first
if (token) {
chrome.identity.removeCachedAuthToken({ token }, async () => {
cachedOAuthToken = null;
tokenExpiryTime = null;
// Get a fresh token
token = await getValidToken(true);
// Retry the request
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});
});
} else {
// No token to remove, just get a fresh one
cachedOAuthToken = null;
tokenExpiryTime = null;
token = await getValidToken(true);
// Retry the request
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});
}
}
if (!response.ok) {
const errorText = await response.text();
console.error(`Gmail API request failed: ${response.status} ${response.statusText}`, errorText);
throw new Error(`Gmail API request failed: ${response.status} ${response.statusText}`);
}
return response;
} catch (error) {
console.error('Gmail API request failed:', error);
throw error;
}
}
// Data validation helpers
function validateThreadData(threadData) {
if (!threadData) throw new Error('Thread data is required');
if (!Array.isArray(threadData.messages)) throw new Error('Thread messages must be an array');
if (threadData.messages.length === 0) throw new Error('Thread must contain at least one message');
// No sender validation at all
}
function validateDraftData(draftData) {
if (!draftData) throw new Error('Draft data is required');
if (!draftData.recipient) throw new Error('Draft missing recipient');
if (!draftData.subject) throw new Error('Draft missing subject');
if (!draftData.content) throw new Error('Draft missing content');
}
// Enhanced message listener with validation and feedback
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('Received message:', message);
if (message.action === 'generateDraft') {
try {
// Validate input
if (!message.threadMessages) {
throw new Error('Thread messages are required');
}
// Send progress update
chrome.tabs.sendMessage(sender.tab.id, {
action: 'updateProgress',
status: 'Generating draft...'
});
handleGenerateDraft(message.threadMessages, message.contextPrompt)
.then(draft => {
console.log('Draft generated successfully:', draft);
chrome.tabs.sendMessage(sender.tab.id, {
action: 'updateProgress',
status: 'Draft generated successfully'
});
sendResponse({ draft });
})
.catch(error => {
console.error('Drafting failed:', error);
chrome.tabs.sendMessage(sender.tab.id, {
action: 'updateProgress',
status: 'Error: ' + error.message,
error: true
});
sendResponse({ error: error.message || 'Drafting failed' });
});
return true;
} catch (error) {
console.error('Validation failed:', error);
sendResponse({ error: error.message });
return false;
}
} else if (message.action === 'parseMimeEmail') {
try {
console.log('Parsing MIME email from content script request');
console.log('MIME content length:', message.mimeContent.length);
console.log('MIME content preview:', message.mimeContent.substring(0, 100) + '...');
const emailData = extractEmailThread(message.mimeContent);
console.log('Email parsed successfully:', emailData);
console.log('Subject:', emailData.subject);
console.log('From:', emailData.sender.name, '<' + emailData.sender.email + '>');
console.log('To:', emailData.recipient.name, '<' + emailData.recipient.email + '>');
console.log('Content length:', emailData.content.length);
sendResponse({ success: true, emailData });
} catch (error) {
console.error('Email parsing failed:', error);
sendResponse({ success: false, error: error.message || 'Email parsing failed' });
}
return true; // async
} else if (message.action === 'getOAuthToken') {
getValidToken()
.then(token => sendResponse({ token }))
.catch(error => sendResponse({ error: error.message }));
return true;
}
});
// Utility: Format the entire thread as a single string
function formatThreadAsString(messages) {
console.log('Formatting thread as string');
console.log('Thread messages type:', typeof messages);
if (!Array.isArray(messages)) {
console.log('Thread messages is not an array, converting to string');
return messages ? String(messages).trim() : '';
}
console.log('Thread contains', messages.length, 'messages');
// Format each message with sender, timestamp, and content
return messages.map(msg =>
`${msg.senderName} <${msg.sender}>\n${msg.timestamp}\n\n${msg.content}`
).join('\n---\n');
}
// Helper: Summarize and truncate thread for OpenAI API
function prepareThreadForOpenAI(messages, maxChars = 250000) {
console.log('\n=== PREPARE THREAD FOR OPENAI ===');
console.log('Input messages:', Array.isArray(messages) ? messages.length : 'Not an array');
console.log('Max characters allowed:', maxChars);
if (!Array.isArray(messages)) {
console.log('Messages is not an array, returning as string');
return String(messages);
}
// Log structure of first message for debugging
if (messages.length > 0) {
console.log('First message structure:');
console.log(JSON.stringify(messages[0], null, 2));
}
// Format each message with clear structure
const formatMessage = (msg, index) => {
const sender = msg.senderName || msg.from || 'Unknown';
const email = msg.sender || msg.from || 'unknown@email.com';
const timestamp = msg.timestamp || msg.date || 'No timestamp';
let content = msg.content || '';
// Clean up content - remove excessive whitespace but preserve code blocks
content = content
.replace(/\[CODE BLOCK\]/g, '\n```')
.replace(/\[\/CODE BLOCK\]/g, '```\n')
.trim();
// Format the message with clear boundaries
const formatted = [
`=== Message ${index + 1} ===`,
`From: ${sender} <${email}>`,
`Date: ${timestamp}`,
'',
content,
''
].join('\n');
console.log(`Message ${index + 1} formatted length:`, formatted.length);
return formatted;
};
// If the thread is short enough, return as is with nice formatting
let fullText = messages.map(formatMessage).join('\n---\n');
console.log('Full formatted text length:', fullText.length);
if (fullText.length <= maxChars) {
console.log('Thread is within max chars, returning full text');
return fullText;
}
console.log('Thread exceeds max chars, summarizing...');
// Otherwise, include recent messages in full and summarize older ones
let result = '';
let recentMessages = [];
let totalLength = 0;
// Start from the most recent message and work backwards
for (let i = messages.length - 1; i >= 0; i--) {
const formatted = formatMessage(messages[i], i);
if (totalLength + formatted.length + 100 > maxChars * 0.8) {
// We've hit our limit
break;
}
recentMessages.unshift(formatted);
totalLength += formatted.length + 10; // Account for separator
}
// Add summary header if we didn't include all messages
if (recentMessages.length < messages.length) {
const omittedCount = messages.length - recentMessages.length;
result = `[Note: ${omittedCount} earlier messages omitted for length. Showing ${recentMessages.length} most recent messages.]\n\n`;
console.log(`Summarized ${omittedCount} earlier messages`);
}
result += recentMessages.join('\n---\n');
console.log('Final thread length:', result.length);
console.log('=== END PREPARE THREAD FOR OPENAI ===\n');
return result;
}
async function handleGenerateDraft(threadMessages, contextPrompt = null) {
console.log('=== HANDLE GENERATE DRAFT ===');
console.log('handleGenerateDraft called with:', { threadMessages, contextPrompt });
try {
checkRateLimit('openai');
const settings = await getFromStorage(['openAIApiKey', 'customPrompt', 'customGptEndpoint']);
if (!settings.openAIApiKey) throw new Error('OpenAI API key not set');
if (!settings.customGptEndpoint) throw new Error('Assistant ID not set');
// Validate thread data
if (Array.isArray(threadMessages)) {
validateThreadData({ messages: threadMessages });
}
const apiKey = settings.openAIApiKey;
const assistantId = settings.customGptEndpoint || '';
const basePrompt = settings.customPrompt || '';
console.log('\n=== SETTINGS ===');
console.log('Assistant ID:', assistantId);
console.log('Base Prompt:', basePrompt);
console.log('Context Prompt:', contextPrompt);
// Prepare thread for OpenAI API (truncate/summarize if needed)
let formattedThread = '';
if (typeof threadMessages === 'string' && threadMessages.startsWith('MIME-Version:')) {
console.log('Detected MIME email format, parsing...');
try {
const emailData = extractEmailThread(threadMessages);
console.log('MIME email parsed successfully');
console.log('Email data:', {
subject: emailData.subject,
sender: emailData.sender,
recipient: emailData.recipient,
contentLength: emailData.content.length
});
formattedThread = `From: ${emailData.sender.name} <${emailData.sender.email}>\n` +
`To: ${emailData.recipient.name} <${emailData.recipient.email}>\n` +
`Subject: ${emailData.subject}\n` +
`Date: ${emailData.date}\n\n` +
`${emailData.content}`;
console.log('Formatted thread length:', formattedThread.length);
} catch (error) {
console.error('Failed to parse MIME email:', error);
console.log('Falling back to raw content');
formattedThread = threadMessages; // Fallback to raw content
}
} else {
// Use the new truncation/summarization helper
formattedThread = prepareThreadForOpenAI(threadMessages);
console.log('\n=== FORMATTED THREAD ===');
console.log('Formatted thread length:', formattedThread.length);
console.log('Formatted thread preview:');
console.log(formattedThread.substring(0, 1000));
if (formattedThread.length > 1000) {
console.log('... [truncated for console] ...');
}
}
if (!apiKey) {
console.error('API key not set.');
throw new Error('API key not set.');
}
if (!assistantId) {
console.error('Assistant ID not set.');
throw new Error('Assistant ID not set.');
}
// Always include the base prompt, and append the context prompt if present
let promptToUse = basePrompt;
if (c
30 June 2025 at 05:00 ontextPrompt && contextPrompt.trim()) {
console.log('Adding context-specific prompt:', contextPrompt.trim());
promptToUse += '\n\nAdditional instructions: ' + contextPrompt.trim();
}
const messageContent = `Instructions: ${promptToUse}\n\nEmail Thread:\n${formattedThread}`;
console.log('\n=== FINAL PAYLOAD TO OPENAI ===');
console.log('Using combined prompt:', promptToUse);
console.log('Total message content length:', messageContent.length);
console.log('\nFull message content being sent to OpenAI:');
console.log('------------------------');
console.log(messageContent);
console.log('------------------------\n');
// 1. Create a thread
console.log('Creating OpenAI thread...');
const threadResponse = await fetch('https://api.openai.com/v1/threads', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'OpenAI-Beta': 'assistants=v2'
}
});
if (!threadResponse.ok) {
const errorText = await threadResponse.text();
console.error('Failed to create thread:', threadResponse.status, errorText);
throw new Error('Failed to create thread');
}
const thread = await threadResponse.json();
console.log('Thread created with ID:', thread.id);
// 2. Add a message to the thread with the prompt and thread content
console.log('\nAdding message to thread...');
console.log('Message payload:');
console.log(JSON.stringify({
role: 'user',
content: messageContent
}, null, 2));
const messageResponse = await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'OpenAI-Beta': 'assistants=v2'
},
body: JSON.stringify({
role: 'user',
content: messageContent
})
});
if (!messageResponse.ok) {
const errorText = await messageResponse.text();
console.error('Failed to add message to thread:', messageResponse.status, errorText);
throw new Error('Failed to add message to thread');
}
const message = await messageResponse.json();
console.log('Message added to thread with ID:', message.id);
// 3. Run the assistant
console.log('\nRunning assistant with ID:', assistantId);
const runResponse = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'OpenAI-Beta': 'assistants=v2'
},
body: JSON.stringify({
assistant_id: assistantId
})
});
if (!runResponse.ok) {
const errorText = await runResponse.text();
console.error('Failed to start run:', runResponse.status, errorText);
throw new Error('Failed to start run');
}
const run = await runResponse.json();
console.log('Run started with ID:', run.id);
// 4. Poll for completion
console.log('\nPolling for run completion...');
let completed = false;
let response = null;
let pollCount = 0;
while (!completed) {
pollCount++;
console.log(`Polling attempt ${pollCount}...`);
const statusResponse = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'OpenAI-Beta': 'assistants=v2'
}
});
if (!statusResponse.ok) {
const errorText = await statusResponse.text();
console.error('Failed to check run status:', statusResponse.status, errorText);
throw new Error('Failed to check run status');
}
const status = await statusResponse.json();
console.log('Run status:', status.status);
if (status.status === 'completed') {
console.log('Run completed successfully');
completed = true;
// 5. Get the messages
console.log('Retrieving messages...');
const messagesResponse = await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'OpenAI-Beta': 'assistants=v2'
}
});
if (!messagesResponse.ok) {
const errorText = await messagesResponse.text();
console.error('Failed to get messages:', messagesResponse.status, errorText);
throw new Error('Failed to get messages');
}
const messages = await messagesResponse.json();
console.log('Retrieved', messages.data.length, 'messages');
console.log('\n=== OPENAI RESPONSE ===');
console.log('Full messages response:', JSON.stringify(messages, null, 2));
response = messages.data[0].content[0].text.value;
console.log('\nExtracted response:');
console.log(response);
console.log('Response length:', response.length);
console.log('=== END OPENAI RESPONSE ===\n');
} else if (status.status === 'failed') {
console.error('Run failed:', status.last_error);
throw new Error('Run failed: ' + (status.last_error?.message || 'Unknown error'));
} else {
// Wait before checking again
console.log('Run still in progress, waiting before next poll...');
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
console.log('=== END HANDLE GENERATE DRAFT ===\n');
return response;
} catch (error) {
console.error('Draft generation failed:', error);
throw error;
}
}
function getFromStorage(keys) {
return new Promise((resolve) => {
chrome.storage.local.get(keys, resolve);
});
}
// Handle extension icon click
chrome.action.onClicked.addListener(async (tab) => {
// Check if we're on Gmail, Instantly, or Plusvibe/Pipl
if (!tab.url.includes('mail.google.com') &&
!tab.url.includes('app.instantly.ai') &&
!tab.url.includes('app.pipl.ai')) {
console.log('Extension clicked on unsupported tab, ignoring');
return;
}
console.log('Extension icon clicked, toggling UI on:', tab.url);
// Send message to content script to toggle UI visibility
chrome.tabs.sendMessage(tab.id, {
action: 'toggleUI'
});
});