moderation decision payload. * * @var array> */ private $moderation_result_map = []; /** * Runtime map: fingerprint => forced comment_approved value. * * @var array */ private $forced_approved_map = []; /** * Request-local depth for comment submissions already admitted by the REST controller. * * @var int */ private $captcha_verified_comment_request_depth = 0; 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 begin_captcha_verified_comment_request(): void { $this->captcha_verified_comment_request_depth++; } public function end_captcha_verified_comment_request(): void { $this->captcha_verified_comment_request_depth = max(0, $this->captcha_verified_comment_request_depth - 1); } 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 { $actor = get_current_user_id(); if (!function_exists('aksrt_user_can') || !aksrt_user_can('comment_add', $actor, (int) ($commentdata['comment_post_ID'] ?? 0))) { wp_die( esc_html__('当前账号没有发表评论的权限。', 'aksrt'), esc_html__('评论提交失败', 'aksrt'), ['response' => $actor > 0 ? 403 : 401, 'back_link' => true] ); } // 人机验证:原生 wp-comments-post 必须验证;REST 已在 Controller 准入时验证一次。 if ($this->captcha_verified_comment_request_depth === 0) { $payload = is_array($_POST ?? null) ? wp_unslash($_POST) : []; if (isset($payload['captcha']) && is_array($payload['captcha'])) { $payload = array_merge($payload, $payload['captcha']); } $captcha_result = aksrt_require_captcha('comment', $payload); if ($captcha_result !== true) { $msg = is_wp_error($captcha_result) ? $captcha_result->get_error_message() : __('人机验证失败', 'aksrt'); wp_die( esc_html($msg !== '' ? $msg : __('人机验证失败', 'aksrt')), esc_html__('评论提交失败', 'aksrt'), ['response' => 400, 'back_link' => true] ); } } $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( '

' . esc_html($rate_result) . '

', __('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( '

' . esc_html($geoip_result) . '

', __('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; } }