diff --git a/backend/server.js b/backend/server.js index 06dca9f..7fdb995 100644 --- a/backend/server.js +++ b/backend/server.js @@ -12,7 +12,18 @@ const PORT = process.env.PORT || 3000; const MAX_PROFILES = 5; const AI_AUTO_COMMENT_SOURCE = 'extension-ai-button'; -const AI_AUTO_COMMENT_DAILY_LIMIT_MAX = 500; +const AI_AUTO_COMMENT_BURST_WINDOW_MINUTES = 10; +const AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS = Object.freeze({ + enabled: 1, + requests_per_minute: 2, + requests_per_hour: 20, + requests_per_day: 80, + min_delay_seconds: 45, + burst_limit: 5, + cooldown_minutes: 15, + active_hours_start: null, + active_hours_end: null +}); const DEFAULT_PROFILE_NAMES = { 1: 'Profil 1', 2: 'Profil 2', @@ -811,15 +822,15 @@ function getProfileName(profileNumber) { return DEFAULT_PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`; } -function normalizeAIAutoCommentDailyLimit(value) { +function normalizeNonNegativeInteger(value, fallback, max) { if (value === null || typeof value === 'undefined' || value === '') { - return 0; + return fallback; } const parsed = parseInt(value, 10); if (Number.isNaN(parsed) || parsed < 0) { - return 0; + return fallback; } - return Math.min(AI_AUTO_COMMENT_DAILY_LIMIT_MAX, parsed); + return Math.min(max, parsed); } function getLocalDateKey(date = new Date()) { @@ -829,147 +840,498 @@ function getLocalDateKey(date = new Date()) { return `${year}-${month}-${day}`; } -function getNextLocalMidnightIso(date = new Date()) { +function getNextLocalMidnight(date = new Date()) { const nextMidnight = new Date(date); nextMidnight.setHours(24, 0, 0, 0); - return nextMidnight.toISOString(); + return nextMidnight; } -function buildAIAutoCommentLimitPayload(profileNumber, dailyLimit, usedToday, date = new Date()) { - const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); - if (!normalizedProfileNumber) { +function getLocalMidnightIso(date = new Date()) { + const midnight = new Date(date); + midnight.setHours(0, 0, 0, 0); + return midnight.toISOString(); +} + +function getNextLocalMidnightIso(date = new Date()) { + return getNextLocalMidnight(date).toISOString(); +} + +function formatAIAutoCommentTimeValue(value) { + if (typeof value !== 'string') { return null; } - const normalizedDailyLimit = normalizeAIAutoCommentDailyLimit(dailyLimit); - const normalizedUsedToday = Math.max(0, parseInt(usedToday, 10) || 0); + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const match = /^(\d{1,2}):(\d{2})$/.exec(trimmed); + if (!match) { + return null; + } + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2], 10); + if (Number.isNaN(hours) || Number.isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { + return null; + } + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; +} + +function timeValueToMinutes(value) { + const formatted = formatAIAutoCommentTimeValue(value); + if (!formatted) { + return null; + } + const [hours, minutes] = formatted.split(':').map((entry) => parseInt(entry, 10)); + return (hours * 60) + minutes; +} + +function normalizeAIAutoCommentRateLimitSettings(raw = {}) { + const defaults = AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS; + const normalized = { + enabled: raw.enabled === undefined || raw.enabled === null + ? defaults.enabled + : (raw.enabled ? 1 : 0), + requests_per_minute: normalizeNonNegativeInteger(raw.requests_per_minute, defaults.requests_per_minute, 60), + requests_per_hour: normalizeNonNegativeInteger(raw.requests_per_hour, defaults.requests_per_hour, 500), + requests_per_day: normalizeNonNegativeInteger(raw.requests_per_day, defaults.requests_per_day, 5000), + min_delay_seconds: normalizeNonNegativeInteger(raw.min_delay_seconds, defaults.min_delay_seconds, 3600), + burst_limit: normalizeNonNegativeInteger(raw.burst_limit, defaults.burst_limit, 100), + burst_window_minutes: AI_AUTO_COMMENT_BURST_WINDOW_MINUTES, + cooldown_minutes: normalizeNonNegativeInteger(raw.cooldown_minutes, defaults.cooldown_minutes, 1440), + active_hours_start: formatAIAutoCommentTimeValue(raw.active_hours_start), + active_hours_end: formatAIAutoCommentTimeValue(raw.active_hours_end) + }; + + if (!normalized.active_hours_start || !normalized.active_hours_end) { + normalized.active_hours_start = null; + normalized.active_hours_end = null; + } + + return normalized; +} + +function getAIAutoCommentRateLimitSettings() { + const row = db.prepare('SELECT * FROM ai_auto_comment_rate_limit_settings WHERE id = 1').get(); + return normalizeAIAutoCommentRateLimitSettings(row || {}); +} + +function saveAIAutoCommentRateLimitSettings(rawSettings = {}) { + const normalized = normalizeAIAutoCommentRateLimitSettings(rawSettings); + db.prepare(` + INSERT INTO ai_auto_comment_rate_limit_settings ( + id, + enabled, + requests_per_minute, + requests_per_hour, + requests_per_day, + min_delay_seconds, + burst_limit, + cooldown_minutes, + active_hours_start, + active_hours_end, + updated_at + ) VALUES ( + 1, + @enabled, + @requests_per_minute, + @requests_per_hour, + @requests_per_day, + @min_delay_seconds, + @burst_limit, + @cooldown_minutes, + @active_hours_start, + @active_hours_end, + CURRENT_TIMESTAMP + ) + ON CONFLICT(id) DO UPDATE SET + enabled = excluded.enabled, + requests_per_minute = excluded.requests_per_minute, + requests_per_hour = excluded.requests_per_hour, + requests_per_day = excluded.requests_per_day, + min_delay_seconds = excluded.min_delay_seconds, + burst_limit = excluded.burst_limit, + cooldown_minutes = excluded.cooldown_minutes, + active_hours_start = excluded.active_hours_start, + active_hours_end = excluded.active_hours_end, + updated_at = CURRENT_TIMESTAMP + `).run(normalized); + return normalized; +} + +function computeNextActiveHoursStart(settings, now = new Date()) { + const startMinutes = timeValueToMinutes(settings.active_hours_start); + const endMinutes = timeValueToMinutes(settings.active_hours_end); + if (startMinutes === null || endMinutes === null) { + return null; + } + + const currentMinutes = (now.getHours() * 60) + now.getMinutes(); + const next = new Date(now); + next.setSeconds(0, 0); + + if (startMinutes === endMinutes) { + return next; + } + + const crossesMidnight = startMinutes > endMinutes; + const shouldStartToday = crossesMidnight + ? currentMinutes < startMinutes && currentMinutes >= endMinutes + : currentMinutes < startMinutes; + + if (!shouldStartToday) { + next.setDate(next.getDate() + 1); + } + + next.setHours(Math.floor(startMinutes / 60), startMinutes % 60, 0, 0); + return next; +} + +function getAIAutoCommentActiveHoursState(settings, now = new Date()) { + const startMinutes = timeValueToMinutes(settings.active_hours_start); + const endMinutes = timeValueToMinutes(settings.active_hours_end); + if (startMinutes === null || endMinutes === null) { + return { + configured: false, + active: true, + nextAllowedAt: null + }; + } + + if (startMinutes === endMinutes) { + return { + configured: true, + active: true, + nextAllowedAt: null + }; + } + + const currentMinutes = (now.getHours() * 60) + now.getMinutes(); + const active = startMinutes < endMinutes + ? currentMinutes >= startMinutes && currentMinutes < endMinutes + : currentMinutes >= startMinutes || currentMinutes < endMinutes; + return { - profile_number: normalizedProfileNumber, - profile_name: getProfileName(normalizedProfileNumber), - daily_limit: normalizedDailyLimit, - used_today: normalizedUsedToday, - remaining_today: normalizedDailyLimit > 0 - ? Math.max(0, normalizedDailyLimit - normalizedUsedToday) - : null, - blocked: normalizedDailyLimit > 0 && normalizedUsedToday >= normalizedDailyLimit, - resets_at: getNextLocalMidnightIso(date) + configured: true, + active, + nextAllowedAt: active ? null : computeNextActiveHoursStart(settings, now)?.toISOString() || null }; } -function listAIAutoCommentProfileLimits() { - const todayKey = getLocalDateKey(); - const limitRows = db.prepare(` - SELECT profile_number, daily_limit - FROM ai_profile_auto_comment_limits - `).all(); - const usageRows = db.prepare(` - SELECT profile_number, used_count - FROM ai_profile_auto_comment_usage - WHERE usage_date = ? - `).all(todayKey); - - const dailyLimitByProfile = new Map(); - limitRows.forEach((row) => { - const profileNumber = sanitizeProfileNumber(row.profile_number); - if (profileNumber) { - dailyLimitByProfile.set(profileNumber, normalizeAIAutoCommentDailyLimit(row.daily_limit)); - } - }); - - const usageByProfile = new Map(); - usageRows.forEach((row) => { - const profileNumber = sanitizeProfileNumber(row.profile_number); - if (profileNumber) { - usageByProfile.set(profileNumber, Math.max(0, parseInt(row.used_count, 10) || 0)); - } - }); - - return Array.from({ length: MAX_PROFILES }, (_unused, index) => { - const profileNumber = index + 1; - return buildAIAutoCommentLimitPayload( - profileNumber, - dailyLimitByProfile.get(profileNumber) || 0, - usageByProfile.get(profileNumber) || 0 - ); - }); +function shouldApplyAIAutoCommentCooldown(error) { + if (!error) { + return false; + } + const status = error.status || error.statusCode || null; + if (status === 429 || status === 403) { + return true; + } + const baseMessage = typeof error.message === 'string' ? error.message : ''; + const errorDetails = error.apiError && typeof error.apiError === 'object' + ? (error.apiError.error?.message || error.apiError.error || error.apiError.message || '') + : ''; + const combinedMessage = `${baseMessage} ${errorDetails}`.toLowerCase(); + return combinedMessage + ? RATE_LIMIT_KEYWORDS.some(keyword => combinedMessage.includes(keyword)) + : false; } -function getAIAutoCommentProfileLimit(profileNumber) { +function getAIAutoCommentCooldownDecision(settings, error, now = new Date()) { + if (!shouldApplyAIAutoCommentCooldown(error)) { + return null; + } + + const configuredSeconds = Math.max(0, (settings.cooldown_minutes || 0) * 60); + const autoDisableDecision = determineAutoDisable(error); + let untilMs = now.getTime() + (configuredSeconds * 1000); + + if (autoDisableDecision && autoDisableDecision.until instanceof Date && !Number.isNaN(autoDisableDecision.until.getTime())) { + untilMs = Math.max(untilMs, autoDisableDecision.until.getTime()); + } + + return { + until: new Date(untilMs), + reason: truncateString(error.message || 'AI-Fehler', 240) + }; +} + +function getAIAutoCommentProfileState(profileNumber) { const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); if (!normalizedProfileNumber) { return null; } - - const todayKey = getLocalDateKey(); - const limitRow = db.prepare(` - SELECT daily_limit - FROM ai_profile_auto_comment_limits + const row = db.prepare(` + SELECT profile_number, cooldown_until, cooldown_reason + FROM ai_auto_comment_rate_limit_profile_state WHERE profile_number = ? `).get(normalizedProfileNumber); - const usageRow = db.prepare(` - SELECT used_count - FROM ai_profile_auto_comment_usage - WHERE profile_number = ? - AND usage_date = ? - `).get(normalizedProfileNumber, todayKey); - - return buildAIAutoCommentLimitPayload( - normalizedProfileNumber, - limitRow ? limitRow.daily_limit : 0, - usageRow ? usageRow.used_count : 0 - ); + return row || null; } -function saveAIAutoCommentProfileLimits(profileLimits) { - const rows = Array.isArray(profileLimits) ? profileLimits : []; - const normalizedByProfile = new Map(); +function setAIAutoCommentProfileCooldown(profileNumber, decision) { + const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); + if (!normalizedProfileNumber || !decision || !(decision.until instanceof Date) || Number.isNaN(decision.until.getTime())) { + return null; + } - rows.forEach((entry) => { - const profileNumber = sanitizeProfileNumber(entry && entry.profile_number); - if (!profileNumber) { - return; - } - normalizedByProfile.set(profileNumber, normalizeAIAutoCommentDailyLimit(entry.daily_limit)); - }); - - const upsertLimit = db.prepare(` - INSERT INTO ai_profile_auto_comment_limits (profile_number, daily_limit, updated_at) - VALUES (@profile_number, @daily_limit, CURRENT_TIMESTAMP) + db.prepare(` + INSERT INTO ai_auto_comment_rate_limit_profile_state ( + profile_number, + cooldown_until, + cooldown_reason, + updated_at + ) VALUES ( + @profile_number, + @cooldown_until, + @cooldown_reason, + CURRENT_TIMESTAMP + ) ON CONFLICT(profile_number) DO UPDATE SET - daily_limit = excluded.daily_limit, + cooldown_until = excluded.cooldown_until, + cooldown_reason = excluded.cooldown_reason, updated_at = CURRENT_TIMESTAMP - `); - - const persist = db.transaction(() => { - for (let profileNumber = 1; profileNumber <= MAX_PROFILES; profileNumber += 1) { - upsertLimit.run({ - profile_number: profileNumber, - daily_limit: normalizedByProfile.has(profileNumber) - ? normalizedByProfile.get(profileNumber) - : 0 - }); - } + `).run({ + profile_number: normalizedProfileNumber, + cooldown_until: decision.until.toISOString(), + cooldown_reason: decision.reason || null }); - persist(); - return listAIAutoCommentProfileLimits(); + return getAIAutoCommentProfileState(normalizedProfileNumber); } -function incrementAIAutoCommentProfileUsage(profileNumber) { +function clearExpiredAIAutoCommentProfileCooldown(profileNumber, now = new Date()) { + const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); + if (!normalizedProfileNumber) { + return; + } + db.prepare(` + DELETE FROM ai_auto_comment_rate_limit_profile_state + WHERE profile_number = ? + AND cooldown_until IS NOT NULL + AND datetime(cooldown_until) <= datetime(?) + `).run(normalizedProfileNumber, now.toISOString()); +} + +function getAIAutoCommentEventCountSince(profileNumber, sinceIso, untilIso = null) { + const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); + if (!normalizedProfileNumber) { + return 0; + } + const row = untilIso + ? db.prepare(` + SELECT COUNT(*) AS count + FROM ai_auto_comment_rate_limit_events + WHERE profile_number = ? + AND datetime(created_at) >= datetime(?) + AND datetime(created_at) <= datetime(?) + `).get(normalizedProfileNumber, sinceIso, untilIso) + : db.prepare(` + SELECT COUNT(*) AS count + FROM ai_auto_comment_rate_limit_events + WHERE profile_number = ? + AND datetime(created_at) >= datetime(?) + `).get(normalizedProfileNumber, sinceIso); + return row ? Math.max(0, parseInt(row.count, 10) || 0) : 0; +} + +function getAIAutoCommentLatestEvent(profileNumber) { + const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); + if (!normalizedProfileNumber) { + return null; + } + return db.prepare(` + SELECT created_at + FROM ai_auto_comment_rate_limit_events + WHERE profile_number = ? + ORDER BY datetime(created_at) DESC + LIMIT 1 + `).get(normalizedProfileNumber) || null; +} + +function purgeOldAIAutoCommentRateLimitEvents(now = new Date()) { + const cutoff = new Date(now); + cutoff.setDate(cutoff.getDate() - 8); + db.prepare(` + DELETE FROM ai_auto_comment_rate_limit_events + WHERE datetime(created_at) < datetime(?) + `).run(cutoff.toISOString()); +} + +function getAIAutoCommentOldestEventSince(profileNumber, sinceIso) { + const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); + if (!normalizedProfileNumber) { + return null; + } + return db.prepare(` + SELECT created_at + FROM ai_auto_comment_rate_limit_events + WHERE profile_number = ? + AND datetime(created_at) >= datetime(?) + ORDER BY datetime(created_at) ASC + LIMIT 1 + `).get(normalizedProfileNumber, sinceIso) || null; +} + +function buildAIAutoCommentRateLimitStatus(profileNumber, settings = getAIAutoCommentRateLimitSettings(), now = new Date()) { const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); if (!normalizedProfileNumber) { return null; } - const todayKey = getLocalDateKey(); - db.prepare(` - INSERT INTO ai_profile_auto_comment_usage (profile_number, usage_date, used_count, updated_at) - VALUES (?, ?, 1, CURRENT_TIMESTAMP) - ON CONFLICT(profile_number, usage_date) DO UPDATE SET - used_count = ai_profile_auto_comment_usage.used_count + 1, - updated_at = CURRENT_TIMESTAMP - `).run(normalizedProfileNumber, todayKey); + clearExpiredAIAutoCommentProfileCooldown(normalizedProfileNumber, now); - return getAIAutoCommentProfileLimit(normalizedProfileNumber); + const nowIso = now.toISOString(); + const minuteWindowStart = new Date(now.getTime() - (60 * 1000)).toISOString(); + const hourWindowStart = new Date(now.getTime() - (60 * 60 * 1000)).toISOString(); + const burstWindowStart = new Date(now.getTime() - (settings.burst_window_minutes * 60 * 1000)).toISOString(); + const dayWindowStart = getLocalMidnightIso(now); + + const usedMinute = getAIAutoCommentEventCountSince(normalizedProfileNumber, minuteWindowStart, nowIso); + const usedHour = getAIAutoCommentEventCountSince(normalizedProfileNumber, hourWindowStart, nowIso); + const usedBurst = getAIAutoCommentEventCountSince(normalizedProfileNumber, burstWindowStart, nowIso); + const usedDay = getAIAutoCommentEventCountSince(normalizedProfileNumber, dayWindowStart, nowIso); + const lastEvent = getAIAutoCommentLatestEvent(normalizedProfileNumber); + const profileState = getAIAutoCommentProfileState(normalizedProfileNumber); + const activeHours = getAIAutoCommentActiveHoursState(settings, now); + + let blocked = false; + let blockedReason = null; + let blockedUntil = null; + + if (!settings.enabled) { + blocked = false; + } else if (profileState && profileState.cooldown_until) { + const cooldownUntil = new Date(profileState.cooldown_until); + if (!Number.isNaN(cooldownUntil.getTime()) && cooldownUntil.getTime() > now.getTime()) { + blocked = true; + blockedReason = 'cooldown'; + blockedUntil = cooldownUntil.toISOString(); + } + } + + if (!blocked && activeHours.configured && !activeHours.active) { + blocked = true; + blockedReason = 'active_hours'; + blockedUntil = activeHours.nextAllowedAt; + } + + if (!blocked && settings.min_delay_seconds > 0 && lastEvent && lastEvent.created_at) { + const lastEventDate = new Date(lastEvent.created_at); + if (!Number.isNaN(lastEventDate.getTime())) { + const nextAllowedAt = new Date(lastEventDate.getTime() + (settings.min_delay_seconds * 1000)); + if (nextAllowedAt.getTime() > now.getTime()) { + blocked = true; + blockedReason = 'min_delay'; + blockedUntil = nextAllowedAt.toISOString(); + } + } + } + + if (!blocked && settings.requests_per_minute > 0 && usedMinute >= settings.requests_per_minute) { + const oldest = getAIAutoCommentOldestEventSince(normalizedProfileNumber, minuteWindowStart); + if (oldest && oldest.created_at) { + blocked = true; + blockedReason = 'per_minute'; + blockedUntil = new Date(new Date(oldest.created_at).getTime() + (60 * 1000)).toISOString(); + } + } + + if (!blocked && settings.burst_limit > 0 && usedBurst >= settings.burst_limit) { + const oldest = getAIAutoCommentOldestEventSince(normalizedProfileNumber, burstWindowStart); + if (oldest && oldest.created_at) { + blocked = true; + blockedReason = 'burst'; + blockedUntil = new Date(new Date(oldest.created_at).getTime() + (settings.burst_window_minutes * 60 * 1000)).toISOString(); + } + } + + if (!blocked && settings.requests_per_hour > 0 && usedHour >= settings.requests_per_hour) { + const oldest = getAIAutoCommentOldestEventSince(normalizedProfileNumber, hourWindowStart); + if (oldest && oldest.created_at) { + blocked = true; + blockedReason = 'per_hour'; + blockedUntil = new Date(new Date(oldest.created_at).getTime() + (60 * 60 * 1000)).toISOString(); + } + } + + if (!blocked && settings.requests_per_day > 0 && usedDay >= settings.requests_per_day) { + blocked = true; + blockedReason = 'per_day'; + blockedUntil = getNextLocalMidnightIso(now); + } + + return { + profile_number: normalizedProfileNumber, + profile_name: getProfileName(normalizedProfileNumber), + blocked, + blocked_reason: blockedReason, + blocked_until: blockedUntil, + cooldown_until: profileState?.cooldown_until || null, + cooldown_reason: profileState?.cooldown_reason || null, + usage: { + minute: usedMinute, + hour: usedHour, + day: usedDay, + burst: usedBurst + }, + remaining: { + minute: settings.requests_per_minute > 0 ? Math.max(0, settings.requests_per_minute - usedMinute) : null, + hour: settings.requests_per_hour > 0 ? Math.max(0, settings.requests_per_hour - usedHour) : null, + day: settings.requests_per_day > 0 ? Math.max(0, settings.requests_per_day - usedDay) : null, + burst: settings.burst_limit > 0 ? Math.max(0, settings.burst_limit - usedBurst) : null + }, + last_action_at: lastEvent?.created_at || null, + active_hours: { + configured: activeHours.configured, + active: activeHours.active, + start: settings.active_hours_start, + end: settings.active_hours_end, + next_allowed_at: activeHours.nextAllowedAt + } + }; +} + +function listAIAutoCommentRateLimitStatuses(settings = getAIAutoCommentRateLimitSettings()) { + return Array.from({ length: MAX_PROFILES }, (_unused, index) => ( + buildAIAutoCommentRateLimitStatus(index + 1, settings) + )); +} + +function reserveAIAutoCommentAction(profileNumber, settings = getAIAutoCommentRateLimitSettings()) { + const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); + if (!normalizedProfileNumber) { + return { ok: false, status: null }; + } + + const reserve = db.transaction(() => { + purgeOldAIAutoCommentRateLimitEvents(); + const currentStatus = buildAIAutoCommentRateLimitStatus(normalizedProfileNumber, settings, new Date()); + if (!currentStatus || currentStatus.blocked) { + return { + ok: false, + status: currentStatus + }; + } + + if (!settings.enabled) { + return { + ok: true, + status: currentStatus + }; + } + + const nowIso = new Date().toISOString(); + db.prepare(` + INSERT INTO ai_auto_comment_rate_limit_events (profile_number, created_at) + VALUES (?, ?) + `).run(normalizedProfileNumber, nowIso); + + return { + ok: true, + status: buildAIAutoCommentRateLimitStatus(normalizedProfileNumber, settings, new Date()) + }; + }); + + return reserve(); } function normalizeCreatorName(value) { @@ -1744,20 +2106,40 @@ db.exec(` `); db.exec(` - CREATE TABLE IF NOT EXISTS ai_profile_auto_comment_limits ( - profile_number INTEGER PRIMARY KEY, - daily_limit INTEGER NOT NULL DEFAULT 0, + CREATE TABLE IF NOT EXISTS ai_auto_comment_rate_limit_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 1, + requests_per_minute INTEGER NOT NULL DEFAULT 2, + requests_per_hour INTEGER NOT NULL DEFAULT 20, + requests_per_day INTEGER NOT NULL DEFAULT 80, + min_delay_seconds INTEGER NOT NULL DEFAULT 45, + burst_limit INTEGER NOT NULL DEFAULT 5, + cooldown_minutes INTEGER NOT NULL DEFAULT 15, + active_hours_start TEXT, + active_hours_end TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` - CREATE TABLE IF NOT EXISTS ai_profile_auto_comment_usage ( + CREATE TABLE IF NOT EXISTS ai_auto_comment_rate_limit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, profile_number INTEGER NOT NULL, - usage_date TEXT NOT NULL, - used_count INTEGER NOT NULL DEFAULT 0, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (profile_number, usage_date) + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_ai_auto_comment_rate_limit_events_profile_created + ON ai_auto_comment_rate_limit_events(profile_number, created_at DESC); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS ai_auto_comment_rate_limit_profile_state ( + profile_number INTEGER PRIMARY KEY, + cooldown_until DATETIME, + cooldown_reason TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); @@ -2230,8 +2612,17 @@ ensureColumn('posts', 'is_successful', 'is_successful INTEGER DEFAULT 0'); ensureColumn('ai_settings', 'active_credential_id', 'active_credential_id INTEGER'); ensureColumn('ai_settings', 'prompt_prefix', 'prompt_prefix TEXT'); ensureColumn('ai_settings', 'enabled', 'enabled INTEGER DEFAULT 0'); -ensureColumn('ai_profile_auto_comment_limits', 'daily_limit', 'daily_limit INTEGER NOT NULL DEFAULT 0'); -ensureColumn('ai_profile_auto_comment_usage', 'used_count', 'used_count INTEGER NOT NULL DEFAULT 0'); +ensureColumn('ai_auto_comment_rate_limit_settings', 'enabled', 'enabled INTEGER NOT NULL DEFAULT 1'); +ensureColumn('ai_auto_comment_rate_limit_settings', 'requests_per_minute', 'requests_per_minute INTEGER NOT NULL DEFAULT 2'); +ensureColumn('ai_auto_comment_rate_limit_settings', 'requests_per_hour', 'requests_per_hour INTEGER NOT NULL DEFAULT 20'); +ensureColumn('ai_auto_comment_rate_limit_settings', 'requests_per_day', 'requests_per_day INTEGER NOT NULL DEFAULT 80'); +ensureColumn('ai_auto_comment_rate_limit_settings', 'min_delay_seconds', 'min_delay_seconds INTEGER NOT NULL DEFAULT 45'); +ensureColumn('ai_auto_comment_rate_limit_settings', 'burst_limit', 'burst_limit INTEGER NOT NULL DEFAULT 5'); +ensureColumn('ai_auto_comment_rate_limit_settings', 'cooldown_minutes', 'cooldown_minutes INTEGER NOT NULL DEFAULT 15'); +ensureColumn('ai_auto_comment_rate_limit_settings', 'active_hours_start', 'active_hours_start TEXT'); +ensureColumn('ai_auto_comment_rate_limit_settings', 'active_hours_end', 'active_hours_end TEXT'); +ensureColumn('ai_auto_comment_rate_limit_profile_state', 'cooldown_until', 'cooldown_until DATETIME'); +ensureColumn('ai_auto_comment_rate_limit_profile_state', 'cooldown_reason', 'cooldown_reason TEXT'); ensureColumn('ai_credentials', 'is_active', 'is_active INTEGER DEFAULT 1'); ensureColumn('ai_credentials', 'priority', 'priority INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'base_url', 'base_url TEXT'); @@ -2249,6 +2640,7 @@ ensureColumn('ai_credentials', 'usage_24h_count', 'usage_24h_count INTEGER DEFAU ensureColumn('ai_credentials', 'usage_24h_reset_at', 'usage_24h_reset_at DATETIME'); ensureColumn('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0'); ensureColumn('search_seen_posts', 'sports_auto_hidden', 'sports_auto_hidden INTEGER NOT NULL DEFAULT 0'); +saveAIAutoCommentRateLimitSettings(getAIAutoCommentRateLimitSettings()); db.exec(` CREATE TABLE IF NOT EXISTS ai_usage_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -6860,7 +7252,8 @@ app.get('/api/ai-settings', (req, res) => { res.json({ ...settings, active_credential: activeCredential, - profile_limits: listAIAutoCommentProfileLimits() + rate_limit_settings: getAIAutoCommentRateLimitSettings(), + rate_limit_statuses: listAIAutoCommentRateLimitStatuses() }); } catch (error) { res.status(500).json({ error: error.message }); @@ -6869,7 +7262,7 @@ app.get('/api/ai-settings', (req, res) => { app.put('/api/ai-settings', (req, res) => { try { - const { active_credential_id, prompt_prefix, enabled, profile_limits } = req.body; + const { active_credential_id, prompt_prefix, enabled, rate_limit_settings } = req.body; const existing = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); @@ -6886,9 +7279,9 @@ app.put('/api/ai-settings', (req, res) => { `).run(active_credential_id || null, prompt_prefix, enabled ? 1 : 0); } - const savedProfileLimits = Array.isArray(profile_limits) - ? saveAIAutoCommentProfileLimits(profile_limits) - : listAIAutoCommentProfileLimits(); + const savedRateLimitSettings = rate_limit_settings && typeof rate_limit_settings === 'object' + ? saveAIAutoCommentRateLimitSettings(rate_limit_settings) + : getAIAutoCommentRateLimitSettings(); const updated = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); @@ -6900,7 +7293,8 @@ app.put('/api/ai-settings', (req, res) => { res.json({ ...updated, active_credential: activeCredential, - profile_limits: savedProfileLimits + rate_limit_settings: savedRateLimitSettings, + rate_limit_statuses: listAIAutoCommentRateLimitStatuses(savedRateLimitSettings) }); } catch (error) { res.status(500).json({ error: error.message }); @@ -7507,7 +7901,7 @@ app.post('/api/ai/generate-comment', async (req, res) => { try { const { postText, profileNumber, preferredCredentialId } = requestBody; const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); - let profileLimitInfo = null; + let autoCommentRateLimitStatus = null; if (!postText) { return respondWithTrackedError(400, 'postText is required'); @@ -7516,6 +7910,7 @@ app.post('/api/ai/generate-comment', async (req, res) => { const loadSettingsStartedMs = timingStart(); const settings = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); timingEnd('loadSettingsMs', loadSettingsStartedMs); + const autoCommentRateLimitSettings = getAIAutoCommentRateLimitSettings(); if (!settings || !settings.enabled) { return respondWithTrackedError(400, 'AI comment generation is not enabled'); @@ -7545,20 +7940,32 @@ app.post('/api/ai/generate-comment', async (req, res) => { } const limitCheckStartedMs = timingStart(); - const currentProfileLimit = getAIAutoCommentProfileLimit(normalizedProfileNumber); - if (currentProfileLimit && currentProfileLimit.blocked) { + const reservation = reserveAIAutoCommentAction(normalizedProfileNumber, autoCommentRateLimitSettings); + autoCommentRateLimitStatus = reservation.status || null; + if (!reservation.ok && autoCommentRateLimitStatus && autoCommentRateLimitStatus.blocked) { timingEnd('profileLimitCheckMs', limitCheckStartedMs); + const blockedUntilText = autoCommentRateLimitStatus.blocked_until + ? ` Freigabe ab ${new Date(autoCommentRateLimitStatus.blocked_until).toLocaleString('de-DE')}.` + : ''; + const reasonMap = { + cooldown: 'Cooldown aktiv.', + active_hours: 'Außerhalb der erlaubten Aktivzeiten.', + min_delay: 'Mindestabstand zwischen zwei Aktionen noch nicht erreicht.', + per_minute: 'Minutenlimit erreicht.', + burst: `Burst-Limit für ${autoCommentRateLimitSettings.burst_window_minutes} Minuten erreicht.`, + per_hour: 'Stundenlimit erreicht.', + per_day: 'Tageslimit erreicht.' + }; return respondWithTrackedError( 429, - `Tageslimit für ${currentProfileLimit.profile_name} erreicht (${currentProfileLimit.used_today}/${currentProfileLimit.daily_limit}). Die Aktion ist bis morgen gesperrt.`, + `${autoCommentRateLimitStatus.profile_name}: ${reasonMap[autoCommentRateLimitStatus.blocked_reason] || 'Aktion aktuell gesperrt.'}${blockedUntilText}`, { responseMeta: { - profileLimit: currentProfileLimit + autoCommentRateLimit: autoCommentRateLimitStatus } } ); } - profileLimitInfo = incrementAIAutoCommentProfileUsage(normalizedProfileNumber); timingEnd('profileLimitCheckMs', limitCheckStartedMs); } @@ -7647,7 +8054,7 @@ app.post('/api/ai/generate-comment', async (req, res) => { usedCredential: credential.name, usedCredentialId: credential.id, attempts: attemptDetails, - profileLimit: profileLimitInfo + autoCommentRateLimit: autoCommentRateLimitStatus }, totalDurationMs: backendTimings.totalMs }); @@ -7661,7 +8068,7 @@ app.post('/api/ai/generate-comment', async (req, res) => { usedCredentialId: credential.id, attempts: attemptDetails, rateLimitInfo: rateInfo || null, - profileLimitInfo, + autoCommentRateLimitStatus, traceId, flowId, timings: { @@ -7674,6 +8081,13 @@ app.post('/api/ai/generate-comment', async (req, res) => { console.error(`Failed with credential ${credential.name}:`, error.message); lastError = error; const errorUpdate = updateCredentialUsageOnError(credential.id, error); + if (traceSource === AI_AUTO_COMMENT_SOURCE && normalizedProfileNumber && autoCommentRateLimitSettings.enabled) { + const cooldownDecision = getAIAutoCommentCooldownDecision(autoCommentRateLimitSettings, error); + if (cooldownDecision) { + setAIAutoCommentProfileCooldown(normalizedProfileNumber, cooldownDecision); + autoCommentRateLimitStatus = buildAIAutoCommentRateLimitStatus(normalizedProfileNumber, autoCommentRateLimitSettings); + } + } credentialTimingDetails.push({ credentialId: credential.id, credentialName: credential.name, @@ -7712,7 +8126,8 @@ app.post('/api/ai/generate-comment', async (req, res) => { { attempts: error && error.attempts ? error.attempts : null, responseMeta: { - attempts: error && error.attempts ? error.attempts : null + attempts: error && error.attempts ? error.attempts : null, + autoCommentRateLimit: autoCommentRateLimitStatus } } ); diff --git a/web/index.html b/web/index.html index 9ec0365..ab1d5dd 100644 --- a/web/index.html +++ b/web/index.html @@ -1146,11 +1146,69 @@
- Gilt nur für die Aktion AI - generiere automatisch einen passenden Kommentar im Tracker. 0 bedeutet kein Limit.
+ Gilt nur für die Aktion AI - generiere automatisch einen passenden Kommentar im Tracker. Gezählt wird separat je Profil, die Regeln gelten aber für alle Profile gleich.
0 deaktiviert dieses Teillimit.
0 deaktiviert dieses Teillimit.
0 deaktiviert dieses Teillimit.
0 deaktiviert dieses Teillimit.
Maximale Aktionen in 10 Minuten. 0 deaktiviert dieses Teillimit.
0 deaktiviert den zusätzlichen Cooldown.
Leer lassen für 24/7. Zeiten gelten täglich und dürfen über Mitternacht laufen.
+