From 66221f27c71c182a56bce4a725e45678540dcacc Mon Sep 17 00:00:00 2001 From: Meik Date: Tue, 7 Apr 2026 15:34:18 +0200 Subject: [PATCH] Add per-profile AI comment action limits --- backend/server.js | 225 ++++++++++++++++++++++++++++++++++++++++++++-- web/index.html | 8 ++ web/settings.js | 103 ++++++++++++++++++++- 3 files changed, 329 insertions(+), 7 deletions(-) diff --git a/backend/server.js b/backend/server.js index e91ea5b..06dca9f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -11,6 +11,8 @@ const app = express(); 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 DEFAULT_PROFILE_NAMES = { 1: 'Profil 1', 2: 'Profil 2', @@ -809,6 +811,167 @@ function getProfileName(profileNumber) { return DEFAULT_PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`; } +function normalizeAIAutoCommentDailyLimit(value) { + if (value === null || typeof value === 'undefined' || value === '') { + return 0; + } + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed) || parsed < 0) { + return 0; + } + return Math.min(AI_AUTO_COMMENT_DAILY_LIMIT_MAX, parsed); +} + +function getLocalDateKey(date = new Date()) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function getNextLocalMidnightIso(date = new Date()) { + const nextMidnight = new Date(date); + nextMidnight.setHours(24, 0, 0, 0); + return nextMidnight.toISOString(); +} + +function buildAIAutoCommentLimitPayload(profileNumber, dailyLimit, usedToday, date = new Date()) { + const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); + if (!normalizedProfileNumber) { + return null; + } + const normalizedDailyLimit = normalizeAIAutoCommentDailyLimit(dailyLimit); + const normalizedUsedToday = Math.max(0, parseInt(usedToday, 10) || 0); + 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) + }; +} + +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 getAIAutoCommentProfileLimit(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 + 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 + ); +} + +function saveAIAutoCommentProfileLimits(profileLimits) { + const rows = Array.isArray(profileLimits) ? profileLimits : []; + const normalizedByProfile = new Map(); + + 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) + 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(); +} + +function incrementAIAutoCommentProfileUsage(profileNumber) { + 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); + + return getAIAutoCommentProfileLimit(normalizedProfileNumber); +} + function normalizeCreatorName(value) { if (typeof value !== 'string') { return null; @@ -1580,6 +1743,24 @@ 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, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS ai_profile_auto_comment_usage ( + 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) + ); +`); + db.exec(` CREATE TABLE IF NOT EXISTS search_seen_posts ( url TEXT PRIMARY KEY, @@ -2049,6 +2230,8 @@ 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_credentials', 'is_active', 'is_active INTEGER DEFAULT 1'); ensureColumn('ai_credentials', 'priority', 'priority INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'base_url', 'base_url TEXT'); @@ -6676,7 +6859,8 @@ app.get('/api/ai-settings', (req, res) => { res.json({ ...settings, - active_credential: activeCredential + active_credential: activeCredential, + profile_limits: listAIAutoCommentProfileLimits() }); } catch (error) { res.status(500).json({ error: error.message }); @@ -6685,7 +6869,7 @@ app.get('/api/ai-settings', (req, res) => { app.put('/api/ai-settings', (req, res) => { try { - const { active_credential_id, prompt_prefix, enabled } = req.body; + const { active_credential_id, prompt_prefix, enabled, profile_limits } = req.body; const existing = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); @@ -6702,6 +6886,10 @@ 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 updated = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); let activeCredential = null; @@ -6711,7 +6899,8 @@ app.put('/api/ai-settings', (req, res) => { res.json({ ...updated, - active_credential: activeCredential + active_credential: activeCredential, + profile_limits: savedProfileLimits }); } catch (error) { res.status(500).json({ error: error.message }); @@ -7317,6 +7506,8 @@ app.post('/api/ai/generate-comment', async (req, res) => { try { const { postText, profileNumber, preferredCredentialId } = requestBody; + const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); + let profileLimitInfo = null; if (!postText) { return respondWithTrackedError(400, 'postText is required'); @@ -7348,6 +7539,29 @@ app.post('/api/ai/generate-comment', async (req, res) => { return respondWithTrackedError(400, 'No active AI credentials available'); } + if (traceSource === AI_AUTO_COMMENT_SOURCE) { + if (!normalizedProfileNumber) { + return respondWithTrackedError(400, 'Für die AI-Kommentar-Aktion ist ein gültiges Profil erforderlich'); + } + + const limitCheckStartedMs = timingStart(); + const currentProfileLimit = getAIAutoCommentProfileLimit(normalizedProfileNumber); + if (currentProfileLimit && currentProfileLimit.blocked) { + timingEnd('profileLimitCheckMs', limitCheckStartedMs); + return respondWithTrackedError( + 429, + `Tageslimit für ${currentProfileLimit.profile_name} erreicht (${currentProfileLimit.used_today}/${currentProfileLimit.daily_limit}). Die Aktion ist bis morgen gesperrt.`, + { + responseMeta: { + profileLimit: currentProfileLimit + } + } + ); + } + profileLimitInfo = incrementAIAutoCommentProfileUsage(normalizedProfileNumber); + timingEnd('profileLimitCheckMs', limitCheckStartedMs); + } + let orderedCredentials = credentials; if (typeof preferredCredentialId !== 'undefined' && preferredCredentialId !== null) { const parsedPreferredId = Number(preferredCredentialId); @@ -7362,7 +7576,6 @@ app.post('/api/ai/generate-comment', async (req, res) => { const promptBuildStartedMs = timingStart(); let promptPrefix = settings.prompt_prefix || ''; - const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); if (normalizedProfileNumber) { const friends = db.prepare('SELECT friend_names FROM profile_friends WHERE profile_number = ?').get(normalizedProfileNumber); @@ -7433,7 +7646,8 @@ app.post('/api/ai/generate-comment', async (req, res) => { responseMeta: { usedCredential: credential.name, usedCredentialId: credential.id, - attempts: attemptDetails + attempts: attemptDetails, + profileLimit: profileLimitInfo }, totalDurationMs: backendTimings.totalMs }); @@ -7447,6 +7661,7 @@ app.post('/api/ai/generate-comment', async (req, res) => { usedCredentialId: credential.id, attempts: attemptDetails, rateLimitInfo: rateInfo || null, + profileLimitInfo, traceId, flowId, timings: { diff --git a/web/index.html b/web/index.html index bd0a434..9ec0365 100644 --- a/web/index.html +++ b/web/index.html @@ -1145,6 +1145,14 @@

+
+ +

+ Gilt nur für die Aktion AI - generiere automatisch einen passenden Kommentar im Tracker. 0 bedeutet kein Limit. +

+
+
+