Add per-profile AI comment action limits

This commit is contained in:
2026-04-07 15:34:18 +02:00
parent 81dfb06f24
commit 66221f27c7
3 changed files with 329 additions and 7 deletions

View File

@@ -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: {

View File

@@ -1145,6 +1145,14 @@
</p>
</div>
<div class="form-group">
<label class="form-label">Tageslimit pro Profil für den AI-Kommentar-Button</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.
</p>
<div id="aiProfileCommentLimits"></div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="testBtn">
🧪 Kommentar testen

View File

@@ -41,6 +41,7 @@ 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;
let credentials = [];
let currentSettings = null;
@@ -173,6 +174,7 @@ 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();
}
async function loadHiddenSettings() {
@@ -425,6 +427,94 @@ function normalizeSimilarityImageThresholdInput(value) {
return Math.min(64, Math.max(0, parsed));
}
function normalizeAIProfileDailyLimitInput(value) {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 0) {
return 0;
}
return Math.min(AI_AUTO_COMMENT_DAILY_LIMIT_MAX, parsed);
}
function getDefaultAIProfileLimitEntry(profileNumber) {
return {
profile_number: profileNumber,
profile_name: `Profil ${profileNumber}`,
daily_limit: 0,
used_today: 0,
remaining_today: null
};
}
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)
});
}
});
return Array.from({ length: 5 }, (_unused, index) => {
const profileNumber = index + 1;
return byProfile.get(profileNumber) || getDefaultAIProfileLimitEntry(profileNumber);
});
}
function renderAIProfileLimitInputs() {
const container = document.getElementById('aiProfileCommentLimits');
if (!container) {
return;
}
const entries = getAIProfileLimitEntries();
container.innerHTML = '';
entries.forEach((entry) => {
const wrapper = document.createElement('div');
wrapper.className = 'form-group';
wrapper.style.marginBottom = '12px';
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}`;
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 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`;
wrapper.appendChild(label);
wrapper.appendChild(input);
wrapper.appendChild(help);
container.appendChild(wrapper);
});
}
function applySimilaritySettingsUI() {
const textInput = document.getElementById('similarityTextThreshold');
const imageInput = document.getElementById('similarityImageThreshold');
@@ -998,7 +1088,15 @@ async function saveSettings(e, { silent = false } = {}) {
const data = {
enabled: document.getElementById('aiEnabled').checked,
active_credential_id: parseInt(document.getElementById('activeCredential').value) || null,
prompt_prefix: document.getElementById('aiPromptPrefix').value
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)
};
})
};
const res = await apiFetch(`${API_URL}/ai-settings`, {
@@ -1013,6 +1111,7 @@ async function saveSettings(e, { silent = false } = {}) {
}
currentSettings = await res.json();
renderAIProfileLimitInputs();
if (!silent) {
showSuccess('✅ Einstellungen erfolgreich gespeichert');
}
@@ -1058,7 +1157,7 @@ async function generateTest() {
const res = await apiFetch(`${API_URL}/ai/generate-comment`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({postText: text, profileNumber})
body: JSON.stringify({postText: text, profileNumber, traceSource: 'settings-test'})
});
if (!res.ok) {