Expand AI auto comment rate limiting
This commit is contained in:
@@ -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 {
|
||||
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: false,
|
||||
active: true,
|
||||
nextAllowedAt: 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
|
||||
);
|
||||
});
|
||||
if (startMinutes === endMinutes) {
|
||||
return {
|
||||
configured: true,
|
||||
active: true,
|
||||
nextAllowedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
function getAIAutoCommentProfileLimit(profileNumber) {
|
||||
const currentMinutes = (now.getHours() * 60) + now.getMinutes();
|
||||
const active = startMinutes < endMinutes
|
||||
? currentMinutes >= startMinutes && currentMinutes < endMinutes
|
||||
: currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
active,
|
||||
nextAllowedAt: active ? null : computeNextActiveHoursStart(settings, now)?.toISOString() || null
|
||||
};
|
||||
}
|
||||
|
||||
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 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) {
|
||||
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
|
||||
cooldown_until = excluded.cooldown_until,
|
||||
cooldown_reason = excluded.cooldown_reason,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`).run({
|
||||
profile_number: normalizedProfileNumber,
|
||||
cooldown_until: decision.until.toISOString(),
|
||||
cooldown_reason: decision.reason || null
|
||||
});
|
||||
|
||||
return getAIAutoCommentProfileState(normalizedProfileNumber);
|
||||
}
|
||||
|
||||
function clearExpiredAIAutoCommentProfileCooldown(profileNumber, now = new Date()) {
|
||||
const normalizedProfileNumber = sanitizeProfileNumber(profileNumber);
|
||||
if (!normalizedProfileNumber) {
|
||||
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)
|
||||
ON CONFLICT(profile_number) DO UPDATE SET
|
||||
daily_limit = excluded.daily_limit,
|
||||
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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
persist();
|
||||
return listAIAutoCommentProfileLimits();
|
||||
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 incrementAIAutoCommentProfileUsage(profileNumber) {
|
||||
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
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1146,11 +1146,69 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tageslimit pro Profil für den AI-Kommentar-Button</label>
|
||||
<label class="form-label">
|
||||
<input type="checkbox" id="aiAutoCommentRateLimitsEnabled" class="form-checkbox">
|
||||
<span>Limitschutz für den AI-Kommentar-Button aktivieren</span>
|
||||
</label>
|
||||
<p class="form-help">
|
||||
Gilt nur für die Aktion <code>AI - generiere automatisch einen passenden Kommentar</code> im Tracker. <code>0</code> bedeutet kein Limit.
|
||||
Gilt nur für die Aktion <code>AI - generiere automatisch einen passenden Kommentar</code> im Tracker. Gezählt wird separat je Profil, die Regeln gelten aber für alle Profile gleich.
|
||||
</p>
|
||||
<div id="aiProfileCommentLimits"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aiRequestsPerMinute" class="form-label">Max. Aktionen pro Minute</label>
|
||||
<input type="number" id="aiRequestsPerMinute" class="form-input" min="0" max="60" step="1">
|
||||
<p class="form-help"><code>0</code> deaktiviert dieses Teillimit.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aiRequestsPerHour" class="form-label">Max. Aktionen pro Stunde</label>
|
||||
<input type="number" id="aiRequestsPerHour" class="form-input" min="0" max="500" step="1">
|
||||
<p class="form-help"><code>0</code> deaktiviert dieses Teillimit.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aiRequestsPerDay" class="form-label">Max. Aktionen pro Tag</label>
|
||||
<input type="number" id="aiRequestsPerDay" class="form-input" min="0" max="5000" step="1">
|
||||
<p class="form-help"><code>0</code> deaktiviert dieses Teillimit.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aiMinDelaySeconds" class="form-label">Mindestabstand zwischen Aktionen (Sekunden)</label>
|
||||
<input type="number" id="aiMinDelaySeconds" class="form-input" min="0" max="3600" step="1">
|
||||
<p class="form-help"><code>0</code> deaktiviert dieses Teillimit.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aiBurstLimit" class="form-label">Burst-Limit für kurze Spitzen</label>
|
||||
<input type="number" id="aiBurstLimit" class="form-input" min="0" max="100" step="1">
|
||||
<p class="form-help">Maximale Aktionen in 10 Minuten. <code>0</code> deaktiviert dieses Teillimit.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aiCooldownMinutes" class="form-label">Cooldown nach 429/403/Warnsignal (Minuten)</label>
|
||||
<input type="number" id="aiCooldownMinutes" class="form-input" min="0" max="1440" step="1">
|
||||
<p class="form-help"><code>0</code> deaktiviert den zusätzlichen Cooldown.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Aktivzeiten optional</label>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px;">
|
||||
<div>
|
||||
<label for="aiActiveHoursStart" class="form-label">Von</label>
|
||||
<input type="time" id="aiActiveHoursStart" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="aiActiveHoursEnd" class="form-label">Bis</label>
|
||||
<input type="time" id="aiActiveHoursEnd" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-help">Leer lassen für 24/7. Zeiten gelten täglich und dürfen über Mitternacht laufen.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Aktueller Status je Profil</label>
|
||||
<div id="aiRateLimitProfileStatuses"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
|
||||
271
web/settings.js
271
web/settings.js
@@ -41,7 +41,17 @@ const PROVIDER_INFO = {
|
||||
apiKeyHelp: 'Für lokale OpenAI-kompatible Server (z.B. Ollama) kannst du den Schlüssel leer lassen.'
|
||||
}
|
||||
};
|
||||
const AI_AUTO_COMMENT_DAILY_LIMIT_MAX = 500;
|
||||
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: '',
|
||||
active_hours_end: ''
|
||||
});
|
||||
|
||||
let credentials = [];
|
||||
let currentSettings = null;
|
||||
@@ -174,7 +184,8 @@ async function loadSettings() {
|
||||
document.getElementById('activeCredential').value = currentSettings.active_credential_id || '';
|
||||
document.getElementById('aiPromptPrefix').value = currentSettings.prompt_prefix ||
|
||||
'Schreibe einen freundlichen, authentischen Kommentar auf Deutsch zu folgendem Facebook-Post. Der Kommentar soll natürlich wirken und maximal 2-3 Sätze lang sein:\n\n';
|
||||
renderAIProfileLimitInputs();
|
||||
applyAIAutoCommentRateLimitSettingsUI();
|
||||
renderAIAutoCommentRateLimitStatuses();
|
||||
}
|
||||
|
||||
async function loadHiddenSettings() {
|
||||
@@ -427,91 +438,169 @@ function normalizeSimilarityImageThresholdInput(value) {
|
||||
return Math.min(64, Math.max(0, parsed));
|
||||
}
|
||||
|
||||
function normalizeAIProfileDailyLimitInput(value) {
|
||||
function normalizeAIAutoCommentRateLimitValue(value, fallback, max) {
|
||||
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 getDefaultAIProfileLimitEntry(profileNumber) {
|
||||
function normalizeAIAutoCommentTimeInput(value) {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
return /^\d{2}:\d{2}$/.test(trimmed) ? trimmed : '';
|
||||
}
|
||||
|
||||
function getAIAutoCommentRateLimitSettings() {
|
||||
const raw = currentSettings?.rate_limit_settings || {};
|
||||
return {
|
||||
profile_number: profileNumber,
|
||||
profile_name: `Profil ${profileNumber}`,
|
||||
daily_limit: 0,
|
||||
used_today: 0,
|
||||
remaining_today: null
|
||||
enabled: raw.enabled === undefined || raw.enabled === null
|
||||
? AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.enabled
|
||||
: (raw.enabled ? 1 : 0),
|
||||
requests_per_minute: normalizeAIAutoCommentRateLimitValue(raw.requests_per_minute, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_minute, 60),
|
||||
requests_per_hour: normalizeAIAutoCommentRateLimitValue(raw.requests_per_hour, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_hour, 500),
|
||||
requests_per_day: normalizeAIAutoCommentRateLimitValue(raw.requests_per_day, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_day, 5000),
|
||||
min_delay_seconds: normalizeAIAutoCommentRateLimitValue(raw.min_delay_seconds, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.min_delay_seconds, 3600),
|
||||
burst_limit: normalizeAIAutoCommentRateLimitValue(raw.burst_limit, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.burst_limit, 100),
|
||||
cooldown_minutes: normalizeAIAutoCommentRateLimitValue(raw.cooldown_minutes, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.cooldown_minutes, 1440),
|
||||
active_hours_start: normalizeAIAutoCommentTimeInput(raw.active_hours_start),
|
||||
active_hours_end: normalizeAIAutoCommentTimeInput(raw.active_hours_end)
|
||||
};
|
||||
}
|
||||
|
||||
function getAIProfileLimitEntries() {
|
||||
const rows = Array.isArray(currentSettings?.profile_limits)
|
||||
? currentSettings.profile_limits
|
||||
: [];
|
||||
const byProfile = new Map();
|
||||
rows.forEach((row) => {
|
||||
const profileNumber = parseInt(row?.profile_number, 10);
|
||||
if (!Number.isNaN(profileNumber) && profileNumber >= 1 && profileNumber <= 5) {
|
||||
byProfile.set(profileNumber, {
|
||||
...getDefaultAIProfileLimitEntry(profileNumber),
|
||||
...row,
|
||||
daily_limit: normalizeAIProfileDailyLimitInput(row?.daily_limit),
|
||||
used_today: Math.max(0, parseInt(row?.used_today, 10) || 0)
|
||||
});
|
||||
}
|
||||
});
|
||||
function applyAIAutoCommentRateLimitSettingsUI() {
|
||||
const settings = getAIAutoCommentRateLimitSettings();
|
||||
const toggle = document.getElementById('aiAutoCommentRateLimitsEnabled');
|
||||
const requestsPerMinute = document.getElementById('aiRequestsPerMinute');
|
||||
const requestsPerHour = document.getElementById('aiRequestsPerHour');
|
||||
const requestsPerDay = document.getElementById('aiRequestsPerDay');
|
||||
const minDelaySeconds = document.getElementById('aiMinDelaySeconds');
|
||||
const burstLimit = document.getElementById('aiBurstLimit');
|
||||
const cooldownMinutes = document.getElementById('aiCooldownMinutes');
|
||||
const activeHoursStart = document.getElementById('aiActiveHoursStart');
|
||||
const activeHoursEnd = document.getElementById('aiActiveHoursEnd');
|
||||
|
||||
return Array.from({ length: 5 }, (_unused, index) => {
|
||||
const profileNumber = index + 1;
|
||||
return byProfile.get(profileNumber) || getDefaultAIProfileLimitEntry(profileNumber);
|
||||
if (toggle) toggle.checked = !!settings.enabled;
|
||||
if (requestsPerMinute) requestsPerMinute.value = settings.requests_per_minute;
|
||||
if (requestsPerHour) requestsPerHour.value = settings.requests_per_hour;
|
||||
if (requestsPerDay) requestsPerDay.value = settings.requests_per_day;
|
||||
if (minDelaySeconds) minDelaySeconds.value = settings.min_delay_seconds;
|
||||
if (burstLimit) burstLimit.value = settings.burst_limit;
|
||||
if (cooldownMinutes) cooldownMinutes.value = settings.cooldown_minutes;
|
||||
if (activeHoursStart) activeHoursStart.value = settings.active_hours_start || '';
|
||||
if (activeHoursEnd) activeHoursEnd.value = settings.active_hours_end || '';
|
||||
|
||||
applyAIAutoCommentRateLimitEnabledState(!!settings.enabled);
|
||||
}
|
||||
|
||||
function applyAIAutoCommentRateLimitEnabledState(enabled) {
|
||||
[
|
||||
'aiRequestsPerMinute',
|
||||
'aiRequestsPerHour',
|
||||
'aiRequestsPerDay',
|
||||
'aiMinDelaySeconds',
|
||||
'aiBurstLimit',
|
||||
'aiCooldownMinutes',
|
||||
'aiActiveHoursStart',
|
||||
'aiActiveHoursEnd'
|
||||
].forEach((id) => {
|
||||
const input = document.getElementById(id);
|
||||
if (input) {
|
||||
input.disabled = !enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAIProfileLimitInputs() {
|
||||
const container = document.getElementById('aiProfileCommentLimits');
|
||||
function renderAIAutoCommentRateLimitStatuses() {
|
||||
const container = document.getElementById('aiRateLimitProfileStatuses');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = getAIProfileLimitEntries();
|
||||
const statuses = Array.isArray(currentSettings?.rate_limit_statuses)
|
||||
? currentSettings.rate_limit_statuses
|
||||
: [];
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'form-group';
|
||||
wrapper.style.marginBottom = '12px';
|
||||
if (!statuses.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'form-help';
|
||||
empty.textContent = 'Noch keine Statusdaten verfügbar.';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'form-label';
|
||||
label.setAttribute('for', `aiProfileLimit${entry.profile_number}`);
|
||||
label.textContent = entry.profile_name || `Profil ${entry.profile_number}`;
|
||||
statuses.forEach((status) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'form-group';
|
||||
card.style.marginBottom = '12px';
|
||||
card.style.padding = '12px';
|
||||
card.style.border = '1px solid #dfe3e8';
|
||||
card.style.borderRadius = '8px';
|
||||
card.style.background = status.blocked ? '#fff7f7' : '#f8fafc';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.min = '0';
|
||||
input.max = String(AI_AUTO_COMMENT_DAILY_LIMIT_MAX);
|
||||
input.step = '1';
|
||||
input.id = `aiProfileLimit${entry.profile_number}`;
|
||||
input.className = 'form-input';
|
||||
input.value = String(normalizeAIProfileDailyLimitInput(entry.daily_limit));
|
||||
input.placeholder = '0 = kein Limit';
|
||||
input.addEventListener('blur', () => {
|
||||
input.value = String(normalizeAIProfileDailyLimitInput(input.value));
|
||||
});
|
||||
const title = document.createElement('div');
|
||||
title.className = 'form-label';
|
||||
title.textContent = status.profile_name || `Profil ${status.profile_number}`;
|
||||
card.appendChild(title);
|
||||
|
||||
const help = document.createElement('p');
|
||||
help.className = 'form-help';
|
||||
const dailyLimit = normalizeAIProfileDailyLimitInput(entry.daily_limit);
|
||||
const usedToday = Math.max(0, parseInt(entry.used_today, 10) || 0);
|
||||
help.textContent = dailyLimit > 0
|
||||
? `Heute verwendet: ${usedToday} / ${dailyLimit}`
|
||||
: `Heute verwendet: ${usedToday} · aktuell kein Limit aktiv`;
|
||||
const summary = document.createElement('p');
|
||||
summary.className = 'form-help';
|
||||
const usage = status.usage || {};
|
||||
const remaining = status.remaining || {};
|
||||
summary.textContent = `Minute ${usage.minute ?? 0}${remaining.minute !== null && remaining.minute !== undefined ? ` (${remaining.minute} frei)` : ''} · Stunde ${usage.hour ?? 0}${remaining.hour !== null && remaining.hour !== undefined ? ` (${remaining.hour} frei)` : ''} · Tag ${usage.day ?? 0}${remaining.day !== null && remaining.day !== undefined ? ` (${remaining.day} frei)` : ''} · Burst ${usage.burst ?? 0}${remaining.burst !== null && remaining.burst !== undefined ? ` (${remaining.burst} frei)` : ''}`;
|
||||
card.appendChild(summary);
|
||||
|
||||
wrapper.appendChild(label);
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(help);
|
||||
container.appendChild(wrapper);
|
||||
const details = document.createElement('p');
|
||||
details.className = 'form-help';
|
||||
const detailParts = [];
|
||||
if (status.last_action_at) {
|
||||
detailParts.push(`Letzte Aktion ${formatRelativePast(status.last_action_at)}`);
|
||||
}
|
||||
if (status.active_hours?.configured) {
|
||||
detailParts.push(`Aktivzeit ${status.active_hours.start}–${status.active_hours.end}`);
|
||||
} else {
|
||||
detailParts.push('Aktivzeit 24/7');
|
||||
}
|
||||
if (status.cooldown_until) {
|
||||
detailParts.push(`Cooldown bis ${formatRelativeFuture(status.cooldown_until)}`);
|
||||
}
|
||||
details.textContent = detailParts.join(' · ');
|
||||
card.appendChild(details);
|
||||
|
||||
const state = document.createElement('p');
|
||||
state.className = 'form-help';
|
||||
state.style.marginBottom = '0';
|
||||
if (status.blocked) {
|
||||
const blockedReasonMap = {
|
||||
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'
|
||||
};
|
||||
const untilText = status.blocked_until ? ` bis ${formatRelativeFuture(status.blocked_until)}` : '';
|
||||
state.textContent = `Gesperrt: ${blockedReasonMap[status.blocked_reason] || 'Limit aktiv'}${untilText}`;
|
||||
state.style.color = '#b42318';
|
||||
if (status.cooldown_reason) {
|
||||
state.title = status.cooldown_reason;
|
||||
}
|
||||
} else {
|
||||
state.textContent = 'Aktuell freigegeben';
|
||||
state.style.color = '#027a48';
|
||||
}
|
||||
card.appendChild(state);
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1089,14 +1178,17 @@ async function saveSettings(e, { silent = false } = {}) {
|
||||
enabled: document.getElementById('aiEnabled').checked,
|
||||
active_credential_id: parseInt(document.getElementById('activeCredential').value) || null,
|
||||
prompt_prefix: document.getElementById('aiPromptPrefix').value,
|
||||
profile_limits: Array.from({ length: 5 }, (_unused, index) => {
|
||||
const profileNumber = index + 1;
|
||||
const input = document.getElementById(`aiProfileLimit${profileNumber}`);
|
||||
return {
|
||||
profile_number: profileNumber,
|
||||
daily_limit: normalizeAIProfileDailyLimitInput(input ? input.value : 0)
|
||||
};
|
||||
})
|
||||
rate_limit_settings: {
|
||||
enabled: document.getElementById('aiAutoCommentRateLimitsEnabled').checked ? 1 : 0,
|
||||
requests_per_minute: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiRequestsPerMinute').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_minute, 60),
|
||||
requests_per_hour: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiRequestsPerHour').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_hour, 500),
|
||||
requests_per_day: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiRequestsPerDay').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_day, 5000),
|
||||
min_delay_seconds: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiMinDelaySeconds').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.min_delay_seconds, 3600),
|
||||
burst_limit: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiBurstLimit').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.burst_limit, 100),
|
||||
cooldown_minutes: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiCooldownMinutes').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.cooldown_minutes, 1440),
|
||||
active_hours_start: normalizeAIAutoCommentTimeInput(document.getElementById('aiActiveHoursStart').value),
|
||||
active_hours_end: normalizeAIAutoCommentTimeInput(document.getElementById('aiActiveHoursEnd').value)
|
||||
}
|
||||
};
|
||||
|
||||
const res = await apiFetch(`${API_URL}/ai-settings`, {
|
||||
@@ -1111,7 +1203,8 @@ async function saveSettings(e, { silent = false } = {}) {
|
||||
}
|
||||
|
||||
currentSettings = await res.json();
|
||||
renderAIProfileLimitInputs();
|
||||
applyAIAutoCommentRateLimitSettingsUI();
|
||||
renderAIAutoCommentRateLimitStatuses();
|
||||
if (!silent) {
|
||||
showSuccess('✅ Einstellungen erfolgreich gespeichert');
|
||||
}
|
||||
@@ -1343,6 +1436,38 @@ document.getElementById('generateTestComment').addEventListener('click', generat
|
||||
document.getElementById('purgeHiddenNowBtn').addEventListener('click', purgeHiddenNow);
|
||||
document.getElementById('saveAllFloatingBtn').addEventListener('click', saveAllSettings);
|
||||
|
||||
const aiAutoCommentRateLimitsEnabled = document.getElementById('aiAutoCommentRateLimitsEnabled');
|
||||
if (aiAutoCommentRateLimitsEnabled) {
|
||||
aiAutoCommentRateLimitsEnabled.addEventListener('change', () => {
|
||||
applyAIAutoCommentRateLimitEnabledState(aiAutoCommentRateLimitsEnabled.checked);
|
||||
});
|
||||
}
|
||||
|
||||
[
|
||||
['aiRequestsPerMinute', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_minute, 60],
|
||||
['aiRequestsPerHour', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_hour, 500],
|
||||
['aiRequestsPerDay', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_day, 5000],
|
||||
['aiMinDelaySeconds', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.min_delay_seconds, 3600],
|
||||
['aiBurstLimit', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.burst_limit, 100],
|
||||
['aiCooldownMinutes', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.cooldown_minutes, 1440]
|
||||
].forEach(([id, fallback, max]) => {
|
||||
const input = document.getElementById(id);
|
||||
if (input) {
|
||||
input.addEventListener('blur', () => {
|
||||
input.value = normalizeAIAutoCommentRateLimitValue(input.value, fallback, max);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
['aiActiveHoursStart', 'aiActiveHoursEnd'].forEach((id) => {
|
||||
const input = document.getElementById(id);
|
||||
if (input) {
|
||||
input.addEventListener('blur', () => {
|
||||
input.value = normalizeAIAutoCommentTimeInput(input.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const autoPurgeHiddenToggle = document.getElementById('autoPurgeHiddenToggle');
|
||||
if (autoPurgeHiddenToggle) {
|
||||
autoPurgeHiddenToggle.addEventListener('change', () => {
|
||||
|
||||
Reference in New Issue
Block a user