PluginProbe
wpForo Forum / 3.1.4
wpForo Forum v3.1.4
3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 1.4.2 All 137 releases
wpforo / classes / AIContentModeration.php

AIContentModeration.php in wpForo Forum 3.1.4, at classes/AIContentModeration.php

3,739 lines 117.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace wpforo\classes;
4
5 /**
6 * AI Content Moderation
7 *
8 * Central class for AI-powered content moderation in wpForo.
9 * Hooks into all content events and provides a framework for moderation features.
10 *
11 * Features to be implemented:
12 * - Content Safety & Toxicity Detection
13 * - Spam & Low-Quality Detection
14 * - Rule Compliance & Policy Enforcement
15 * - Content Quality Enhancement
16 * - User Behavior Analysis
17 * - Automated Moderation Actions
18 * - Moderator Assistance Tools
19 *
20 * @since 3.0.0
21 */
22 class AIContentModeration {
23
24 /**
25 * Singleton instance
26 *
27 * @var AIContentModeration|null
28 */
29 private static $instance = null;
30
31 /**
32 * Board ID
33 *
34 * @var int
35 */
36 private $board_id = 0;
37
38 /**
39 * Cached settings
40 *
41 * @var array
42 */
43 private $settings = [];
44
45 /**
46 * Registered moderation handlers
47 *
48 * @var array
49 */
50 private $handlers = [];
51
52 /**
53 * Moderation action constants
54 */
55 const ACTION_APPROVE = 'approve';
56 const ACTION_HOLD = 'hold';
57 const ACTION_REJECT = 'reject';
58 const ACTION_DELETE = 'delete';
59 const ACTION_EDIT = 'edit';
60 const ACTION_MOVE = 'move';
61 const ACTION_CLOSE = 'close';
62 const ACTION_MERGE = 'merge';
63 const ACTION_WARN_USER = 'warn_user';
64 const ACTION_BAN_USER = 'ban_user';
65 const ACTION_SUSPEND_USER = 'suspend_user';
66
67 /**
68 * Spam action setting values
69 */
70 const SPAM_ACTION_UNAPPROVE = 'unapprove';
71 const SPAM_ACTION_UNAPPROVE_BAN = 'unapprove_ban';
72 const SPAM_ACTION_DELETE_AUTHOR = 'delete_author';
73 const SPAM_ACTION_NONE = 'none';
74 const SPAM_ACTION_AUTO_APPROVE = 'auto_approve';
75
76 /**
77 * Default score thresholds (hardcoded, customizable via filters)
78 *
79 * Score ranges:
80 * - 0-40: Clean content (no spam detected)
81 * - 41-69: Uncertain (uses uncertain action setting)
82 * - 70-89: Spam suspected
83 * - 90-100: Spam detected
84 */
85 const SCORE_THRESHOLD_CLEAN = 40; // At or below this = clean (0-40%)
86 const SCORE_THRESHOLD_SUSPECTED = 70; // At or above this = suspected (70-89%)
87 const SCORE_THRESHOLD_DETECTED = 90; // At or above this = detected (90-100%)
88
89 /**
90 * Content type constants
91 */
92 const CONTENT_TOPIC = 'topic';
93 const CONTENT_POST = 'post';
94
95 /**
96 * Event type constants
97 */
98 const EVENT_CREATE = 'create';
99 const EVENT_EDIT = 'edit';
100 const EVENT_APPROVE = 'approve';
101 const EVENT_DELETE = 'delete';
102
103 /**
104 * Get singleton instance
105 *
106 * @return AIContentModeration
107 */
108 public static function get_instance() {
109 if ( null === self::$instance ) {
110 self::$instance = new self();
111 }
112 return self::$instance;
113 }
114
115 /**
116 * Constructor - private for singleton
117 */
118 private function __construct() {
119 // Get current board - extract boardid as int (get_current returns array or object)
120 $board = WPF()->board->get_current();
121 $this->board_id = is_array( $board ) ? ( $board['boardid'] ?? 0 ) : ( $board->boardid ?? 0 );
122
123 // Settings may not be loaded yet during wpForo initialization.
124 // Register content hooks immediately (they check is_enabled() at runtime),
125 // but delay handler registration until settings are available.
126 $this->register_hooks();
127
128 // Try to load settings immediately if available
129 if ( $this->are_settings_available() ) {
130 $this->load_settings();
131 $this->register_moderation_handlers();
132 } else {
133 // Settings not yet loaded - register handler on settings init
134 add_action( 'wpforo_settings_after_init', [ $this, 'on_settings_loaded' ], 10 );
135 }
136 }
137
138 /**
139 * Check if wpForo settings are available
140 *
141 * @return bool
142 */
143 private function are_settings_available() {
144 return ! empty( WPF()->settings ) &&
145 property_exists( WPF()->settings, 'ai' ) &&
146 ! is_null( WPF()->settings->ai );
147 }
148
149 /**
150 * Callback for when settings are loaded
151 */
152 public function on_settings_loaded() {
153 $this->load_settings();
154 $this->register_moderation_handlers();
155 }
156
157 /**
158 * Prevent cloning
159 */
160 private function __clone() {}
161
162 /**
163 * Prevent unserialization
164 */
165 public function __wakeup() {
166 throw new \Exception( 'Cannot unserialize singleton' );
167 }
168
169 // =========================================================================
170 // SETTINGS MANAGEMENT
171 // =========================================================================
172
173 /**
174 * Load all relevant settings
175 *
176 * Loads AI settings, antispam settings, and moderation-related options
177 * from wpForo's settings system.
178 */
179 private function load_settings() {
180 $this->settings = [
181 // AI Settings
182 'ai' => [
183 'enabled' => (bool) wpforo_setting( 'ai', 'assistant' ),
184 'search' => (bool) wpforo_setting( 'ai', 'search' ),
185 'search_quality' => wpforo_setting( 'ai', 'search_quality' ),
186 'translation' => (bool) wpforo_setting( 'ai', 'translation' ),
187 'topic_summary' => (bool) wpforo_setting( 'ai', 'topic_summary' ),
188 'topic_suggestions' => (bool) wpforo_setting( 'ai', 'topic_suggestions' ),
189 ],
190
191 // AI Content Moderation Settings
192 'moderation' => [
193 'spam' => (bool) wpforo_setting( 'ai', 'moderation_spam' ),
194 'toxicity' => (bool) wpforo_setting( 'ai', 'moderation_toxicity' ),
195 'compliance' => (bool) wpforo_setting( 'ai', 'moderation_compliance' ),
196 ],
197
198 // Spam Detection Settings
199 // Note: wpforo_setting() does NOT support a default value as third argument
200 // The third arg is treated as a nested key. Use ?? for defaults instead.
201 'spam' => [
202 'quality' => wpforo_setting( 'ai', 'moderation_spam_quality' ) ?? 'balanced',
203 'use_context' => (bool) ( wpforo_setting( 'ai', 'moderation_spam_use_context' ) ?? true ),
204 'min_indexed' => (int) ( wpforo_setting( 'ai', 'moderation_spam_min_indexed' ) ?? 100 ),
205 'action_detected' => wpforo_setting( 'ai', 'moderation_spam_action_detected' ) ?? 'unapprove_ban',
206 'action_suspected' => wpforo_setting( 'ai', 'moderation_spam_action_suspected' ) ?? 'unapprove_ban',
207 'action_uncertain' => wpforo_setting( 'ai', 'moderation_spam_action_uncertain' ) ?? 'unapprove',
208 'action_clean' => wpforo_setting( 'ai', 'moderation_spam_action_clean' ) ?? 'none',
209 'exempt_minposts' => (int) ( wpforo_setting( 'ai', 'moderation_spam_exempt_minposts' ) ?? 10 ),
210 'autoban_unapproved' => (int) ( wpforo_setting( 'ai', 'moderation_spam_autoban_unapproved' ) ?? 5 ),
211 ],
212
213 // Compliance Settings
214 'compliance' => [
215 'custom_policy_page' => (int) ( wpforo_setting( 'ai', 'moderation_compliance_custom_policy' ) ?? 0 ),
216 'custom_rules_page' => (int) ( wpforo_setting( 'ai', 'moderation_compliance_custom_rules' ) ?? 0 ),
217 'action' => wpforo_setting( 'ai', 'moderation_compliance_action' ) ?? 'unapprove',
218 ],
219
220 // Antispam Settings
221 'antispam' => [
222 'spam_filter' => (bool) wpforo_setting( 'antispam', 'spam_filter' ),
223 'spam_user_ban' => (bool) wpforo_setting( 'antispam', 'spam_user_ban' ),
224 'should_unapprove_after_report' => (bool) wpforo_setting( 'antispam', 'should_unapprove_after_report' ),
225 'spam_filter_level_topic' => (int) wpforo_setting( 'antispam', 'spam_filter_level_topic' ),
226 'spam_filter_level_post' => (int) wpforo_setting( 'antispam', 'spam_filter_level_post' ),
227 'new_user_max_posts' => (int) wpforo_setting( 'antispam', 'new_user_max_posts' ),
228 'unapprove_post_if_user_is_new' => (bool) wpforo_setting( 'antispam', 'unapprove_post_if_user_is_new' ),
229 'min_number_posts_to_link' => (int) wpforo_setting( 'antispam', 'min_number_posts_to_link' ),
230 'min_number_posts_to_attach' => (int) wpforo_setting( 'antispam', 'min_number_posts_to_attach' ),
231 ],
232
233 // Akismet Settings
234 'akismet' => [
235 'enabled' => (bool) wpforo_setting( 'akismet', 'akismet' ),
236 ],
237
238 // AI Content Moderation Settings (to be added)
239 'content_moderation' => [
240 'enabled' => (bool) wpforo_get_option( 'ai_content_moderation_enabled', 0 ),
241 'toxicity_detection' => (bool) wpforo_get_option( 'ai_toxicity_detection', 0 ),
242 'spam_detection' => (bool) wpforo_get_option( 'ai_spam_detection', 0 ),
243 'rule_compliance' => (bool) wpforo_get_option( 'ai_rule_compliance', 0 ),
244 'auto_approve_threshold' => (int) wpforo_get_option( 'ai_auto_approve_threshold', 80 ),
245 'auto_reject_threshold' => (int) wpforo_get_option( 'ai_auto_reject_threshold', 20 ),
246 ],
247 ];
248 }
249
250 /**
251 * Get a specific setting value
252 *
253 * @param string $group Setting group (ai, antispam, akismet, content_moderation)
254 * @param string $key Setting key
255 * @param mixed $default Default value if not found
256 * @return mixed Setting value
257 */
258 public function get_setting( $group, $key, $default = null ) {
259 return $this->settings[ $group ][ $key ] ?? $default;
260 }
261
262 /**
263 * Get all settings for a group
264 *
265 * @param string $group Setting group
266 * @return array Settings array
267 */
268 public function get_settings( $group = '' ) {
269 if ( empty( $group ) ) {
270 return $this->settings;
271 }
272 return $this->settings[ $group ] ?? [];
273 }
274
275 /**
276 * Refresh settings from database
277 *
278 * Call this after settings are updated.
279 */
280 public function refresh_settings() {
281 $this->load_settings();
282 }
283
284 /**
285 * Check if AI content moderation is enabled
286 *
287 * Returns true if any moderation feature is enabled.
288 *
289 * @return bool
290 */
291 public function is_enabled() {
292 return $this->is_spam_detection_enabled()
293 || $this->is_toxicity_detection_enabled()
294 || $this->is_compliance_enabled();
295 }
296
297 /**
298 * Check if AI spam detection is enabled
299 *
300 * @return bool
301 */
302 public function is_spam_detection_enabled() {
303 return $this->get_setting( 'moderation', 'spam', false );
304 }
305
306 /**
307 * Check if AI content safety & toxicity detection is enabled
308 *
309 * @return bool
310 */
311 public function is_toxicity_detection_enabled() {
312 return $this->get_setting( 'moderation', 'toxicity', false );
313 }
314
315 /**
316 * Check if AI rule compliance & policy enforcement is enabled
317 *
318 * @return bool
319 */
320 public function is_compliance_enabled() {
321 return $this->get_setting( 'moderation', 'compliance', false );
322 }
323
324 /**
325 * Get spam detection quality tier
326 *
327 * @return string Quality tier (fast, balanced, advanced, premium)
328 */
329 public function get_spam_quality() {
330 return $this->get_setting( 'spam', 'quality', 'balanced' );
331 }
332
333 /**
334 * Check if forum context should be used for spam detection
335 *
336 * @return bool
337 */
338 public function use_spam_context() {
339 return $this->get_setting( 'spam', 'use_context', true );
340 }
341
342 /**
343 * Get minimum indexed topics required for context
344 *
345 * @return int
346 */
347 public function get_spam_min_indexed() {
348 return $this->get_setting( 'spam', 'min_indexed', 100 );
349 }
350
351 /**
352 * Get score threshold for clean content (no spam)
353 *
354 * Scores below this threshold are considered clean.
355 * Default: 50. Customizable via 'wpforo_spam_threshold_clean' filter.
356 *
357 * @return int Score threshold (0-100)
358 */
359 public function get_spam_threshold_clean() {
360 return apply_filters( 'wpforo_spam_threshold_clean', self::SCORE_THRESHOLD_CLEAN );
361 }
362
363 /**
364 * Get score threshold for suspected spam
365 *
366 * Scores at or above this threshold are suspected spam.
367 * Default: 70. Customizable via 'wpforo_spam_threshold_suspected' filter.
368 *
369 * @return int Score threshold (0-100)
370 */
371 public function get_spam_threshold_suspected() {
372 return apply_filters( 'wpforo_spam_threshold_suspected', self::SCORE_THRESHOLD_SUSPECTED );
373 }
374
375 /**
376 * Get score threshold for detected spam
377 *
378 * Scores at or above this threshold are definite spam.
379 * Default: 90. Customizable via 'wpforo_spam_threshold_detected' filter.
380 *
381 * @return int Score threshold (0-100)
382 */
383 public function get_spam_threshold_detected() {
384 return apply_filters( 'wpforo_spam_threshold_detected', self::SCORE_THRESHOLD_DETECTED );
385 }
386
387 /**
388 * Get action for when spam is detected (score 90-100%)
389 *
390 * @return string Action (unapprove, unapprove_ban, delete_author)
391 */
392 public function get_spam_action_detected() {
393 return $this->get_setting( 'spam', 'action_detected', self::SPAM_ACTION_UNAPPROVE_BAN );
394 }
395
396 /**
397 * Get action for when spam is suspected (score 70-90%)
398 *
399 * @return string Action (unapprove, unapprove_ban, delete_author)
400 */
401 public function get_spam_action_suspected() {
402 return $this->get_setting( 'spam', 'action_suspected', self::SPAM_ACTION_UNAPPROVE );
403 }
404
405 /**
406 * Get action for when spam detection is uncertain (score 41-69%)
407 *
408 * @return string Action (none, unapprove, unapprove_ban, delete_author)
409 */
410 public function get_spam_action_uncertain() {
411 return $this->get_setting( 'spam', 'action_uncertain', self::SPAM_ACTION_UNAPPROVE );
412 }
413
414 /**
415 * Get action for when content is clean (score 0-40%)
416 *
417 * @return string Action (none, auto_approve)
418 */
419 public function get_spam_action_clean() {
420 return $this->get_setting( 'spam', 'action_clean', self::SPAM_ACTION_NONE );
421 }
422
423 /**
424 * Get user groups exempt from spam detection
425 *
426 * @deprecated 2.4.0 Use the "Dashboard - Moderate Topics & Posts" (aum) usergroup permission instead.
427 * This method now always returns an empty array.
428 *
429 * @return array Empty array (deprecated)
430 */
431 public function get_spam_exempt_usergroups() {
432 return [];
433 }
434
435 /**
436 * Get minimum post count for exemption from spam detection
437 *
438 * @return int Minimum post count, 0 = disabled
439 */
440 public function get_spam_exempt_minposts() {
441 return $this->get_setting( 'spam', 'exempt_minposts', 10 );
442 }
443
444 /**
445 * Get unapproved post count threshold for auto-ban
446 *
447 * @return int Unapproved count threshold, 0 = disabled
448 */
449 public function get_spam_autoban_unapproved_threshold() {
450 return (int) $this->get_setting( 'spam', 'autoban_unapproved', 5 );
451 }
452
453 /**
454 * Count user's unapproved posts
455 *
456 * @param int $userid User ID
457 * @return int Number of unapproved posts
458 */
459 public function count_user_unapproved_posts( $userid ) {
460 if ( ! $userid ) {
461 return 0;
462 }
463
464 return (int) WPF()->db->get_var(
465 WPF()->db->prepare(
466 "SELECT COUNT(*) FROM " . WPF()->tables->posts . " WHERE userid = %d AND status = 1",
467 $userid
468 )
469 );
470 }
471
472 /**
473 * Check if user is exempt from spam detection
474 *
475 * @param int $userid User ID
476 * @return bool True if user is exempt
477 */
478 public function is_user_spam_exempt( $userid ) {
479 if ( ! $userid ) {
480 return false; // Guests are never exempt
481 }
482
483 // Check general exemption (admins, moderators)
484 if ( $this->is_user_exempt( $userid ) ) {
485 return true;
486 }
487
488 // Check "Dashboard - Moderate Topics & Posts" permission (aum)
489 // Users with this permission bypass both standard and AI moderation
490 $member = WPF()->member->get_member( $userid );
491 $user_groupids = [];
492 if ( ! empty( $member['groupid'] ) ) {
493 $user_groupids[] = (int) $member['groupid'];
494 }
495 if ( ! empty( $member['secondary_groupids'] ) ) {
496 $user_groupids = array_merge( $user_groupids, array_map( 'intval', (array) $member['secondary_groupids'] ) );
497 }
498 if ( ! empty( $user_groupids ) && WPF()->usergroup->can( 'aum', $user_groupids ) ) {
499 return true;
500 }
501
502 // Check post count exemption
503 $min_posts = $this->get_spam_exempt_minposts();
504 if ( $min_posts > 0 ) {
505 $post_count = WPF()->member->member_approved_posts( $userid );
506 if ( $post_count >= $min_posts ) {
507 return true;
508 }
509 }
510
511 return false;
512 }
513
514 /**
515 * Determine action based on spam score
516 *
517 * Score ranges (customizable via filters):
518 * - 0-40: Clean content -> action_clean setting
519 * - 41-69: Uncertain -> action_uncertain setting
520 * - 70-89: Spam suspected -> action_suspected setting
521 * - 90-100: Spam detected -> action_detected setting
522 *
523 * @param int $score Spam score (0-100)
524 * @return array Action info with 'action' and 'user_action' keys
525 */
526 public function get_spam_action( $score ) {
527 // Get thresholds (can be customized via filters)
528 $threshold_detected = $this->get_spam_threshold_detected();
529 $threshold_suspected = $this->get_spam_threshold_suspected();
530 $threshold_clean = $this->get_spam_threshold_clean();
531
532 $result = [
533 'action' => null, // Content action (hold, delete, approve)
534 'user_action' => null, // User action (ban_user, delete_author)
535 'level' => 'uncertain', // Score level (clean, uncertain, suspected, detected)
536 ];
537
538 // Spam Detected (score 90-100%)
539 if ( $score >= $threshold_detected ) {
540 $result['level'] = 'detected';
541 $action_setting = $this->get_spam_action_detected();
542 $result = $this->map_spam_action_setting( $action_setting, $result );
543 }
544 // Spam Suspected (score 70-89%)
545 elseif ( $score >= $threshold_suspected ) {
546 $result['level'] = 'suspected';
547 $action_setting = $this->get_spam_action_suspected();
548 $result = $this->map_spam_action_setting( $action_setting, $result );
549 }
550 // Clean Content (score 0-40%)
551 elseif ( $score <= $threshold_clean ) {
552 $result['level'] = 'clean';
553 $action_setting = $this->get_spam_action_clean();
554 if ( $action_setting === self::SPAM_ACTION_AUTO_APPROVE ) {
555 $result['action'] = self::ACTION_APPROVE;
556 }
557 // If 'none', action remains null (no action taken)
558 }
559 // Uncertain (score 41-69%) - use uncertain action setting
560 else {
561 $result['level'] = 'uncertain';
562 $action_setting = $this->get_spam_action_uncertain();
563 $result = $this->map_spam_action_setting( $action_setting, $result );
564 }
565
566 /**
567 * Filter the spam action result
568 *
569 * @param array $result Action result with 'action', 'user_action', and 'level'
570 * @param int $score Spam score (0-100)
571 */
572 return apply_filters( 'wpforo_spam_action', $result, $score );
573 }
574
575 /**
576 * Map spam action setting to action constants
577 *
578 * @param string $action_setting Setting value (unapprove, unapprove_ban, delete_author)
579 * @param array $result Current result array
580 * @return array Updated result array
581 */
582 private function map_spam_action_setting( $action_setting, $result ) {
583 switch ( $action_setting ) {
584 case self::SPAM_ACTION_UNAPPROVE:
585 $result['action'] = self::ACTION_HOLD;
586 break;
587
588 case self::SPAM_ACTION_UNAPPROVE_BAN:
589 $result['action'] = self::ACTION_HOLD;
590 $result['user_action'] = self::ACTION_BAN_USER;
591 break;
592
593 case self::SPAM_ACTION_DELETE_AUTHOR:
594 $result['action'] = self::ACTION_DELETE;
595 $result['user_action'] = 'delete_author'; // Special case: delete user with posts
596 break;
597 }
598
599 return $result;
600 }
601
602 /**
603 * Get all spam detection settings
604 *
605 * @return array All spam settings
606 */
607 public function get_spam_settings() {
608 return $this->get_settings( 'spam' );
609 }
610
611 // =========================================================================
612 // HOOK REGISTRATION
613 // =========================================================================
614
615 /**
616 * Register all content event hooks
617 *
618 * Hooks into wpForo's content lifecycle events for topics and posts.
619 */
620 private function register_hooks() {
621 // Topic creation hooks
622 // Priority 25: Run AFTER wpForo built-in antispam (akismet=8, spam_filter=9, auto_moderate=10, remove_links=20)
623 add_filter( 'wpforo_add_topic_data_filter', [ $this, 'filter_topic_on_create' ], 25, 2 );
624 add_action( 'wpforo_after_add_topic', [ $this, 'on_topic_created' ], 10, 2 );
625
626 // Topic edit hooks
627 add_filter( 'wpforo_edit_topic_data_filter', [ $this, 'filter_topic_on_edit' ], 25, 2 );
628 add_action( 'wpforo_after_edit_topic', [ $this, 'on_topic_edited' ], 10, 3 );
629
630 // Topic status hooks
631 add_action( 'wpforo_topic_approve', [ $this, 'on_topic_approved' ], 10, 1 );
632 add_action( 'wpforo_topic_unapprove', [ $this, 'on_topic_unapproved' ], 10, 1 );
633 add_action( 'wpforo_topic_status_update', [ $this, 'on_topic_status_change' ], 10, 2 );
634
635 // Topic management hooks
636 add_action( 'wpforo_before_delete_topic', [ $this, 'on_before_topic_delete' ], 10, 1 );
637 add_action( 'wpforo_after_delete_topic', [ $this, 'on_topic_deleted' ], 10, 1 );
638 add_action( 'wpforo_after_move_topic', [ $this, 'on_topic_moved' ], 10, 2 );
639 add_action( 'wpforo_after_merge_topic', [ $this, 'on_topics_merged' ], 10, 5 );
640
641 // Post/Reply creation hooks
642 // Priority 25: Run AFTER wpForo built-in antispam (akismet=8, spam_filter=9, auto_moderate=10, remove_links=20)
643 add_filter( 'wpforo_add_post_data_filter', [ $this, 'filter_post_on_create' ], 25, 1 );
644 add_action( 'wpforo_after_add_post', [ $this, 'on_post_created' ], 10, 3 );
645
646 // Post/Reply edit hooks
647 add_filter( 'wpforo_edit_post_data_filter', [ $this, 'filter_post_on_edit' ], 25, 1 );
648 add_action( 'wpforo_after_edit_post', [ $this, 'on_post_edited' ], 10, 4 );
649
650 // Post/Reply status hooks
651 add_action( 'wpforo_post_approve', [ $this, 'on_post_approved' ], 10, 1 );
652 add_action( 'wpforo_post_unapprove', [ $this, 'on_post_unapproved' ], 10, 1 );
653 add_action( 'wpforo_post_status_update', [ $this, 'on_post_status_change' ], 10, 2 );
654
655 // Post/Reply management hooks
656 add_action( 'wpforo_before_delete_post', [ $this, 'on_before_post_delete' ], 10, 1 );
657 add_action( 'wpforo_after_delete_post', [ $this, 'on_post_deleted' ], 10, 1 );
658
659 // User hooks for behavior analysis
660 add_action( 'wpforo_after_ban_user', [ $this, 'on_user_banned' ], 10, 1 );
661 add_action( 'wpforo_after_unban_user', [ $this, 'on_user_unbanned' ], 10, 1 );
662
663 // Display moderation report under posts for authorized users
664 add_action( 'wpforo_post_content_footer', [ $this, 'display_moderation_report' ], 10, 4 );
665
666 // Delete moderation report when content is approved (report no longer needed)
667 // Note: wpforo_post_approve fires for all posts including first posts (topics)
668 add_action( 'wpforo_post_approve', [ $this, 'on_post_approved' ], 10, 1 );
669
670 // Cron job for cleaning up old moderation logs
671 add_action( 'wpforo_ai_moderation_cleanup', [ $this, 'cron_moderation_cleanup' ] );
672
673 // Note: Moderation report styles are defined in theme style.css files
674 // with proper wpForo specificity (#wpforo #wpforo-wrap prefix)
675
676 // Note: Moderation handlers are registered separately after settings are loaded.
677 // See constructor and on_settings_loaded() for handler registration.
678 }
679
680 /**
681 * Register built-in moderation handlers
682 *
683 * These handlers perform the actual AI analysis of content.
684 */
685 private function register_moderation_handlers() {
686 // Spam detection handler (priority 10 - first)
687 if ( $this->is_spam_detection_enabled() ) {
688 $this->register_handler( 'spam', [ $this, 'spam_detection_handler' ], 10 );
689 }
690
691 // Future handlers:
692 // if ( $this->is_toxicity_detection_enabled() ) {
693 // $this->register_handler( 'toxicity', [ $this, 'toxicity_detection_handler' ], 20 );
694 // }
695 }
696
697 // =========================================================================
698 // HANDLER REGISTRATION
699 // =========================================================================
700
701 /**
702 * Register a moderation handler
703 *
704 * Handlers are called during content analysis to check for specific issues.
705 *
706 * @param string $id Unique handler ID
707 * @param callable $callback Callback function that receives content data
708 * @param int $priority Priority (lower = earlier)
709 */
710 public function register_handler( $id, $callback, $priority = 10 ) {
711 $this->handlers[ $id ] = [
712 'callback' => $callback,
713 'priority' => $priority,
714 ];
715
716 // Sort handlers by priority
717 uasort( $this->handlers, function( $a, $b ) {
718 return $a['priority'] <=> $b['priority'];
719 } );
720 }
721
722 /**
723 * Unregister a moderation handler
724 *
725 * @param string $id Handler ID
726 */
727 public function unregister_handler( $id ) {
728 unset( $this->handlers[ $id ] );
729 }
730
731 /**
732 * Get registered handlers
733 *
734 * @return array
735 */
736 public function get_handlers() {
737 return $this->handlers;
738 }
739
740 // =========================================================================
741 // UNIFIED MODERATION HANDLER
742 // =========================================================================
743
744 /**
745 * Unified moderation handler
746 *
747 * Calls the AI backend API to analyze content for spam, toxicity, and compliance.
748 * Uses the unified /moderation/analyze endpoint for efficiency (single LLM call).
749 *
750 * @param array $context Analysis context from build_analysis_context()
751 * @return array|null Analysis results or null on failure
752 */
753 public function spam_detection_handler( $context ) {
754 // Check if user is exempt from spam detection
755 $exempt_minposts = $this->get_setting( 'spam', 'exempt_minposts', 10 );
756 $user_post_count = $context['user_info']['post_count'] ?? 0;
757
758 if ( $user_post_count >= $exempt_minposts ) {
759 return null;
760 }
761
762 // Skip for moderators and admins
763 if ( ! empty( $context['user_info']['is_moderator'] ) || ! empty( $context['user_info']['is_admin'] ) ) {
764 return null;
765 }
766
767 // Get AI client
768 $ai_client = $this->get_ai_client();
769 if ( ! $ai_client || ! $ai_client->is_service_available() ) {
770 return null;
771 }
772
773 // Check which features are enabled
774 $spam_enabled = $this->is_spam_detection_enabled();
775 $toxicity_enabled = $this->is_toxicity_detection_enabled();
776 $compliance_enabled = $this->is_compliance_enabled();
777
778 // Build base request data
779 $forum = $context['forum'] ?? [];
780
781 // Build enhanced forum description with site, board, and parent forum context
782 // This helps AI understand what topics are appropriate for this forum
783 // Hierarchy: Site > Board > Parent Category > Current Category
784 $forum_description = $forum['description'] ?? '';
785 $forum_title = $forum['title'] ?? '';
786
787 // Get board context (what this forum installation is about)
788 $board_settings = WPF()->board->get_current( 'settings' );
789 $board_title = $board_settings['title'] ?? '';
790 $board_description = $board_settings['desc'] ?? '';
791
792 // Get site context (WordPress site info)
793 $site_name = get_bloginfo( 'name' );
794 $site_description = get_bloginfo( 'description' );
795
796 // Get parent forum context (if this forum has a parent category)
797 $parent_title = '';
798 $parent_description = '';
799 $parent_id = (int) ( $forum['parentid'] ?? 0 );
800 if ( $parent_id > 0 && WPF()->forum ) {
801 $parent_forum = WPF()->forum->get_forum( $parent_id );
802 if ( ! empty( $parent_forum ) && is_array( $parent_forum ) ) {
803 $parent_title = $parent_forum['title'] ?? '';
804 $parent_description = $parent_forum['description'] ?? '';
805 }
806 }
807
808 // Build context string: Site > Board > Parent Category > Current Category
809 $context_parts = [];
810
811 if ( $site_name || $site_description ) {
812 $site_context = 'Website: ' . ( $site_name ?: 'Unknown' );
813 if ( $site_description ) {
814 $site_context .= ' - ' . $site_description;
815 }
816 $context_parts[] = $site_context;
817 }
818
819 if ( $board_title || $board_description ) {
820 $board_context = 'Forum: ' . ( $board_title ?: 'Community' );
821 if ( $board_description ) {
822 $board_context .= ' - ' . $board_description;
823 }
824 $context_parts[] = $board_context;
825 }
826
827 // Add parent category if exists (this is the top-level category)
828 if ( $parent_title || $parent_description ) {
829 $parent_context = 'Parent Category: ' . ( $parent_title ?: 'General' );
830 if ( $parent_description ) {
831 $parent_context .= ' - ' . $parent_description;
832 }
833 $context_parts[] = $parent_context;
834 }
835
836 // Add current forum/category context
837 if ( $forum_title || $forum_description ) {
838 $current_context = 'Category: ' . ( $forum_title ?: 'General' );
839 if ( $forum_description ) {
840 $current_context .= ' - ' . $forum_description;
841 }
842 $context_parts[] = $current_context;
843 }
844
845 $forum_description = implode( '. ', $context_parts );
846
847 $request_data = [
848 'content_type' => $context['content_type'],
849 'title' => $context['title'] ?? '',
850 'body' => $context['body'] ?? '',
851 'quality' => $this->get_spam_quality(),
852 'forum' => [
853 'id' => (int) ( $forum['forumid'] ?? 0 ),
854 'title' => $forum['title'] ?? '',
855 'description' => $forum_description,
856 'slug' => $forum['slug'] ?? '',
857 ],
858 'user' => [
859 'userid' => (int) $context['userid'],
860 'display_name' => $context['user_info']['display_name'] ?? 'User',
861 'post_count' => $user_post_count,
862 'registration_days' => $this->get_user_registration_days( $context['userid'] ),
863 'is_banned' => (bool) ( $context['user_info']['status'] === 'banned' ),
864 'usergroup_id' => (int) ( $context['user_info']['groupid'] ?? 0 ),
865 ],
866 ];
867
868 // Determine which endpoint to use
869 $use_unified = $toxicity_enabled || $compliance_enabled;
870
871 if ( $use_unified ) {
872 // Use unified /moderation/analyze endpoint
873 $request_data['spam'] = [
874 'enabled' => $spam_enabled,
875 ];
876
877 $request_data['toxicity'] = [
878 'enabled' => $toxicity_enabled,
879 'sensitivity' => $this->get_toxicity_sensitivity(),
880 ];
881
882 // Build compliance data with timestamps for cache validation
883 // Note: sources_modified must be an object (not array) for API validation
884 $sources_modified = $compliance_enabled ? $this->get_compliance_sources_modified() : null;
885 $request_data['compliance'] = [
886 'enabled' => $compliance_enabled,
887 'sources_modified' => $sources_modified,
888 ];
889
890 // Add context settings for spam
891 if ( $spam_enabled ) {
892 // Forum context only works in cloud mode (local mode has no S3 Vectors index)
893 $request_data['use_forum_context'] = $this->use_spam_context() && ! WPF()->vector_storage->is_local_mode();
894 $request_data['min_indexed_topics'] = $this->get_setting( 'spam', 'min_indexed', 100 );
895 $request_data['board_id'] = $this->board_id;
896 }
897
898 $endpoint = '/moderation/analyze';
899 } else {
900 // Use spam-only endpoint (more efficient)
901 // Forum context only works in cloud mode (local mode has no S3 Vectors index)
902 $request_data['use_forum_context'] = $this->use_spam_context() && ! WPF()->vector_storage->is_local_mode();
903 $request_data['min_indexed_topics'] = $this->get_setting( 'spam', 'min_indexed', 100 );
904 $request_data['board_id'] = $this->board_id;
905
906 $endpoint = '/moderation/spam/detect';
907 }
908
909 // Make API request
910 \wpforo_ai_log( 'debug', "Calling endpoint: $endpoint", 'Moderation' );
911 $response = $ai_client->api_post( $endpoint, $request_data, 30 );
912
913 \wpforo_ai_log( 'debug', 'API response: ' . ( is_wp_error( $response ) ? 'WP_Error: ' . $response->get_error_message() : wp_json_encode( $response ) ), 'Moderation' );
914
915 // Check for errors
916 if ( is_wp_error( $response ) ) {
917 \wpforo_ai_log( 'error', 'API error: ' . $response->get_error_message(), 'Moderation' );
918 return null;
919 }
920
921 // Process response
922 if ( empty( $response['success'] ) ) {
923 \wpforo_ai_log( 'error', 'API returned unsuccessful response', 'Moderation' );
924 return null;
925 }
926
927 // Parse response based on endpoint used
928 if ( $use_unified ) {
929 return $this->parse_unified_response( $response, $spam_enabled, $toxicity_enabled, $compliance_enabled );
930 } else {
931 return $this->parse_spam_response( $response );
932 }
933 }
934
935 /**
936 * Parse spam-only endpoint response
937 *
938 * @param array $response API response
939 * @return array Parsed result
940 */
941 protected function parse_spam_response( $response ) {
942 return [
943 'type' => 'spam',
944 'spam_score' => (int) ( $response['spam_score'] ?? 0 ),
945 'is_spam' => (bool) ( $response['is_spam'] ?? false ),
946 'confidence' => (float) ( $response['confidence'] ?? 0.0 ),
947 'indicators' => $response['indicators'] ?? [],
948 'analysis_summary' => $response['analysis_summary'] ?? '',
949 'credits_used' => (int) ( $response['credits_used'] ?? 0 ),
950 'context_used' => (bool) ( $response['context_used'] ?? false ),
951 ];
952 }
953
954 /**
955 * Parse unified endpoint response
956 *
957 * @param array $response API response
958 * @param bool $spam_enabled Spam detection enabled
959 * @param bool $toxicity_enabled Toxicity detection enabled
960 * @param bool $compliance_enabled Compliance detection enabled
961 * @return array Parsed result
962 */
963 protected function parse_unified_response( $response, $spam_enabled, $toxicity_enabled, $compliance_enabled ) {
964 $result = [
965 'type' => 'unified',
966 'credits_used' => (int) ( $response['credits_used'] ?? 0 ),
967 'spam' => null,
968 'toxicity' => null,
969 'compliance' => null,
970 ];
971
972 // Parse spam results
973 if ( $spam_enabled && isset( $response['spam'] ) ) {
974 $spam = $response['spam'];
975 $result['spam'] = [
976 'score' => (int) ( $spam['score'] ?? 0 ),
977 'is_spam' => (bool) ( $spam['is_spam'] ?? false ),
978 'confidence' => (float) ( $spam['confidence'] ?? 0.0 ),
979 'indicators' => $spam['indicators'] ?? [],
980 'summary' => $spam['summary'] ?? '',
981 ];
982 // For backwards compatibility, set main fields
983 $result['spam_score'] = $result['spam']['score'];
984 $result['is_spam'] = $result['spam']['is_spam'];
985 $result['confidence'] = $result['spam']['confidence'];
986 $result['indicators'] = $result['spam']['indicators'];
987 $result['analysis_summary'] = $result['spam']['summary'];
988 }
989
990 // Parse toxicity results
991 if ( $toxicity_enabled && isset( $response['toxicity'] ) ) {
992 $toxicity = $response['toxicity'];
993 $result['toxicity'] = [
994 'score' => (int) ( $toxicity['score'] ?? 0 ),
995 'is_toxic' => (bool) ( $toxicity['is_toxic'] ?? false ),
996 'confidence' => (float) ( $toxicity['confidence'] ?? 0.0 ),
997 'categories' => $toxicity['categories'] ?? [],
998 'summary' => $toxicity['summary'] ?? '',
999 ];
1000 }
1001
1002 // Parse compliance results
1003 if ( $compliance_enabled && isset( $response['compliance'] ) ) {
1004 $compliance = $response['compliance'];
1005 $result['compliance'] = [
1006 'score' => (int) ( $compliance['score'] ?? 0 ),
1007 'is_compliant' => (bool) ( $compliance['is_compliant'] ?? true ),
1008 'confidence' => (float) ( $compliance['confidence'] ?? 0.0 ),
1009 'violations' => $compliance['violations'] ?? [],
1010 'summary' => $compliance['summary'] ?? '',
1011 ];
1012 }
1013
1014 // Parse overall results (contains action and primary_reason)
1015 if ( isset( $response['overall'] ) ) {
1016 $overall = $response['overall'];
1017 $result['overall'] = [
1018 'action' => $overall['action'] ?? 'review',
1019 'primary_reason' => $overall['primary_reason'] ?? 'none',
1020 'summary' => $overall['summary'] ?? '',
1021 ];
1022 }
1023
1024 return $result;
1025 }
1026
1027 /**
1028 * Get toxicity detection sensitivity setting
1029 *
1030 * @return string Sensitivity level (low, medium, high)
1031 */
1032 public function get_toxicity_sensitivity() {
1033 return wpforo_setting( 'ai', 'moderation_toxicity_sensitivity' ) ?? 'medium';
1034 }
1035
1036 /**
1037 * Get toxicity action setting
1038 *
1039 * @return string Action (none, unapprove, unapprove_ban)
1040 */
1041 public function get_toxicity_action() {
1042 return wpforo_setting( 'ai', 'moderation_toxicity_action' ) ?? 'unapprove';
1043 }
1044
1045 /**
1046 * Get compliance action setting
1047 *
1048 * @return string Action (none, unapprove, unapprove_ban)
1049 */
1050 public function get_compliance_action() {
1051 return $this->get_setting( 'compliance', 'action', 'unapprove' );
1052 }
1053
1054 /**
1055 * Get all compliance content sources with their content and timestamps
1056 *
1057 * Gathers content from:
1058 * - Built-in forum privacy policy (if enabled)
1059 * - Built-in forum rules (if enabled)
1060 * - Custom policy page (if selected)
1061 * - Custom rules page (if selected)
1062 *
1063 * @return array Array of sources with type, content, and modified timestamp
1064 */
1065 public function get_compliance_sources() {
1066 $sources = [];
1067 $legal = WPF()->settings->legal;
1068
1069 // Built-in forum privacy policy
1070 if ( ! empty( $legal['checkbox_forum_privacy'] ) && ! empty( $legal['forum_privacy_text'] ) ) {
1071 $sources[] = [
1072 'type' => 'builtin_policy',
1073 'content' => wp_strip_all_tags( $legal['forum_privacy_text'] ),
1074 'modified' => $this->get_option_modified_time( 'wpforo_legal' ),
1075 ];
1076 }
1077
1078 // Built-in forum rules
1079 if ( ! empty( $legal['rules_checkbox'] ) && ! empty( $legal['rules_text'] ) ) {
1080 $sources[] = [
1081 'type' => 'builtin_rules',
1082 'content' => wp_strip_all_tags( $legal['rules_text'] ),
1083 'modified' => $this->get_option_modified_time( 'wpforo_legal' ),
1084 ];
1085 }
1086
1087 // Custom policy page
1088 $custom_policy_id = $this->get_setting( 'compliance', 'custom_policy_page', 0 );
1089 if ( $custom_policy_id ) {
1090 $page = get_post( $custom_policy_id );
1091 if ( $page && $page->post_status === 'publish' ) {
1092 $sources[] = [
1093 'type' => 'custom_policy',
1094 'content' => wp_strip_all_tags( $page->post_content ),
1095 'modified' => strtotime( $page->post_modified_gmt ),
1096 ];
1097 }
1098 }
1099
1100 // Custom rules page
1101 $custom_rules_id = $this->get_setting( 'compliance', 'custom_rules_page', 0 );
1102 if ( $custom_rules_id ) {
1103 $page = get_post( $custom_rules_id );
1104 if ( $page && $page->post_status === 'publish' ) {
1105 $sources[] = [
1106 'type' => 'custom_rules',
1107 'content' => wp_strip_all_tags( $page->post_content ),
1108 'modified' => strtotime( $page->post_modified_gmt ),
1109 ];
1110 }
1111 }
1112
1113 return $sources;
1114 }
1115
1116 /**
1117 * Get just the modification timestamps for compliance sources
1118 *
1119 * Used for checking if cached rules are still valid.
1120 *
1121 * @return array Associative array of source type => timestamp (or null if not configured)
1122 */
1123 public function get_compliance_sources_modified() {
1124 $legal = WPF()->settings->legal;
1125 $modified = [];
1126
1127 // Built-in policy
1128 $modified['builtin_policy'] = ( ! empty( $legal['checkbox_forum_privacy'] ) && ! empty( $legal['forum_privacy_text'] ) )
1129 ? $this->get_option_modified_time( 'wpforo_legal' )
1130 : null;
1131
1132 // Built-in rules
1133 $modified['builtin_rules'] = ( ! empty( $legal['rules_checkbox'] ) && ! empty( $legal['rules_text'] ) )
1134 ? $this->get_option_modified_time( 'wpforo_legal' )
1135 : null;
1136
1137 // Custom policy page
1138 $custom_policy_id = $this->get_setting( 'compliance', 'custom_policy_page', 0 );
1139 if ( $custom_policy_id ) {
1140 $page = get_post( $custom_policy_id );
1141 $modified['custom_policy'] = ( $page && $page->post_status === 'publish' )
1142 ? strtotime( $page->post_modified_gmt )
1143 : null;
1144 } else {
1145 $modified['custom_policy'] = null;
1146 }
1147
1148 // Custom rules page
1149 $custom_rules_id = $this->get_setting( 'compliance', 'custom_rules_page', 0 );
1150 if ( $custom_rules_id ) {
1151 $page = get_post( $custom_rules_id );
1152 $modified['custom_rules'] = ( $page && $page->post_status === 'publish' )
1153 ? strtotime( $page->post_modified_gmt )
1154 : null;
1155 } else {
1156 $modified['custom_rules'] = null;
1157 }
1158
1159 return $modified;
1160 }
1161
1162 /**
1163 * Get the last modified time for a WordPress option
1164 *
1165 * Since options don't have a modified timestamp, we use a custom option
1166 * that's updated when settings are saved.
1167 *
1168 * @param string $option_name Option name
1169 * @return int Unix timestamp or 0 if not tracked
1170 */
1171 protected function get_option_modified_time( $option_name ) {
1172 // We store a timestamp when legal settings are saved
1173 $modified_key = $option_name . '_modified';
1174 $modified = get_option( $modified_key, 0 );
1175
1176 // If not tracked, use a fallback (settings init time or current time)
1177 if ( ! $modified ) {
1178 // Store current time as initial timestamp
1179 $modified = time();
1180 update_option( $modified_key, $modified, false );
1181 }
1182
1183 return (int) $modified;
1184 }
1185
1186 /**
1187 * Check if compliance sources have content
1188 *
1189 * @return bool True if at least one compliance source is configured
1190 */
1191 public function has_compliance_sources() {
1192 $sources = $this->get_compliance_sources();
1193 return ! empty( $sources );
1194 }
1195
1196 /**
1197 * Sync compliance rules with the backend
1198 *
1199 * Sends all policy/rules content to the backend for rule extraction.
1200 * The backend uses AI to extract keywords and patterns.
1201 *
1202 * @return array|WP_Error Sync result or error
1203 */
1204 public function sync_compliance_rules() {
1205 $ai_client = $this->get_ai_client();
1206 if ( ! $ai_client || ! $ai_client->is_service_available() ) {
1207 return new \WP_Error( 'not_connected', wpforo_phrase( 'AI service not available', false ) );
1208 }
1209
1210 $sources = $this->get_compliance_sources();
1211 if ( empty( $sources ) ) {
1212 return new \WP_Error( 'no_sources', wpforo_phrase( 'No policy or rules content configured', false ) );
1213 }
1214
1215 // Send to backend for rule extraction
1216 $response = $ai_client->api_post( '/moderation/compliance/sync', [
1217 'sources' => $sources,
1218 ], 60 ); // Longer timeout for AI extraction
1219
1220 if ( is_wp_error( $response ) ) {
1221 return $response;
1222 }
1223
1224 if ( empty( $response['success'] ) ) {
1225 return new \WP_Error(
1226 'sync_failed',
1227 $response['error'] ?? wpforo_phrase( 'Failed to sync compliance rules', false )
1228 );
1229 }
1230
1231 // Store sync timestamp locally
1232 update_option( 'wpforo_compliance_last_synced', time(), false );
1233 update_option( 'wpforo_compliance_sources_hash', $response['content_hash'] ?? '', false );
1234
1235 return [
1236 'success' => true,
1237 'content_hash' => $response['content_hash'] ?? '',
1238 'synced_at' => time(),
1239 'rules_count' => $response['rules_count'] ?? 0,
1240 ];
1241 }
1242
1243 /**
1244 * Get user registration days
1245 *
1246 * @param int $userid User ID
1247 * @return int Days since registration
1248 */
1249 protected function get_user_registration_days( $userid ) {
1250 if ( ! $userid ) {
1251 return 0;
1252 }
1253
1254 $user = get_userdata( $userid );
1255 if ( ! $user || empty( $user->user_registered ) ) {
1256 return 0;
1257 }
1258
1259 $registered = strtotime( $user->user_registered );
1260 $now = time();
1261 $days = floor( ( $now - $registered ) / DAY_IN_SECONDS );
1262
1263 return max( 0, (int) $days );
1264 }
1265
1266 // =========================================================================
1267 // CONTENT FILTERS (Pre-save)
1268 // =========================================================================
1269
1270 /**
1271 * Check if user has any unapproved posts
1272 *
1273 * If user has unapproved posts, their new content should also be unapproved
1274 * without spending credits on AI spam detection. This prevents spam users
1275 * from flooding the system while waiting for moderation.
1276 *
1277 * @param int $userid User ID
1278 * @return bool True if user has unapproved posts
1279 */
1280 protected function user_has_unapproved_posts( $userid ) {
1281 if ( ! $userid ) {
1282 return false; // Guests don't have post history
1283 }
1284
1285 // Use wpForo's moderation class if available
1286 if ( isset( WPF()->moderation ) && method_exists( WPF()->moderation, 'has_unapproved' ) ) {
1287 return WPF()->moderation->has_unapproved( $userid );
1288 }
1289
1290 // Fallback: direct database check
1291 global $wpdb;
1292 $has_unapproved = WPF()->db->get_var(
1293 WPF()->db->prepare(
1294 "SELECT postid FROM " . WPF()->tables->posts . " WHERE userid = %d AND status = 1 LIMIT 1",
1295 $userid
1296 )
1297 );
1298
1299 return ! empty( $has_unapproved );
1300 }
1301
1302 /**
1303 * Filter topic data on creation
1304 *
1305 * Called before topic is saved to database.
1306 * Can modify content, set status, or block creation.
1307 *
1308 * Note: This filter runs at priority 25, AFTER wpForo's built-in antispam features.
1309 * If the topic is already unapproved (status=1), we skip AI processing to save resources.
1310 *
1311 * @param array $args Topic data
1312 * @param array $forum Forum data
1313 * @return array Modified topic data (or empty to block)
1314 */
1315 public function filter_topic_on_create( $args, $forum ) {
1316 if ( ! $this->is_enabled() || empty( $args ) ) {
1317 return $args;
1318 }
1319
1320 // Skip AI moderation for AI-generated content (created by AI Tasks)
1321 if ( ! empty( $args['is_ai_generated'] ) ) {
1322 return $args;
1323 }
1324
1325 // Skip AI moderation if content is already unapproved by wpForo built-in antispam
1326 // This saves credits and resources - no need to double-check already flagged content
1327 if ( isset( $args['status'] ) && (int) $args['status'] === 1 ) {
1328 // If unapproved due to flood protection, log the specific reason
1329 if ( ! empty( $args['_flood_reason'] ) ) {
1330 $userid = $args['userid'] ?? WPF()->current_userid;
1331 $flood_reason = $args['_flood_reason'];
1332 $analysis_summary = $this->get_flood_moderation_message( $flood_reason );
1333
1334 $log_data = [
1335 'content_type' => self::CONTENT_TOPIC,
1336 'content_id' => 0, // Not saved yet
1337 'topicid' => 0,
1338 'forumid' => $forum['forumid'] ?? 0,
1339 'userid' => $userid,
1340 'moderation_type' => 'flood',
1341 'score' => 100,
1342 'is_flagged' => 1,
1343 'confidence' => 1.0,
1344 'action_taken' => 'unapprove',
1345 'action_reason' => 'flood_' . $flood_reason,
1346 'analysis_summary' => $analysis_summary,
1347 'quality_tier' => 'rule_based',
1348 'credits_used' => 0,
1349 'content_preview' => isset( $args['title'] ) ? wp_trim_words( $args['title'], 20 ) : null,
1350 ];
1351 $this->save_moderation_log( $log_data );
1352
1353 // Also log to AI Logs
1354 $this->log_flood_to_ai_logs( 'topic', $userid, $flood_reason, $analysis_summary, $log_data );
1355 }
1356 return $args;
1357 }
1358
1359 // Skip AI moderation for users exempt from spam detection
1360 // This includes users with "Dashboard - Moderate Topics & Posts" (aum) permission
1361 $userid = $args['userid'] ?? WPF()->current_userid;
1362 if ( $this->is_user_spam_exempt( $userid ) ) {
1363 return $args;
1364 }
1365
1366 // If user has ANY unapproved posts, auto-unapprove new content without AI check
1367 // This saves credits and prevents spam flooding while waiting for moderation
1368 if ( $userid && $this->user_has_unapproved_posts( $userid ) ) {
1369 $args['status'] = 1;
1370
1371 // Check if user should be auto-banned based on unapproved posts count
1372 $unapproved_count = $this->count_user_unapproved_posts( $userid );
1373 $autoban_threshold = $this->get_spam_autoban_unapproved_threshold();
1374 $should_ban = $autoban_threshold > 0 && ( $unapproved_count + 1 ) >= $autoban_threshold;
1375 $action_taken = $should_ban ? 'unapprove_ban' : 'unapprove';
1376 $action_reason = $should_ban ? 'autoban_unapproved_threshold' : 'user_has_unapproved_posts';
1377 $analysis_summary = $should_ban
1378 ? sprintf(
1379 wpforo_phrase( 'User auto-banned: reached %d unapproved posts (threshold: %d). Content auto-unapproved.', false ),
1380 $unapproved_count + 1,
1381 $autoban_threshold
1382 )
1383 : wpforo_phrase( 'Content auto-unapproved because user has existing unapproved posts awaiting moderation.', false );
1384
1385 // Log this decision to the moderation table
1386 $this->save_moderation_log( [
1387 'content_type' => self::CONTENT_TOPIC,
1388 'content_id' => 0, // Not saved yet
1389 'topicid' => 0,
1390 'forumid' => $forum['forumid'] ?? 0,
1391 'userid' => $userid,
1392 'moderation_type' => 'spam',
1393 'score' => 100,
1394 'is_flagged' => 1,
1395 'confidence' => 1.0,
1396 'action_taken' => $action_taken,
1397 'action_reason' => $action_reason,
1398 'analysis_summary' => $analysis_summary,
1399 'quality_tier' => 'rule_based',
1400 'credits_used' => 0,
1401 'content_preview' => isset( $args['title'] ) ? wp_trim_words( $args['title'], 20 ) : null,
1402 ] );
1403
1404 // Ban user if threshold reached
1405 if ( $should_ban ) {
1406 $this->ban_user( $userid, $analysis_summary );
1407 }
1408
1409 return $args;
1410 }
1411
1412 return $this->analyze_content( $args, self::CONTENT_TOPIC, self::EVENT_CREATE, [
1413 'forum' => $forum,
1414 ] );
1415 }
1416
1417 /**
1418 * Filter topic data on edit
1419 *
1420 * Called before topic is updated in database.
1421 *
1422 * Note: This filter runs at priority 25, AFTER wpForo's built-in antispam features.
1423 * If the topic is already unapproved (status=1), we skip AI processing to save resources.
1424 *
1425 * @param array $args Topic data
1426 * @param array $forum Forum data
1427 * @return array Modified topic data
1428 */
1429 public function filter_topic_on_edit( $args, $forum ) {
1430 if ( ! $this->is_enabled() || empty( $args ) ) {
1431 return $args;
1432 }
1433
1434 // Skip AI moderation if content is already unapproved by wpForo built-in antispam
1435 if ( isset( $args['status'] ) && (int) $args['status'] === 1 ) {
1436 return $args;
1437 }
1438
1439 // Skip AI moderation for users exempt from spam detection
1440 // This includes users with "Dashboard - Moderate Topics & Posts" (aum) permission
1441 $userid = $args['userid'] ?? WPF()->current_userid;
1442 if ( $this->is_user_spam_exempt( $userid ) ) {
1443 return $args;
1444 }
1445
1446 return $this->analyze_content( $args, self::CONTENT_TOPIC, self::EVENT_EDIT, [
1447 'forum' => $forum,
1448 ] );
1449 }
1450
1451 /**
1452 * Filter post data on creation
1453 *
1454 * Called before post is saved to database.
1455 *
1456 * Note: This filter runs at priority 25, AFTER wpForo's built-in antispam features.
1457 * If the post is already unapproved (status=1), we skip AI processing to save resources.
1458 *
1459 * @param array $post Post data
1460 * @return array Modified post data (or empty to block)
1461 */
1462 public function filter_post_on_create( $post ) {
1463 if ( ! $this->is_enabled() || empty( $post ) ) {
1464 return $post;
1465 }
1466
1467 // Skip AI moderation for AI-generated content (created by AI Tasks)
1468 if ( ! empty( $post['is_ai_generated'] ) ) {
1469 return $post;
1470 }
1471
1472 // Skip AI moderation if content is already unapproved by wpForo built-in antispam
1473 // This saves credits and resources - no need to double-check already flagged content
1474 if ( isset( $post['status'] ) && (int) $post['status'] === 1 ) {
1475 // If unapproved due to flood protection, log the specific reason
1476 if ( ! empty( $post['_flood_reason'] ) ) {
1477 $userid = $post['userid'] ?? WPF()->current_userid;
1478 $flood_reason = $post['_flood_reason'];
1479 $analysis_summary = $this->get_flood_moderation_message( $flood_reason );
1480
1481 $log_data = [
1482 'content_type' => self::CONTENT_POST,
1483 'content_id' => 0, // Not saved yet
1484 'topicid' => $post['topicid'] ?? 0,
1485 'forumid' => $post['forumid'] ?? 0,
1486 'userid' => $userid,
1487 'moderation_type' => 'flood',
1488 'score' => 100,
1489 'is_flagged' => 1,
1490 'confidence' => 1.0,
1491 'action_taken' => 'unapprove',
1492 'action_reason' => 'flood_' . $flood_reason,
1493 'analysis_summary' => $analysis_summary,
1494 'quality_tier' => 'rule_based',
1495 'credits_used' => 0,
1496 'content_preview' => isset( $post['body'] ) ? wp_trim_words( wp_strip_all_tags( $post['body'] ), 20 ) : null,
1497 ];
1498 $this->save_moderation_log( $log_data );
1499
1500 // Also log to AI Logs
1501 $this->log_flood_to_ai_logs( 'post', $userid, $flood_reason, $analysis_summary, $log_data );
1502 }
1503 return $post;
1504 }
1505
1506 // Skip AI moderation for users exempt from spam detection
1507 // This includes users with "Dashboard - Moderate Topics & Posts" (aum) permission
1508 $userid = $post['userid'] ?? WPF()->current_userid;
1509 if ( $this->is_user_spam_exempt( $userid ) ) {
1510 return $post;
1511 }
1512
1513 // If user has ANY unapproved posts, auto-unapprove new content without AI check
1514 // This saves credits and prevents spam flooding while waiting for moderation
1515 if ( $userid && $this->user_has_unapproved_posts( $userid ) ) {
1516 $post['status'] = 1;
1517
1518 // Check if user should be auto-banned based on unapproved posts count
1519 $unapproved_count = $this->count_user_unapproved_posts( $userid );
1520 $autoban_threshold = $this->get_spam_autoban_unapproved_threshold();
1521 $should_ban = $autoban_threshold > 0 && ( $unapproved_count + 1 ) >= $autoban_threshold;
1522 $action_taken = $should_ban ? 'unapprove_ban' : 'unapprove';
1523 $action_reason = $should_ban ? 'autoban_unapproved_threshold' : 'user_has_unapproved_posts';
1524 $analysis_summary = $should_ban
1525 ? sprintf(
1526 wpforo_phrase( 'User auto-banned: reached %d unapproved posts (threshold: %d). Content auto-unapproved.', false ),
1527 $unapproved_count + 1,
1528 $autoban_threshold
1529 )
1530 : wpforo_phrase( 'Content auto-unapproved because user has existing unapproved posts awaiting moderation.', false );
1531
1532 // Log this decision to the moderation table
1533 $this->save_moderation_log( [
1534 'content_type' => self::CONTENT_POST,
1535 'content_id' => 0, // Not saved yet
1536 'topicid' => $post['topicid'] ?? 0,
1537 'forumid' => $post['forumid'] ?? 0,
1538 'userid' => $userid,
1539 'moderation_type' => 'spam',
1540 'score' => 100,
1541 'is_flagged' => 1,
1542 'confidence' => 1.0,
1543 'action_taken' => $action_taken,
1544 'action_reason' => $action_reason,
1545 'analysis_summary' => $analysis_summary,
1546 'quality_tier' => 'rule_based',
1547 'credits_used' => 0,
1548 'content_preview' => isset( $post['body'] ) ? wp_trim_words( wp_strip_all_tags( $post['body'] ), 20 ) : null,
1549 ] );
1550
1551 // Ban user if threshold reached
1552 if ( $should_ban ) {
1553 $this->ban_user( $userid, $analysis_summary );
1554 }
1555
1556 return $post;
1557 }
1558
1559 // Get forum context for proper logging
1560 $forum_context = [];
1561 if ( ! empty( $post['forumid'] ) ) {
1562 $forum_context['forum'] = wpforo_forum( $post['forumid'] );
1563 }
1564
1565 return $this->analyze_content( $post, self::CONTENT_POST, self::EVENT_CREATE, $forum_context );
1566 }
1567
1568 /**
1569 * Filter post data on edit
1570 *
1571 * Called before post is updated in database.
1572 *
1573 * Note: This filter runs at priority 25, AFTER wpForo's built-in antispam features.
1574 * If the post is already unapproved (status=1), we skip AI processing to save resources.
1575 *
1576 * @param array $args Post data
1577 * @return array Modified post data
1578 */
1579 public function filter_post_on_edit( $args ) {
1580 if ( ! $this->is_enabled() || empty( $args ) ) {
1581 return $args;
1582 }
1583
1584 // Skip AI moderation if content is already unapproved by wpForo built-in antispam
1585 if ( isset( $args['status'] ) && (int) $args['status'] === 1 ) {
1586 return $args;
1587 }
1588
1589 // Skip AI moderation for users exempt from spam detection
1590 // This includes users with "Front - Can pass moderation" (aup) permission
1591 $userid = $args['userid'] ?? WPF()->current_userid;
1592 if ( $this->is_user_spam_exempt( $userid ) ) {
1593 return $args;
1594 }
1595
1596 // Get forum context for proper logging
1597 $forum_context = [];
1598 if ( ! empty( $args['forumid'] ) ) {
1599 $forum_context['forum'] = wpforo_forum( $args['forumid'] );
1600 }
1601
1602 return $this->analyze_content( $args, self::CONTENT_POST, self::EVENT_EDIT, $forum_context );
1603 }
1604
1605 // =========================================================================
1606 // CONTENT ANALYSIS (Core Logic)
1607 // =========================================================================
1608
1609 /**
1610 * Analyze content through registered handlers
1611 *
1612 * This is the main entry point for content analysis.
1613 * Runs content through all registered handlers and applies moderation decisions.
1614 *
1615 * @param array $data Content data (topic or post array)
1616 * @param string $content_type Content type (topic or post)
1617 * @param string $event_type Event type (create, edit, approve)
1618 * @param array $context Additional context (forum, etc.)
1619 * @return array Modified content data
1620 */
1621 protected function analyze_content( $data, $content_type, $event_type, $context = [] ) {
1622 // Build analysis context
1623 $analysis_context = $this->build_analysis_context( $data, $content_type, $event_type, $context );
1624
1625 // Run through all registered handlers
1626 $results = [];
1627 foreach ( $this->handlers as $id => $handler ) {
1628 $result = call_user_func( $handler['callback'], $analysis_context );
1629 if ( ! empty( $result ) ) {
1630 $results[ $id ] = $result;
1631 }
1632 }
1633
1634 // Allow external filtering of analysis results
1635 $results = apply_filters( 'wpforo_ai_moderation_results', $results, $analysis_context );
1636
1637 // Make moderation decision based on results
1638 $decision = $this->make_decision( $results, $analysis_context );
1639
1640 // Execute decision actions
1641 $data = $this->execute_decision( $data, $decision, $analysis_context );
1642
1643 // Log the moderation action
1644 $this->log_moderation( $analysis_context, $results, $decision );
1645
1646 return $data;
1647 }
1648
1649 /**
1650 * Build analysis context for handlers
1651 *
1652 * @param array $data Content data
1653 * @param string $content_type Content type
1654 * @param string $event_type Event type
1655 * @param array $context Additional context
1656 * @return array Analysis context
1657 */
1658 protected function build_analysis_context( $data, $content_type, $event_type, $context = [] ) {
1659 $userid = $data['userid'] ?? get_current_user_id();
1660
1661 // Get user info and trust level
1662 $user_info = $this->get_user_moderation_info( $userid );
1663
1664 return [
1665 'content_type' => $content_type,
1666 'event_type' => $event_type,
1667 'data' => $data,
1668 'title' => $data['title'] ?? '',
1669 'body' => $data['body'] ?? '',
1670 'userid' => $userid,
1671 'user_info' => $user_info,
1672 'forum' => $context['forum'] ?? null,
1673 'board_id' => $this->board_id,
1674 'settings' => $this->settings,
1675 'timestamp' => current_time( 'mysql', true ), // UTC for timezone conversion
1676 ];
1677 }
1678
1679 /**
1680 * Get user information relevant to moderation
1681 *
1682 * @param int $userid User ID
1683 * @return array User moderation info
1684 */
1685 protected function get_user_moderation_info( $userid ) {
1686 if ( ! $userid ) {
1687 return [
1688 'is_guest' => true,
1689 'is_new' => true,
1690 'is_trusted' => false,
1691 'is_moderator' => false,
1692 'post_count' => 0,
1693 'trust_level' => 0,
1694 'points' => 0,
1695 'status' => 'guest',
1696 'warnings' => 0,
1697 ];
1698 }
1699
1700 $member = WPF()->member->get_member( $userid );
1701 if ( empty( $member ) ) {
1702 return [
1703 'is_guest' => true,
1704 'is_new' => true,
1705 'is_trusted' => false,
1706 'is_moderator' => false,
1707 'is_admin' => false,
1708 'post_count' => 0,
1709 'trust_level' => 0,
1710 'points' => 0,
1711 'status' => 'unknown',
1712 'warnings' => 0,
1713 ];
1714 }
1715
1716 $post_count = (int) wpfval( $member, 'posts', 0 );
1717 $points = (float) wpfval( $member, 'points', 0 );
1718 $rating = wpfval( $member, 'rating', [] );
1719 $trust_level = (int) wpfval( $rating, 'level', 0 );
1720 $new_user_threshold = $this->get_setting( 'antispam', 'new_user_max_posts', 3 );
1721
1722 // Get display name
1723 $user = get_userdata( $userid );
1724 $display_name = $user ? $user->display_name : 'User';
1725
1726 // Get user's group IDs for permission checks (primary + secondary)
1727 $user_groupids = [];
1728 if ( ! empty( $member['groupid'] ) ) {
1729 $user_groupids[] = (int) $member['groupid'];
1730 }
1731 if ( ! empty( $member['secondary_groupids'] ) ) {
1732 $user_groupids = array_merge( $user_groupids, array_map( 'intval', (array) $member['secondary_groupids'] ) );
1733 }
1734
1735 return [
1736 'is_guest' => false,
1737 'is_new' => $post_count < $new_user_threshold,
1738 'is_trusted' => $trust_level >= 3, // Trusted Member level
1739 'is_moderator' => ! empty( $user_groupids ) && WPF()->usergroup->can( 'em', $user_groupids ), // Edit members permission
1740 'is_admin' => ! empty( $user_groupids ) && WPF()->usergroup->can( 'ms', $user_groupids ), // Manage settings permission
1741 'post_count' => $post_count,
1742 'trust_level' => $trust_level,
1743 'points' => $points,
1744 'status' => $member['status'] ?? 'active',
1745 'warnings' => $this->get_user_warning_count( $userid ),
1746 'display_name' => $display_name,
1747 'groupid' => (int) wpfval( $member, 'groupid', 0 ),
1748 'member' => $member,
1749 ];
1750 }
1751
1752 /**
1753 * Get user warning count
1754 *
1755 * @param int $userid User ID
1756 * @return int Warning count
1757 */
1758 protected function get_user_warning_count( $userid ) {
1759 // TODO: Implement warning tracking
1760 return 0;
1761 }
1762
1763 /**
1764 * Make moderation decision based on handler results
1765 *
1766 * @param array $results Handler results
1767 * @param array $analysis_context Analysis context
1768 * @return array Decision with action and reason
1769 */
1770 protected function make_decision( $results, $analysis_context ) {
1771 // Default: approve content
1772 $decision = [
1773 'action' => self::ACTION_APPROVE,
1774 'reason' => '',
1775 'primary_reason' => 'none',
1776 'confidence' => 100,
1777 'details' => [],
1778 'spam_score' => 0,
1779 'indicators' => [],
1780 'credits_used' => 0,
1781 ];
1782
1783 // Process spam detection results
1784 if ( ! empty( $results['spam'] ) ) {
1785 $spam_result = $results['spam'];
1786 $spam_score = $spam_result['spam_score'] ?? 0;
1787 $is_spam = $spam_result['is_spam'] ?? false;
1788 $confidence = $spam_result['confidence'] ?? 0.0;
1789
1790 $decision['spam_score'] = $spam_score;
1791 $decision['confidence'] = (int) ( $confidence * 100 );
1792 $decision['indicators'] = $spam_result['indicators'] ?? [];
1793 $decision['credits_used'] = $spam_result['credits_used'] ?? 0;
1794 $decision['details'] = [
1795 'type' => 'spam',
1796 'analysis_summary' => $spam_result['analysis_summary'] ?? '',
1797 'context_used' => $spam_result['context_used'] ?? false,
1798 ];
1799
1800 // Determine action based on spam score AND is_spam flag
1801 // The AI returns both a score AND a boolean is_spam judgment
1802 // We trust the is_spam flag when confidence is high enough
1803 $threshold_detected = $this->get_spam_threshold_detected(); // 90
1804 $threshold_suspected = $this->get_spam_threshold_suspected(); // 70
1805 $threshold_clean = $this->get_spam_threshold_clean(); // 50
1806
1807 // If AI explicitly says is_spam=true with decent confidence (>= 60%),
1808 // treat as suspected even if score is below threshold
1809 $ai_flag_threshold = 60; // Minimum confidence to trust is_spam flag
1810 $trust_ai_flag = $is_spam && ( $confidence * 100 ) >= $ai_flag_threshold && $spam_score > $threshold_clean;
1811
1812 if ( $spam_score >= $threshold_detected ) {
1813 // High confidence spam - use detected action
1814 $action = $this->get_spam_action_detected();
1815 $decision['reason'] = sprintf(
1816 wpforo_phrase( 'Spam detected (score: %d%%). %s', false ),
1817 $spam_score,
1818 $spam_result['analysis_summary'] ?? ''
1819 );
1820 $decision = $this->apply_spam_action( $decision, $action, 'detected' );
1821
1822 } elseif ( $spam_score >= $threshold_suspected ) {
1823 // Suspicious content - use suspected action
1824 $action = $this->get_spam_action_suspected();
1825 $decision['reason'] = sprintf(
1826 wpforo_phrase( 'Spam suspected (score: %d%%). %s', false ),
1827 $spam_score,
1828 $spam_result['analysis_summary'] ?? ''
1829 );
1830 $decision = $this->apply_spam_action( $decision, $action, 'suspected' );
1831
1832 } elseif ( $spam_score <= $threshold_clean ) {
1833 // Clean content - use clean action
1834 $action = $this->get_spam_action_clean();
1835 $decision['reason'] = sprintf(
1836 wpforo_phrase( 'Content passed spam check (score: %d%%).', false ),
1837 $spam_score
1838 );
1839 $decision = $this->apply_spam_action( $decision, $action, 'clean' );
1840
1841 } elseif ( $trust_ai_flag ) {
1842 // AI says is_spam=true with confidence, treat as suspected (override uncertain)
1843 $action = $this->get_spam_action_suspected();
1844 $decision['reason'] = sprintf(
1845 wpforo_phrase( 'AI flagged as spam (score: %d%%, confidence: %d%%). %s', false ),
1846 $spam_score,
1847 (int) ( $confidence * 100 ),
1848 $spam_result['analysis_summary'] ?? ''
1849 );
1850 $decision = $this->apply_spam_action( $decision, $action, 'suspected' );
1851
1852 } else {
1853 // Uncertain (score 41-69%) - use uncertain action setting
1854 $action = $this->get_spam_action_uncertain();
1855 $decision['reason'] = sprintf(
1856 wpforo_phrase( 'Spam uncertain (score: %d%%). %s', false ),
1857 $spam_score,
1858 $spam_result['analysis_summary'] ?? ''
1859 );
1860 $decision = $this->apply_spam_action( $decision, $action, 'uncertain' );
1861 }
1862 }
1863
1864 // Process toxicity detection results (if enabled)
1865 // Toxicity can override approve decision, but not a more severe action
1866 if ( ! empty( $results['spam']['toxicity'] ) ) {
1867 $toxicity_result = $results['spam']['toxicity'];
1868 $is_toxic = $toxicity_result['is_toxic'] ?? false;
1869
1870 if ( $is_toxic ) {
1871 $toxicity_score = $toxicity_result['score'] ?? 0;
1872 $toxicity_action = $this->get_toxicity_action();
1873
1874 // Only apply toxicity action if it's more severe than current decision
1875 // or if content was approved
1876 $should_apply = ( $decision['action'] === self::ACTION_APPROVE );
1877
1878 if ( $should_apply ) {
1879 $decision['toxicity_score'] = $toxicity_score;
1880 $decision['toxicity_categories'] = $toxicity_result['categories'] ?? [];
1881 $decision['details']['toxicity'] = [
1882 'summary' => $toxicity_result['summary'] ?? '',
1883 'categories' => $toxicity_result['categories'] ?? [],
1884 ];
1885
1886 switch ( $toxicity_action ) {
1887 case 'unapprove':
1888 $decision['action'] = self::ACTION_HOLD;
1889 $decision['primary_reason'] = 'toxicity';
1890 $decision['reason'] = sprintf(
1891 wpforo_phrase( 'Toxic content detected (score: %d%%). %s', false ),
1892 $toxicity_score,
1893 $toxicity_result['summary'] ?? ''
1894 );
1895 break;
1896
1897 case 'unapprove_ban':
1898 $decision['action'] = self::ACTION_HOLD;
1899 $decision['user_action'] = self::ACTION_BAN_USER;
1900 $decision['primary_reason'] = 'toxicity';
1901 $decision['reason'] = sprintf(
1902 wpforo_phrase( 'Toxic content detected (score: %d%%). User banned. %s', false ),
1903 $toxicity_score,
1904 $toxicity_result['summary'] ?? ''
1905 );
1906 break;
1907
1908 case 'none':
1909 default:
1910 // Log but take no action
1911 $decision['reason'] .= sprintf(
1912 wpforo_phrase( ' Toxicity noted (score: %d%%).', false ),
1913 $toxicity_score
1914 );
1915 break;
1916 }
1917 } else {
1918 // Append toxicity info to reason
1919 $decision['reason'] .= sprintf(
1920 wpforo_phrase( ' Also toxic (score: %d%%).', false ),
1921 $toxicity_result['score'] ?? 0
1922 );
1923 }
1924 }
1925 }
1926
1927 // Process compliance results (if enabled)
1928 // Compliance can override approve decision, but not a more severe action
1929 if ( ! empty( $results['spam']['compliance'] ) ) {
1930 $compliance_result = $results['spam']['compliance'];
1931 $is_compliant = $compliance_result['is_compliant'] ?? true;
1932
1933 if ( ! $is_compliant ) {
1934 $compliance_score = $compliance_result['score'] ?? 0;
1935 $compliance_action = $this->get_compliance_action();
1936 $violations = $compliance_result['violations'] ?? [];
1937
1938 // Only apply compliance action if content was approved
1939 $should_apply = ( $decision['action'] === self::ACTION_APPROVE );
1940
1941 if ( $should_apply ) {
1942 $decision['compliance_score'] = $compliance_score;
1943 $decision['compliance_violations'] = $violations;
1944 $decision['details']['compliance'] = [
1945 'summary' => $compliance_result['summary'] ?? '',
1946 'violations' => $violations,
1947 ];
1948
1949 switch ( $compliance_action ) {
1950 case 'unapprove':
1951 $decision['action'] = self::ACTION_HOLD;
1952 $decision['primary_reason'] = 'compliance';
1953 $decision['reason'] = sprintf(
1954 wpforo_phrase( 'Policy violation detected (score: %d%%). %s', false ),
1955 $compliance_score,
1956 $compliance_result['summary'] ?? ''
1957 );
1958 break;
1959
1960 case 'unapprove_ban':
1961 $decision['action'] = self::ACTION_HOLD;
1962 $decision['user_action'] = self::ACTION_BAN_USER;
1963 $decision['primary_reason'] = 'compliance';
1964 $decision['reason'] = sprintf(
1965 wpforo_phrase( 'Policy violation detected (score: %d%%). User banned. %s', false ),
1966 $compliance_score,
1967 $compliance_result['summary'] ?? ''
1968 );
1969 break;
1970
1971 case 'none':
1972 default:
1973 // Log but take no action
1974 $decision['reason'] .= sprintf(
1975 wpforo_phrase( ' Policy violation noted (score: %d%%).', false ),
1976 $compliance_score
1977 );
1978 break;
1979 }
1980 } else {
1981 // Append compliance info to reason
1982 $decision['reason'] .= sprintf(
1983 wpforo_phrase( ' Also violates policy (score: %d%%).', false ),
1984 $compliance_result['score'] ?? 0
1985 );
1986 }
1987 }
1988 }
1989
1990 // Allow external decision making (can override our decision)
1991 $decision = apply_filters( 'wpforo_ai_moderation_decision', $decision, $results, $analysis_context );
1992
1993 \wpforo_ai_log( 'info', 'Final decision: action=' . $decision['action'] . ', reason=' . $decision['reason'], 'Moderation' );
1994 return $decision;
1995 }
1996
1997 /**
1998 * Apply spam action to decision
1999 *
2000 * @param array $decision Decision array
2001 * @param string $action Action setting (unapprove, unapprove_ban, delete_author, none, auto_approve)
2002 * @param string $level Detection level (detected, suspected, clean)
2003 * @return array Modified decision
2004 */
2005 protected function apply_spam_action( $decision, $action, $level ) {
2006 switch ( $action ) {
2007 case self::SPAM_ACTION_UNAPPROVE:
2008 $decision['action'] = self::ACTION_HOLD;
2009 $decision['primary_reason'] = 'spam';
2010 break;
2011
2012 case self::SPAM_ACTION_UNAPPROVE_BAN:
2013 $decision['action'] = self::ACTION_HOLD;
2014 $decision['user_action'] = self::ACTION_BAN_USER;
2015 $decision['primary_reason'] = 'spam';
2016 break;
2017
2018 case self::SPAM_ACTION_DELETE_AUTHOR:
2019 $decision['action'] = self::ACTION_DELETE;
2020 $decision['user_action'] = self::ACTION_BAN_USER;
2021 $decision['primary_reason'] = 'spam';
2022 break;
2023
2024 case self::SPAM_ACTION_AUTO_APPROVE:
2025 $decision['action'] = self::ACTION_APPROVE;
2026 break;
2027
2028 case self::SPAM_ACTION_NONE:
2029 default:
2030 // No action - keep current decision
2031 break;
2032 }
2033
2034 $decision['action_level'] = $level;
2035 return $decision;
2036 }
2037
2038 /**
2039 * Execute moderation decision
2040 *
2041 * @param array $data Content data
2042 * @param array $decision Moderation decision
2043 * @param array $analysis_context Analysis context
2044 * @return array Modified content data
2045 */
2046 protected function execute_decision( $data, $decision, $analysis_context ) {
2047 \wpforo_ai_log( 'info', 'execute_decision() - action: ' . $decision['action'], 'Moderation' );
2048 switch ( $decision['action'] ) {
2049 case self::ACTION_HOLD:
2050 \wpforo_ai_log( 'info', 'Setting status to 1 (unapproved)', 'Moderation' );
2051 // Set status to unapproved
2052 $data['status'] = 1;
2053 break;
2054
2055 case self::ACTION_REJECT:
2056 case self::ACTION_DELETE:
2057 // Return empty to block content creation
2058 return [];
2059
2060 case self::ACTION_EDIT:
2061 // Apply content modifications
2062 if ( ! empty( $decision['modifications'] ) ) {
2063 $data = array_merge( $data, $decision['modifications'] );
2064 }
2065 break;
2066
2067 case self::ACTION_APPROVE:
2068 default:
2069 // Allow content as-is
2070 break;
2071 }
2072
2073 // Apply any user-level actions
2074 if ( ! empty( $decision['user_action'] ) ) {
2075 $this->execute_user_action( $decision['user_action'], $analysis_context['userid'], $decision['reason'] );
2076 }
2077
2078 return $data;
2079 }
2080
2081 /**
2082 * Execute user-level moderation action
2083 *
2084 * @param string $action Action to take
2085 * @param int $userid User ID
2086 * @param string $reason Reason for action
2087 */
2088 protected function execute_user_action( $action, $userid, $reason = '' ) {
2089 if ( ! $userid ) {
2090 return;
2091 }
2092
2093 switch ( $action ) {
2094 case self::ACTION_WARN_USER:
2095 $this->warn_user( $userid, $reason );
2096 break;
2097
2098 case self::ACTION_BAN_USER:
2099 $this->ban_user( $userid, $reason );
2100 break;
2101
2102 case self::ACTION_SUSPEND_USER:
2103 $this->suspend_user( $userid, $reason );
2104 break;
2105 }
2106 }
2107
2108 // =========================================================================
2109 // POST-EVENT HOOKS (After save)
2110 // =========================================================================
2111
2112 /**
2113 * Called after topic is created
2114 *
2115 * @param array $topic Topic data
2116 * @param array $forum Forum data
2117 */
2118 public function on_topic_created( $topic, $forum ) {
2119 // Update moderation log with actual topic ID (was 0 during filter)
2120 $this->update_pending_moderation_log( self::CONTENT_TOPIC, $topic, $forum );
2121
2122 do_action( 'wpforo_ai_moderation_topic_created', $topic, $forum );
2123 }
2124
2125 /**
2126 * Called after topic is edited
2127 *
2128 * @param array $topic_data Full topic data
2129 * @param array $args Edit arguments
2130 * @param array $forum Forum data
2131 */
2132 public function on_topic_edited( $topic_data, $args, $forum ) {
2133 do_action( 'wpforo_ai_moderation_topic_edited', $topic_data, $args, $forum );
2134 }
2135
2136 /**
2137 * Called when topic is approved
2138 *
2139 * @param array $topic Topic data
2140 */
2141 public function on_topic_approved( $topic ) {
2142 do_action( 'wpforo_ai_moderation_topic_approved', $topic );
2143 }
2144
2145 /**
2146 * Called when topic is unapproved
2147 *
2148 * @param array $topic Topic data
2149 */
2150 public function on_topic_unapproved( $topic ) {
2151 do_action( 'wpforo_ai_moderation_topic_unapproved', $topic );
2152 }
2153
2154 /**
2155 * Called on any topic status change
2156 *
2157 * @param array $topic Topic data
2158 * @param int $status New status
2159 */
2160 public function on_topic_status_change( $topic, $status ) {
2161 do_action( 'wpforo_ai_moderation_topic_status_changed', $topic, $status );
2162 }
2163
2164 /**
2165 * Called before topic deletion
2166 *
2167 * @param array $topic Topic data
2168 */
2169 public function on_before_topic_delete( $topic ) {
2170 do_action( 'wpforo_ai_moderation_before_topic_delete', $topic );
2171 }
2172
2173 /**
2174 * Called after topic deletion
2175 *
2176 * Deletes the moderation log from local database since deleted content
2177 * no longer needs the report displayed. CloudWatch logs remain intact.
2178 *
2179 * @param array $topic Topic data
2180 */
2181 public function on_topic_deleted( $topic ) {
2182 if ( ! empty( $topic['topicid'] ) ) {
2183 $this->delete_moderation_log( self::CONTENT_TOPIC, (int) $topic['topicid'] );
2184 }
2185
2186 do_action( 'wpforo_ai_moderation_topic_deleted', $topic );
2187 }
2188
2189 /**
2190 * Called after topic is moved
2191 *
2192 * @param array $topic Topic data
2193 * @param int $forumid New forum ID
2194 */
2195 public function on_topic_moved( $topic, $forumid ) {
2196 do_action( 'wpforo_ai_moderation_topic_moved', $topic, $forumid );
2197 }
2198
2199 /**
2200 * Called after topics are merged
2201 *
2202 * @param array $target Target topic
2203 * @param array $current Source topic
2204 * @param array $postids Merged post IDs
2205 * @param bool $to_target_title Update titles
2206 * @param bool $append Append posts
2207 */
2208 public function on_topics_merged( $target, $current, $postids, $to_target_title, $append ) {
2209 do_action( 'wpforo_ai_moderation_topics_merged', $target, $current, $postids );
2210 }
2211
2212 /**
2213 * Called after post is created
2214 *
2215 * @param array $post Post data
2216 * @param array $topic Topic data
2217 * @param array $forum Forum data
2218 */
2219 public function on_post_created( $post, $topic, $forum ) {
2220 // Update moderation log with actual post ID (was 0 during filter)
2221 $this->update_pending_moderation_log( self::CONTENT_POST, $post, $forum );
2222
2223 do_action( 'wpforo_ai_moderation_post_created', $post, $topic, $forum );
2224 }
2225
2226 /**
2227 * Called after post is edited
2228 *
2229 * @param array $post Post data
2230 * @param array $topic Topic data
2231 * @param array $forum Forum data
2232 * @param array $args Edit arguments
2233 */
2234 public function on_post_edited( $post, $topic, $forum, $args ) {
2235 do_action( 'wpforo_ai_moderation_post_edited', $post, $topic, $forum, $args );
2236 }
2237
2238 /**
2239 * Called when post is unapproved
2240 *
2241 * @param array $post Post data
2242 */
2243 public function on_post_unapproved( $post ) {
2244 do_action( 'wpforo_ai_moderation_post_unapproved', $post );
2245 }
2246
2247 /**
2248 * Called on any post status change
2249 *
2250 * @param array $post Post data
2251 * @param int $status New status
2252 */
2253 public function on_post_status_change( $post, $status ) {
2254 do_action( 'wpforo_ai_moderation_post_status_changed', $post, $status );
2255 }
2256
2257 /**
2258 * Called before post deletion
2259 *
2260 * @param array $post Post data
2261 */
2262 public function on_before_post_delete( $post ) {
2263 do_action( 'wpforo_ai_moderation_before_post_delete', $post );
2264 }
2265
2266 /**
2267 * Called after post deletion
2268 *
2269 * Deletes the moderation log from local database since deleted content
2270 * no longer needs the report displayed. CloudWatch logs remain intact.
2271 *
2272 * @param array $post Post data
2273 */
2274 public function on_post_deleted( $post ) {
2275 if ( ! empty( $post['postid'] ) ) {
2276 // Check if this is the first post (topic) or a reply
2277 $is_first_post = ! empty( $post['is_first_post'] );
2278 $content_type = $is_first_post ? self::CONTENT_TOPIC : self::CONTENT_POST;
2279 $content_id = $is_first_post ? ( $post['topicid'] ?? $post['postid'] ) : $post['postid'];
2280
2281 $this->delete_moderation_log( $content_type, (int) $content_id );
2282 }
2283
2284 do_action( 'wpforo_ai_moderation_post_deleted', $post );
2285 }
2286
2287 /**
2288 * Called when user is banned
2289 *
2290 * @param int $userid User ID
2291 */
2292 public function on_user_banned( $userid ) {
2293 do_action( 'wpforo_ai_moderation_user_banned', $userid );
2294 }
2295
2296 /**
2297 * Called when user is unbanned
2298 *
2299 * @param int $userid User ID
2300 */
2301 public function on_user_unbanned( $userid ) {
2302 do_action( 'wpforo_ai_moderation_user_unbanned', $userid );
2303 }
2304
2305 /**
2306 * Called when a post is approved
2307 *
2308 * Deletes the moderation report from local database since approved content
2309 * no longer needs the report displayed. CloudWatch logs remain intact.
2310 *
2311 * @param array $post Post data
2312 */
2313 public function on_post_approved( $post ) {
2314 if ( empty( $post['postid'] ) ) {
2315 return;
2316 }
2317
2318 // Check if this is the first post (topic) or a reply
2319 $is_first_post = ! empty( $post['is_first_post'] );
2320 $content_type = $is_first_post ? self::CONTENT_TOPIC : self::CONTENT_POST;
2321 $content_id = $is_first_post ? ( $post['topicid'] ?? $post['postid'] ) : $post['postid'];
2322
2323 $this->delete_moderation_log( $content_type, (int) $content_id );
2324 }
2325
2326 /**
2327 * Delete moderation log from local database
2328 *
2329 * Removes the moderation report for approved content.
2330 * This only affects the local wpForo database - CloudWatch logs are preserved.
2331 *
2332 * @param string $content_type Content type (topic, post)
2333 * @param int $content_id Content ID
2334 * @return bool True if deleted, false otherwise
2335 */
2336 public function delete_moderation_log( $content_type, $content_id ) {
2337 global $wpdb;
2338
2339 if ( empty( $content_type ) || empty( $content_id ) ) {
2340 return false;
2341 }
2342
2343 $result = $wpdb->delete(
2344 WPF()->tables->ai_moderation,
2345 [
2346 'content_type' => $content_type,
2347 'content_id' => $content_id,
2348 ],
2349 [ '%s', '%d' ]
2350 );
2351
2352 return $result !== false;
2353 }
2354
2355 // =========================================================================
2356 // MODERATION ACTIONS
2357 // =========================================================================
2358
2359 /**
2360 * Approve a topic
2361 *
2362 * @param int $topicid Topic ID
2363 * @return bool Success
2364 */
2365 public function approve_topic( $topicid ) {
2366 return WPF()->topic->set_status( $topicid, 0 );
2367 }
2368
2369 /**
2370 * Unapprove/hold a topic
2371 *
2372 * @param int $topicid Topic ID
2373 * @return bool Success
2374 */
2375 public function hold_topic( $topicid ) {
2376 return WPF()->topic->set_status( $topicid, 1 );
2377 }
2378
2379 /**
2380 * Delete a topic
2381 *
2382 * @param int $topicid Topic ID
2383 * @param bool $check_permissions Check user permissions
2384 * @return bool Success
2385 */
2386 public function delete_topic( $topicid, $check_permissions = false ) {
2387 return WPF()->topic->delete( $topicid, true, $check_permissions );
2388 }
2389
2390 /**
2391 * Move a topic to different forum
2392 *
2393 * @param int $topicid Topic ID
2394 * @param int $forumid Target forum ID
2395 * @return bool Success
2396 */
2397 public function move_topic( $topicid, $forumid ) {
2398 return WPF()->topic->move( $topicid, $forumid );
2399 }
2400
2401 /**
2402 * Close a topic (lock)
2403 *
2404 * @param int $topicid Topic ID
2405 * @return bool Success
2406 */
2407 public function close_topic( $topicid ) {
2408 return WPF()->topic->close( $topicid );
2409 }
2410
2411 /**
2412 * Open a topic (unlock)
2413 *
2414 * @param int $topicid Topic ID
2415 * @return bool Success
2416 */
2417 public function open_topic( $topicid ) {
2418 return WPF()->topic->open( $topicid );
2419 }
2420
2421 /**
2422 * Merge topics
2423 *
2424 * @param int $target_topicid Target topic ID
2425 * @param int $source_topicid Source topic ID
2426 * @param array $postids Specific post IDs to merge (empty = all)
2427 * @param bool $update_titles Update post titles to match target
2428 * @param bool $append Append posts to end of target
2429 * @return bool Success
2430 */
2431 public function merge_topics( $target_topicid, $source_topicid, $postids = [], $update_titles = false, $append = true ) {
2432 $target = WPF()->topic->get_topic( $target_topicid );
2433 $source = WPF()->topic->get_topic( $source_topicid );
2434
2435 if ( ! $target || ! $source ) {
2436 return false;
2437 }
2438
2439 return WPF()->topic->merge( $target, $source, $postids, $update_titles, $append );
2440 }
2441
2442 /**
2443 * Approve a post
2444 *
2445 * @param int $postid Post ID
2446 * @return bool Success
2447 */
2448 public function approve_post( $postid ) {
2449 return WPF()->post->set_status( $postid, 0 );
2450 }
2451
2452 /**
2453 * Unapprove/hold a post
2454 *
2455 * @param int $postid Post ID
2456 * @return bool Success
2457 */
2458 public function hold_post( $postid ) {
2459 return WPF()->post->set_status( $postid, 1 );
2460 }
2461
2462 /**
2463 * Delete a post
2464 *
2465 * @param int $postid Post ID
2466 * @param bool $check_permissions Check user permissions
2467 * @return bool Success
2468 */
2469 public function delete_post( $postid, $check_permissions = false ) {
2470 return WPF()->post->delete( $postid, true, true, [], $check_permissions );
2471 }
2472
2473 /**
2474 * Edit content (redact PII, profanity, etc.)
2475 *
2476 * @param int $id Content ID (topic or post)
2477 * @param string $content_type Content type (topic or post)
2478 * @param string $new_body New body content
2479 * @param string $new_title New title (optional, for topics)
2480 * @return bool Success
2481 */
2482 public function edit_content( $id, $content_type, $new_body, $new_title = null ) {
2483 global $wpdb;
2484
2485 if ( $content_type === self::CONTENT_TOPIC ) {
2486 $table = WPF()->tables->topics;
2487 $id_column = 'topicid';
2488 $update_data = [ 'body' => $new_body ];
2489 if ( $new_title !== null ) {
2490 $update_data['title'] = $new_title;
2491 }
2492 } else {
2493 $table = WPF()->tables->posts;
2494 $id_column = 'postid';
2495 $update_data = [ 'body' => $new_body ];
2496 if ( $new_title !== null ) {
2497 $update_data['title'] = $new_title;
2498 }
2499 }
2500
2501 $result = $wpdb->update(
2502 $table,
2503 $update_data,
2504 [ $id_column => $id ]
2505 );
2506
2507 WPF()->ram_cache->reset( $content_type );
2508
2509 return $result !== false;
2510 }
2511
2512 /**
2513 * Warn a user
2514 *
2515 * @param int $userid User ID
2516 * @param string $reason Warning reason
2517 * @return bool Success
2518 */
2519 public function warn_user( $userid, $reason = '' ) {
2520 // TODO: Implement user warning system
2521 // This would involve:
2522 // 1. Storing warning in database
2523 // 2. Sending notification to user
2524 // 3. Incrementing warning count
2525 do_action( 'wpforo_ai_moderation_user_warned', $userid, $reason );
2526 return true;
2527 }
2528
2529 /**
2530 * Ban a user
2531 *
2532 * @param int $userid User ID
2533 * @param string $reason Ban reason
2534 * @return bool Success
2535 */
2536 public function ban_user( $userid, $reason = '' ) {
2537 // Use direct database update to bypass wpForo's "can't ban yourself" check
2538 // This is necessary because AI moderation runs in the context of the posting user
2539 global $wpdb;
2540
2541 // Get the user's profile table
2542 $profile_table = WPF()->tables->profiles;
2543
2544 // Update user status to 'banned' directly
2545 // wpForo uses the 'status' field for banning, not a separate usergroup
2546 $result = $wpdb->update(
2547 $profile_table,
2548 [ 'status' => 'banned' ],
2549 [ 'userid' => (int) $userid ],
2550 [ '%s' ],
2551 [ '%d' ]
2552 );
2553
2554 // Clear user cache to reflect the ban immediately
2555 WPF()->member->reset( $userid );
2556
2557 // Also clear general wpForo caches that may reference this user
2558 if ( function_exists( 'wpforo_clean_cache' ) ) {
2559 wpforo_clean_cache( 'user', $userid );
2560 }
2561
2562 if ( $result !== false ) {
2563 do_action( 'wpforo_ai_moderation_user_banned_by_ai', $userid, $reason );
2564
2565 // Log the ban action
2566 \wpforo_ai_log( 'info', sprintf( 'User #%d banned by AI. Reason: %s', $userid, $reason ), 'Moderation' );
2567 }
2568
2569 return $result !== false;
2570 }
2571
2572 /**
2573 * Suspend a user temporarily
2574 *
2575 * @param int $userid User ID
2576 * @param string $reason Suspension reason
2577 * @param int $duration Duration in seconds (0 = permanent)
2578 * @return bool Success
2579 */
2580 public function suspend_user( $userid, $reason = '', $duration = 0 ) {
2581 // Deactivate user (wpForo's version of suspension)
2582 $result = WPF()->member->deactivate( $userid );
2583
2584 if ( $result && $duration > 0 ) {
2585 // Schedule reactivation
2586 wp_schedule_single_event(
2587 time() + $duration,
2588 'wpforo_ai_moderation_reactivate_user',
2589 [ $userid ]
2590 );
2591 }
2592
2593 if ( $result ) {
2594 do_action( 'wpforo_ai_moderation_user_suspended', $userid, $reason, $duration );
2595 }
2596
2597 return $result;
2598 }
2599
2600 // =========================================================================
2601 // LOGGING
2602 // =========================================================================
2603
2604 /**
2605 * Log moderation action
2606 *
2607 * @param array $context Analysis context
2608 * @param array $results Handler results
2609 * @param array $decision Moderation decision
2610 */
2611 protected function log_moderation( $context, $results, $decision ) {
2612 // Only log if there was actual AI analysis
2613 if ( empty( $results ) && $decision['action'] === self::ACTION_APPROVE ) {
2614 return;
2615 }
2616
2617 // Determine action taken string
2618 $action_taken = 'none';
2619 switch ( $decision['action'] ) {
2620 case self::ACTION_HOLD:
2621 $action_taken = ! empty( $decision['user_action'] ) && $decision['user_action'] === self::ACTION_BAN_USER
2622 ? 'unapprove_ban'
2623 : 'unapprove';
2624 break;
2625 case self::ACTION_DELETE:
2626 $action_taken = 'delete_author';
2627 break;
2628 case self::ACTION_APPROVE:
2629 $action_taken = $decision['spam_score'] > 0 ? 'auto_approve' : 'approve';
2630 break;
2631 }
2632
2633 // Build log data
2634 $forum = $context['forum'] ?? [];
2635
2636 // Determine moderation type and score based on what was detected
2637 $spam_score = $decision['spam_score'] ?? 0;
2638 $toxicity_score = $decision['toxicity_score'] ?? 0;
2639 $compliance_score = $decision['compliance_score'] ?? 0;
2640
2641 // Initialize with defaults (will be overwritten below)
2642 $moderation_type = 'spam';
2643 $score = 0;
2644
2645 // Use primary_reason from API response if available (preferred method)
2646 $primary_reason = $decision['primary_reason'] ?? null;
2647 if ( $primary_reason && $primary_reason !== 'none' ) {
2648 // Map API primary_reason to moderation_type
2649 switch ( $primary_reason ) {
2650 case 'spam':
2651 $moderation_type = 'spam';
2652 $score = $spam_score;
2653 break;
2654 case 'toxicity':
2655 $moderation_type = 'toxicity';
2656 $score = $toxicity_score;
2657 break;
2658 case 'compliance':
2659 $moderation_type = 'compliance';
2660 $score = $compliance_score;
2661 break;
2662 default:
2663 // Unknown reason, fall through to score-based logic
2664 $primary_reason = null;
2665 }
2666 }
2667
2668 // Fallback: Use the highest score and appropriate type (priority: compliance > toxicity > spam)
2669 if ( ! $primary_reason || $primary_reason === 'none' ) {
2670 if ( $compliance_score > $spam_score && $compliance_score > $toxicity_score && $compliance_score > 0 ) {
2671 $moderation_type = 'compliance';
2672 $score = $compliance_score;
2673 } elseif ( $toxicity_score > $spam_score && $toxicity_score > 0 ) {
2674 $moderation_type = 'toxicity';
2675 $score = $toxicity_score;
2676 } elseif ( $spam_score > 0 ) {
2677 $moderation_type = 'spam';
2678 $score = $spam_score;
2679 } elseif ( $toxicity_score > 0 ) {
2680 $moderation_type = 'toxicity';
2681 $score = $toxicity_score;
2682 } elseif ( $compliance_score > 0 ) {
2683 $moderation_type = 'compliance';
2684 $score = $compliance_score;
2685 } else {
2686 $moderation_type = 'spam';
2687 $score = 0;
2688 }
2689 }
2690
2691 $log_data = [
2692 'content_type' => $context['content_type'],
2693 'content_id' => 0, // Not saved yet, will be updated after save
2694 'topicid' => 0, // Will be updated after save
2695 'forumid' => (int) ( $forum['forumid'] ?? 0 ),
2696 'userid' => $context['userid'],
2697 'moderation_type' => $moderation_type,
2698 'score' => $score,
2699 'is_flagged' => ( $decision['action'] !== self::ACTION_APPROVE ) ? 1 : 0,
2700 'confidence' => ( $decision['confidence'] ?? 100 ) / 100,
2701 'action_taken' => $action_taken,
2702 'action_reason' => $decision['action_level'] ?? null,
2703 'analysis_summary' => $decision['reason'] ?? null,
2704 'indicators' => ! empty( $decision['indicators'] ) ? wp_json_encode( $decision['indicators'] ) : null,
2705 'quality_tier' => $this->get_spam_quality(),
2706 'credits_used' => $decision['credits_used'] ?? 0,
2707 'content_preview' => isset( $context['title'] ) ? wp_trim_words( $context['title'], 20 ) : null,
2708 ];
2709
2710 $this->save_moderation_log( $log_data );
2711
2712 // Also log to AI Logs for visibility in AI Features > AI Logs tab
2713 $this->log_to_ai_logs( $context, $decision, $log_data );
2714
2715 do_action( 'wpforo_ai_moderation_logged', $context, $results, $decision );
2716 }
2717
2718 /**
2719 * Log moderation action to AI Logs table
2720 *
2721 * This ensures moderation actions appear in the AI Features > AI Logs tab
2722 * alongside other AI actions (search, translation, etc.)
2723 *
2724 * @param array $context Analysis context
2725 * @param array $decision Moderation decision
2726 * @param array $log_data Moderation log data
2727 */
2728 protected function log_to_ai_logs( $context, $decision, $log_data ) {
2729 if ( ! isset( WPF()->ai_logs ) || ! method_exists( WPF()->ai_logs, 'log' ) ) {
2730 return;
2731 }
2732
2733 // Determine action type based on moderation type
2734 $action_type = 'moderation';
2735 if ( $log_data['moderation_type'] === 'spam' ) {
2736 $action_type = 'spam_detection';
2737 }
2738
2739 // Status is always 'success' since the moderation completed
2740 // The action_taken and response_summary indicate if content was flagged
2741 $status = 'success';
2742
2743 // Build request summary
2744 $request_summary = sprintf(
2745 '%s %s: "%s"',
2746 ucfirst( $context['content_type'] ?? 'content' ),
2747 $context['event_type'] ?? 'submitted',
2748 wp_trim_words( $context['title'] ?? $context['body'] ?? '', 10 )
2749 );
2750
2751 // Build response summary
2752 $response_parts = [];
2753 if ( $log_data['score'] > 0 ) {
2754 $response_parts[] = sprintf( '%s score: %d%%', ucfirst( $log_data['moderation_type'] ), $log_data['score'] );
2755 }
2756 if ( $log_data['action_taken'] && $log_data['action_taken'] !== 'none' ) {
2757 $response_parts[] = sprintf( 'Action: %s', str_replace( '_', ' ', $log_data['action_taken'] ) );
2758 }
2759 if ( ! empty( $decision['reason'] ) ) {
2760 $response_parts[] = $decision['reason'];
2761 }
2762 $response_summary = implode( ' | ', $response_parts );
2763
2764 // Prepare extra data for detailed view
2765 $extra_data = [
2766 'moderation_type' => $log_data['moderation_type'],
2767 'score' => $log_data['score'],
2768 'is_flagged' => $log_data['is_flagged'],
2769 'confidence' => $log_data['confidence'],
2770 'action_taken' => $log_data['action_taken'],
2771 'action_reason' => $log_data['action_reason'],
2772 'quality_tier' => $log_data['quality_tier'],
2773 ];
2774 if ( ! empty( $decision['indicators'] ) ) {
2775 $extra_data['indicators'] = $decision['indicators'];
2776 }
2777
2778 WPF()->ai_logs->log( [
2779 'action_type' => $action_type,
2780 'userid' => $context['userid'] ?? 0,
2781 'user_type' => ( $context['userid'] ?? 0 ) > 0 ? 'user' : 'guest',
2782 'credits_used' => $log_data['credits_used'] ?? 0,
2783 'status' => $status,
2784 'content_type' => $context['content_type'] ?? null,
2785 'content_id' => $log_data['content_id'] ?? null,
2786 'forumid' => $log_data['forumid'] ?? null,
2787 'topicid' => $log_data['topicid'] ?? null,
2788 'request_summary' => $request_summary,
2789 'response_summary' => $response_summary,
2790 'duration_ms' => $log_data['detection_time_ms'] ?? 0,
2791 'extra_data' => wp_json_encode( $extra_data ),
2792 ] );
2793 }
2794
2795 /**
2796 * Log flood control action to AI Logs table
2797 *
2798 * @param string $content_type 'topic' or 'post'
2799 * @param int $userid User ID
2800 * @param string $flood_reason Flood reason code
2801 * @param string $analysis_summary Human-readable message
2802 * @param array $log_data Moderation log data
2803 */
2804 protected function log_flood_to_ai_logs( $content_type, $userid, $flood_reason, $analysis_summary, $log_data ) {
2805 if ( ! isset( WPF()->ai_logs ) || ! method_exists( WPF()->ai_logs, 'log' ) ) {
2806 return;
2807 }
2808
2809 $request_summary = sprintf(
2810 '%s submitted by user',
2811 ucfirst( $content_type )
2812 );
2813
2814 $response_summary = sprintf(
2815 'Flood protection: %s | Action: unapproved',
2816 $analysis_summary
2817 );
2818
2819 $extra_data = [
2820 'moderation_type' => 'flood',
2821 'flood_reason' => $flood_reason,
2822 'score' => 100,
2823 'is_flagged' => 1,
2824 'action_taken' => 'unapprove',
2825 'quality_tier' => 'rule_based',
2826 ];
2827
2828 WPF()->ai_logs->log( [
2829 'action_type' => 'moderation',
2830 'userid' => $userid,
2831 'user_type' => $userid > 0 ? 'user' : 'guest',
2832 'credits_used' => 0,
2833 'status' => 'success',
2834 'content_type' => $content_type,
2835 'content_id' => $log_data['content_id'] ?? null,
2836 'forumid' => $log_data['forumid'] ?? null,
2837 'topicid' => $log_data['topicid'] ?? null,
2838 'request_summary' => $request_summary,
2839 'response_summary' => $response_summary,
2840 'duration_ms' => 0,
2841 'extra_data' => wp_json_encode( $extra_data ),
2842 ] );
2843 }
2844
2845 // =========================================================================
2846 // UTILITY METHODS
2847 // =========================================================================
2848
2849 /**
2850 * Check if user is exempt from moderation
2851 *
2852 * Moderators and admins can be exempt from AI moderation.
2853 *
2854 * @param int $userid User ID
2855 * @return bool True if exempt
2856 */
2857 public function is_user_exempt( $userid ) {
2858 if ( ! $userid ) {
2859 return false;
2860 }
2861
2862 // Get user's group IDs (primary + secondary)
2863 $member = WPF()->member->get_member( $userid );
2864 $user_groupids = [];
2865 if ( ! empty( $member['groupid'] ) ) {
2866 $user_groupids[] = (int) $member['groupid'];
2867 }
2868 if ( ! empty( $member['secondary_groupids'] ) ) {
2869 $user_groupids = array_merge( $user_groupids, array_map( 'intval', (array) $member['secondary_groupids'] ) );
2870 }
2871
2872 if ( empty( $user_groupids ) ) {
2873 return false;
2874 }
2875
2876 // Admins and moderators are exempt (em permission covers both)
2877 if ( WPF()->usergroup->can( 'em', $user_groupids ) ) {
2878 return true;
2879 }
2880
2881 return false;
2882 }
2883
2884 /**
2885 * Get human-readable message for flood protection moderation
2886 *
2887 * @param string $flood_reason The flood reason code (per_minute, per_hour, ip_per_hour, etc.)
2888 * @return string Localized message explaining the flood protection action
2889 */
2890 protected function get_flood_moderation_message( $flood_reason ) {
2891 switch ( $flood_reason ) {
2892 case 'per_minute':
2893 return wpforo_phrase( 'Content auto-unapproved: Exceeded maximum posts per minute (flood protection).', false );
2894 case 'per_hour':
2895 return wpforo_phrase( 'Content auto-unapproved: Exceeded maximum posts per hour (flood protection).', false );
2896 case 'ip_per_hour':
2897 return wpforo_phrase( 'Content auto-unapproved: Exceeded maximum posts per hour from this IP address (flood protection).', false );
2898 case 'temp_ban':
2899 return wpforo_phrase( 'Content auto-unapproved: User is temporarily banned due to flood protection.', false );
2900 case 'interval':
2901 return wpforo_phrase( 'Content auto-unapproved: Posted too quickly (flood interval not met).', false );
2902 default:
2903 return wpforo_phrase( 'Content auto-unapproved: Flood protection triggered.', false );
2904 }
2905 }
2906
2907 /**
2908 * Get AI client instance
2909 *
2910 * @return \wpforo\classes\AIClient|null
2911 */
2912 protected function get_ai_client() {
2913 return WPF()->ai_client ?? null;
2914 }
2915
2916 /**
2917 * Check if AI services are available
2918 *
2919 * @return bool
2920 */
2921 public function is_ai_available() {
2922 $ai_client = $this->get_ai_client();
2923 return $ai_client && $ai_client->is_service_available();
2924 }
2925
2926 // =========================================================================
2927 // DATABASE LOGGING METHODS
2928 // =========================================================================
2929
2930 /**
2931 * Save moderation result to database
2932 *
2933 * @param array $data Moderation data
2934 * @return int|false Insert ID on success, false on failure
2935 */
2936 public function save_moderation_log( $data ) {
2937 global $wpdb;
2938
2939 $defaults = [
2940 'content_type' => '',
2941 'content_id' => 0,
2942 'topicid' => 0,
2943 'forumid' => 0,
2944 'userid' => 0,
2945 'moderation_type' => 'spam',
2946 'score' => 0,
2947 'is_flagged' => 0,
2948 'confidence' => 0.00,
2949 'action_taken' => null,
2950 'action_reason' => null,
2951 'indicators' => null,
2952 'analysis_summary' => null,
2953 'quality_tier' => 'balanced',
2954 'credits_used' => 0,
2955 'context_used' => 0,
2956 'indexed_topics_count' => 0,
2957 'detection_time_ms' => 0,
2958 'content_preview' => null,
2959 'created' => current_time( 'mysql', true ), // UTC for timezone conversion
2960 ];
2961
2962 $data = wp_parse_args( $data, $defaults );
2963
2964 // Skip saving clean moderation logs (score < 50%) by default.
2965 // Use filter 'wpforo_ai_save_clean_moderation_logs' to override (return true to save all logs).
2966 $score = (int) $data['score'];
2967 if ( $score < 50 ) {
2968 $save_clean_logs = apply_filters( 'wpforo_ai_save_clean_moderation_logs', false, $data );
2969 if ( ! $save_clean_logs ) {
2970 return false;
2971 }
2972 }
2973
2974 // Encode indicators as JSON if array
2975 if ( is_array( $data['indicators'] ) ) {
2976 $data['indicators'] = wp_json_encode( $data['indicators'] );
2977 }
2978
2979 // Truncate content preview
2980 if ( $data['content_preview'] && strlen( $data['content_preview'] ) > 500 ) {
2981 $data['content_preview'] = substr( $data['content_preview'], 0, 497 ) . '...';
2982 }
2983
2984 $result = $wpdb->insert(
2985 WPF()->tables->ai_moderation,
2986 [
2987 'content_type' => $data['content_type'],
2988 'content_id' => $data['content_id'],
2989 'topicid' => $data['topicid'],
2990 'forumid' => $data['forumid'],
2991 'userid' => $data['userid'],
2992 'moderation_type' => $data['moderation_type'],
2993 'score' => $data['score'],
2994 'is_flagged' => $data['is_flagged'],
2995 'confidence' => $data['confidence'],
2996 'action_taken' => $data['action_taken'],
2997 'action_reason' => $data['action_reason'],
2998 'indicators' => $data['indicators'],
2999 'analysis_summary' => $data['analysis_summary'],
3000 'quality_tier' => $data['quality_tier'],
3001 'credits_used' => $data['credits_used'],
3002 'context_used' => $data['context_used'],
3003 'indexed_topics_count' => $data['indexed_topics_count'],
3004 'detection_time_ms' => $data['detection_time_ms'],
3005 'content_preview' => $data['content_preview'],
3006 'created' => $data['created'],
3007 ],
3008 [ '%s', '%d', '%d', '%d', '%d', '%s', '%d', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s' ]
3009 );
3010
3011 if ( $result === false ) {
3012 return false;
3013 }
3014
3015 // Auto-schedule cleanup cron if not already scheduled
3016 $this->schedule_moderation_cleanup();
3017
3018 return $wpdb->insert_id;
3019 }
3020
3021 /**
3022 * Update pending moderation log with actual content ID
3023 *
3024 * Called after topic/post is saved to update the log entry that was
3025 * created with content_id = 0 during the pre-save filter.
3026 *
3027 * @param string $content_type Content type (topic or post)
3028 * @param array $content Topic or post data with actual ID
3029 * @param array $forum Forum data
3030 */
3031 protected function update_pending_moderation_log( $content_type, $content, $forum ) {
3032 global $wpdb;
3033
3034 // Get the actual content ID
3035 $content_id = 0;
3036 $topicid = 0;
3037
3038 if ( $content_type === self::CONTENT_TOPIC ) {
3039 $content_id = (int) ( $content['topicid'] ?? 0 );
3040 $topicid = $content_id;
3041 } else {
3042 $content_id = (int) ( $content['postid'] ?? 0 );
3043 $topicid = (int) ( $content['topicid'] ?? 0 );
3044 }
3045
3046 if ( ! $content_id ) {
3047 return; // No valid content ID
3048 }
3049
3050 $userid = (int) ( $content['userid'] ?? 0 );
3051 $forumid = (int) ( $content['forumid'] ?? $forum['forumid'] ?? 0 );
3052
3053 // Find the most recent pending log entry for this user in this forum
3054 // (content_id = 0 means it was created during pre-save filter)
3055 $log_id = $wpdb->get_var(
3056 $wpdb->prepare(
3057 "SELECT id FROM " . WPF()->tables->ai_moderation . "
3058 WHERE content_type = %s
3059 AND content_id = 0
3060 AND userid = %d
3061 AND forumid = %d
3062 ORDER BY created DESC
3063 LIMIT 1",
3064 $content_type,
3065 $userid,
3066 $forumid
3067 )
3068 );
3069
3070 if ( ! $log_id ) {
3071 return; // No pending log entry found
3072 }
3073
3074 // Update the log entry with actual content ID
3075 $wpdb->update(
3076 WPF()->tables->ai_moderation,
3077 [
3078 'content_id' => $content_id,
3079 'topicid' => $topicid,
3080 ],
3081 [ 'id' => $log_id ],
3082 [ '%d', '%d' ],
3083 [ '%d' ]
3084 );
3085 }
3086
3087 /**
3088 * Get moderation logs for content
3089 *
3090 * @param string $content_type Content type (topic or post)
3091 * @param int $content_id Content ID
3092 * @return array Moderation logs
3093 */
3094 public function get_moderation_logs( $content_type, $content_id ) {
3095 global $wpdb;
3096
3097 $results = $wpdb->get_results(
3098 $wpdb->prepare(
3099 "SELECT * FROM " . WPF()->tables->ai_moderation . "
3100 WHERE content_type = %s AND content_id = %d
3101 ORDER BY created DESC",
3102 $content_type,
3103 $content_id
3104 ),
3105 ARRAY_A
3106 );
3107
3108 // Decode indicators JSON
3109 foreach ( $results as &$row ) {
3110 if ( ! empty( $row['indicators'] ) ) {
3111 $row['indicators'] = json_decode( $row['indicators'], true );
3112 }
3113 }
3114
3115 return $results;
3116 }
3117
3118 /**
3119 * Get latest moderation log for content
3120 *
3121 * @param string $content_type Content type (topic or post)
3122 * @param int $content_id Content ID
3123 * @param string $moderation_type Moderation type (spam, toxicity, etc.)
3124 * @return array|null Moderation log or null
3125 */
3126 public function get_latest_moderation( $content_type, $content_id, $moderation_type = null ) {
3127 global $wpdb;
3128
3129 // If no specific type requested, get the latest log regardless of type
3130 if ( $moderation_type === null ) {
3131 $result = $wpdb->get_row(
3132 $wpdb->prepare(
3133 "SELECT * FROM " . WPF()->tables->ai_moderation . "
3134 WHERE content_type = %s AND content_id = %d
3135 ORDER BY created DESC
3136 LIMIT 1",
3137 $content_type,
3138 $content_id
3139 ),
3140 ARRAY_A
3141 );
3142 } else {
3143 $result = $wpdb->get_row(
3144 $wpdb->prepare(
3145 "SELECT * FROM " . WPF()->tables->ai_moderation . "
3146 WHERE content_type = %s AND content_id = %d AND moderation_type = %s
3147 ORDER BY created DESC
3148 LIMIT 1",
3149 $content_type,
3150 $content_id,
3151 $moderation_type
3152 ),
3153 ARRAY_A
3154 );
3155 }
3156
3157 if ( $result && ! empty( $result['indicators'] ) ) {
3158 $result['indicators'] = json_decode( $result['indicators'], true );
3159 }
3160
3161 return $result;
3162 }
3163
3164 /**
3165 * Get flagged content for review
3166 *
3167 * @param array $args Query arguments
3168 * @return array Flagged content
3169 */
3170 public function get_flagged_content( $args = [] ) {
3171 global $wpdb;
3172
3173 $defaults = [
3174 'moderation_type' => null,
3175 'forumid' => null,
3176 'min_score' => 50,
3177 'reviewed' => false, // false = unreviewed only
3178 'limit' => 50,
3179 'offset' => 0,
3180 'order_by' => 'score',
3181 'order' => 'DESC',
3182 ];
3183
3184 $args = wp_parse_args( $args, $defaults );
3185
3186 $where = [ 'is_flagged = 1' ];
3187 $params = [];
3188
3189 if ( $args['moderation_type'] ) {
3190 $where[] = 'moderation_type = %s';
3191 $params[] = $args['moderation_type'];
3192 }
3193
3194 if ( $args['forumid'] ) {
3195 $where[] = 'forumid = %d';
3196 $params[] = $args['forumid'];
3197 }
3198
3199 if ( $args['min_score'] > 0 ) {
3200 $where[] = 'score >= %d';
3201 $params[] = $args['min_score'];
3202 }
3203
3204 if ( $args['reviewed'] === false ) {
3205 $where[] = 'reviewed_by IS NULL';
3206 } elseif ( $args['reviewed'] === true ) {
3207 $where[] = 'reviewed_by IS NOT NULL';
3208 }
3209
3210 $where_sql = implode( ' AND ', $where );
3211 $order_by = in_array( $args['order_by'], [ 'score', 'created', 'confidence' ], true )
3212 ? $args['order_by']
3213 : 'score';
3214 $order = $args['order'] === 'ASC' ? 'ASC' : 'DESC';
3215
3216 $sql = "SELECT * FROM " . WPF()->tables->ai_moderation . "
3217 WHERE $where_sql
3218 ORDER BY $order_by $order
3219 LIMIT %d OFFSET %d";
3220
3221 $params[] = $args['limit'];
3222 $params[] = $args['offset'];
3223
3224 $results = $wpdb->get_results(
3225 $wpdb->prepare( $sql, ...$params ),
3226 ARRAY_A
3227 );
3228
3229 foreach ( $results as &$row ) {
3230 if ( ! empty( $row['indicators'] ) ) {
3231 $row['indicators'] = json_decode( $row['indicators'], true );
3232 }
3233 }
3234
3235 return $results;
3236 }
3237
3238 /**
3239 * Mark moderation as reviewed
3240 *
3241 * @param int $id Moderation log ID
3242 * @param int $reviewer_id Reviewer user ID
3243 * @param string $action Action taken (override)
3244 * @param string $notes Review notes
3245 * @return bool Success
3246 */
3247 public function mark_as_reviewed( $id, $reviewer_id, $action = null, $notes = '' ) {
3248 global $wpdb;
3249
3250 $result = $wpdb->update(
3251 WPF()->tables->ai_moderation,
3252 [
3253 'reviewed_by' => $reviewer_id,
3254 'reviewed_at' => current_time( 'mysql', true ), // UTC for timezone conversion
3255 'review_action' => $action,
3256 'review_notes' => $notes,
3257 ],
3258 [ 'id' => $id ],
3259 [ '%d', '%s', '%s', '%s' ],
3260 [ '%d' ]
3261 );
3262
3263 return $result !== false;
3264 }
3265
3266 /**
3267 * Get moderation statistics
3268 *
3269 * @param array $args Query arguments
3270 * @return array Statistics
3271 */
3272 public function get_moderation_stats( $args = [] ) {
3273 global $wpdb;
3274
3275 $defaults = [
3276 'moderation_type' => null,
3277 'forumid' => null,
3278 'days' => 30,
3279 ];
3280
3281 $args = wp_parse_args( $args, $defaults );
3282
3283 $where = [ '1=1' ];
3284 $params = [];
3285
3286 if ( $args['moderation_type'] ) {
3287 $where[] = 'moderation_type = %s';
3288 $params[] = $args['moderation_type'];
3289 }
3290
3291 if ( $args['forumid'] ) {
3292 $where[] = 'forumid = %d';
3293 $params[] = $args['forumid'];
3294 }
3295
3296 if ( $args['days'] > 0 ) {
3297 $where[] = 'created >= DATE_SUB(NOW(), INTERVAL %d DAY)';
3298 $params[] = $args['days'];
3299 }
3300
3301 $where_sql = implode( ' AND ', $where );
3302
3303 $sql = "SELECT
3304 COUNT(*) as total_checks,
3305 SUM(is_flagged) as total_flagged,
3306 SUM(CASE WHEN action_taken = 'approve' THEN 1 ELSE 0 END) as auto_approved,
3307 SUM(CASE WHEN action_taken = 'hold' THEN 1 ELSE 0 END) as auto_held,
3308 SUM(CASE WHEN action_taken = 'delete' THEN 1 ELSE 0 END) as auto_deleted,
3309 SUM(CASE WHEN action_taken = 'ban_user' THEN 1 ELSE 0 END) as auto_banned,
3310 AVG(score) as avg_score,
3311 SUM(credits_used) as total_credits,
3312 AVG(detection_time_ms) as avg_detection_time,
3313 SUM(CASE WHEN reviewed_by IS NOT NULL THEN 1 ELSE 0 END) as reviewed_count
3314 FROM " . WPF()->tables->ai_moderation . "
3315 WHERE $where_sql";
3316
3317 if ( ! empty( $params ) ) {
3318 $result = $wpdb->get_row( $wpdb->prepare( $sql, ...$params ), ARRAY_A );
3319 } else {
3320 $result = $wpdb->get_row( $sql, ARRAY_A );
3321 }
3322
3323 return $result ?: [
3324 'total_checks' => 0,
3325 'total_flagged' => 0,
3326 'auto_approved' => 0,
3327 'auto_held' => 0,
3328 'auto_deleted' => 0,
3329 'auto_banned' => 0,
3330 'avg_score' => 0,
3331 'total_credits' => 0,
3332 'avg_detection_time' => 0,
3333 'reviewed_count' => 0,
3334 ];
3335 }
3336
3337 /**
3338 * Delete old moderation logs
3339 *
3340 * @param int $days Delete logs older than this many days
3341 * @return int Number of deleted rows
3342 */
3343 public function cleanup_old_logs( $days = 90 ) {
3344 global $wpdb;
3345
3346 $result = $wpdb->query(
3347 $wpdb->prepare(
3348 "DELETE FROM " . WPF()->tables->ai_moderation . "
3349 WHERE created < DATE_SUB(NOW(), INTERVAL %d DAY)",
3350 $days
3351 )
3352 );
3353
3354 return $result !== false ? $result : 0;
3355 }
3356
3357 /**
3358 * Cron callback for moderation log cleanup
3359 *
3360 * Runs daily to remove old moderation log entries.
3361 * Retention period is controlled by 'wpforo_ai_moderation_log_retention_days' filter (default 90).
3362 */
3363 public function cron_moderation_cleanup() {
3364 $retention_days = apply_filters( 'wpforo_ai_moderation_log_retention_days', 90 );
3365 $deleted = $this->cleanup_old_logs( $retention_days );
3366
3367 if ( $deleted > 0 ) {
3368 \wpforo_ai_log( 'info', "Cron cleanup: deleted {$deleted} logs older than {$retention_days} days", 'Moderation' );
3369 }
3370 }
3371
3372 /**
3373 * Schedule moderation log cleanup cron job
3374 *
3375 * Should be called on plugin activation or when moderation logs are created.
3376 */
3377 public function schedule_moderation_cleanup() {
3378 if ( ! wp_next_scheduled( 'wpforo_ai_moderation_cleanup' ) ) {
3379 // Schedule to run at 4 AM server time (off-peak hours)
3380 $next_run = strtotime( 'tomorrow 4:00am' );
3381 wp_schedule_event( $next_run, 'daily', 'wpforo_ai_moderation_cleanup' );
3382 }
3383 }
3384
3385 /**
3386 * Unschedule moderation log cleanup cron job
3387 *
3388 * Should be called on plugin deactivation.
3389 */
3390 public function unschedule_moderation_cleanup() {
3391 $timestamp = wp_next_scheduled( 'wpforo_ai_moderation_cleanup' );
3392 if ( $timestamp ) {
3393 wp_unschedule_event( $timestamp, 'wpforo_ai_moderation_cleanup' );
3394 }
3395 }
3396
3397 // =========================================================================
3398 // MODERATION REPORT DISPLAY (Admin View)
3399 // =========================================================================
3400
3401 /**
3402 * Display moderation report under posts for authorized users
3403 *
3404 * Shows AI moderation analysis results to users with 'au' (approve/unapprove)
3405 * permission for the current forum.
3406 *
3407 * @param array $post Post data
3408 * @param array $topic Topic data
3409 * @param array $forum Forum data
3410 * @param int $layout_id Layout ID
3411 */
3412 public function display_moderation_report( $post, $topic, $forum, $layout_id ) {
3413 // Check if user has 'au' permission for this forum
3414 // Use $forum parameter (more reliable) with fallback to $post['forumid']
3415 $forumid = (int) ( $forum['forumid'] ?? $post['forumid'] ?? 0 );
3416 if ( ! $forumid || ! WPF()->perm->forum_can( 'au', $forumid ) ) {
3417 return;
3418 }
3419
3420 // Post authors should NEVER see their own moderation reports
3421 $current_userid = WPF()->current_userid;
3422 $post_userid = (int) ( $post['userid'] ?? 0 );
3423 if ( $current_userid && $current_userid === $post_userid ) {
3424 return;
3425 }
3426
3427 // Determine content type and ID
3428 $is_first_post = ! empty( $post['is_first_post'] );
3429 $content_type = $is_first_post ? self::CONTENT_TOPIC : self::CONTENT_POST;
3430 $content_id = $is_first_post ? ( $post['topicid'] ?? $post['postid'] ) : $post['postid'];
3431
3432 // Get the latest moderation log for this content
3433 $moderation = $this->get_latest_moderation( $content_type, (int) $content_id );
3434
3435 // If no moderation log exists, don't display anything
3436 if ( empty( $moderation ) ) {
3437 return;
3438 }
3439
3440 // Render the moderation report
3441 $this->render_moderation_report( $moderation, $post );
3442 }
3443
3444 /**
3445 * Render the moderation report HTML
3446 *
3447 * @param array $moderation Moderation log data
3448 * @param array $post Post data
3449 */
3450 protected function render_moderation_report( $moderation, $post ) {
3451 $score = (int) ( $moderation['score'] ?? 0 );
3452 $is_flagged = (bool) ( $moderation['is_flagged'] ?? false );
3453 $confidence = (float) ( $moderation['confidence'] ?? 0 );
3454 $action = $moderation['action_taken'] ?? 'none';
3455 $summary = $moderation['analysis_summary'] ?? '';
3456 $indicators = $moderation['indicators'] ?? [];
3457 $quality = $moderation['quality_tier'] ?? 'fast';
3458 $credits = (int) ( $moderation['credits_used'] ?? 0 );
3459 $created = $moderation['created'] ?? '';
3460 $mod_type = $moderation['moderation_type'] ?? 'spam';
3461 $is_ai = ( $quality !== 'rule_based' );
3462
3463 // Decode indicators if string
3464 if ( is_string( $indicators ) && ! empty( $indicators ) ) {
3465 $indicators = json_decode( $indicators, true ) ?: [];
3466 }
3467
3468 // Determine status color
3469 $status_class = 'wpf-ai-mod-clean';
3470 $status_label = wpforo_phrase( 'Clean', false );
3471 if ( $score >= 85 ) {
3472 $status_class = 'wpf-ai-mod-detected';
3473 $status_label = wpforo_phrase( 'Detected', false );
3474 } elseif ( $score >= 70 ) {
3475 $status_class = 'wpf-ai-mod-suspected';
3476 $status_label = wpforo_phrase( 'Suspected', false );
3477 } elseif ( $score >= 51 ) {
3478 $status_class = 'wpf-ai-mod-uncertain';
3479 $status_label = wpforo_phrase( 'Uncertain', false );
3480 }
3481
3482 // Action label
3483 $action_labels = [
3484 'none' => wpforo_phrase( 'No action', false ),
3485 'approve' => wpforo_phrase( 'Auto-approved', false ),
3486 'auto_approve' => wpforo_phrase( 'Auto-approved', false ),
3487 'unapprove' => wpforo_phrase( 'Unapproved', false ),
3488 'unapprove_ban' => wpforo_phrase( 'Unapproved + Banned', false ),
3489 'delete_author' => wpforo_phrase( 'Deleted + Banned', false ),
3490 ];
3491 $action_label = $action_labels[ $action ] ?? $action;
3492
3493 // Moderation type label (short for row display)
3494 if ( $is_ai ) {
3495 $type_labels = [
3496 'spam' => wpforo_phrase( 'Spam Detection', false ),
3497 'toxicity' => wpforo_phrase( 'Toxicity Detection', false ),
3498 'compliance' => wpforo_phrase( 'Policy Compliance', false ),
3499 ];
3500 $type_label = $type_labels[ $mod_type ] ?? ucfirst( $mod_type );
3501 } else {
3502 $type_label = wpforo_phrase( 'Auto Moderation', false );
3503 }
3504
3505 // Feature name (full name for footer)
3506 if ( $is_ai ) {
3507 $feature_names = [
3508 'spam' => wpforo_phrase( 'AI Spam Detection', false ),
3509 'toxicity' => wpforo_phrase( 'AI Content Safety & Toxicity Detection', false ),
3510 'compliance' => wpforo_phrase( 'AI Policy Compliance', false ),
3511 ];
3512 $feature_name = $feature_names[ $mod_type ] ?? wpforo_phrase( 'AI Content Moderation', false );
3513 } else {
3514 $feature_name = wpforo_phrase( 'Auto Moderation', false );
3515 }
3516
3517 // Quality tier label
3518 $quality_labels = [
3519 'fast' => wpforo_phrase( 'Fast', false ),
3520 'balanced' => wpforo_phrase( 'Balanced', false ),
3521 'advanced' => wpforo_phrase( 'Advanced', false ),
3522 'premium' => wpforo_phrase( 'Premium', false ),
3523 'rule_based' => wpforo_phrase( 'Rule-based', false ),
3524 ];
3525 $quality_label = $quality_labels[ $quality ] ?? $quality;
3526
3527 ?>
3528 <div class="wpf-ai-moderation-report <?php echo esc_attr( $status_class ); ?>">
3529 <div class="wpf-ai-mod-header">
3530 <span class="wpf-ai-mod-icon">
3531 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/></svg>
3532 </span>
3533 <span class="wpf-ai-mod-title"><?php $is_ai ? wpforo_phrase( 'AI Moderation Report' ) : wpforo_phrase( 'Moderation Report' ); ?></span>
3534 <span class="wpf-ai-mod-status"><?php echo esc_html( $status_label ); ?></span>
3535 </div>
3536
3537 <div class="wpf-ai-mod-body">
3538 <div class="wpf-ai-mod-row">
3539 <span class="wpf-ai-mod-label"><?php wpforo_phrase( 'Type:' ); ?></span>
3540 <span class="wpf-ai-mod-value"><?php echo esc_html( $type_label ); ?></span>
3541 </div>
3542
3543 <div class="wpf-ai-mod-row">
3544 <span class="wpf-ai-mod-label"><?php wpforo_phrase( 'Score:' ); ?></span>
3545 <span class="wpf-ai-mod-value">
3546 <?php if ( $is_ai ) : ?>
3547 <span class="wpf-ai-mod-score"><?php echo esc_html( $score ); ?>%</span>
3548 <span class="wpf-ai-mod-confidence">(<?php printf( wpforo_phrase( '%d%% confidence', false ), round( $confidence * 100 ) ); ?>)</span>
3549 <?php else : ?>
3550 <span class="wpf-ai-mod-score">-</span>
3551 <?php endif; ?>
3552 </span>
3553 </div>
3554
3555 <div class="wpf-ai-mod-row">
3556 <span class="wpf-ai-mod-label"><?php wpforo_phrase( 'Action:' ); ?></span>
3557 <span class="wpf-ai-mod-value"><?php echo esc_html( $action_label ); ?></span>
3558 </div>
3559
3560 <?php if ( ! empty( $summary ) ) : ?>
3561 <div class="wpf-ai-mod-row wpf-ai-mod-summary">
3562 <span class="wpf-ai-mod-label"><?php wpforo_phrase( 'Summary:' ); ?></span>
3563 <span class="wpf-ai-mod-value"><?php echo esc_html( $summary ); ?></span>
3564 </div>
3565 <?php endif; ?>
3566
3567 <?php if ( ! empty( $indicators ) && is_array( $indicators ) ) : ?>
3568 <div class="wpf-ai-mod-indicators">
3569 <span class="wpf-ai-mod-label"><?php wpforo_phrase( 'Indicators:' ); ?></span>
3570 <ul class="wpf-ai-mod-indicator-list">
3571 <?php foreach ( $indicators as $indicator ) : ?>
3572 <li class="wpf-ai-mod-indicator wpf-ai-mod-severity-<?php echo esc_attr( strtolower( $indicator['severity'] ?? 'medium' ) ); ?>">
3573 <span class="wpf-ai-mod-indicator-cat"><?php echo esc_html( $indicator['category'] ?? '' ); ?></span>
3574 <?php if ( ! empty( $indicator['description'] ) ) : ?>
3575 <span class="wpf-ai-mod-indicator-desc"><?php echo esc_html( $indicator['description'] ); ?></span>
3576 <?php endif; ?>
3577 </li>
3578 <?php endforeach; ?>
3579 </ul>
3580 </div>
3581 <?php endif; ?>
3582
3583 <div class="wpf-ai-mod-meta">
3584 <?php if ( $is_ai ) : ?>
3585 <span class="wpf-ai-mod-quality"><?php wpforo_phrase( 'AI Quality:' ); ?> <?php echo esc_html( $quality_label ); ?> (<?php echo esc_html( $credits ); ?> <?php echo ( $credits == 1 ) ? wpforo_phrase( 'credit', false ) : wpforo_phrase( 'credits', false ); ?>)</span>
3586 <?php endif; ?>
3587 <span class="wpf-ai-mod-feature"><?php echo esc_html( $feature_name ); ?></span>
3588 </div>
3589 </div>
3590 </div>
3591 <?php
3592 }
3593
3594 /**
3595 * Output moderation report styles in wp_head
3596 *
3597 * Only outputs styles on wpForo pages.
3598 */
3599 public function output_moderation_report_styles() {
3600 // Only output on wpForo pages
3601 if ( ! function_exists( 'is_wpforo_page' ) || ! is_wpforo_page() ) {
3602 return;
3603 }
3604
3605 // Only output if user might see moderation reports
3606 // (checking here would be too slow, so we always output on wpForo pages)
3607 echo '<style id="wpforo-ai-moderation-report-styles">' . self::get_moderation_report_styles() . '</style>';
3608 }
3609
3610 /**
3611 * Get CSS styles for moderation report
3612 *
3613 * @return string CSS styles
3614 */
3615 public static function get_moderation_report_styles() {
3616 return '
3617 .wpf-ai-moderation-report {
3618 margin: 15px 0;
3619 padding: 12px 15px;
3620 border-radius: 6px;
3621 border: 1px solid #e0e0e0;
3622 background: #f8f9fa;
3623 font-size: 13px;
3624 }
3625 .wpf-ai-moderation-report.wpf-ai-mod-clean {
3626 border-color: #c3e6cb;
3627 background: #d4edda;
3628 }
3629 .wpf-ai-moderation-report.wpf-ai-mod-uncertain {
3630 border-color: #ffeeba;
3631 background: #fff3cd;
3632 }
3633 .wpf-ai-moderation-report.wpf-ai-mod-suspected {
3634 border-color: #ffcc80;
3635 background: #ffe0b2;
3636 }
3637 .wpf-ai-moderation-report.wpf-ai-mod-detected {
3638 border-color: #f5c6cb;
3639 background: #f8d7da;
3640 }
3641 .wpf-ai-mod-header {
3642 display: flex;
3643 align-items: center;
3644 gap: 8px;
3645 margin-bottom: 10px;
3646 padding-bottom: 8px;
3647 border-bottom: 1px solid rgba(0,0,0,0.1);
3648 }
3649 .wpf-ai-mod-icon svg {
3650 display: block;
3651 }
3652 .wpf-ai-mod-title {
3653 font-weight: 600;
3654 flex-grow: 1;
3655 }
3656 .wpf-ai-mod-status {
3657 font-size: 11px;
3658 font-weight: 500;
3659 text-transform: uppercase;
3660 padding: 2px 8px;
3661 border-radius: 3px;
3662 background: rgba(0,0,0,0.1);
3663 }
3664 .wpf-ai-mod-body {
3665 display: flex;
3666 flex-direction: column;
3667 gap: 6px;
3668 }
3669 .wpf-ai-mod-row {
3670 display: flex;
3671 gap: 8px;
3672 }
3673 .wpf-ai-mod-label {
3674 font-weight: 500;
3675 color: #555;
3676 min-width: 70px;
3677 }
3678 .wpf-ai-mod-value {
3679 color: #333;
3680 }
3681 .wpf-ai-mod-score {
3682 font-weight: 600;
3683 }
3684 .wpf-ai-mod-confidence {
3685 color: #666;
3686 font-size: 12px;
3687 }
3688 .wpf-ai-mod-summary {
3689 flex-direction: column;
3690 }
3691 .wpf-ai-mod-summary .wpf-ai-mod-value {
3692 margin-top: 2px;
3693 font-style: italic;
3694 }
3695 .wpf-ai-mod-indicators {
3696 margin-top: 6px;
3697 }
3698 .wpf-ai-mod-indicator-list {
3699 list-style: none;
3700 margin: 4px 0 0 0;
3701 padding: 0;
3702 }
3703 .wpf-ai-mod-indicator {
3704 display: flex;
3705 gap: 6px;
3706 padding: 4px 8px;
3707 margin: 2px 0;
3708 border-radius: 3px;
3709 font-size: 12px;
3710 }
3711 .wpf-ai-mod-indicator.wpf-ai-mod-severity-high {
3712 background: rgba(220, 53, 69, 0.15);
3713 }
3714 .wpf-ai-mod-indicator.wpf-ai-mod-severity-medium {
3715 background: rgba(255, 193, 7, 0.15);
3716 }
3717 .wpf-ai-mod-indicator.wpf-ai-mod-severity-low {
3718 background: rgba(108, 117, 125, 0.1);
3719 }
3720 .wpf-ai-mod-indicator-cat {
3721 font-weight: 500;
3722 text-transform: capitalize;
3723 }
3724 .wpf-ai-mod-indicator-desc {
3725 color: #666;
3726 }
3727 .wpf-ai-mod-meta {
3728 display: flex;
3729 justify-content: space-between;
3730 margin-top: 8px;
3731 padding-top: 8px;
3732 border-top: 1px solid rgba(0,0,0,0.1);
3733 font-size: 11px;
3734 color: #888;
3735 }
3736 ';
3737 }
3738 }
3739