diff --git a/extension/content.js b/extension/content.js index aca3e9c..e138bde 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1016,6 +1016,12 @@ const aiCredentialCache = { timestamp: 0, pending: null }; +const AI_SETTINGS_CACHE_TTL = 30 * 1000; +const aiSettingsCache = { + data: null, + timestamp: 0, + pending: null +}; const MODERATION_SETTINGS_CACHE_TTL = 5 * 60 * 1000; const moderationSettingsCache = { data: null, @@ -1177,6 +1183,81 @@ async function fetchActiveAICredentials(forceRefresh = false) { } } +async function fetchAISettings(forceRefresh = false) { + const now = Date.now(); + if (!forceRefresh && aiSettingsCache.data && (now - aiSettingsCache.timestamp < AI_SETTINGS_CACHE_TTL)) { + return aiSettingsCache.data; + } + + if (aiSettingsCache.pending) { + try { + return await aiSettingsCache.pending; + } catch (error) { + // fall through to retry below + } + } + + aiSettingsCache.pending = (async () => { + const response = await backendFetch(`${API_URL}/ai-settings`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'AI-Einstellungen konnten nicht geladen werden'); + } + + const settings = await response.json(); + aiSettingsCache.data = settings; + aiSettingsCache.timestamp = Date.now(); + return settings; + })(); + + try { + return await aiSettingsCache.pending; + } finally { + aiSettingsCache.pending = null; + } +} + +function getAIAutoCommentRateLimitStatusFromSettings(settings, profileNumber) { + const normalizedProfile = parseInt(profileNumber, 10); + if (!settings || !Number.isInteger(normalizedProfile)) { + return null; + } + const statuses = Array.isArray(settings.rate_limit_statuses) ? settings.rate_limit_statuses : []; + return statuses.find((entry) => parseInt(entry?.profile_number, 10) === normalizedProfile) || null; +} + +function formatAIAutoCommentRateLimitReason(status) { + if (!status || !status.blocked_reason) { + return 'Aktion aktuell gesperrt'; + } + const reasonMap = { + cooldown: 'Cooldown aktiv', + active_hours: 'Außerhalb der Aktivzeiten', + min_delay: 'Mindestabstand noch nicht erreicht', + per_minute: 'Minutenlimit erreicht', + burst: 'Burst-Limit erreicht', + per_hour: 'Stundenlimit erreicht', + per_day: 'Tageslimit erreicht' + }; + return reasonMap[status.blocked_reason] || 'Aktion aktuell gesperrt'; +} + +function formatAIAutoCommentRateLimitUntil(iso) { + if (!iso) { + return ''; + } + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return ''; + } + return date.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} + function normalizeModerationSettings(payload) { if (!payload || typeof payload !== 'object') { return { @@ -6339,6 +6420,8 @@ async function generateAIComment(postText, profileNumber, options = {}) { error: message }); const error = new Error(message); + error.status = response.status; + error.responseData = errorData; error.aiTrace = { traceId: responseTraceId, flowId: responseFlowId, @@ -6370,7 +6453,8 @@ async function generateAIComment(postText, profileNumber, options = {}) { traceId: effectiveTraceId, flowId: effectiveFlowId, requestAttempts, - backendTimings: data.timings && data.timings.backend ? data.timings.backend : null + backendTimings: data.timings && data.timings.backend ? data.timings.backend : null, + autoCommentRateLimitStatus: data.autoCommentRateLimitStatus || null }; return returnMeta ? result : sanitizedComment; } @@ -6395,7 +6479,7 @@ async function generateAIComment(postText, profileNumber, options = {}) { } lastError = error; - if (cancelled) { + if (cancelled || (error && error.status === 429)) { break; } } @@ -6414,7 +6498,7 @@ async function generateAIComment(postText, profileNumber, options = {}) { requestAttempts: requestAttempts.slice() }; } - if (lastError.name === 'AbortError' || isCancellationError(lastError)) { + if (lastError.name === 'AbortError' || isCancellationError(lastError) || lastError.status === 429) { throw lastError; } } @@ -6473,11 +6557,7 @@ async function handleSelectionAIRequest(selectionText, sendResponse) { */ async function isAIEnabled() { try { - const response = await backendFetch(`${API_URL}/ai-settings`); - if (!response.ok) { - return false; - } - const settings = await response.json(); + const settings = await fetchAISettings(); return settings && settings.enabled === 1; } catch (error) { console.warn('[FB Tracker] Failed to check AI settings:', error); @@ -6621,6 +6701,7 @@ async function addAICommentButton(container, postElement) { let notePreviewElement = null; let noteClearButton = null; + let rateLimitRefreshPromise = null; const truncateNoteForPreview = (note) => { if (!note) { @@ -6629,6 +6710,103 @@ async function addAICommentButton(container, postElement) { return note.length > 120 ? `${note.slice(0, 117)}…` : note; }; + const getDefaultButtonTitle = () => { + const hasNote = getAdditionalNote().trim().length > 0; + return hasNote + ? 'Generiere automatisch einen passenden Kommentar (mit Zusatzinfo)' + : 'Generiere automatisch einen passenden Kommentar'; + }; + + const buildBlockedButtonTitle = (status) => { + if (!status || !status.blocked) { + return getDefaultButtonTitle(); + } + const untilText = status.blocked_until + ? ` Freigabe ab ${formatAIAutoCommentRateLimitUntil(status.blocked_until)}.` + : ''; + return `${formatAIAutoCommentRateLimitReason(status)}.${untilText}`.trim(); + }; + + const showBlockedToast = (status) => { + if (!status || !status.blocked) { + return; + } + const untilText = status.blocked_until + ? ` Freigabe ab ${formatAIAutoCommentRateLimitUntil(status.blocked_until)}.` + : ''; + showToast(`⏳ ${status.profile_name || 'Profil'}: ${formatAIAutoCommentRateLimitReason(status)}.${untilText}`.trim(), 'info'); + }; + + const applyAvailabilityState = (status = null, options = {}) => { + const { preserveText = false } = options; + const blocked = Boolean(status && status.blocked); + button._aiAvailability = status || null; + button.dataset.aiAvailability = blocked ? 'blocked' : 'available'; + + if (blocked) { + wrapper.style.opacity = '0.72'; + wrapper.style.boxShadow = baseWrapperShadow; + wrapper.style.transform = 'translateY(0)'; + wrapper.style.background = 'linear-gradient(135deg, #8b929e 0%, #5f6773 100%)'; + button.style.cursor = 'not-allowed'; + dropdownButton.style.cursor = 'not-allowed'; + dropdownButton.style.opacity = '0.75'; + button.title = buildBlockedButtonTitle(status); + dropdownButton.title = buildBlockedButtonTitle(status); + dropdownButton.setAttribute('aria-label', buildBlockedButtonTitle(status)); + if (!preserveText && (button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) { + button.textContent = `${button.dataset.aiOriginalText || baseButtonText} 🔒`; + } + } else { + wrapper.style.opacity = '1'; + wrapper.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; + button.style.cursor = 'pointer'; + dropdownButton.style.cursor = 'pointer'; + dropdownButton.style.opacity = '1'; + button.title = getDefaultButtonTitle(); + dropdownButton.title = 'AI auswählen'; + dropdownButton.setAttribute('aria-label', 'AI auswählen'); + if (!preserveText && (button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) { + button.textContent = button.dataset.aiOriginalText || baseButtonText; + } + if (!wrapper.classList.contains('fb-tracker-ai-wrapper--open') && !(button.matches(':hover') || dropdownButton.matches(':hover'))) { + setHoverState(false); + } + } + }; + + const refreshAvailabilityState = async (forceRefresh = false, explicitProfileNumber = null) => { + if (rateLimitRefreshPromise) { + try { + return await rateLimitRefreshPromise; + } catch (error) { + return null; + } + } + + rateLimitRefreshPromise = (async () => { + try { + const profileNumber = explicitProfileNumber || await fetchBackendProfileNumber(); + if (!profileNumber) { + applyAvailabilityState(null); + return null; + } + const settings = await fetchAISettings(forceRefresh); + const status = getAIAutoCommentRateLimitStatusFromSettings(settings, profileNumber); + applyAvailabilityState(status); + return status; + } catch (error) { + console.warn('[FB Tracker] Failed to refresh AI rate limit status:', error); + applyAvailabilityState(null); + return null; + } finally { + rateLimitRefreshPromise = null; + } + })(); + + return await rateLimitRefreshPromise; + }; + const updateNoteIndicator = () => { const note = getAdditionalNote(); const hasNote = note.trim().length > 0; @@ -6636,9 +6814,7 @@ async function addAICommentButton(container, postElement) { if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) { button.textContent = button.dataset.aiOriginalText; } - button.title = hasNote - ? 'Generiere automatisch einen passenden Kommentar (mit Zusatzinfo)' - : 'Generiere automatisch einen passenden Kommentar'; + applyAvailabilityState(button._aiAvailability || null); }; const updateNotePreview = () => { @@ -7072,6 +7248,11 @@ async function addAICommentButton(container, postElement) { return; } + if (button.dataset.aiAvailability === 'blocked') { + showBlockedToast(button._aiAvailability || null); + return; + } + if (dropdownOpen) { closeDropdown(); return; @@ -7104,6 +7285,7 @@ async function addAICommentButton(container, postElement) { const originalText = button.dataset.aiOriginalText || '✨ AI'; const currentState = button.dataset.aiState || 'idle'; + const currentAvailability = button._aiAvailability || null; if (currentState === 'processing') { const runningContext = button._aiContext; @@ -7124,6 +7306,11 @@ async function addAICommentButton(container, postElement) { return; } + if (currentAvailability && currentAvailability.blocked) { + showBlockedToast(currentAvailability); + return; + } + const aiContext = { cancelled: false, abortController: new AbortController(), @@ -7228,11 +7415,13 @@ async function addAICommentButton(container, postElement) { dropdownButton.textContent = '▾'; dropdownButton.setAttribute('aria-busy', 'false'); button.textContent = text; + applyAvailabilityState(button._aiAvailability || null, { preserveText: true }); if (revertDelay > 0) { setTimeout(() => { if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) { button.textContent = originalText; + applyAvailabilityState(button._aiAvailability || null); } }, revertDelay); } @@ -7361,6 +7550,19 @@ async function addAICommentButton(container, postElement) { throwIfCancelled(); + beginPhase('rateLimitStatusLookupMs'); + const liveRateLimitStatus = await refreshAvailabilityState(true, profileNumber); + endPhase('rateLimitStatusLookupMs', { + blocked: Boolean(liveRateLimitStatus && liveRateLimitStatus.blocked) + }); + if (liveRateLimitStatus && liveRateLimitStatus.blocked) { + flowTrace.status = 'blocked'; + flowTrace.frontendError = liveRateLimitStatus.blocked_reason || 'AI_RATE_LIMIT_BLOCKED'; + restoreIdle(`${originalText} 🔒`); + showBlockedToast(liveRateLimitStatus); + return; + } + beginPhase('aiRequestMs'); const aiResult = await generateAIComment(postText, profileNumber, { signal: aiContext.abortController.signal, @@ -7374,6 +7576,11 @@ async function addAICommentButton(container, postElement) { requestAttempts: Array.isArray(aiResult.requestAttempts) ? aiResult.requestAttempts.length : 0 }); mergeTraceInfo(aiResult); + if (aiResult.autoCommentRateLimitStatus) { + applyAvailabilityState(aiResult.autoCommentRateLimitStatus); + } else { + void refreshAvailabilityState(true); + } throwIfCancelled(); @@ -7486,6 +7693,10 @@ async function addAICommentButton(container, postElement) { mergeTraceInfo(error.aiTrace); } + if (error && error.status === 429) { + await refreshAvailabilityState(true); + } + const cancelled = aiContext.cancelled || isCancellationError(error); if (cancelled) { console.log('[FB Tracker] AI comment operation cancelled'); @@ -7535,9 +7746,14 @@ async function addAICommentButton(container, postElement) { button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); + if (button.dataset.aiAvailability === 'blocked') { + showBlockedToast(button._aiAvailability || null); + return; + } startAIFlow(); }); + void refreshAvailabilityState(false); container.appendChild(wrapper); }