Add comment spam classification and captcha checks.
Score artwork comments with local signatures plus Together AI, and optionally require Turnstile before posting.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Moderation;
|
||||
|
||||
use App\Data\Moderation\CommentSpamClassification;
|
||||
|
||||
interface CommentSpamClassifier
|
||||
{
|
||||
public function classify(string $content): CommentSpamClassification;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data\Moderation;
|
||||
|
||||
final class CommentSpamClassification
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $spam,
|
||||
public readonly float $confidence,
|
||||
public readonly string $reason,
|
||||
public readonly string $provider,
|
||||
public readonly string $model,
|
||||
public readonly int $latencyMs,
|
||||
) {}
|
||||
}
|
||||
@@ -3,15 +3,17 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMention;
|
||||
use App\Notifications\ArtworkCommentedNotification;
|
||||
use App\Notifications\ArtworkMentionedNotification;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Services\CommentReactionService;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Moderation\CommentSpamService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -31,7 +33,10 @@ class ArtworkCommentController extends Controller
|
||||
{
|
||||
private const MAX_LENGTH = 10_000;
|
||||
|
||||
public function __construct(private readonly CommentReactionService $commentReactions) {}
|
||||
public function __construct(
|
||||
private readonly CommentReactionService $commentReactions,
|
||||
private readonly CommentSpamService $commentSpam,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// List
|
||||
@@ -41,14 +46,14 @@ class ArtworkCommentController extends Controller
|
||||
{
|
||||
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = 20;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = 20;
|
||||
|
||||
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
|
||||
$comments = ArtworkComment::with([
|
||||
'user', 'user.profile',
|
||||
'approvedReplies',
|
||||
])
|
||||
'user', 'user.profile',
|
||||
'approvedReplies',
|
||||
])
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('is_approved', true)
|
||||
->whereNull('parent_id')
|
||||
@@ -58,15 +63,15 @@ class ArtworkCommentController extends Controller
|
||||
$userId = $request->user()?->id;
|
||||
$commentIds = $this->commentIds($comments->getCollection());
|
||||
$reactionTotals = $this->commentReactions->forComments($commentIds, $userId);
|
||||
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals));
|
||||
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals));
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $comments->currentPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'total' => $comments->total(),
|
||||
'per_page' => $comments->perPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'total' => $comments->total(),
|
||||
'per_page' => $comments->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -80,7 +85,7 @@ class ArtworkCommentController extends Controller
|
||||
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
||||
|
||||
$request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
||||
'content' => ['required', 'string', 'min:1', 'max:'.self::MAX_LENGTH],
|
||||
'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
|
||||
]);
|
||||
|
||||
@@ -109,43 +114,54 @@ class ArtworkCommentController extends Controller
|
||||
$rendered = ContentSanitizer::render($raw);
|
||||
|
||||
$comment = ArtworkComment::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'parent_id' => $parentId,
|
||||
'content' => $raw, // legacy column (plain text fallback)
|
||||
'raw_content' => $raw,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'parent_id' => $parentId,
|
||||
'content' => $raw, // legacy column (plain text fallback)
|
||||
'raw_content' => $raw,
|
||||
'rendered_content' => $rendered,
|
||||
'is_approved' => true, // auto-approve; extend with moderation as needed
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$moderation = $this->commentSpam->moderate($comment, $request->user());
|
||||
|
||||
// Bust the comments cache for this user's 'all' feed
|
||||
Cache::forget('comments.latest.all.page1');
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
|
||||
if ($comment->is_approved) {
|
||||
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
|
||||
}
|
||||
|
||||
// Record activity event (fire-and-forget; never break the response)
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $request->user()->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_COMMENT,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
ActivityEvent::record(
|
||||
actorId: $request->user()->id,
|
||||
type: ActivityEvent::TYPE_COMMENT,
|
||||
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logComment(
|
||||
(int) $request->user()->id,
|
||||
(int) $comment->id,
|
||||
$parentId !== null,
|
||||
['artwork_id' => (int) $artwork->id],
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
if ($comment->is_approved) {
|
||||
app(UserActivityService::class)->logComment(
|
||||
(int) $request->user()->id,
|
||||
(int) $comment->id,
|
||||
$parentId !== null,
|
||||
['artwork_id' => (int) $artwork->id],
|
||||
);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
$reactionTotals = $this->commentReactions->forComments([$comment->id], $request->user()->id);
|
||||
|
||||
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals)], 201);
|
||||
return response()->json([
|
||||
'data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals),
|
||||
'moderation' => ['status' => $moderation['status']],
|
||||
], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -160,10 +176,10 @@ class ArtworkCommentController extends Controller
|
||||
Gate::authorize('update', $comment);
|
||||
|
||||
$request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
||||
'content' => ['required', 'string', 'min:1', 'max:'.self::MAX_LENGTH],
|
||||
]);
|
||||
|
||||
$raw = $request->input('content');
|
||||
$raw = $request->input('content');
|
||||
$errors = ContentSanitizer::validate($raw);
|
||||
if ($errors) {
|
||||
return response()->json(['errors' => ['content' => $errors]], 422);
|
||||
@@ -172,8 +188,8 @@ class ArtworkCommentController extends Controller
|
||||
$rendered = ContentSanitizer::render($raw);
|
||||
|
||||
$comment->update([
|
||||
'content' => $raw,
|
||||
'raw_content' => $raw,
|
||||
'content' => $raw,
|
||||
'raw_content' => $raw,
|
||||
'rendered_content' => $rendered,
|
||||
]);
|
||||
|
||||
@@ -206,27 +222,27 @@ class ArtworkCommentController extends Controller
|
||||
|
||||
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false, array $reactionTotals = []): array
|
||||
{
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
|
||||
$data = [
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'rendered_content' => $this->renderCommentContent($c),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'can_edit' => $currentUserId === $userId,
|
||||
'can_delete' => $currentUserId === $userId,
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'can_edit' => $currentUserId === $userId,
|
||||
'can_delete' => $currentUserId === $userId,
|
||||
'user' => [
|
||||
'id' => $userId,
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
'id' => $userId,
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@'.$user->username : '/profile/'.$userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
'reactions' => $reactionTotals[(int) $c->id] ?? [],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CommentCaptchaMiddleware
|
||||
{
|
||||
public function __construct(private readonly BotProtectionService $botProtection) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! (bool) config('comment_spam.captcha.enabled', false)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$assessment = $this->botProtection->assess($request, 'comment_create');
|
||||
if ((bool) ($assessment['blocked'] ?? false)) {
|
||||
return response()->json(['message' => 'Suspicious activity detected.', 'errors' => ['bot' => ['Suspicious activity detected.']]], 429);
|
||||
}
|
||||
$threshold = (int) config('comment_spam.captcha.threshold', 40);
|
||||
if ((int) ($assessment['risk_score'] ?? 0) < $threshold) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$token = (string) ($request->input('comment-turnstile-response') ?: $request->header('X-Turnstile-Token', ''));
|
||||
$valid = false;
|
||||
if ($token !== '') {
|
||||
try {
|
||||
$valid = (bool) Http::asForm()->timeout(5)->post(
|
||||
(string) config('comment_spam.captcha.verify_url'),
|
||||
['secret' => (string) config('comment_spam.captcha.secret_key'), 'response' => $token, 'remoteip' => $request->ip()],
|
||||
)->json('success', false);
|
||||
} catch (\Throwable) {
|
||||
$valid = (bool) config('comment_spam.captcha.fail_open', false);
|
||||
}
|
||||
}
|
||||
|
||||
if ($valid) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'message' => 'Complete the captcha challenge to continue.',
|
||||
'errors' => ['captcha' => ['Complete the captcha challenge to continue.']],
|
||||
'requires_captcha' => true,
|
||||
'captcha' => [
|
||||
'provider' => 'turnstile',
|
||||
'siteKey' => (string) config('comment_spam.captcha.site_key'),
|
||||
'inputName' => 'comment-turnstile-response',
|
||||
'scriptUrl' => (string) config('comment_spam.captcha.script_url'),
|
||||
],
|
||||
'captcha_provider' => 'turnstile',
|
||||
'captcha_site_key' => (string) config('comment_spam.captcha.site_key'),
|
||||
'captcha_input' => 'comment-turnstile-response',
|
||||
'captcha_script_url' => (string) config('comment_spam.captcha.script_url'),
|
||||
];
|
||||
|
||||
return response()->json($payload, 422);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\ArtworkCommentObserver;
|
||||
use App\Services\ContentSanitizer;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* App\Models\ArtworkComment
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property int $user_id
|
||||
* @property string|null $content Legacy plain-text column
|
||||
* @property string|null $raw_content User-submitted Markdown
|
||||
* @property string|null $rendered_content Cached sanitized HTML
|
||||
* @property bool $is_approved
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property int $user_id
|
||||
* @property string|null $content Legacy plain-text column
|
||||
* @property string|null $raw_content User-submitted Markdown
|
||||
* @property string|null $rendered_content Cached sanitized HTML
|
||||
* @property bool $is_approved
|
||||
* @property-read Artwork $artwork
|
||||
* @property-read User $user
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|CommentReaction[] $reactions
|
||||
* @property-read Collection|CommentReaction[] $reactions
|
||||
*/
|
||||
#[ObservedBy([ArtworkCommentObserver::class])]
|
||||
class ArtworkComment extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $table = 'artwork_comments';
|
||||
protected $table = 'artwork_comments';
|
||||
|
||||
protected $fillable = [
|
||||
'legacy_id',
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'parent_id',
|
||||
'content',
|
||||
'raw_content',
|
||||
'rendered_content',
|
||||
'is_approved',
|
||||
];
|
||||
protected $fillable = [
|
||||
'legacy_id',
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'parent_id',
|
||||
'content',
|
||||
'raw_content',
|
||||
'rendered_content',
|
||||
'is_approved',
|
||||
'spam_score',
|
||||
'spam_probability',
|
||||
'spam_reason',
|
||||
'moderation_source',
|
||||
'moderated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_approved' => 'boolean',
|
||||
];
|
||||
protected $casts = [
|
||||
'is_approved' => 'boolean',
|
||||
'spam_score' => 'integer',
|
||||
'spam_probability' => 'integer',
|
||||
'moderated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_id');
|
||||
}
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function replies(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at');
|
||||
}
|
||||
public function replies(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively eager-load approved replies (tree structure).
|
||||
*/
|
||||
public function approvedReplies(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id')
|
||||
->where('is_approved', true)
|
||||
->orderBy('created_at')
|
||||
->with(['user.profile', 'approvedReplies']);
|
||||
}
|
||||
/**
|
||||
* Recursively eager-load approved replies (tree structure).
|
||||
*/
|
||||
public function approvedReplies(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id')
|
||||
->where('is_approved', true)
|
||||
->orderBy('created_at')
|
||||
->with(['user.profile', 'approvedReplies']);
|
||||
}
|
||||
|
||||
public function reactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CommentReaction::class, 'comment_id');
|
||||
}
|
||||
public function reactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CommentReaction::class, 'comment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the best available rendered content for display.
|
||||
* Falls back to escaping raw legacy content if rendering isn't done yet.
|
||||
*/
|
||||
public function getDisplayHtml(): string
|
||||
{
|
||||
if ($this->rendered_content !== null) {
|
||||
return $this->rendered_content;
|
||||
}
|
||||
/**
|
||||
* Return the best available rendered content for display.
|
||||
* Falls back to escaping raw legacy content if rendering isn't done yet.
|
||||
*/
|
||||
public function getDisplayHtml(): string
|
||||
{
|
||||
if ($this->rendered_content !== null) {
|
||||
return $this->rendered_content;
|
||||
}
|
||||
|
||||
// Lazy render: raw_content takes priority over legacy content
|
||||
$raw = $this->raw_content ?? $this->content ?? '';
|
||||
return \App\Services\ContentSanitizer::render($raw);
|
||||
}
|
||||
// Lazy render: raw_content takes priority over legacy content
|
||||
$raw = $this->raw_content ?? $this->content ?? '';
|
||||
|
||||
return ContentSanitizer::render($raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CommentSpamSignature extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'content_hash', 'pattern_signature', 'source', 'reason',
|
||||
'confidence', 'hit_count', 'reviewed_by', 'metadata', 'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'confidence' => 'integer',
|
||||
'hit_count' => 'integer',
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?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 !== []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Contracts\Moderation\CommentSpamClassifier;
|
||||
use App\Data\Moderation\CommentSpamClassification;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
final class TogetherCommentSpamClassifier implements CommentSpamClassifier
|
||||
{
|
||||
public function classify(string $content): CommentSpamClassification
|
||||
{
|
||||
$config = (array) config('comment_spam.ai', []);
|
||||
$key = (string) ($config['api_key'] ?? '');
|
||||
$model = (string) ($config['model'] ?? '');
|
||||
if ($key === '' || $model === '') {
|
||||
throw new RuntimeException('Together AI is not configured.');
|
||||
}
|
||||
if (! in_array($model, (array) ($config['allowed_models'] ?? []), true)) {
|
||||
throw new RuntimeException('Unsupported Together AI comment spam model.');
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$response = Http::timeout(min(5, max(1, (int) ($config['timeout'] ?? 5))))
|
||||
->withToken($key)
|
||||
->post(rtrim((string) ($config['base_url'] ?? 'https://api.together.xyz/v1'), '/').'/chat/completions', [
|
||||
'model' => $model,
|
||||
'temperature' => 0,
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => 'Classify the comment as spam or not spam. Return only JSON: {"spam":true|false,"confidence":0.0,"reason":"short explanation"}. Confidence must be a number from 0 to 1. Spam includes advertising, SEO promotion, unsolicited links, scams, and bot-like promotional repetition.'],
|
||||
['role' => 'user', 'content' => mb_substr($content, 0, (int) ($config['max_input_chars'] ?? 2500))],
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException('Together AI request failed with status '.$response->status().'.');
|
||||
}
|
||||
|
||||
$message = Arr::get($response->json(), 'choices.0.message.content');
|
||||
$decoded = is_string($message) ? json_decode($message, true) : null;
|
||||
if (! is_array($decoded) || ! is_bool($decoded['spam'] ?? null)
|
||||
|| ! is_numeric($decoded['confidence'] ?? null)
|
||||
|| $decoded['confidence'] < 0 || $decoded['confidence'] > 1
|
||||
|| ! is_string($decoded['reason'] ?? null)) {
|
||||
throw new RuntimeException('Together AI returned invalid moderation JSON.');
|
||||
}
|
||||
|
||||
return new CommentSpamClassification(
|
||||
$decoded['spam'],
|
||||
(float) $decoded['confidence'],
|
||||
mb_substr(trim($decoded['reason']), 0, 500),
|
||||
'together',
|
||||
$model,
|
||||
(int) round((microtime(true) - $started) * 1000),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user