Expand AI auto comment rate limiting

This commit is contained in:
2026-04-07 15:55:04 +02:00
parent 9ca9233bf6
commit b5b44d1304
3 changed files with 812 additions and 214 deletions

View File

@@ -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
}
}
);