Files
wordpress-theme-aksrt/inc/classes/class-aksrt-moderation.php
T

308 lines
13 KiB
PHP

<?php
/**
* AKSRT Comment Moderation System
*
* @package AKSRT
* @version 3.4.1
*/
defined('ABSPATH') || exit;
class AKSRT_Moderation {
use AKSRT_Moderation_Part_1;
use AKSRT_Moderation_Part_2;
private static $instance = null;
private $config = [];
/**
* Runtime map: fingerprint => moderation decision payload.
*
* @var array<string, array<string, mixed>>
*/
private $moderation_result_map = [];
/**
* Runtime map: fingerprint => forced comment_approved value.
*
* @var array<string, string|int>
*/
private $forced_approved_map = [];
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->config = get_option('aksrt_moderation_config', []);
}
public function init(): void {
add_filter('preprocess_comment', [$this, 'process'], 5);
add_filter('pre_comment_approved', [$this, 'enforce_pre_comment_approved'], 50, 2);
add_action('comment_post', [$this, 'after_post'], 10, 3);
// Native WP Admin > Comments column.
add_filter('manage_edit-comments_columns', [$this, 'register_comments_columns']);
add_action('manage_comments_custom_column', [$this, 'render_comments_custom_column'], 10, 2);
}
public function get_config(): array {
$defaults = [
'enabled' => false,
'akismet_enabled' => false,
'akismet_site_url' => '',
'akismet_blog_lang' => 'zh-CN',
'akismet_api_key' => '',
'ai_enabled' => false,
'ai_moderate_comments' => true,
'ai_moderate_background' => true,
'auto_approve_low_risk' => true,
'auto_reject_high_risk' => true,
'low_risk_max_score' => 35,
'high_risk_min_score' => 80,
'blocked_keywords' => '',
'rate_limit_enabled' => false,
'rate_limit_min_interval' => 8,
'rate_limit_max_per_hour' => 15,
'geoip_enabled' => false,
'geoip_provider' => 'ipapi',
'geoip_api_key' => '',
'geoip_block_countries' => [],
];
return array_merge($defaults, $this->config);
}
/**
* Main moderation entry.
*/
public function process(array $commentdata): array {
$config = $this->get_config();
if (empty($config['enabled'])) {
return $commentdata;
}
$client_ip = $this->get_client_ip();
$decision = $this->new_default_result();
$decision['ip'] = $client_ip;
try {
// 1) Rate limit.
$rate_result = $this->check_rate_limit($commentdata, $config, $client_ip);
if ($rate_result !== true) {
$this->remember_result($commentdata, [
'result' => 'blocked',
'source' => 'rate_limit',
'reason' => 'rate_limit_triggered',
]);
wp_die(
'<p>' . esc_html($rate_result) . '</p>',
__('Rate limit', 'aksrt'),
['response' => 429]
);
}
// 2) GeoIP restriction.
$geoip_result = $this->check_geoip($config, $client_ip);
if ($geoip_result !== true) {
$this->remember_result($commentdata, [
'result' => 'blocked',
'source' => 'geoip',
'reason' => 'geoip_blocked',
]);
wp_die(
'<p>' . esc_html($geoip_result) . '</p>',
__('Geo restriction', 'aksrt'),
['response' => 403]
);
}
// 3) Blocked keywords: force pending and write AI-like reason.
$keyword = $this->check_blocked_keywords($commentdata, $config);
$keyword_hit = !empty($keyword['hit']);
if ($keyword_hit) {
$commentdata['comment_approved'] = 0;
$decision['result'] = 'pending';
$decision['source'] = 'ai';
$decision['reason'] = 'keyword_hit';
$decision['ai_flagged'] = '1';
$decision['ai_score'] = '100';
$decision['ai_top_category'] = 'blocked_keyword';
$decision['keyword'] = (string) ($keyword['keyword'] ?? '');
$decision['keyword_match_type'] = (string) ($keyword['match_type'] ?? 'plain');
}
// 4) Akismet.
$akismet = $this->check_akismet($commentdata, $config, $client_ip);
if ($akismet === 'spam') {
$commentdata['comment_approved'] = 'spam';
$decision['result'] = 'spam';
$decision['source'] = 'akismet';
$decision['reason'] = 'spam';
$decision['akismet'] = 'spam';
} elseif ($akismet === 'ham') {
$decision['akismet'] = 'ham';
if ($decision['source'] === 'wp_default') {
$decision['source'] = 'akismet';
$decision['reason'] = 'ham';
}
} elseif ($akismet === 'error') {
$decision['akismet'] = 'error';
} else {
$decision['akismet'] = 'unknown';
}
// 5) AI moderation provider (Baidu only).
// If keyword already hit, keep keyword decision locked and skip AI override.
if (!$keyword_hit) {
$ai = $this->check_ai_moderation($commentdata, $config);
if (is_array($ai)) {
$score = (float) ($ai['score'] ?? 0);
$flagged = !empty($ai['flagged']) ? '1' : '0';
$top_category = (string) ($ai['top_category'] ?? '');
$decision['ai_score'] = (string) round($score, 2);
$decision['ai_flagged'] = $flagged;
$decision['ai_top_category'] = $top_category;
if (!empty($config['auto_reject_high_risk']) && $score >= (float) ($config['high_risk_min_score'] ?? 80)) {
// High-risk comments are auto rejected into spam list.
$commentdata['comment_approved'] = 'spam';
$decision['result'] = 'spam';
$decision['source'] = 'ai';
$decision['reason'] = 'ai_high_risk_auto_reject';
} elseif (!empty($config['auto_approve_low_risk']) && $score <= (float) ($config['low_risk_max_score'] ?? 35)) {
// Do not override stricter decision.
if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') {
$commentdata['comment_approved'] = 1;
$decision['result'] = 'approved';
$decision['source'] = 'ai';
$decision['reason'] = 'ai_low_risk_auto_approve';
}
} else {
if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') {
$commentdata['comment_approved'] = 0;
$decision['result'] = 'pending';
$decision['source'] = 'ai';
$decision['reason'] = 'ai_medium_risk_pending';
}
}
} elseif ($ai === 'rejected') {
$commentdata['comment_approved'] = 'spam';
$decision['result'] = 'spam';
$decision['source'] = 'ai';
$decision['reason'] = 'ai_rejected_auto_reject';
} elseif ($ai === 'error') {
// Fail-safe: AI enabled but response is invalid/unusable, do not auto approve.
if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') {
$commentdata['comment_approved'] = 0;
$decision['result'] = 'pending';
}
$decision['source'] = 'ai';
$decision['reason'] = 'ai_response_invalid_pending';
$decision['ai_flagged'] = '1';
}
}
$decision = $this->enrich_moderation_result($decision, $commentdata);
$this->remember_result($commentdata, $decision);
} catch (Exception $e) {
// Intentionally silent: keep moderation flow without writing runtime logs.
}
return $commentdata;
}
/**
* Keep pre_comment_approved in sync with preprocess_comment outcome.
*/
public function enforce_pre_comment_approved($approved, array $commentdata) {
if (!is_array($commentdata)) {
return $approved;
}
$fingerprint = $this->comment_fingerprint($commentdata);
if ($fingerprint !== '' && array_key_exists($fingerprint, $this->forced_approved_map)) {
$forced = $this->forced_approved_map[$fingerprint];
return $forced;
}
if (array_key_exists('comment_approved', $commentdata)) {
return $commentdata['comment_approved'];
}
return $approved;
}
/**
* Persist moderation details to comment meta.
*/
public function after_post(int $comment_id, $comment_approved, array $commentdata = []): void {
$comment = get_comment($comment_id);
if (!$comment instanceof WP_Comment) {
return;
}
$decision = $this->resolve_result_from_runtime_or_default($commentdata, $comment);
$decision = $this->enrich_moderation_result($decision, (array) $comment);
update_comment_meta($comment_id, 'aksrt_moderation_result', (string) $decision['result']);
update_comment_meta($comment_id, 'aksrt_moderation_source', (string) $decision['source']);
update_comment_meta($comment_id, 'aksrt_moderation_reason', (string) $decision['reason']);
update_comment_meta($comment_id, 'aksrt_moderation_akismet', (string) $decision['akismet']);
update_comment_meta($comment_id, 'aksrt_moderation_ai_score', (string) $decision['ai_score']);
update_comment_meta($comment_id, 'aksrt_moderation_ai_flagged', (string) $decision['ai_flagged']);
update_comment_meta($comment_id, 'aksrt_moderation_ai_top_category', (string) $decision['ai_top_category']);
update_comment_meta($comment_id, 'aksrt_moderation_keyword', (string) $decision['keyword']);
update_comment_meta($comment_id, 'aksrt_moderation_keyword_match_type', (string) $decision['keyword_match_type']);
$moderation_user_id = isset($comment->user_id) ? (int) $comment->user_id : 0;
$moderation_user_ip = trim((string) ($comment->comment_author_IP ?? ''));
if ($moderation_user_ip === '') {
$moderation_user_ip = trim((string) ($decision['ip'] ?? ''));
}
if ($moderation_user_ip === '') {
$moderation_user_ip = trim((string) ($commentdata['comment_author_IP'] ?? ($commentdata['comment_author_ip'] ?? '')));
}
update_comment_meta($comment_id, 'aksrt_moderation_user_id', (string) $moderation_user_id);
update_comment_meta($comment_id, 'aksrt_moderation_user_ip', $moderation_user_ip);
// Convenience marker for external tools expecting Akismet style keys.
if ($decision['source'] === 'akismet' && $decision['akismet'] === 'spam') {
update_comment_meta($comment_id, 'akismet_result', 'true');
} elseif ($decision['source'] === 'akismet' && $decision['akismet'] === 'ham') {
update_comment_meta($comment_id, 'akismet_result', 'false');
}
// Clean runtime maps.
$fp_comment = $this->comment_fingerprint((array) $comment);
$fp_data = $this->comment_fingerprint($commentdata);
foreach (array_unique(array_filter([$fp_comment, $fp_data])) as $fp) {
unset($this->moderation_result_map[$fp], $this->forced_approved_map[$fp]);
}
}
public function register_comments_columns(array $columns): array {
$new = [];
foreach ($columns as $key => $label) {
$new[$key] = $label;
if ($key === 'comment') {
$new['aksrt_moderation'] = __('Moderation', 'aksrt');
}
}
if (!isset($new['aksrt_moderation'])) {
$new['aksrt_moderation'] = __('Moderation', 'aksrt');
}
return $new;
}
}
return AKSRT_Moderation::get_instance();