Files
SkinbaseNova/app/Services/Moderation/CommentSpamService.php
T
klevze 586c44ba74 Add comment spam classification and captcha checks.
Score artwork comments with local signatures plus Together AI, and optionally require Turnstile before posting.
2026-09-20 14:48:50 +02:00

180 lines
8.1 KiB
PHP

<?php
namespace App\Services\Moderation;
use App\Contracts\Moderation\CommentSpamClassifier;
use App\Models\ArtworkComment;
use App\Models\CommentSpamSignature;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Throwable;
class CommentSpamService
{
public function __construct(
private readonly ContentModerationProcessingService $processing,
private readonly CommentSpamClassifier $ai,
) {}
/**
* Classify after the comment exists, so the existing moderation queue can
* retain the full source context and admin review workflow.
*
* @return array{status:string, score:int, probability:int, source:string, reason:string}
*/
public function moderate(ArtworkComment $comment, User $user, ?string $mode = null): array
{
$mode ??= (string) config('comment_spam.mode', 'enforce');
$content = (string) ($comment->raw_content ?? $comment->content ?? '');
$hash = hash('sha256', $this->normalize($content));
if (! (bool) config('comment_spam.enabled', true) || $mode === 'off') {
return $this->saveMetadata($comment, 0, 0, 'disabled', 'Comment spam protection disabled.');
}
$signature = $this->signature($hash);
if ($signature !== null) {
$this->recordSignatureHit($signature);
$isSpam = ($signature->source === 'spam' || $signature->source === 'admin_spam');
return $this->finish($comment, $mode, $isSpam ? 100 : 0, $isSpam ? 100 : 0, 'signature', (string) $signature->reason, $isSpam);
}
$scan = $this->processing->process($content, [
'content_type' => 'artwork_comment',
'content_id' => (int) $comment->id,
'artwork_id' => (int) $comment->artwork_id,
'user_id' => (int) $user->id,
'content_snapshot' => $content,
'comment_spam_mode' => $mode,
], true);
$local = (int) $scan['result']->score;
$probability = $local;
$source = 'local';
$reason = implode(' ', array_slice($scan['result']->reasons, 0, 2));
$isSpam = $local >= (int) config('comment_spam.local_spam_min', 70);
$needsAi = $local > (int) config('comment_spam.local_safe_max', 29) && ! $isSpam;
$aiPending = false;
// Repeated external links and explicit copy/paste promotions are
// deterministic spam signals. Do not let an AI false negative make
// an obvious advertisement public.
if ($this->isObviousPromotionalSpam($content, (array) $scan['result']->matchedLinks)) {
$local = max($local, (int) config('comment_spam.local_spam_min', 70));
$probability = 100;
$isSpam = true;
$needsAi = false;
$source = 'local_hard_rule';
$reason = trim($reason.' Repeated promotional external link pattern.');
}
if ($needsAi && (bool) config('comment_spam.ai_enabled', true)) {
try {
$assessment = $this->ai->classify($content);
$probability = $assessment->spam
? max($local, (int) round($assessment->confidence * 100))
: $local;
$source = 'local+ai';
$reason = trim(implode(' ', array_filter([$reason, $assessment->reason])));
$isSpam = $assessment->spam && $assessment->confidence >= (float) config('comment_spam.ai.spam_confidence', 0.90);
$aiPending = $assessment->spam && ! $isSpam;
Log::info('comment_spam_ai_classification', ['comment_id' => $comment->id, 'provider' => $assessment->provider, 'model' => $assessment->model, 'spam' => $assessment->spam, 'confidence' => $assessment->confidence, 'latency_ms' => $assessment->latencyMs, 'decision' => $isSpam ? 'spam' : ($assessment->spam ? 'pending' : 'approved')]);
} catch (Throwable) {
$source = 'local+ai_error';
$aiPending = true;
}
}
$status = $mode === 'observe'
? 'observed'
: ($isSpam ? 'spam' : ($needsAi && $aiPending ? 'pending' : 'approved'));
$comment->forceFill([
'is_approved' => $mode === 'enforce' ? ! in_array($status, ['spam', 'pending'], true) : true,
'spam_score' => min(100, $local),
'spam_probability' => min(100, $probability),
'spam_reason' => $reason !== '' ? mb_substr($reason, 0, 500) : null,
'moderation_source' => $source,
'moderated_at' => now(),
])->save();
if ($mode === 'enforce' && $status === 'spam') {
$this->rememberSignature($hash, 'spam', $reason, $probability);
}
return compact('status', 'local', 'probability', 'source', 'reason') + ['score' => $local];
}
private function finish(ArtworkComment $comment, string $mode, int $score, int $probability, string $source, string $reason, ?bool $isSpam = null): array
{
$isSpam ??= $score >= (int) config('comment_spam.local_spam_min', 70);
$status = $mode === 'observe' ? 'observed' : ($isSpam ? 'spam' : 'approved');
$comment->forceFill([
'is_approved' => $mode !== 'enforce' || ! $isSpam,
'spam_score' => $score,
'spam_probability' => $probability,
'spam_reason' => mb_substr($reason, 0, 500),
'moderation_source' => $source,
'moderated_at' => now(),
])->save();
return compact('status', 'score', 'probability', 'source', 'reason');
}
private function saveMetadata(ArtworkComment $comment, int $score, int $probability, string $source, string $reason): array
{
$comment->forceFill(['spam_score' => $score, 'spam_probability' => $probability, 'spam_reason' => $reason, 'moderation_source' => $source, 'moderated_at' => now()])->save();
return ['status' => 'approved', 'score' => $score, 'probability' => $probability, 'source' => $source, 'reason' => $reason];
}
private function signature(string $hash): ?CommentSpamSignature
{
return Cache::remember('comment-spam:'.$hash, now()->addMinutes((int) config('comment_spam.signature_cache_minutes', 1440)), fn () => CommentSpamSignature::query()->where('content_hash', $hash)->first());
}
private function recordSignatureHit(CommentSpamSignature $signature): void
{
try {
if (! Schema::hasColumn('comment_spam_signatures', 'hit_count')) {
return;
}
CommentSpamSignature::query()->whereKey($signature->getKey())->increment('hit_count');
} catch (Throwable $e) {
Log::warning('comment_spam_signature_hit_failed', [
'signature_id' => $signature->getKey(),
'message' => $e->getMessage(),
]);
}
}
private function rememberSignature(string $hash, string $source, string $reason, int $confidence): void
{
CommentSpamSignature::query()->updateOrCreate(['content_hash' => $hash], ['source' => $source, 'reason' => mb_substr($reason, 0, 500), 'confidence' => min(100, $confidence), 'created_at' => now()]);
Cache::forget('comment-spam:'.$hash);
}
private function normalize(string $content): string
{
return mb_strtolower((string) preg_replace('/\s+/u', ' ', trim($content)));
}
private function isObviousPromotionalSpam(string $content, array $matchedLinks): bool
{
$links = array_values(array_unique(array_map(
fn (string $link): string => mb_strtolower(trim($link)),
array_filter($matchedLinks, 'is_string'),
)));
preg_match_all('#https?://[^\s<>\[\]"\'`\)]+#iu', $content, $matches);
$allLinks = array_map('mb_strtolower', $matches[0] ?? []);
$hasRepeatedLink = count($allLinks) >= 2 && count(array_unique($allLinks)) < count($allLinks);
$hasPromotion = preg_match('/\b(copy\s*(?:&|and)\s*paste|buy\s+now|cheap\s+seo|guaranteed\s+traffic|visit\s+my\s+profile)\b/iu', $content) === 1;
return $hasRepeatedLink || ($hasPromotion && $links !== []);
}
}