peertube-plugin-german-ai-mod
Version:
Local AI moderation for German comments using ml6team DistilBERT and deepset BERT models
822 lines (718 loc) • 32.2 kB
JavaScript
async function register ({ registerHook, peertubeHelpers, registerSetting, settingsManager, getRouter, registerClientRoute }) {
const { logger, database } = peertubeHelpers
// Register plugin settings
await registerSetting({
name: 'endpoint',
label: 'AI Endpoint URL',
type: 'input',
default: 'http://ai-moderator:8000/analyze',
private: false,
descriptionHTML: 'URL des AI-Moderation-Services. Muss innerhalb des Docker-Netzwerks erreichbar sein.'
})
await registerSetting({
name: 'threshold',
label: 'Toxizitäts-Schwelle (0–1)',
type: 'input',
default: '0.7',
private: false,
descriptionHTML: `
<div style="margin-top: 8px; padding: 12px; background: #f8f9fa; border-radius: 6px; border-left: 4px solid #17a2b8;">
<strong>Schwelle für allgemeine Toxizität (Primary Model)</strong>
<p style="margin: 8px 0 0 0; font-size: 0.9em;">
<strong>Empfohlene Werte:</strong><br>
• <strong>0.7</strong> (Standard) - Blockiert nur explizite Beleidigungen<br>
• <strong>0.5</strong> (Strikt) - Blockiert mehr aggressive Kommentare<br>
• <strong>0.8</strong> (Mild) - Blockiert nur sehr explizite Beleidigungen
</p>
</div>
`
})
await registerSetting({
name: 'hate_threshold',
label: 'Hate Speech Schwelle (0–1)',
type: 'input',
default: '0.5',
private: false,
descriptionHTML: `
<div style="margin-top: 8px; padding: 12px; background: #f8f9fa; border-radius: 6px; border-left: 4px solid #dc3545;">
<strong>Schwelle für Hate Speech (Secondary Model)</strong>
<p style="margin: 8px 0 0 0; font-size: 0.9em;">
<strong>Empfohlene Werte:</strong><br>
• <strong>0.5</strong> (Standard) - Blockiert Hate Speech<br>
• <strong>0.4</strong> (Strikt) - Blockiert mehr grenzwertige Kommentare<br>
• <strong>0.6</strong> (Mild) - Blockiert nur sehr expliziten Hate Speech
</p>
<p style="margin: 8px 0 0 0; font-size: 0.85em; color: #6c757d;">
<strong>Hinweis:</strong> Kommentare werden blockiert, wenn <strong>entweder</strong> die Toxizitäts-Schwelle <strong>oder</strong> die Hate Speech Schwelle überschritten wird.
</p>
</div>
`
})
await registerSetting({
name: 'non_german_action',
label: 'Aktion für nicht-deutsche Kommentare',
type: 'select',
options: [
{ value: 'moderate', label: 'Zur Moderation zurückhalten' },
{ value: 'block', label: 'Blockieren' }
],
default: 'moderate',
private: false,
descriptionHTML: `
<div style="margin-top: 8px; padding: 12px; background: #fff3cd; border-radius: 6px; border-left: 4px solid #ffc107;">
<strong>Verhalten für Kommentare in anderen Sprachen</strong>
<p style="margin: 8px 0 0 0; font-size: 0.9em;">
• <strong>Zur Moderation zurückhalten</strong> - Kommentare werden in die Moderations-Warteschlange verschoben (empfohlen)<br>
• <strong>Blockieren</strong> - Kommentare werden sofort blockiert
</p>
<p style="margin: 8px 0 0 0; font-size: 0.85em; color: #856404;">
<strong>Hinweis:</strong> Die AI-Modelle sind speziell für deutsche Sprache trainiert. Kommentare in anderen Sprachen können nicht zuverlässig auf Toxizität geprüft werden.
</p>
</div>
`
})
await registerSetting({
name: 'moderation-allowed-roles',
label: 'Berechtigte Rollen für Moderation',
type: 'select',
options: [
{ label: 'Alle angemeldeten Benutzer (mit eigenen Videos)', value: 'all' },
{ label: 'Nur Administratoren', value: 'admin' },
{ label: 'Administratoren und Moderatoren', value: 'admin-mod' }
],
default: 'all',
private: false,
descriptionHTML: 'Wer darf Kommentare moderieren? Administratoren sehen alle Kommentare, andere Benutzer nur Kommentare zu ihren eigenen Videos.'
})
await registerSetting({
name: 'moderation-allowed-users',
label: 'Berechtigte Benutzer für Moderation (durch Komma getrennt)',
type: 'input-textarea',
default: '',
private: false,
descriptionHTML: 'Benutzernamen durch Komma getrennt. Leer = alle Benutzer mit passender Rolle haben Zugriff. Wenn angegeben, haben nur diese Benutzer Zugriff (zusätzlich zur Rollenprüfung).'
})
// Initialize database table for blocked comments
async function initDatabase () {
try {
const query = `
CREATE TABLE IF NOT EXISTS "pluginGermanAiModBlockedComments" (
"id" SERIAL PRIMARY KEY,
"text" TEXT NOT NULL,
"videoId" INTEGER,
"videoUuid" VARCHAR(255),
"userId" INTEGER,
"userUsername" VARCHAR(255),
"blockReason" VARCHAR(50) NOT NULL,
"score" REAL,
"hateScore" REAL,
"language" VARCHAR(10),
"createdAt" TIMESTAMP DEFAULT NOW(),
"approved" BOOLEAN DEFAULT FALSE,
"approvedBy" INTEGER,
"approvedAt" TIMESTAMP
)
`
await database.query(query)
logger.info('[AI Mod] Database table initialized')
} catch (err) {
logger.error('[AI Mod] Error initializing database:', err)
}
}
await initDatabase()
// Save blocked comment to database
async function saveBlockedComment (text, params, blockReason, score, hateScore, language) {
try {
const videoId = params?.video?.id || null
const videoUuid = params?.video?.uuid || null
const userId = params?.user?.id || null
const userUsername = params?.user?.username || null
// Debug log
logger.info('[AI Mod] saveBlockedComment called with blockReason=%s, lang=%s, videoId=%s, userId=%s, videoUuid=%s',
blockReason, language, videoId, userId, videoUuid
)
const query = `
INSERT INTO "pluginGermanAiModBlockedComments"
("text", "videoId", "videoUuid", "userId", "userUsername", "blockReason", "score", "hateScore", "language")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING "id"
`
const insertParams = [
text || '',
videoId,
videoUuid,
userId,
userUsername,
blockReason, // 'toxic' or 'non_german'
score || null,
hateScore || null,
language || null
]
logger.info('[AI Mod] Saving blocked comment - query params:', JSON.stringify(insertParams, null, 2))
// ⚠️ ВАЖНО: без type, и разбираем [rows, metadata]
const [rows] = await database.query(query, { bind: insertParams })
if (!rows || !rows[0]) {
logger.warn('[AI Mod] Insert succeeded but no rows returned')
return null
}
logger.info(`[AI Mod] Blocked comment saved to DB with id: ${rows[0].id}`)
return rows[0].id
} catch (err) {
logger.error('[AI Mod] Error saving blocked comment:', err)
return null
}
}
/**
* Check comment with AI service
* @param {string} text - Comment text
* @returns {Promise<{allowed: boolean, score: number, requiresModeration: boolean, language: string}>}
*/
async function checkCommentWithAI (text) {
const endpoint = await settingsManager.getSetting('endpoint') || 'http://ai-moderator:8000/analyze'
const thresholdStr = await settingsManager.getSetting('threshold') || '0.7'
const threshold = Number(thresholdStr) || 0.7
const hateThresholdStr = await settingsManager.getSetting('hate_threshold') || '0.5'
const hateThreshold = Number(hateThresholdStr) || 0.5
logger.info(`[AI Mod DEBUG] Settings - threshold: ${threshold}, hateThreshold: ${hateThreshold}, endpoint: ${endpoint}`)
if (!text || !text.trim()) {
logger.info('[AI Mod DEBUG] Empty text, allowing')
return { allowed: true, score: 0, hateScore: 0, requiresModeration: false, language: 'unknown', isGerman: false }
}
logger.info(`[AI Mod DEBUG] Checking text: "${text.substring(0, 100)}..."`)
// Timeout to prevent hanging requests
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 3000)
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
signal: controller.signal
})
clearTimeout(timeout)
if (!res.ok) {
logger.error(`[AI Mod] HTTP ${res.status} from AI service`)
return { allowed: true, score: 0, hateScore: 0, requiresModeration: false, language: 'unknown', isGerman: false } // fail-open
}
const data = await res.json()
logger.info(`[AI Mod DEBUG] AI response: ${JSON.stringify(data)}`)
const score = typeof data.score === 'number' ? data.score : 0
const hateScore = typeof data.hate_score === 'number' ? data.hate_score : 0
// Toxic if primary score >= threshold OR hate score >= hateThreshold
// Note: AI service always returns toxic: false, plugin decides based on configured thresholds
const toxic = (score >= threshold) || (hateScore >= hateThreshold)
const requiresModeration = !!data.requires_moderation
const language = data.language || 'unknown'
// Ensure isGerman is explicitly boolean: true only if explicitly true, false otherwise
const isGerman = data.is_german === true || data.is_german === 'true' || data.is_german === 1
logger.info(`[AI Mod DEBUG] Calculated - toxic: ${toxic}, allowed: ${!toxic}, score: ${score}, hateScore: ${hateScore}, isGerman: ${isGerman}`)
return {
allowed: !toxic,
score,
hateScore,
requiresModeration,
language,
isGerman
}
} catch (err) {
clearTimeout(timeout)
// If AI service is down - log error but don't break UX (fail-open)
logger.error('[AI Mod] AI service error', { err: err.message || String(err) })
return { allowed: true, score: 0, hateScore: 0, requiresModeration: false, language: 'unknown', isGerman: false }
}
}
/**
* Handle comment moderation based on AI result
* @param {boolean} accepted - Current acceptance status
* @param {object} params - Comment parameters
* @returns {Promise<{accepted: boolean, errorMessage?: string, heldForReview?: boolean}>}
*/
async function handleCommentModeration (accepted, params) {
logger.info(`[AI Mod DEBUG] handleCommentModeration called - accepted: ${accepted}`)
// Log video info if available
if (params && params.video) {
logger.info(`[AI Mod DEBUG] Video info - commentsPolicy: ${params.video.commentsPolicy}, comments: ${params.video.comments}`)
}
if (!accepted) {
logger.info('[AI Mod DEBUG] Already rejected by another hook or PeerTube policy')
return { accepted: false, errorMessage: 'Comment has been rejected by another moderation rule.' }
}
// Debug: log params structure safely
if (params) {
const paramKeys = Object.keys(params)
logger.info(`[AI Mod DEBUG] params keys: ${paramKeys.join(', ')}`)
// Try to log commentBody structure
if (params.commentBody) {
if (typeof params.commentBody === 'string') {
logger.info(`[AI Mod DEBUG] commentBody is string: "${params.commentBody.substring(0, 50)}..."`)
} else if (typeof params.commentBody === 'object') {
const commentBodyKeys = Object.keys(params.commentBody || {})
logger.info(`[AI Mod DEBUG] commentBody keys: ${commentBodyKeys.join(', ')}`)
if (params.commentBody.text) {
logger.info(`[AI Mod DEBUG] commentBody.text exists: "${String(params.commentBody.text).substring(0, 50)}..."`)
}
}
}
}
// Try multiple ways to extract text
let text = ''
// Method 1: commentBody.text (object)
if (params && params.commentBody) {
if (typeof params.commentBody === 'string') {
text = params.commentBody
} else if (params.commentBody.text) {
text = params.commentBody.text
} else if (params.commentBody.body) {
text = typeof params.commentBody.body === 'string' ? params.commentBody.body : params.commentBody.body.text || ''
}
}
// Method 2: body.text
if (!text && params && params.body) {
if (typeof params.body === 'string') {
text = params.body
} else if (params.body.text) {
text = params.body.text
}
}
// Method 3: comment.text
if (!text && params && params.comment) {
if (typeof params.comment === 'string') {
text = params.comment
} else if (params.comment.text) {
text = params.comment.text
} else if (params.comment.body) {
text = typeof params.comment.body === 'string' ? params.comment.body : params.comment.body.text || ''
}
}
// Method 4: direct text
if (!text && params && params.text) {
text = params.text
}
// Method 5: req.body (for API requests)
if (!text && params && params.req && params.req.body) {
if (typeof params.req.body === 'string') {
text = params.req.body
} else if (params.req.body.text) {
text = params.req.body.text
} else if (params.req.body.commentBody) {
text = typeof params.req.body.commentBody === 'string'
? params.req.body.commentBody
: params.req.body.commentBody.text || ''
}
}
// Clean up text - remove HTML tags if present
text = (text || '').trim()
if (text && text.includes('<')) {
// Simple HTML tag removal (basic, but should work for most cases)
text = text.replace(/<[^>]*>/g, '').trim()
}
logger.info(`[AI Mod DEBUG] Extracted text from params: "${text.substring(0, 100)}${text.length > 100 ? '...' : ''}" (length: ${text.length})`)
const { allowed, score, hateScore, requiresModeration, language, isGerman } = await checkCommentWithAI(text)
logger.info(`[AI Mod DEBUG] checkCommentWithAI result - allowed: ${allowed}, score: ${score}, hateScore: ${hateScore}, isGerman: ${isGerman} (type: ${typeof isGerman}), requiresModeration: ${requiresModeration}, language: ${language}`)
// Handle non-German comments
// Only block/moderate if comment is explicitly not German AND requires moderation
// Empty text or unknown language should be allowed (fail-open)
logger.info(`[AI Mod DEBUG] Checking non-German condition: isGerman === false: ${isGerman === false}, requiresModeration: ${requiresModeration}, hasText: ${!!(text && text.trim())}`)
if (isGerman === false && requiresModeration && text && text.trim()) {
const nonGermanAction = await settingsManager.getSetting('non_german_action') || 'moderate'
if (nonGermanAction === 'block') {
logger.info(`[AI Mod] Blocked non-German comment (lang=${language}): ${text.substring(0, 50)}...`)
// Save to database for moderation
await saveBlockedComment(text, params, 'non_german', null, null, language)
return {
accepted: false,
errorMessage: `Kommentar abgelehnt: Kommentare in der Sprache "${language}" sind nicht erlaubt. Nur Kommentare auf Deutsch sind erlaubt.`
}
} else {
// moderate: save to database for manual review
logger.info(`[AI Mod] Non-German comment sent to moderation (lang=${language}): ${text.substring(0, 50)}...`)
await saveBlockedComment(text, params, 'non_german', null, null, language)
return { accepted: false, errorMessage: 'Kommentar wurde zur manuellen Überprüfung gesendet.' }
}
}
// Handle toxic German comments
if (!allowed) {
const scoreInfo = hateScore > 0 ? `score=${score.toFixed(3)}, hate=${hateScore.toFixed(3)}` : `score=${score.toFixed(3)}`
logger.info(`[AI Mod] Blocked toxic comment (${scoreInfo}, lang=${language}): ${text.substring(0, 50)}...`)
// Save to database for moderation
await saveBlockedComment(text, params, 'toxic', score, hateScore, language)
// Create user-friendly error message in German
let errorMsg = 'Kommentar abgelehnt: Der Kommentar enthält beleidigende Inhalte.'
if (hateScore >= 0.5) {
errorMsg = 'Kommentar abgelehnt: Der Kommentar enthält beleidigende Inhalte und Hassrede.'
} else if (score >= 0.7) {
errorMsg = 'Kommentar abgelehnt: Der Kommentar enthält beleidigende Inhalte.'
}
return { accepted: false, errorMessage: errorMsg }
}
logger.info(`[AI Mod DEBUG] Comment allowed, returning true`)
return { accepted: true }
}
/**
* Extract accepted boolean from hook result (supports both old and new API)
* @param {boolean|object} acceptedOrResult - Either boolean (old API) or {accepted: boolean} (new API)
* @returns {boolean}
*/
function getAcceptedBool (acceptedOrResult) {
// New API (PeerTube 7.x): { accepted: boolean, errorMessage?: string }
if (acceptedOrResult && typeof acceptedOrResult === 'object' && typeof acceptedOrResult.accepted === 'boolean') {
return acceptedOrResult.accepted
}
// Old API: just boolean
if (typeof acceptedOrResult === 'boolean') {
return acceptedOrResult
}
// Fail-open: allow by default if format is unexpected
logger.warn('[AI Mod] Unexpected acceptedOrResult format, defaulting to true')
return true
}
/**
* Wrapper for accept hooks that handles both old and new PeerTube API
* @param {boolean|object} acceptedOrResult - Hook result from previous handlers
* @param {object} params - Comment parameters
* @returns {Promise<object>} - {accepted: boolean}
*/
async function wrapAcceptHook (acceptedOrResult, params) {
logger.info(`[AI Mod DEBUG] wrapAcceptHook called - acceptedOrResult type: ${typeof acceptedOrResult}, value: ${JSON.stringify(acceptedOrResult)}`)
const acceptedBool = getAcceptedBool(acceptedOrResult)
logger.info(`[AI Mod DEBUG] Extracted acceptedBool: ${acceptedBool}`)
const result = await handleCommentModeration(acceptedBool, params)
// result is now an object: { accepted: boolean, errorMessage?: string, heldForReview?: boolean }
logger.info(`[AI Mod DEBUG] wrapAcceptHook returning: ${JSON.stringify(result)}`)
// Always return object as PeerTube 7.x expects
// PeerTube 7.x supports: { accepted: boolean, errorMessage?: string }
return { accepted: result.accepted, errorMessage: result.errorMessage }
}
// Hook for local new threads (main comments)
registerHook({
target: 'filter:api.video-thread.create.accept.result',
handler: async (acceptedOrResult, params) => {
return await wrapAcceptHook(acceptedOrResult, params)
}
})
// Hook for local replies
registerHook({
target: 'filter:api.video-comment-reply.create.accept.result',
handler: async (acceptedOrResult, params) => {
return await wrapAcceptHook(acceptedOrResult, params)
}
})
// Hook for remote comments from federation
registerHook({
target: 'filter:activity-pub.remote-video-comment.create.accept.result',
handler: async (acceptedOrResult, params) => {
return await wrapAcceptHook(acceptedOrResult, params)
}
})
// API Routes for moderation
if (getRouter) {
const router = getRouter()
// Check if user can moderate comments (root admin or video owner)
async function checkModerationAccess (req, res, next) {
try {
logger.info('[AI Mod] checkModerationAccess called for:', req.method, req.path)
let user = null
try {
// In PeerTube 7.3, getAuthUser only takes res parameter
user = await peertubeHelpers.user.getAuthUser(res)
logger.info('[AI Mod] User from getAuthUser:', user ? `${user.username} (role: ${user.role})` : 'null')
} catch (authError) {
logger.error('[AI Mod] getAuthUser failed:', authError.message, authError.stack)
logger.error('[AI Mod] Request headers:', JSON.stringify(req.headers, null, 2))
return res.status(401).json({
error: 'Authentifizierung erforderlich',
debug: {
authError: authError.message,
method: req.method,
path: req.path
}
})
}
if (!user) {
logger.warn('[AI Mod] No user found')
return res.status(401).json({ error: 'Authentifizierung erforderlich' })
}
// Get moderation access settings
const settings = await settingsManager.getSettings([
'moderation-allowed-roles',
'moderation-allowed-users'
])
const allowedRoles = settings['moderation-allowed-roles'] || 'all'
const userRole = user.role // 0 = Admin, 1 = Moderator, 2 = User
// Check role-based access
if (allowedRoles === 'admin' && userRole !== 0) {
return res.status(403).json({ error: 'Nur für Administratoren' })
}
if (allowedRoles === 'admin-mod' && userRole > 1) {
return res.status(403).json({ error: 'Nur für Administratoren und Moderatoren' })
}
// Check user-specific access (if configured)
const allowedUsers = settings['moderation-allowed-users']
if (allowedUsers && allowedUsers.trim()) {
const userList = allowedUsers.split(',').map(u => u.trim()).filter(u => u)
if (userList.length > 0 && !userList.includes(user.username)) {
return res.status(403).json({ error: 'Benutzer nicht berechtigt' })
}
}
// Root admin has access to all comments
if (user.role === 0) {
req.user = user
req.isRootAdmin = true
return next()
}
// For non-root users, we'll filter by their videos in the routes
req.user = user
req.isRootAdmin = false
next()
} catch (err) {
logger.error('[AI Mod] Error checking moderation access:', err)
res.status(401).json({ error: 'Authentifizierung erforderlich' })
}
}
// Get user's video IDs (for channel owners)
async function getUserVideoIds (userId) {
try {
const query = `
SELECT v.id
FROM "video" AS v
JOIN "videoChannel" AS c ON v."channelId" = c.id
JOIN "account" AS a ON c."accountId" = a.id
WHERE a."userId" = $1
`
const rows = await database.query(query, {
type: 'SELECT',
bind: [userId]
})
logger.info('[AI Mod] getUserVideoIds userId=%s → %d videos', userId, rows.length)
return rows.map(row => row.id)
} catch (err) {
logger.error('[AI Mod] Error getting user video IDs:', err)
return []
}
}
// Check access endpoint (for client-side access checks)
router.get('/check-access', checkModerationAccess, async (req, res) => {
try {
res.json({
allowed: true,
user: {
id: req.user.id,
username: req.user.username,
role: req.user.role,
roleText: req.user.role === 0 ? 'Administrator' : req.user.role === 1 ? 'Moderator' : 'Benutzer'
},
isRootAdmin: req.isRootAdmin || false
})
} catch (err) {
logger.error('[AI Mod] Error in check-access:', err)
res.status(500).json({ error: 'Serverfehler' })
}
})
// Get blocked comments
router.get('/blocked-comments', checkModerationAccess, async (req, res) => {
try {
const { page = 1, limit = 50, blockReason, approved, videoId } = req.query
const pageNum = parseInt(page) || 1
const limitNum = parseInt(limit) || 50
const offset = (pageNum - 1) * limitNum
let query = 'SELECT * FROM "pluginGermanAiModBlockedComments" WHERE 1=1'
const params = []
let paramIndex = 1
// Filter by video if not root admin
if (!req.isRootAdmin) {
const userVideoIds = await getUserVideoIds(req.user.id)
logger.info('[AI Mod] User video IDs for filtering:', userVideoIds, 'length:', userVideoIds.length, 'user:', req.user.username, 'userId:', req.user.id)
if (userVideoIds.length === 0) {
// User has no videos, return empty result
logger.info('[AI Mod] User has no videos, returning empty result')
return res.json({
data: [],
total: 0,
page: pageNum,
limit: limitNum,
isRootAdmin: false
})
}
// Use IN clause instead of ANY for better compatibility
const placeholders = userVideoIds.map((_, i) => `$${paramIndex + i}`).join(', ')
query += ` AND "videoId" IN (${placeholders})`
params.push(...userVideoIds)
paramIndex += userVideoIds.length
logger.info('[AI Mod] Added video filter - placeholders:', placeholders, 'paramIndex:', paramIndex, 'params length:', params.length)
}
// Filter by specific video if provided
if (videoId) {
query += ` AND "videoId" = $${paramIndex}`
params.push(parseInt(videoId))
paramIndex++
}
if (blockReason) {
query += ` AND "blockReason" = $${paramIndex}`
params.push(blockReason)
paramIndex++
}
if (approved !== undefined) {
query += ` AND "approved" = $${paramIndex}`
params.push(approved === 'true')
paramIndex++
}
// Add LIMIT and OFFSET directly (no need for parameters)
query += ` ORDER BY "createdAt" DESC LIMIT ${limitNum} OFFSET ${offset}`
logger.info('[AI Mod] Query:', query)
logger.info('[AI Mod] Params count:', params.length, 'Params:', JSON.stringify(params, null, 2))
// ⚠️ ВАЖНО: SELECT → сразу массив строк
const rows = await database.query(query, {
type: 'SELECT',
bind: params
})
// Get total count with same filters
let countQuery = 'SELECT COUNT(*) as count FROM "pluginGermanAiModBlockedComments" WHERE 1=1'
const countParams = []
let countParamIndex = 1
if (!req.isRootAdmin) {
const userVideoIds = await getUserVideoIds(req.user.id)
if (userVideoIds.length > 0) {
// Use IN clause instead of ANY for better compatibility
const placeholders = userVideoIds.map((_, i) => `$${countParamIndex + i}`).join(', ')
countQuery += ` AND "videoId" IN (${placeholders})`
countParams.push(...userVideoIds)
countParamIndex += userVideoIds.length
} else {
countQuery += ' AND 1=0'
}
}
if (videoId) {
countQuery += ` AND "videoId" = $${countParamIndex}`
countParams.push(parseInt(videoId))
countParamIndex++
}
if (blockReason) {
countQuery += ` AND "blockReason" = $${countParamIndex}`
countParams.push(blockReason)
countParamIndex++
}
if (approved !== undefined) {
countQuery += ` AND "approved" = $${countParamIndex}`
countParams.push(approved === 'true')
countParamIndex++
}
const countRows = await database.query(countQuery, {
type: 'SELECT',
bind: countParams
})
const total = countRows.length ? parseInt(countRows[0].count, 10) : 0
res.json({
data: rows,
total,
page: pageNum,
limit: limitNum,
isRootAdmin: req.isRootAdmin
})
} catch (err) {
logger.error('[AI Mod] Error getting blocked comments:', err)
res.status(500).json({ error: 'Serverfehler' })
}
})
// Approve (unblock) comment
router.post('/blocked-comments/:id/approve', checkModerationAccess, async (req, res) => {
try {
const { id } = req.params
const userId = req.user.id
// First check if user has access to this comment
const checkQuery = 'SELECT "videoId" FROM "pluginGermanAiModBlockedComments" WHERE "id" = $1'
const checkRows = await database.query(checkQuery, {
type: 'SELECT',
bind: [id]
})
if (checkRows.length === 0) {
return res.status(404).json({ error: 'Kommentar nicht gefunden' })
}
// If not root admin, verify user owns the video
if (!req.isRootAdmin) {
const userVideoIds = await getUserVideoIds(req.user.id)
if (!userVideoIds.includes(checkRows[0].videoId)) {
return res.status(403).json({ error: 'Keine Berechtigung für dieses Video' })
}
}
const query = `
UPDATE "pluginGermanAiModBlockedComments"
SET "approved" = true, "approvedBy" = $1, "approvedAt" = NOW()
WHERE "id" = $2
RETURNING *
`
// ⚠️ Без type, используем деструктуризацию для RETURNING
const [updateRows] = await database.query(query, {
bind: [req.user.id, id]
})
logger.info(`[AI Mod] Comment ${id} approved by user ${req.user.username}`)
res.json({ success: true, comment: updateRows[0] })
} catch (err) {
logger.error('[AI Mod] Error approving comment:', err)
res.status(500).json({ error: 'Serverfehler' })
}
})
// Delete blocked comment
router.delete('/blocked-comments/:id', checkModerationAccess, async (req, res) => {
try {
const { id } = req.params
// First check if user has access to this comment
const checkQuery = 'SELECT "videoId" FROM "pluginGermanAiModBlockedComments" WHERE "id" = $1'
const checkRows = await database.query(checkQuery, {
type: 'SELECT',
bind: [id]
})
if (checkRows.length === 0) {
return res.status(404).json({ error: 'Kommentar nicht gefunden' })
}
// If not root admin, verify user owns the video
if (!req.isRootAdmin) {
const userVideoIds = await getUserVideoIds(req.user.id)
if (!userVideoIds.includes(checkRows[0].videoId)) {
return res.status(403).json({ error: 'Keine Berechtigung für dieses Video' })
}
}
const query = 'DELETE FROM "pluginGermanAiModBlockedComments" WHERE "id" = $1'
await database.query(query, {
bind: [id]
})
logger.info(`[AI Mod] Comment ${id} deleted by user ${req.user.username}`)
res.json({ success: true })
} catch (err) {
logger.error('[AI Mod] Error deleting comment:', err)
res.status(500).json({ error: 'Serverfehler' })
}
})
// Get user's videos for filtering
router.get('/my-videos', checkModerationAccess, async (req, res) => {
try {
if (req.isRootAdmin) {
// Root admin sees all videos
const query = 'SELECT id, uuid, name FROM "video" ORDER BY "createdAt" DESC LIMIT 1000'
const rows = await database.query(query, {
type: 'SELECT'
})
res.json({ videos: rows })
} else {
// Channel owners see only their videos
const userVideoIds = await getUserVideoIds(req.user.id)
if (userVideoIds.length === 0) {
return res.json({ videos: [] })
}
const query = 'SELECT id, uuid, name FROM "video" WHERE id = ANY($1::int[]) ORDER BY "createdAt" DESC'
const rows = await database.query(query, {
type: 'SELECT',
bind: [userVideoIds]
})
res.json({ videos: rows })
}
} catch (err) {
logger.error('[AI Mod] Error getting videos:', err)
res.status(500).json({ error: 'Serverfehler' })
}
})
}
}
module.exports = {
register,
unregister () {}
}