PluginProbe
wpForo Forum / 3.0.2
wpForo Forum v3.0.2
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.0.2, at classes/AIContentModeration.php

3,720 lines 116.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 $request_data['use_forum_context'] = $this->use_spam_context();
893 $request_data['min_indexed_topics'] = $this->get_setting( 'spam', 'min_indexed', 100 );
894 $request_data['board_id'] = $this->board_id;
895 }
896
897 $endpoint = '/moderation/analyze';
898 } else {
899 // Use spam-only endpoint (more efficient)
900 $request_data['use_forum_context'] = $this->use_spam_context();
901 $request_data['min_indexed_topics'] = $this->get_setting( 'spam', 'min_indexed', 100 );
902 $request_data['board_id'] = $this->board_id;
903
904 $endpoint = '/moderation/spam/detect';
905 }
906
907 // Make API request
908 \wpforo_ai_log( 'debug', "Calling endpoint: $endpoint", 'Moderation' );
909 $response = $ai_client->api_post( $endpoint, $request_data, 30 );
910
911 \wpforo_ai_log( 'debug', 'API response: ' . ( is_wp_error( $response ) ? 'WP_Error: ' . $response->get_error_message() : wp_json_encode( $response ) ), 'Moderation' );
912
913 // Check for errors
914 if ( is_wp_error( $response ) ) {
915 \wpforo_ai_log( 'error', 'API error: ' . $response->get_error_message(), 'Moderation' );
916 return null;
917 }
918
919 // Process response
920 if ( empty( $response['success'] ) ) {
921 \wpforo_ai_log( 'error', 'API returned unsuccessful response', 'Moderation' );
922 return null;
923 }
924
925 // Parse response based on endpoint used
926 if ( $use_unified ) {
927 return $this->parse_unified_response( $response, $spam_enabled, $toxicity_enabled, $compliance_enabled );
928 } else {
929 return $this->parse_spam_response( $response );
930 }
931 }
932
933 /**
934 * Parse spam-only endpoint response
935 *
936 * @param array $response API response
937 * @return array Parsed result
938 */
939 protected function parse_spam_response( $response ) {
940 return [
941 'type' => 'spam',
942 'spam_score' => (int) ( $response['spam_score'] ?? 0 ),
943 'is_spam' => (bool) ( $response['is_spam'] ?? false ),
944 'confidence' => (float) ( $response['confidence'] ?? 0.0 ),
945 'indicators' => $response['indicators'] ?? [],
946 'analysis_summary' => $response['analysis_summary'] ?? '',
947 'credits_used' => (int) ( $response['credits_used'] ?? 0 ),
948 'context_used' => (bool) ( $response['context_used'] ?? false ),
949 ];
950 }
951
952 /**
953 * Parse unified endpoint response
954 *
955 * @param array $response API response
956 * @param bool $spam_enabled Spam detection enabled
957 * @param bool $toxicity_enabled Toxicity detection enabled
958 * @param bool $compliance_enabled Compliance detection enabled
959 * @return array Parsed result
960 */
961 protected function parse_unified_response( $response, $spam_enabled, $toxicity_enabled, $compliance_enabled ) {
962 $result = [
963 'type' => 'unified',
964 'credits_used' => (int) ( $response['credits_used'] ?? 0 ),
965 'spam' => null,
966 'toxicity' => null,
967 'compliance' => null,
968 ];
969
970 // Parse spam results
971 if ( $spam_enabled && isset( $response['spam'] ) ) {
972 $spam = $response['spam'];
973 $result['spam'] = [
974 'score' => (int) ( $spam['score'] ?? 0 ),
975 'is_spam' => (bool) ( $spam['is_spam'] ?? false ),
976 'confidence' => (float) ( $spam['confidence'] ?? 0.0 ),
977 'indicators' => $spam['indicators'] ?? [],
978 'summary' => $spam['summary'] ?? '',
979 ];
980 // For backwards compatibility, set main fields
981 $result['spam_score'] = $result['spam']['score'];
982 $result['is_spam'] = $result['spam']['is_spam'];
983 $result['confidence'] = $result['spam']['confidence'];
984 $result['indicators'] = $result['spam']['indicators'];
985 $result['analysis_summary'] = $result['spam']['summary'];
986 }
987
988 // Parse toxicity results
989 if ( $toxicity_enabled && isset( $response['toxicity'] ) ) {
990 $toxicity = $response['toxicity'];
991 $result['toxicity'] = [
992 'score' => (int) ( $toxicity['score'] ?? 0 ),
993 'is_toxic' => (bool) ( $toxicity['is_toxic'] ?? false ),
994 'confidence' => (float) ( $toxicity['confidence'] ?? 0.0 ),
995 'categories' => $toxicity['categories'] ?? [],
996 'summary' => $toxicity['summary'] ?? '',
997 ];
998 }
999
1000 // Parse compliance results
1001 if ( $compliance_enabled && isset( $response['compliance'] ) ) {
1002 $compliance = $response['compliance'];
1003 $result['compliance'] = [
1004 'score' => (int) ( $compliance['score'] ?? 0 ),
1005 'is_compliant' => (bool) ( $compliance['is_compliant'] ?? true ),
1006 'confidence' => (float) ( $compliance['confidence'] ?? 0.0 ),
1007 'violations' => $compliance['violations'] ?? [],
1008 'summary' => $compliance['summary'] ?? '',
1009 ];
1010 }
1011
1012 // Parse overall results (contains action and primary_reason)
1013 if ( isset( $response['overall'] ) ) {
1014 $overall = $response['overall'];
1015 $result['overall'] = [
1016 'action' => $overall['action'] ?? 'review',
1017 'primary_reason' => $overall['primary_reason'] ?? 'none',
1018 'summary' => $overall['summary'] ?? '',
1019 ];
1020 }
1021
1022 return $result;
1023 }
1024
1025 /**
1026 * Get toxicity detection sensitivity setting
1027 *
1028 * @return string Sensitivity level (low, medium, high)
1029 */
1030 public function get_toxicity_sensitivity() {
1031 return wpforo_setting( 'ai', 'moderation_toxicity_sensitivity' ) ?? 'medium';
1032 }
1033
1034 /**
1035 * Get toxicity action setting
1036 *
1037 * @return string Action (none, unapprove, unapprove_ban)
1038 */
1039 public function get_toxicity_action() {
1040 return wpforo_setting( 'ai', 'moderation_toxicity_action' ) ?? 'unapprove';
1041 }
1042
1043 /**
1044 * Get compliance action setting
1045 *
1046 * @return string Action (none, unapprove, unapprove_ban)
1047 */
1048 public function get_compliance_action() {
1049 return $this->get_setting( 'compliance', 'action', 'unapprove' );
1050 }
1051
1052 /**
1053 * Get all compliance content sources with their content and timestamps
1054 *
1055 * Gathers content from:
1056 * - Built-in forum privacy policy (if enabled)
1057 * - Built-in forum rules (if enabled)
1058 * - Custom policy page (if selected)
1059 * - Custom rules page (if selected)
1060 *
1061 * @return array Array of sources with type, content, and modified timestamp
1062 */
1063 public function get_compliance_sources() {
1064 $sources = [];
1065 $legal = WPF()->settings->legal;
1066
1067 // Built-in forum privacy policy
1068 if ( ! empty( $legal['checkbox_forum_privacy'] ) && ! empty( $legal['forum_privacy_text'] ) ) {
1069 $sources[] = [
1070 'type' => 'builtin_policy',
1071 'content' => wp_strip_all_tags( $legal['forum_privacy_text'] ),
1072 'modified' => $this->get_option_modified_time( 'wpforo_legal' ),
1073 ];
1074 }
1075
1076 // Built-in forum rules
1077 if ( ! empty( $legal['rules_checkbox'] ) && ! empty( $legal['rules_text'] ) ) {
1078 $sources[] = [
1079 'type' => 'builtin_rules',
1080 'content' => wp_strip_all_tags( $legal['rules_text'] ),
1081 'modified' => $this->get_option_modified_time( 'wpforo_legal' ),
1082 ];
1083 }
1084
1085 // Custom policy page
1086 $custom_policy_id = $this->get_setting( 'compliance', 'custom_policy_page', 0 );
1087 if ( $custom_policy_id ) {
1088 $page = get_post( $custom_policy_id );
1089 if ( $page && $page->post_status === 'publish' ) {
1090 $sources[] = [
1091 'type' => 'custom_policy',
1092 'content' => wp_strip_all_tags( $page->post_content ),
1093 'modified' => strtotime( $page->post_modified_gmt ),
1094 ];
1095 }
1096 }
1097
1098 // Custom rules page
1099 $custom_rules_id = $this->get_setting( 'compliance', 'custom_rules_page', 0 );
1100 if ( $custom_rules_id ) {
1101 $page = get_post( $custom_rules_id );
1102 if ( $page && $page->post_status === 'publish' ) {
1103 $sources[] = [
1104 'type' => 'custom_rules',
1105 'content' => wp_strip_all_tags( $page->post_content ),
1106 'modified' => strtotime( $page->post_modified_gmt ),
1107 ];
1108 }
1109 }
1110
1111 return $sources;
1112 }
1113
1114 /**
1115 * Get just the modification timestamps for compliance sources
1116 *
1117 * Used for checking if cached rules are still valid.
1118 *
1119 * @return array Associative array of source type => timestamp (or null if not configured)
1120 */
1121 public function get_compliance_sources_modified() {
1122 $legal = WPF()->settings->legal;
1123 $modified = [];
1124
1125 // Built-in policy
1126 $modified['builtin_policy'] = ( ! empty( $legal['checkbox_forum_privacy'] ) && ! empty( $legal['forum_privacy_text'] ) )
1127 ? $this->get_option_modified_time( 'wpforo_legal' )
1128 : null;
1129
1130 // Built-in rules
1131 $modified['builtin_rules'] = ( ! empty( $legal['rules_checkbox'] ) && ! empty( $legal['rules_text'] ) )
1132 ? $this->get_option_modified_time( 'wpforo_legal' )
1133 : null;
1134
1135 // Custom policy page
1136 $custom_policy_id = $this->get_setting( 'compliance', 'custom_policy_page', 0 );
1137 if ( $custom_policy_id ) {
1138 $page = get_post( $custom_policy_id );
1139 $modified['custom_policy'] = ( $page && $page->post_status === 'publish' )
1140 ? strtotime( $page->post_modified_gmt )
1141 : null;
1142 } else {
1143 $modified['custom_policy'] = null;
1144 }
1145
1146 // Custom rules page
1147 $custom_rules_id = $this->get_setting( 'compliance', 'custom_rules_page', 0 );
1148 if ( $custom_rules_id ) {
1149 $page = get_post( $custom_rules_id );
1150 $modified['custom_rules'] = ( $page && $page->post_status === 'publish' )
1151 ? strtotime( $page->post_modified_gmt )
1152 : null;
1153 } else {
1154 $modified['custom_rules'] = null;
1155 }
1156
1157 return $modified;
1158 }
1159
1160 /**
1161 * Get the last modified time for a WordPress option
1162 *
1163 * Since options don't have a modified timestamp, we use a custom option
1164 * that's updated when settings are saved.
1165 *
1166 * @param string $option_name Option name
1167 * @return int Unix timestamp or 0 if not tracked
1168 */
1169 protected function get_option_modified_time( $option_name ) {
1170 // We store a timestamp when legal settings are saved
1171 $modified_key = $option_name . '_modified';
1172 $modified = get_option( $modified_key, 0 );
1173
1174 // If not tracked, use a fallback (settings init time or current time)
1175 if ( ! $modified ) {
1176 // Store current time as initial timestamp
1177 $modified = time();
1178 update_option( $modified_key, $modified, false );
1179 }
1180
1181 return (int) $modified;
1182 }
1183
1184 /**
1185 * Check if compliance sources have content
1186 *
1187 * @return bool True if at least one compliance source is configured
1188 */
1189 public function has_compliance_sources() {
1190 $sources = $this->get_compliance_sources();
1191 return ! empty( $sources );
1192 }
1193
1194 /**
1195 * Sync compliance rules with the backend
1196 *
1197 * Sends all policy/rules content to the backend for rule extraction.
1198 * The backend uses AI to extract keywords and patterns.
1199 *
1200 * @return array|WP_Error Sync result or error
1201 */
1202 public function sync_compliance_rules() {
1203 $ai_client = $this->get_ai_client();
1204 if ( ! $ai_client || ! $ai_client->is_service_available() ) {
1205 return new \WP_Error( 'not_connected', wpforo_phrase( 'AI service not available', false ) );
1206 }
1207
1208 $sources = $this->get_compliance_sources();
1209 if ( empty( $sources ) ) {
1210 return new \WP_Error( 'no_sources', wpforo_phrase( 'No policy or rules content configured', false ) );
1211 }
1212
1213 // Send to backend for rule extraction
1214 $response = $ai_client->api_post( '/moderation/compliance/sync', [
1215 'sources' => $sources,
1216 ], 60 ); // Longer timeout for AI extraction
1217
1218 if ( is_wp_error( $response ) ) {
1219 return $response;
1220 }
1221
1222 if ( empty( $response['success'] ) ) {
1223 return new \WP_Error(
1224 'sync_failed',
1225 $response['error'] ?? wpforo_phrase( 'Failed to sync compliance rules', false )
1226 );
1227 }
1228
1229 // Store sync timestamp locally
1230 update_option( 'wpforo_compliance_last_synced', time(), false );
1231 update_option( 'wpforo_compliance_sources_hash', $response['content_hash'] ?? '', false );
1232
1233 return [
1234 'success' => true,
1235 'content_hash' => $response['content_hash'] ?? '',
1236 'synced_at' => time(),
1237 'rules_count' => $response['rules_count'] ?? 0,
1238 ];
1239 }
1240
1241 /**
1242 * Get user registration days
1243 *
1244 * @param int $userid User ID
1245 * @return int Days since registration
1246 */
1247 protected function get_user_registration_days( $userid ) {
1248 if ( ! $userid ) {
1249 return 0;
1250 }
1251
1252 $user = get_userdata( $userid );
1253 if ( ! $user || empty( $user->user_registered ) ) {
1254 return 0;
1255 }
1256
1257 $registered = strtotime( $user->user_registered );
1258 $now = time();
1259 $days = floor( ( $now - $registered ) / DAY_IN_SECONDS );
1260
1261 return max( 0, (int) $days );
1262 }
1263
1264 // =========================================================================
1265 // CONTENT FILTERS (Pre-save)
1266 // =========================================================================
1267
1268 /**
1269 * Check if user has any unapproved posts
1270 *
1271 * If user has unapproved posts, their new content should also be unapproved
1272 * without spending credits on AI spam detection. This prevents spam users
1273 * from flooding the system while waiting for moderation.
1274 *
1275 * @param int $userid User ID
1276 * @return bool True if user has unapproved posts
1277 */
1278 protected function user_has_unapproved_posts( $userid ) {
1279 if ( ! $userid ) {
1280 return false; // Guests don't have post history
1281 }
1282
1283 // Use wpForo's moderation class if available
1284 if ( isset( WPF()->moderation ) && method_exists( WPF()->moderation, 'has_unapproved' ) ) {
1285 return WPF()->moderation->has_unapproved( $userid );
1286 }
1287
1288 // Fallback: direct database check
1289 global $wpdb;
1290 $has_unapproved = WPF()->db->get_var(
1291 WPF()->db->prepare(
1292 "SELECT postid FROM " . WPF()->tables->posts . " WHERE userid = %d AND status = 1 LIMIT 1",
1293 $userid
1294 )
1295 );
1296
1297 return ! empty( $has_unapproved );
1298 }
1299
1300 /**
1301 * Filter topic data on creation
1302 *
1303 * Called before topic is saved to database.
1304 * Can modify content, set status, or block creation.
1305 *
1306 * Note: This filter runs at priority 25, AFTER wpForo's built-in antispam features.
1307 * If the topic is already unapproved (status=1), we skip AI processing to save resources.
1308 *
1309 * @param array $args Topic data
1310 * @param array $forum Forum data
1311 * @return array Modified topic data (or empty to block)
1312 */
1313 public function filter_topic_on_create( $args, $forum ) {
1314 if ( ! $this->is_enabled() || empty( $args ) ) {
1315 return $args;
1316 }
1317
1318 // Skip AI moderation for AI-generated content (created by AI Tasks)
1319 if ( ! empty( $args['is_ai_generated'] ) ) {
1320 return $args;
1321 }
1322
1323 // Skip AI moderation if content is already unapproved by wpForo built-in antispam
1324 // This saves credits and resources - no need to double-check already flagged content
1325 if ( isset( $args['status'] ) && (int) $args['status'] === 1 ) {
1326 // If unapproved due to flood protection, log the specific reason
1327 if ( ! empty( $args['_flood_reason'] ) ) {
1328 $userid = $args['userid'] ?? WPF()->current_userid;
1329 $flood_reason = $args['_flood_reason'];
1330 $analysis_summary = $this->get_flood_moderation_message( $flood_reason );
1331
1332 $log_data = [
1333 'content_type' => self::CONTENT_TOPIC,
1334 'content_id' => 0, // Not saved yet
1335 'topicid' => 0,
1336 'forumid' => $forum['forumid'] ?? 0,
1337 'userid' => $userid,
1338 'moderation_type' => 'flood',
1339 'score' => 100,
1340 'is_flagged' => 1,
1341 'confidence' => 1.0,
1342 'action_taken' => 'unapprove',
1343 'action_reason' => 'flood_' . $flood_reason,
1344 'analysis_summary' => $analysis_summary,
1345 'quality_tier' => 'rule_based',
1346 'credits_used' => 0,
1347 'content_preview' => isset( $args['title'] ) ? wp_trim_words( $args['title'], 20 ) : null,
1348 ];
1349 $this->save_moderation_log( $log_data );
1350
1351 // Also log to AI Logs
1352 $this->log_flood_to_ai_logs( 'topic', $userid, $flood_reason, $analysis_summary, $log_data );
1353 }
1354 return $args;
1355 }
1356
1357 // Skip AI moderation for users exempt from spam detection
1358 // This includes users with "Dashboard - Moderate Topics & Posts" (aum) permission
1359 $userid = $args['userid'] ?? WPF()->current_userid;
1360 if ( $this->is_user_spam_exempt( $userid ) ) {
1361 return $args;
1362 }
1363
1364 // If user has ANY unapproved posts, auto-unapprove new content without AI check
1365 // This saves credits and prevents spam flooding while waiting for moderation
1366 if ( $userid && $this->user_has_unapproved_posts( $userid ) ) {
1367 $args['status'] = 1;
1368
1369 // Check if user should be auto-banned based on unapproved posts count
1370 $unapproved_count = $this->count_user_unapproved_posts( $userid );
1371 $autoban_threshold = $this->get_spam_autoban_unapproved_threshold();
1372 $should_ban = $autoban_threshold > 0 && ( $unapproved_count + 1 ) >= $autoban_threshold;
1373 $action_taken = $should_ban ? 'unapprove_ban' : 'unapprove';
1374 $action_reason = $should_ban ? 'autoban_unapproved_threshold' : 'user_has_unapproved_posts';
1375 $analysis_summary = $should_ban
1376 ? sprintf(
1377 wpforo_phrase( 'User auto-banned: reached %d unapproved posts (threshold: %d). Content auto-unapproved.', false ),
1378 $unapproved_count + 1,
1379 $autoban_threshold
1380 )
1381 : wpforo_phrase( 'Content auto-unapproved because user has existing unapproved posts awaiting moderation.', false );
1382
1383 // Log this decision to the moderation table
1384 $this->save_moderation_log( [
1385 'content_type' => self::CONTENT_TOPIC,
1386 'content_id' => 0, // Not saved yet
1387 'topicid' => 0,
1388 'forumid' => $forum['forumid'] ?? 0,
1389 'userid' => $userid,
1390 'moderation_type' => 'spam',
1391 'score' => 100,
1392 'is_flagged' => 1,
1393 'confidence' => 1.0,
1394 'action_taken' => $action_taken,
1395 'action_reason' => $action_reason,
1396 'analysis_summary' => $analysis_summary,
1397 'quality_tier' => 'rule_based',
1398 'credits_used' => 0,
1399 'content_preview' => isset( $args['title'] ) ? wp_trim_words( $args['title'], 20 ) : null,
1400 ] );
1401
1402 // Ban user if threshold reached
1403 if ( $should_ban ) {
1404 $this->ban_user( $userid, $analysis_summary );
1405 }
1406
1407 return $args;
1408 }
1409
1410 return $this->analyze_content( $args, self::CONTENT_TOPIC, self::EVENT_CREATE, [
1411 'forum' => $forum,
1412 ] );
1413 }
1414
1415 /**
1416 * Filter topic data on edit
1417 *
1418 * Called before topic is updated in database.
1419 *
1420 * Note: This filter runs at priority 25, AFTER wpForo's built-in antispam features.
1421 * If the topic is already unapproved (status=1), we skip AI processing to save resources.
1422 *
1423 * @param array $args Topic data
1424 * @param array $forum Forum data
1425 * @return array Modified topic data
1426 */
1427 public function filter_topic_on_edit( $args, $forum ) {
1428 if ( ! $this->is_enabled() || empty( $args ) ) {
1429 return $args;
1430 }
1431
1432 // Skip AI moderation if content is already unapproved by wpForo built-in antispam
1433 if ( isset( $args['status'] ) && (int) $args['status'] === 1 ) {
1434 return $args;
1435 }
1436
1437 // Skip AI moderation for users exempt from spam detection
1438 // This includes users with "Dashboard - Moderate Topics & Posts" (aum) permission
1439 $userid = $args['userid'] ?? WPF()->current_userid;
1440 if ( $this->is_user_spam_exempt( $userid ) ) {
1441 return $args;
1442 }
1443
1444 return $this->analyze_content( $args, self::CONTENT_TOPIC, self::EVENT_EDIT, [
1445 'forum' => $forum,
1446 ] );
1447 }
1448
1449 /**
1450 * Filter post data on creation
1451 *
1452 * Called before post is saved to database.
1453 *
1454 * Note: This filter runs at priority 25, AFTER wpForo's built-in antispam features.
1455 * If the post is already unapproved (status=1), we skip AI processing to save resources.
1456 *
1457 * @param array $post Post data
1458 * @return array Modified post data (or empty to block)
1459 */
1460 public function filter_post_on_create( $post ) {
1461 if ( ! $this->is_enabled() || empty( $post ) ) {
1462 return $post;
1463 }
1464
1465 // Skip AI moderation for AI-generated content (created by AI Tasks)
1466 if ( ! empty( $post['is_ai_generated'] ) ) {
1467 return $post;
1468 }
1469
1470 // Skip AI moderation if content is already unapproved by wpForo built-in antispam
1471 // This saves credits and resources - no need to double-check already flagged content
1472 if ( isset( $post['status'] ) && (int) $post['status'] === 1 ) {
1473 // If unapproved due to flood protection, log the specific reason
1474 if ( ! empty( $post['_flood_reason'] ) ) {
1475 $userid = $post['userid'] ?? WPF()->current_userid;
1476 $flood_reason = $post['_flood_reason'];
1477 $analysis_summary = $this->get_flood_moderation_message( $flood_reason );
1478
1479 $log_data = [
1480 'content_type' => self::CONTENT_POST,
1481 'content_id' => 0, // Not saved yet
1482 'topicid' => $post['topicid'] ?? 0,
1483 'forumid' => $post['forumid'] ?? 0,
1484 'userid' => $userid,
1485 'moderation_type' => 'flood',
1486 'score' => 100,
1487 'is_flagged' => 1,
1488 'confidence' => 1.0,
1489 'action_taken' => 'unapprove',
1490 'action_reason' => 'flood_' . $flood_reason,
1491 'analysis_summary' => $analysis_summary,
1492 'quality_tier' => 'rule_based',
1493 'credits_used' => 0,
1494 'content_preview' => isset( $post['body'] ) ? wp_trim_words( wp_strip_all_tags( $post['body'] ), 20 ) : null,
1495 ];
1496 $this->save_moderation_log( $log_data );
1497
1498 // Also log to AI Logs
1499 $this->log_flood_to_ai_logs( 'post', $userid, $flood_reason, $analysis_summary, $log_data );
1500 }
1501 return $post;
1502 }
1503
1504 // Skip AI moderation for users exempt from spam detection
1505 // This includes users with "Dashboard - Moderate Topics & Posts" (aum) permission
1506 $userid = $post['userid'] ?? WPF()->current_userid;
1507 if ( $this->is_user_spam_exempt( $userid ) ) {
1508 return $post;
1509 }
1510
1511 // If user has ANY unapproved posts, auto-unapprove new content without AI check
1512 // This saves credits and prevents spam flooding while waiting for moderation
1513 if ( $userid && $this->user_has_unapproved_posts( $userid ) ) {
1514 $post['status'] = 1;
1515
1516 // Check if user should be auto-banned based on unapproved posts count
1517 $unapproved_count = $this->count_user_unapproved_posts( $userid );
1518 $autoban_threshold = $this->get_spam_autoban_unapproved_threshold();
1519 $should_ban = $autoban_threshold > 0 && ( $unapproved_count + 1 ) >= $autoban_threshold;
1520 $action_taken = $should_ban ? 'unapprove_ban' : 'unapprove';
1521 $action_reason = $should_ban ? 'autoban_unapproved_threshold' : 'user_has_unapproved_posts';
1522 $analysis_summary = $should_ban
1523 ? sprintf(
1524 wpforo_phrase( 'User auto-banned: reached %d unapproved posts (threshold: %d). Content auto-unapproved.', false ),
1525 $unapproved_count + 1,
1526 $autoban_threshold
1527 )
1528 : wpforo_phrase( 'Content auto-unapproved because user has existing unapproved posts awaiting moderation.', false );
1529
1530 // Log this decision to the moderation table
1531 $this->save_moderation_log( [
1532 'content_type' => self::CONTENT_POST,
1533 'content_id' => 0, // Not saved yet
1534 'topicid' => $post['topicid'] ?? 0,
1535 'forumid' => $post['forumid'] ?? 0,
1536 'userid' => $userid,
1537 'moderation_type' => 'spam',
1538 'score' => 100,
1539 'is_flagged' => 1,
1540 'confidence' => 1.0,
1541 'action_taken' => $action_taken,
1542 'action_reason' => $action_reason,
1543 'analysis_summary' => $analysis_summary,
1544 'quality_tier' => 'rule_based',
1545 'credits_used' => 0,
1546 'content_preview' => isset( $post['body'] ) ? wp_trim_words( wp_strip_all_tags( $post['body'] ), 20 ) : null,
1547 ] );
1548
1549 // Ban user if threshold reached
1550 if ( $should_ban ) {
1551 $this->ban_user( $userid, $analysis_summary );
1552 }
1553
1554 return $post;
1555 }
1556
1557 // Get forum context for proper logging
1558 $forum_context = [];
1559 if ( ! empty( $post['forumid'] ) ) {
1560 $forum_context['forum'] = wpforo_forum( $post['forumid'] );
1561 }
1562
1563 return $this->analyze_content( $post, self::CONTENT_POST, self::EVENT_CREATE, $forum_context );
1564 }
1565
1566 /**
1567 * Filter post data on edit
1568 *
1569 * Called before post is updated in database.
1570 *
1571 * Note: This filter runs at priority 25, AFTER wpForo's built-in antispam features.
1572 * If the post is already unapproved (status=1), we skip AI processing to save resources.
1573 *
1574 * @param array $args Post data
1575 * @return array Modified post data
1576 */
1577 public function filter_post_on_edit( $args ) {
1578 if ( ! $this->is_enabled() || empty( $args ) ) {
1579 return $args;
1580 }
1581
1582 // Skip AI moderation if content is already unapproved by wpForo built-in antispam
1583 if ( isset( $args['status'] ) && (int) $args['status'] === 1 ) {
1584 return $args;
1585 }
1586
1587 // Skip AI moderation for users exempt from spam detection
1588 // This includes users with "Front - Can pass moderation" (aup) permission
1589 $userid = $args['userid'] ?? WPF()->current_userid;
1590 if ( $this->is_user_spam_exempt( $userid ) ) {
1591 return $args;
1592 }
1593
1594 // Get forum context for proper logging
1595 $forum_context = [];
1596 if ( ! empty( $args['forumid'] ) ) {
1597 $forum_context['forum'] = wpforo_forum( $args['forumid'] );
1598 }
1599
1600 return $this->analyze_content( $args, self::CONTENT_POST, self::EVENT_EDIT, $forum_context );
1601 }
1602
1603 // =========================================================================
1604 // CONTENT ANALYSIS (Core Logic)
1605 // =========================================================================
1606
1607 /**
1608 * Analyze content through registered handlers
1609 *
1610 * This is the main entry point for content analysis.
1611 * Runs content through all registered handlers and applies moderation decisions.
1612 *
1613 * @param array $data Content data (topic or post array)
1614 * @param string $content_type Content type (topic or post)
1615 * @param string $event_type Event type (create, edit, approve)
1616 * @param array $context Additional context (forum, etc.)
1617 * @return array Modified content data
1618 */
1619 protected function analyze_content( $data, $content_type, $event_type, $context = [] ) {
1620 // Build analysis context
1621 $analysis_context = $this->build_analysis_context( $data, $content_type, $event_type, $context );
1622
1623 // Run through all registered handlers
1624 $results = [];
1625 foreach ( $this->handlers as $id => $handler ) {
1626 $result = call_user_func( $handler['callback'], $analysis_context );
1627 if ( ! empty( $result ) ) {
1628 $results[ $id ] = $result;
1629 }
1630 }
1631
1632 // Allow external filtering of analysis results
1633 $results = apply_filters( 'wpforo_ai_moderation_results', $results, $analysis_context );
1634
1635 // Make moderation decision based on results
1636 $decision = $this->make_decision( $results, $analysis_context );
1637
1638 // Execute decision actions
1639 $data = $this->execute_decision( $data, $decision, $analysis_context );
1640
1641 // Log the moderation action
1642 $this->log_moderation( $analysis_context, $results, $decision );
1643
1644 return $data;
1645 }
1646
1647 /**
1648 * Build analysis context for handlers
1649 *
1650 * @param array $data Content data
1651 * @param string $content_type Content type
1652 * @param string $event_type Event type
1653 * @param array $context Additional context
1654 * @return array Analysis context
1655 */
1656 protected function build_analysis_context( $data, $content_type, $event_type, $context = [] ) {
1657 $userid = $data['userid'] ?? get_current_user_id();
1658
1659 // Get user info and trust level
1660 $user_info = $this->get_user_moderation_info( $userid );
1661
1662 return [
1663 'content_type' => $content_type,
1664 'event_type' => $event_type,
1665 'data' => $data,
1666 'title' => $data['title'] ?? '',
1667 'body' => $data['body'] ?? '',
1668 'userid' => $userid,
1669 'user_info' => $user_info,
1670 'forum' => $context['forum'] ?? null,
1671 'board_id' => $this->board_id,
1672 'settings' => $this->settings,
1673 'timestamp' => current_time( 'mysql' ),
1674 ];
1675 }
1676
1677 /**
1678 * Get user information relevant to moderation
1679 *
1680 * @param int $userid User ID
1681 * @return array User moderation info
1682 */
1683 protected function get_user_moderation_info( $userid ) {
1684 if ( ! $userid ) {
1685 return [
1686 'is_guest' => true,
1687 'is_new' => true,
1688 'is_trusted' => false,
1689 'is_moderator' => false,
1690 'post_count' => 0,
1691 'trust_level' => 0,
1692 'points' => 0,
1693 'status' => 'guest',
1694 'warnings' => 0,
1695 ];
1696 }
1697
1698 $member = WPF()->member->get_member( $userid );
1699 if ( empty( $member ) ) {
1700 return [
1701 'is_guest' => true,
1702 'is_new' => true,
1703 'is_trusted' => false,
1704 'is_moderator' => false,
1705 'is_admin' => false,
1706 'post_count' => 0,
1707 'trust_level' => 0,
1708 'points' => 0,
1709 'status' => 'unknown',
1710 'warnings' => 0,
1711 ];
1712 }
1713
1714 $post_count = (int) wpfval( $member, 'posts', 0 );
1715 $points = (float) wpfval( $member, 'points', 0 );
1716 $rating = wpfval( $member, 'rating', [] );
1717 $trust_level = (int) wpfval( $rating, 'level', 0 );
1718 $new_user_threshold = $this->get_setting( 'antispam', 'new_user_max_posts', 3 );
1719
1720 // Get display name
1721 $user = get_userdata( $userid );
1722 $display_name = $user ? $user->display_name : 'User';
1723
1724 return [
1725 'is_guest' => false,
1726 'is_new' => $post_count < $new_user_threshold,
1727 'is_trusted' => $trust_level >= 3, // Trusted Member level
1728 'is_moderator' => WPF()->usergroup->can( 'em' ), // Edit members permission
1729 'is_admin' => WPF()->usergroup->can( 'ms' ), // Manage settings permission
1730 'post_count' => $post_count,
1731 'trust_level' => $trust_level,
1732 'points' => $points,
1733 'status' => $member['status'] ?? 'active',
1734 'warnings' => $this->get_user_warning_count( $userid ),
1735 'display_name' => $display_name,
1736 'groupid' => (int) wpfval( $member, 'groupid', 0 ),
1737 'member' => $member,
1738 ];
1739 }
1740
1741 /**
1742 * Get user warning count
1743 *
1744 * @param int $userid User ID
1745 * @return int Warning count
1746 */
1747 protected function get_user_warning_count( $userid ) {
1748 // TODO: Implement warning tracking
1749 return 0;
1750 }
1751
1752 /**
1753 * Make moderation decision based on handler results
1754 *
1755 * @param array $results Handler results
1756 * @param array $analysis_context Analysis context
1757 * @return array Decision with action and reason
1758 */
1759 protected function make_decision( $results, $analysis_context ) {
1760 // Default: approve content
1761 $decision = [
1762 'action' => self::ACTION_APPROVE,
1763 'reason' => '',
1764 'primary_reason' => 'none',
1765 'confidence' => 100,
1766 'details' => [],
1767 'spam_score' => 0,
1768 'indicators' => [],
1769 'credits_used' => 0,
1770 ];
1771
1772 // Process spam detection results
1773 if ( ! empty( $results['spam'] ) ) {
1774 $spam_result = $results['spam'];
1775 $spam_score = $spam_result['spam_score'] ?? 0;
1776 $is_spam = $spam_result['is_spam'] ?? false;
1777 $confidence = $spam_result['confidence'] ?? 0.0;
1778
1779 $decision['spam_score'] = $spam_score;
1780 $decision['confidence'] = (int) ( $confidence * 100 );
1781 $decision['indicators'] = $spam_result['indicators'] ?? [];
1782 $decision['credits_used'] = $spam_result['credits_used'] ?? 0;
1783 $decision['details'] = [
1784 'type' => 'spam',
1785 'analysis_summary' => $spam_result['analysis_summary'] ?? '',
1786 'context_used' => $spam_result['context_used'] ?? false,
1787 ];
1788
1789 // Determine action based on spam score AND is_spam flag
1790 // The AI returns both a score AND a boolean is_spam judgment
1791 // We trust the is_spam flag when confidence is high enough
1792 $threshold_detected = $this->get_spam_threshold_detected(); // 90
1793 $threshold_suspected = $this->get_spam_threshold_suspected(); // 70
1794 $threshold_clean = $this->get_spam_threshold_clean(); // 50
1795
1796 // If AI explicitly says is_spam=true with decent confidence (>= 60%),
1797 // treat as suspected even if score is below threshold
1798 $ai_flag_threshold = 60; // Minimum confidence to trust is_spam flag
1799 $trust_ai_flag = $is_spam && ( $confidence * 100 ) >= $ai_flag_threshold && $spam_score > $threshold_clean;
1800
1801 if ( $spam_score >= $threshold_detected ) {
1802 // High confidence spam - use detected action
1803 $action = $this->get_spam_action_detected();
1804 $decision['reason'] = sprintf(
1805 wpforo_phrase( 'Spam detected (score: %d%%). %s', false ),
1806 $spam_score,
1807 $spam_result['analysis_summary'] ?? ''
1808 );
1809 $decision = $this->apply_spam_action( $decision, $action, 'detected' );
1810
1811 } elseif ( $spam_score >= $threshold_suspected ) {
1812 // Suspicious content - use suspected action
1813 $action = $this->get_spam_action_suspected();
1814 $decision['reason'] = sprintf(
1815 wpforo_phrase( 'Spam suspected (score: %d%%). %s', false ),
1816 $spam_score,
1817 $spam_result['analysis_summary'] ?? ''
1818 );
1819 $decision = $this->apply_spam_action( $decision, $action, 'suspected' );
1820
1821 } elseif ( $spam_score <= $threshold_clean ) {
1822 // Clean content - use clean action
1823 $action = $this->get_spam_action_clean();
1824 $decision['reason'] = sprintf(
1825 wpforo_phrase( 'Content passed spam check (score: %d%%).', false ),
1826 $spam_score
1827 );
1828 $decision = $this->apply_spam_action( $decision, $action, 'clean' );
1829
1830 } elseif ( $trust_ai_flag ) {
1831 // AI says is_spam=true with confidence, treat as suspected (override uncertain)
1832 $action = $this->get_spam_action_suspected();
1833 $decision['reason'] = sprintf(
1834 wpforo_phrase( 'AI flagged as spam (score: %d%%, confidence: %d%%). %s', false ),
1835 $spam_score,
1836 (int) ( $confidence * 100 ),
1837 $spam_result['analysis_summary'] ?? ''
1838 );
1839 $decision = $this->apply_spam_action( $decision, $action, 'suspected' );
1840
1841 } else {
1842 // Uncertain (score 41-69%) - use uncertain action setting
1843 $action = $this->get_spam_action_uncertain();
1844 $decision['reason'] = sprintf(
1845 wpforo_phrase( 'Spam uncertain (score: %d%%). %s', false ),
1846 $spam_score,
1847 $spam_result['analysis_summary'] ?? ''
1848 );
1849 $decision = $this->apply_spam_action( $decision, $action, 'uncertain' );
1850 }
1851 }
1852
1853 // Process toxicity detection results (if enabled)
1854 // Toxicity can override approve decision, but not a more severe action
1855 if ( ! empty( $results['spam']['toxicity'] ) ) {
1856 $toxicity_result = $results['spam']['toxicity'];
1857 $is_toxic = $toxicity_result['is_toxic'] ?? false;
1858
1859 if ( $is_toxic ) {
1860 $toxicity_score = $toxicity_result['score'] ?? 0;
1861 $toxicity_action = $this->get_toxicity_action();
1862
1863 // Only apply toxicity action if it's more severe than current decision
1864 // or if content was approved
1865 $should_apply = ( $decision['action'] === self::ACTION_APPROVE );
1866
1867 if ( $should_apply ) {
1868 $decision['toxicity_score'] = $toxicity_score;
1869 $decision['toxicity_categories'] = $toxicity_result['categories'] ?? [];
1870 $decision['details']['toxicity'] = [
1871 'summary' => $toxicity_result['summary'] ?? '',
1872 'categories' => $toxicity_result['categories'] ?? [],
1873 ];
1874
1875 switch ( $toxicity_action ) {
1876 case 'unapprove':
1877 $decision['action'] = self::ACTION_HOLD;
1878 $decision['primary_reason'] = 'toxicity';
1879 $decision['reason'] = sprintf(
1880 wpforo_phrase( 'Toxic content detected (score: %d%%). %s', false ),
1881 $toxicity_score,
1882 $toxicity_result['summary'] ?? ''
1883 );
1884 break;
1885
1886 case 'unapprove_ban':
1887 $decision['action'] = self::ACTION_HOLD;
1888 $decision['user_action'] = self::ACTION_BAN_USER;
1889 $decision['primary_reason'] = 'toxicity';
1890 $decision['reason'] = sprintf(
1891 wpforo_phrase( 'Toxic content detected (score: %d%%). User banned. %s', false ),
1892 $toxicity_score,
1893 $toxicity_result['summary'] ?? ''
1894 );
1895 break;
1896
1897 case 'none':
1898 default:
1899 // Log but take no action
1900 $decision['reason'] .= sprintf(
1901 wpforo_phrase( ' Toxicity noted (score: %d%%).', false ),
1902 $toxicity_score
1903 );
1904 break;
1905 }
1906 } else {
1907 // Append toxicity info to reason
1908 $decision['reason'] .= sprintf(
1909 wpforo_phrase( ' Also toxic (score: %d%%).', false ),
1910 $toxicity_result['score'] ?? 0
1911 );
1912 }
1913 }
1914 }
1915
1916 // Process compliance results (if enabled)
1917 // Compliance can override approve decision, but not a more severe action
1918 if ( ! empty( $results['spam']['compliance'] ) ) {
1919 $compliance_result = $results['spam']['compliance'];
1920 $is_compliant = $compliance_result['is_compliant'] ?? true;
1921
1922 if ( ! $is_compliant ) {
1923 $compliance_score = $compliance_result['score'] ?? 0;
1924 $compliance_action = $this->get_compliance_action();
1925 $violations = $compliance_result['violations'] ?? [];
1926
1927 // Only apply compliance action if content was approved
1928 $should_apply = ( $decision['action'] === self::ACTION_APPROVE );
1929
1930 if ( $should_apply ) {
1931 $decision['compliance_score'] = $compliance_score;
1932 $decision['compliance_violations'] = $violations;
1933 $decision['details']['compliance'] = [
1934 'summary' => $compliance_result['summary'] ?? '',
1935 'violations' => $violations,
1936 ];
1937
1938 switch ( $compliance_action ) {
1939 case 'unapprove':
1940 $decision['action'] = self::ACTION_HOLD;
1941 $decision['primary_reason'] = 'compliance';
1942 $decision['reason'] = sprintf(
1943 wpforo_phrase( 'Policy violation detected (score: %d%%). %s', false ),
1944 $compliance_score,
1945 $compliance_result['summary'] ?? ''
1946 );
1947 break;
1948
1949 case 'unapprove_ban':
1950 $decision['action'] = self::ACTION_HOLD;
1951 $decision['user_action'] = self::ACTION_BAN_USER;
1952 $decision['primary_reason'] = 'compliance';
1953 $decision['reason'] = sprintf(
1954 wpforo_phrase( 'Policy violation detected (score: %d%%). User banned. %s', false ),
1955 $compliance_score,
1956 $compliance_result['summary'] ?? ''
1957 );
1958 break;
1959
1960 case 'none':
1961 default:
1962 // Log but take no action
1963 $decision['reason'] .= sprintf(
1964 wpforo_phrase( ' Policy violation noted (score: %d%%).', false ),
1965 $compliance_score
1966 );
1967 break;
1968 }
1969 } else {
1970 // Append compliance info to reason
1971 $decision['reason'] .= sprintf(
1972 wpforo_phrase( ' Also violates policy (score: %d%%).', false ),
1973 $compliance_result['score'] ?? 0
1974 );
1975 }
1976 }
1977 }
1978
1979 // Allow external decision making (can override our decision)
1980 $decision = apply_filters( 'wpforo_ai_moderation_decision', $decision, $results, $analysis_context );
1981
1982 \wpforo_ai_log( 'info', 'Final decision: action=' . $decision['action'] . ', reason=' . $decision['reason'], 'Moderation' );
1983 return $decision;
1984 }
1985
1986 /**
1987 * Apply spam action to decision
1988 *
1989 * @param array $decision Decision array
1990 * @param string $action Action setting (unapprove, unapprove_ban, delete_author, none, auto_approve)
1991 * @param string $level Detection level (detected, suspected, clean)
1992 * @return array Modified decision
1993 */
1994 protected function apply_spam_action( $decision, $action, $level ) {
1995 switch ( $action ) {
1996 case self::SPAM_ACTION_UNAPPROVE:
1997 $decision['action'] = self::ACTION_HOLD;
1998 $decision['primary_reason'] = 'spam';
1999 break;
2000
2001 case self::SPAM_ACTION_UNAPPROVE_BAN:
2002 $decision['action'] = self::ACTION_HOLD;
2003 $decision['user_action'] = self::ACTION_BAN_USER;
2004 $decision['primary_reason'] = 'spam';
2005 break;
2006
2007 case self::SPAM_ACTION_DELETE_AUTHOR:
2008 $decision['action'] = self::ACTION_DELETE;
2009 $decision['user_action'] = self::ACTION_BAN_USER;
2010 $decision['primary_reason'] = 'spam';
2011 break;
2012
2013 case self::SPAM_ACTION_AUTO_APPROVE:
2014 $decision['action'] = self::ACTION_APPROVE;
2015 break;
2016
2017 case self::SPAM_ACTION_NONE:
2018 default:
2019 // No action - keep current decision
2020 break;
2021 }
2022
2023 $decision['action_level'] = $level;
2024 return $decision;
2025 }
2026
2027 /**
2028 * Execute moderation decision
2029 *
2030 * @param array $data Content data
2031 * @param array $decision Moderation decision
2032 * @param array $analysis_context Analysis context
2033 * @return array Modified content data
2034 */
2035 protected function execute_decision( $data, $decision, $analysis_context ) {
2036 \wpforo_ai_log( 'info', 'execute_decision() - action: ' . $decision['action'], 'Moderation' );
2037 switch ( $decision['action'] ) {
2038 case self::ACTION_HOLD:
2039 \wpforo_ai_log( 'info', 'Setting status to 1 (unapproved)', 'Moderation' );
2040 // Set status to unapproved
2041 $data['status'] = 1;
2042 break;
2043
2044 case self::ACTION_REJECT:
2045 case self::ACTION_DELETE:
2046 // Return empty to block content creation
2047 return [];
2048
2049 case self::ACTION_EDIT:
2050 // Apply content modifications
2051 if ( ! empty( $decision['modifications'] ) ) {
2052 $data = array_merge( $data, $decision['modifications'] );
2053 }
2054 break;
2055
2056 case self::ACTION_APPROVE:
2057 default:
2058 // Allow content as-is
2059 break;
2060 }
2061
2062 // Apply any user-level actions
2063 if ( ! empty( $decision['user_action'] ) ) {
2064 $this->execute_user_action( $decision['user_action'], $analysis_context['userid'], $decision['reason'] );
2065 }
2066
2067 return $data;
2068 }
2069
2070 /**
2071 * Execute user-level moderation action
2072 *
2073 * @param string $action Action to take
2074 * @param int $userid User ID
2075 * @param string $reason Reason for action
2076 */
2077 protected function execute_user_action( $action, $userid, $reason = '' ) {
2078 if ( ! $userid ) {
2079 return;
2080 }
2081
2082 switch ( $action ) {
2083 case self::ACTION_WARN_USER:
2084 $this->warn_user( $userid, $reason );
2085 break;
2086
2087 case self::ACTION_BAN_USER:
2088 $this->ban_user( $userid, $reason );
2089 break;
2090
2091 case self::ACTION_SUSPEND_USER:
2092 $this->suspend_user( $userid, $reason );
2093 break;
2094 }
2095 }
2096
2097 // =========================================================================
2098 // POST-EVENT HOOKS (After save)
2099 // =========================================================================
2100
2101 /**
2102 * Called after topic is created
2103 *
2104 * @param array $topic Topic data
2105 * @param array $forum Forum data
2106 */
2107 public function on_topic_created( $topic, $forum ) {
2108 // Update moderation log with actual topic ID (was 0 during filter)
2109 $this->update_pending_moderation_log( self::CONTENT_TOPIC, $topic, $forum );
2110
2111 do_action( 'wpforo_ai_moderation_topic_created', $topic, $forum );
2112 }
2113
2114 /**
2115 * Called after topic is edited
2116 *
2117 * @param array $topic_data Full topic data
2118 * @param array $args Edit arguments
2119 * @param array $forum Forum data
2120 */
2121 public function on_topic_edited( $topic_data, $args, $forum ) {
2122 do_action( 'wpforo_ai_moderation_topic_edited', $topic_data, $args, $forum );
2123 }
2124
2125 /**
2126 * Called when topic is approved
2127 *
2128 * @param array $topic Topic data
2129 */
2130 public function on_topic_approved( $topic ) {
2131 do_action( 'wpforo_ai_moderation_topic_approved', $topic );
2132 }
2133
2134 /**
2135 * Called when topic is unapproved
2136 *
2137 * @param array $topic Topic data
2138 */
2139 public function on_topic_unapproved( $topic ) {
2140 do_action( 'wpforo_ai_moderation_topic_unapproved', $topic );
2141 }
2142
2143 /**
2144 * Called on any topic status change
2145 *
2146 * @param array $topic Topic data
2147 * @param int $status New status
2148 */
2149 public function on_topic_status_change( $topic, $status ) {
2150 do_action( 'wpforo_ai_moderation_topic_status_changed', $topic, $status );
2151 }
2152
2153 /**
2154 * Called before topic deletion
2155 *
2156 * @param array $topic Topic data
2157 */
2158 public function on_before_topic_delete( $topic ) {
2159 do_action( 'wpforo_ai_moderation_before_topic_delete', $topic );
2160 }
2161
2162 /**
2163 * Called after topic deletion
2164 *
2165 * Deletes the moderation log from local database since deleted content
2166 * no longer needs the report displayed. CloudWatch logs remain intact.
2167 *
2168 * @param array $topic Topic data
2169 */
2170 public function on_topic_deleted( $topic ) {
2171 if ( ! empty( $topic['topicid'] ) ) {
2172 $this->delete_moderation_log( self::CONTENT_TOPIC, (int) $topic['topicid'] );
2173 }
2174
2175 do_action( 'wpforo_ai_moderation_topic_deleted', $topic );
2176 }
2177
2178 /**
2179 * Called after topic is moved
2180 *
2181 * @param array $topic Topic data
2182 * @param int $forumid New forum ID
2183 */
2184 public function on_topic_moved( $topic, $forumid ) {
2185 do_action( 'wpforo_ai_moderation_topic_moved', $topic, $forumid );
2186 }
2187
2188 /**
2189 * Called after topics are merged
2190 *
2191 * @param array $target Target topic
2192 * @param array $current Source topic
2193 * @param array $postids Merged post IDs
2194 * @param bool $to_target_title Update titles
2195 * @param bool $append Append posts
2196 */
2197 public function on_topics_merged( $target, $current, $postids, $to_target_title, $append ) {
2198 do_action( 'wpforo_ai_moderation_topics_merged', $target, $current, $postids );
2199 }
2200
2201 /**
2202 * Called after post is created
2203 *
2204 * @param array $post Post data
2205 * @param array $topic Topic data
2206 * @param array $forum Forum data
2207 */
2208 public function on_post_created( $post, $topic, $forum ) {
2209 // Update moderation log with actual post ID (was 0 during filter)
2210 $this->update_pending_moderation_log( self::CONTENT_POST, $post, $forum );
2211
2212 do_action( 'wpforo_ai_moderation_post_created', $post, $topic, $forum );
2213 }
2214
2215 /**
2216 * Called after post is edited
2217 *
2218 * @param array $post Post data
2219 * @param array $topic Topic data
2220 * @param array $forum Forum data
2221 * @param array $args Edit arguments
2222 */
2223 public function on_post_edited( $post, $topic, $forum, $args ) {
2224 do_action( 'wpforo_ai_moderation_post_edited', $post, $topic, $forum, $args );
2225 }
2226
2227 /**
2228 * Called when post is unapproved
2229 *
2230 * @param array $post Post data
2231 */
2232 public function on_post_unapproved( $post ) {
2233 do_action( 'wpforo_ai_moderation_post_unapproved', $post );
2234 }
2235
2236 /**
2237 * Called on any post status change
2238 *
2239 * @param array $post Post data
2240 * @param int $status New status
2241 */
2242 public function on_post_status_change( $post, $status ) {
2243 do_action( 'wpforo_ai_moderation_post_status_changed', $post, $status );
2244 }
2245
2246 /**
2247 * Called before post deletion
2248 *
2249 * @param array $post Post data
2250 */
2251 public function on_before_post_delete( $post ) {
2252 do_action( 'wpforo_ai_moderation_before_post_delete', $post );
2253 }
2254
2255 /**
2256 * Called after post deletion
2257 *
2258 * Deletes the moderation log from local database since deleted content
2259 * no longer needs the report displayed. CloudWatch logs remain intact.
2260 *
2261 * @param array $post Post data
2262 */
2263 public function on_post_deleted( $post ) {
2264 if ( ! empty( $post['postid'] ) ) {
2265 // Check if this is the first post (topic) or a reply
2266 $is_first_post = ! empty( $post['is_first_post'] );
2267 $content_type = $is_first_post ? self::CONTENT_TOPIC : self::CONTENT_POST;
2268 $content_id = $is_first_post ? ( $post['topicid'] ?? $post['postid'] ) : $post['postid'];
2269
2270 $this->delete_moderation_log( $content_type, (int) $content_id );
2271 }
2272
2273 do_action( 'wpforo_ai_moderation_post_deleted', $post );
2274 }
2275
2276 /**
2277 * Called when user is banned
2278 *
2279 * @param int $userid User ID
2280 */
2281 public function on_user_banned( $userid ) {
2282 do_action( 'wpforo_ai_moderation_user_banned', $userid );
2283 }
2284
2285 /**
2286 * Called when user is unbanned
2287 *
2288 * @param int $userid User ID
2289 */
2290 public function on_user_unbanned( $userid ) {
2291 do_action( 'wpforo_ai_moderation_user_unbanned', $userid );
2292 }
2293
2294 /**
2295 * Called when a post is approved
2296 *
2297 * Deletes the moderation report from local database since approved content
2298 * no longer needs the report displayed. CloudWatch logs remain intact.
2299 *
2300 * @param array $post Post data
2301 */
2302 public function on_post_approved( $post ) {
2303 if ( empty( $post['postid'] ) ) {
2304 return;
2305 }
2306
2307 // Check if this is the first post (topic) or a reply
2308 $is_first_post = ! empty( $post['is_first_post'] );
2309 $content_type = $is_first_post ? self::CONTENT_TOPIC : self::CONTENT_POST;
2310 $content_id = $is_first_post ? ( $post['topicid'] ?? $post['postid'] ) : $post['postid'];
2311
2312 $this->delete_moderation_log( $content_type, (int) $content_id );
2313 }
2314
2315 /**
2316 * Delete moderation log from local database
2317 *
2318 * Removes the moderation report for approved content.
2319 * This only affects the local wpForo database - CloudWatch logs are preserved.
2320 *
2321 * @param string $content_type Content type (topic, post)
2322 * @param int $content_id Content ID
2323 * @return bool True if deleted, false otherwise
2324 */
2325 public function delete_moderation_log( $content_type, $content_id ) {
2326 global $wpdb;
2327
2328 if ( empty( $content_type ) || empty( $content_id ) ) {
2329 return false;
2330 }
2331
2332 $result = $wpdb->delete(
2333 WPF()->tables->ai_moderation,
2334 [
2335 'content_type' => $content_type,
2336 'content_id' => $content_id,
2337 ],
2338 [ '%s', '%d' ]
2339 );
2340
2341 return $result !== false;
2342 }
2343
2344 // =========================================================================
2345 // MODERATION ACTIONS
2346 // =========================================================================
2347
2348 /**
2349 * Approve a topic
2350 *
2351 * @param int $topicid Topic ID
2352 * @return bool Success
2353 */
2354 public function approve_topic( $topicid ) {
2355 return WPF()->topic->set_status( $topicid, 0 );
2356 }
2357
2358 /**
2359 * Unapprove/hold a topic
2360 *
2361 * @param int $topicid Topic ID
2362 * @return bool Success
2363 */
2364 public function hold_topic( $topicid ) {
2365 return WPF()->topic->set_status( $topicid, 1 );
2366 }
2367
2368 /**
2369 * Delete a topic
2370 *
2371 * @param int $topicid Topic ID
2372 * @param bool $check_permissions Check user permissions
2373 * @return bool Success
2374 */
2375 public function delete_topic( $topicid, $check_permissions = false ) {
2376 return WPF()->topic->delete( $topicid, true, $check_permissions );
2377 }
2378
2379 /**
2380 * Move a topic to different forum
2381 *
2382 * @param int $topicid Topic ID
2383 * @param int $forumid Target forum ID
2384 * @return bool Success
2385 */
2386 public function move_topic( $topicid, $forumid ) {
2387 return WPF()->topic->move( $topicid, $forumid );
2388 }
2389
2390 /**
2391 * Close a topic (lock)
2392 *
2393 * @param int $topicid Topic ID
2394 * @return bool Success
2395 */
2396 public function close_topic( $topicid ) {
2397 return WPF()->topic->close( $topicid );
2398 }
2399
2400 /**
2401 * Open a topic (unlock)
2402 *
2403 * @param int $topicid Topic ID
2404 * @return bool Success
2405 */
2406 public function open_topic( $topicid ) {
2407 return WPF()->topic->open( $topicid );
2408 }
2409
2410 /**
2411 * Merge topics
2412 *
2413 * @param int $target_topicid Target topic ID
2414 * @param int $source_topicid Source topic ID
2415 * @param array $postids Specific post IDs to merge (empty = all)
2416 * @param bool $update_titles Update post titles to match target
2417 * @param bool $append Append posts to end of target
2418 * @return bool Success
2419 */
2420 public function merge_topics( $target_topicid, $source_topicid, $postids = [], $update_titles = false, $append = true ) {
2421 $target = WPF()->topic->get_topic( $target_topicid );
2422 $source = WPF()->topic->get_topic( $source_topicid );
2423
2424 if ( ! $target || ! $source ) {
2425 return false;
2426 }
2427
2428 return WPF()->topic->merge( $target, $source, $postids, $update_titles, $append );
2429 }
2430
2431 /**
2432 * Approve a post
2433 *
2434 * @param int $postid Post ID
2435 * @return bool Success
2436 */
2437 public function approve_post( $postid ) {
2438 return WPF()->post->set_status( $postid, 0 );
2439 }
2440
2441 /**
2442 * Unapprove/hold a post
2443 *
2444 * @param int $postid Post ID
2445 * @return bool Success
2446 */
2447 public function hold_post( $postid ) {
2448 return WPF()->post->set_status( $postid, 1 );
2449 }
2450
2451 /**
2452 * Delete a post
2453 *
2454 * @param int $postid Post ID
2455 * @param bool $check_permissions Check user permissions
2456 * @return bool Success
2457 */
2458 public function delete_post( $postid, $check_permissions = false ) {
2459 return WPF()->post->delete( $postid, true, true, [], $check_permissions );
2460 }
2461
2462 /**
2463 * Edit content (redact PII, profanity, etc.)
2464 *
2465 * @param int $id Content ID (topic or post)
2466 * @param string $content_type Content type (topic or post)
2467 * @param string $new_body New body content
2468 * @param string $new_title New title (optional, for topics)
2469 * @return bool Success
2470 */
2471 public function edit_content( $id, $content_type, $new_body, $new_title = null ) {
2472 global $wpdb;
2473
2474 if ( $content_type === self::CONTENT_TOPIC ) {
2475 $table = WPF()->tables->topics;
2476 $id_column = 'topicid';
2477 $update_data = [ 'body' => $new_body ];
2478 if ( $new_title !== null ) {
2479 $update_data['title'] = $new_title;
2480 }
2481 } else {
2482 $table = WPF()->tables->posts;
2483 $id_column = 'postid';
2484 $update_data = [ 'body' => $new_body ];
2485 if ( $new_title !== null ) {
2486 $update_data['title'] = $new_title;
2487 }
2488 }
2489
2490 $result = $wpdb->update(
2491 $table,
2492 $update_data,
2493 [ $id_column => $id ]
2494 );
2495
2496 WPF()->ram_cache->clean( $content_type );
2497
2498 return $result !== false;
2499 }
2500
2501 /**
2502 * Warn a user
2503 *
2504 * @param int $userid User ID
2505 * @param string $reason Warning reason
2506 * @return bool Success
2507 */
2508 public function warn_user( $userid, $reason = '' ) {
2509 // TODO: Implement user warning system
2510 // This would involve:
2511 // 1. Storing warning in database
2512 // 2. Sending notification to user
2513 // 3. Incrementing warning count
2514 do_action( 'wpforo_ai_moderation_user_warned', $userid, $reason );
2515 return true;
2516 }
2517
2518 /**
2519 * Ban a user
2520 *
2521 * @param int $userid User ID
2522 * @param string $reason Ban reason
2523 * @return bool Success
2524 */
2525 public function ban_user( $userid, $reason = '' ) {
2526 // Use direct database update to bypass wpForo's "can't ban yourself" check
2527 // This is necessary because AI moderation runs in the context of the posting user
2528 global $wpdb;
2529
2530 // Get the user's profile table
2531 $profile_table = WPF()->tables->profiles;
2532
2533 // Update user status to 'banned' directly
2534 // wpForo uses the 'status' field for banning, not a separate usergroup
2535 $result = $wpdb->update(
2536 $profile_table,
2537 [ 'status' => 'banned' ],
2538 [ 'userid' => (int) $userid ],
2539 [ '%s' ],
2540 [ '%d' ]
2541 );
2542
2543 // Clear user cache to reflect the ban immediately
2544 WPF()->member->reset( $userid );
2545
2546 // Also clear general wpForo caches that may reference this user
2547 if ( function_exists( 'wpforo_clean_cache' ) ) {
2548 wpforo_clean_cache( 'user', $userid );
2549 }
2550
2551 if ( $result !== false ) {
2552 do_action( 'wpforo_ai_moderation_user_banned_by_ai', $userid, $reason );
2553
2554 // Log the ban action
2555 \wpforo_ai_log( 'info', sprintf( 'User #%d banned by AI. Reason: %s', $userid, $reason ), 'Moderation' );
2556 }
2557
2558 return $result !== false;
2559 }
2560
2561 /**
2562 * Suspend a user temporarily
2563 *
2564 * @param int $userid User ID
2565 * @param string $reason Suspension reason
2566 * @param int $duration Duration in seconds (0 = permanent)
2567 * @return bool Success
2568 */
2569 public function suspend_user( $userid, $reason = '', $duration = 0 ) {
2570 // Deactivate user (wpForo's version of suspension)
2571 $result = WPF()->member->deactivate( $userid );
2572
2573 if ( $result && $duration > 0 ) {
2574 // Schedule reactivation
2575 wp_schedule_single_event(
2576 time() + $duration,
2577 'wpforo_ai_moderation_reactivate_user',
2578 [ $userid ]
2579 );
2580 }
2581
2582 if ( $result ) {
2583 do_action( 'wpforo_ai_moderation_user_suspended', $userid, $reason, $duration );
2584 }
2585
2586 return $result;
2587 }
2588
2589 // =========================================================================
2590 // LOGGING
2591 // =========================================================================
2592
2593 /**
2594 * Log moderation action
2595 *
2596 * @param array $context Analysis context
2597 * @param array $results Handler results
2598 * @param array $decision Moderation decision
2599 */
2600 protected function log_moderation( $context, $results, $decision ) {
2601 // Only log if there was actual AI analysis
2602 if ( empty( $results ) && $decision['action'] === self::ACTION_APPROVE ) {
2603 return;
2604 }
2605
2606 // Determine action taken string
2607 $action_taken = 'none';
2608 switch ( $decision['action'] ) {
2609 case self::ACTION_HOLD:
2610 $action_taken = ! empty( $decision['user_action'] ) && $decision['user_action'] === self::ACTION_BAN_USER
2611 ? 'unapprove_ban'
2612 : 'unapprove';
2613 break;
2614 case self::ACTION_DELETE:
2615 $action_taken = 'delete_author';
2616 break;
2617 case self::ACTION_APPROVE:
2618 $action_taken = $decision['spam_score'] > 0 ? 'auto_approve' : 'approve';
2619 break;
2620 }
2621
2622 // Build log data
2623 $forum = $context['forum'] ?? [];
2624
2625 // Determine moderation type and score based on what was detected
2626 $spam_score = $decision['spam_score'] ?? 0;
2627 $toxicity_score = $decision['toxicity_score'] ?? 0;
2628 $compliance_score = $decision['compliance_score'] ?? 0;
2629
2630 // Initialize with defaults (will be overwritten below)
2631 $moderation_type = 'spam';
2632 $score = 0;
2633
2634 // Use primary_reason from API response if available (preferred method)
2635 $primary_reason = $decision['primary_reason'] ?? null;
2636 if ( $primary_reason && $primary_reason !== 'none' ) {
2637 // Map API primary_reason to moderation_type
2638 switch ( $primary_reason ) {
2639 case 'spam':
2640 $moderation_type = 'spam';
2641 $score = $spam_score;
2642 break;
2643 case 'toxicity':
2644 $moderation_type = 'toxicity';
2645 $score = $toxicity_score;
2646 break;
2647 case 'compliance':
2648 $moderation_type = 'compliance';
2649 $score = $compliance_score;
2650 break;
2651 default:
2652 // Unknown reason, fall through to score-based logic
2653 $primary_reason = null;
2654 }
2655 }
2656
2657 // Fallback: Use the highest score and appropriate type (priority: compliance > toxicity > spam)
2658 if ( ! $primary_reason || $primary_reason === 'none' ) {
2659 if ( $compliance_score > $spam_score && $compliance_score > $toxicity_score && $compliance_score > 0 ) {
2660 $moderation_type = 'compliance';
2661 $score = $compliance_score;
2662 } elseif ( $toxicity_score > $spam_score && $toxicity_score > 0 ) {
2663 $moderation_type = 'toxicity';
2664 $score = $toxicity_score;
2665 } elseif ( $spam_score > 0 ) {
2666 $moderation_type = 'spam';
2667 $score = $spam_score;
2668 } elseif ( $toxicity_score > 0 ) {
2669 $moderation_type = 'toxicity';
2670 $score = $toxicity_score;
2671 } elseif ( $compliance_score > 0 ) {
2672 $moderation_type = 'compliance';
2673 $score = $compliance_score;
2674 } else {
2675 $moderation_type = 'spam';
2676 $score = 0;
2677 }
2678 }
2679
2680 $log_data = [
2681 'content_type' => $context['content_type'],
2682 'content_id' => 0, // Not saved yet, will be updated after save
2683 'topicid' => 0, // Will be updated after save
2684 'forumid' => (int) ( $forum['forumid'] ?? 0 ),
2685 'userid' => $context['userid'],
2686 'moderation_type' => $moderation_type,
2687 'score' => $score,
2688 'is_flagged' => ( $decision['action'] !== self::ACTION_APPROVE ) ? 1 : 0,
2689 'confidence' => ( $decision['confidence'] ?? 100 ) / 100,
2690 'action_taken' => $action_taken,
2691 'action_reason' => $decision['action_level'] ?? null,
2692 'analysis_summary' => $decision['reason'] ?? null,
2693 'indicators' => ! empty( $decision['indicators'] ) ? wp_json_encode( $decision['indicators'] ) : null,
2694 'quality_tier' => $this->get_spam_quality(),
2695 'credits_used' => $decision['credits_used'] ?? 0,
2696 'content_preview' => isset( $context['title'] ) ? wp_trim_words( $context['title'], 20 ) : null,
2697 ];
2698
2699 $this->save_moderation_log( $log_data );
2700
2701 // Also log to AI Logs for visibility in AI Features > AI Logs tab
2702 $this->log_to_ai_logs( $context, $decision, $log_data );
2703
2704 do_action( 'wpforo_ai_moderation_logged', $context, $results, $decision );
2705 }
2706
2707 /**
2708 * Log moderation action to AI Logs table
2709 *
2710 * This ensures moderation actions appear in the AI Features > AI Logs tab
2711 * alongside other AI actions (search, translation, etc.)
2712 *
2713 * @param array $context Analysis context
2714 * @param array $decision Moderation decision
2715 * @param array $log_data Moderation log data
2716 */
2717 protected function log_to_ai_logs( $context, $decision, $log_data ) {
2718 if ( ! isset( WPF()->ai_logs ) || ! method_exists( WPF()->ai_logs, 'log' ) ) {
2719 return;
2720 }
2721
2722 // Determine action type based on moderation type
2723 $action_type = 'moderation';
2724 if ( $log_data['moderation_type'] === 'spam' ) {
2725 $action_type = 'spam_detection';
2726 }
2727
2728 // Status is always 'success' since the moderation completed
2729 // The action_taken and response_summary indicate if content was flagged
2730 $status = 'success';
2731
2732 // Build request summary
2733 $request_summary = sprintf(
2734 '%s %s: "%s"',
2735 ucfirst( $context['content_type'] ?? 'content' ),
2736 $context['event_type'] ?? 'submitted',
2737 wp_trim_words( $context['title'] ?? $context['body'] ?? '', 10 )
2738 );
2739
2740 // Build response summary
2741 $response_parts = [];
2742 if ( $log_data['score'] > 0 ) {
2743 $response_parts[] = sprintf( '%s score: %d%%', ucfirst( $log_data['moderation_type'] ), $log_data['score'] );
2744 }
2745 if ( $log_data['action_taken'] && $log_data['action_taken'] !== 'none' ) {
2746 $response_parts[] = sprintf( 'Action: %s', str_replace( '_', ' ', $log_data['action_taken'] ) );
2747 }
2748 if ( ! empty( $decision['reason'] ) ) {
2749 $response_parts[] = $decision['reason'];
2750 }
2751 $response_summary = implode( ' | ', $response_parts );
2752
2753 // Prepare extra data for detailed view
2754 $extra_data = [
2755 'moderation_type' => $log_data['moderation_type'],
2756 'score' => $log_data['score'],
2757 'is_flagged' => $log_data['is_flagged'],
2758 'confidence' => $log_data['confidence'],
2759 'action_taken' => $log_data['action_taken'],
2760 'action_reason' => $log_data['action_reason'],
2761 'quality_tier' => $log_data['quality_tier'],
2762 ];
2763 if ( ! empty( $decision['indicators'] ) ) {
2764 $extra_data['indicators'] = $decision['indicators'];
2765 }
2766
2767 WPF()->ai_logs->log( [
2768 'action_type' => $action_type,
2769 'userid' => $context['userid'] ?? 0,
2770 'user_type' => ( $context['userid'] ?? 0 ) > 0 ? 'user' : 'guest',
2771 'credits_used' => $log_data['credits_used'] ?? 0,
2772 'status' => $status,
2773 'content_type' => $context['content_type'] ?? null,
2774 'content_id' => $log_data['content_id'] ?? null,
2775 'forumid' => $log_data['forumid'] ?? null,
2776 'topicid' => $log_data['topicid'] ?? null,
2777 'request_summary' => $request_summary,
2778 'response_summary' => $response_summary,
2779 'duration_ms' => $log_data['detection_time_ms'] ?? 0,
2780 'extra_data' => wp_json_encode( $extra_data ),
2781 ] );
2782 }
2783
2784 /**
2785 * Log flood control action to AI Logs table
2786 *
2787 * @param string $content_type 'topic' or 'post'
2788 * @param int $userid User ID
2789 * @param string $flood_reason Flood reason code
2790 * @param string $analysis_summary Human-readable message
2791 * @param array $log_data Moderation log data
2792 */
2793 protected function log_flood_to_ai_logs( $content_type, $userid, $flood_reason, $analysis_summary, $log_data ) {
2794 if ( ! isset( WPF()->ai_logs ) || ! method_exists( WPF()->ai_logs, 'log' ) ) {
2795 return;
2796 }
2797
2798 $request_summary = sprintf(
2799 '%s submitted by user',
2800 ucfirst( $content_type )
2801 );
2802
2803 $response_summary = sprintf(
2804 'Flood protection: %s | Action: unapproved',
2805 $analysis_summary
2806 );
2807
2808 $extra_data = [
2809 'moderation_type' => 'flood',
2810 'flood_reason' => $flood_reason,
2811 'score' => 100,
2812 'is_flagged' => 1,
2813 'action_taken' => 'unapprove',
2814 'quality_tier' => 'rule_based',
2815 ];
2816
2817 WPF()->ai_logs->log( [
2818 'action_type' => 'moderation',
2819 'userid' => $userid,
2820 'user_type' => $userid > 0 ? 'user' : 'guest',
2821 'credits_used' => 0,
2822 'status' => 'success',
2823 'content_type' => $content_type,
2824 'content_id' => $log_data['content_id'] ?? null,
2825 'forumid' => $log_data['forumid'] ?? null,
2826 'topicid' => $log_data['topicid'] ?? null,
2827 'request_summary' => $request_summary,
2828 'response_summary' => $response_summary,
2829 'duration_ms' => 0,
2830 'extra_data' => wp_json_encode( $extra_data ),
2831 ] );
2832 }
2833
2834 // =========================================================================
2835 // UTILITY METHODS
2836 // =========================================================================
2837
2838 /**
2839 * Check if user is exempt from moderation
2840 *
2841 * Moderators and admins can be exempt from AI moderation.
2842 *
2843 * @param int $userid User ID
2844 * @return bool True if exempt
2845 */
2846 public function is_user_exempt( $userid ) {
2847 if ( ! $userid ) {
2848 return false;
2849 }
2850
2851 // Admins are always exempt
2852 if ( WPF()->usergroup->can( 'ms' ) ) { // Manage settings = admin
2853 return true;
2854 }
2855
2856 // Check if moderators are exempt (configurable)
2857 $moderators_exempt = apply_filters( 'wpforo_ai_moderation_moderators_exempt', true );
2858 if ( $moderators_exempt && WPF()->usergroup->can( 'em' ) ) { // Edit members = moderator
2859 return true;
2860 }
2861
2862 return false;
2863 }
2864
2865 /**
2866 * Get human-readable message for flood protection moderation
2867 *
2868 * @param string $flood_reason The flood reason code (per_minute, per_hour, ip_per_hour, etc.)
2869 * @return string Localized message explaining the flood protection action
2870 */
2871 protected function get_flood_moderation_message( $flood_reason ) {
2872 switch ( $flood_reason ) {
2873 case 'per_minute':
2874 return wpforo_phrase( 'Content auto-unapproved: Exceeded maximum posts per minute (flood protection).', false );
2875 case 'per_hour':
2876 return wpforo_phrase( 'Content auto-unapproved: Exceeded maximum posts per hour (flood protection).', false );
2877 case 'ip_per_hour':
2878 return wpforo_phrase( 'Content auto-unapproved: Exceeded maximum posts per hour from this IP address (flood protection).', false );
2879 case 'temp_ban':
2880 return wpforo_phrase( 'Content auto-unapproved: User is temporarily banned due to flood protection.', false );
2881 case 'interval':
2882 return wpforo_phrase( 'Content auto-unapproved: Posted too quickly (flood interval not met).', false );
2883 default:
2884 return wpforo_phrase( 'Content auto-unapproved: Flood protection triggered.', false );
2885 }
2886 }
2887
2888 /**
2889 * Get AI client instance
2890 *
2891 * @return \wpforo\classes\AIClient|null
2892 */
2893 protected function get_ai_client() {
2894 return WPF()->ai_client ?? null;
2895 }
2896
2897 /**
2898 * Check if AI services are available
2899 *
2900 * @return bool
2901 */
2902 public function is_ai_available() {
2903 $ai_client = $this->get_ai_client();
2904 return $ai_client && $ai_client->is_service_available();
2905 }
2906
2907 // =========================================================================
2908 // DATABASE LOGGING METHODS
2909 // =========================================================================
2910
2911 /**
2912 * Save moderation result to database
2913 *
2914 * @param array $data Moderation data
2915 * @return int|false Insert ID on success, false on failure
2916 */
2917 public function save_moderation_log( $data ) {
2918 global $wpdb;
2919
2920 $defaults = [
2921 'content_type' => '',
2922 'content_id' => 0,
2923 'topicid' => 0,
2924 'forumid' => 0,
2925 'userid' => 0,
2926 'moderation_type' => 'spam',
2927 'score' => 0,
2928 'is_flagged' => 0,
2929 'confidence' => 0.00,
2930 'action_taken' => null,
2931 'action_reason' => null,
2932 'indicators' => null,
2933 'analysis_summary' => null,
2934 'quality_tier' => 'balanced',
2935 'credits_used' => 0,
2936 'context_used' => 0,
2937 'indexed_topics_count' => 0,
2938 'detection_time_ms' => 0,
2939 'content_preview' => null,
2940 'created' => current_time( 'mysql' ),
2941 ];
2942
2943 $data = wp_parse_args( $data, $defaults );
2944
2945 // Skip saving clean moderation logs (score < 50%) by default.
2946 // Use filter 'wpforo_ai_save_clean_moderation_logs' to override (return true to save all logs).
2947 $score = (int) $data['score'];
2948 if ( $score < 50 ) {
2949 $save_clean_logs = apply_filters( 'wpforo_ai_save_clean_moderation_logs', false, $data );
2950 if ( ! $save_clean_logs ) {
2951 return false;
2952 }
2953 }
2954
2955 // Encode indicators as JSON if array
2956 if ( is_array( $data['indicators'] ) ) {
2957 $data['indicators'] = wp_json_encode( $data['indicators'] );
2958 }
2959
2960 // Truncate content preview
2961 if ( $data['content_preview'] && strlen( $data['content_preview'] ) > 500 ) {
2962 $data['content_preview'] = substr( $data['content_preview'], 0, 497 ) . '...';
2963 }
2964
2965 $result = $wpdb->insert(
2966 WPF()->tables->ai_moderation,
2967 [
2968 'content_type' => $data['content_type'],
2969 'content_id' => $data['content_id'],
2970 'topicid' => $data['topicid'],
2971 'forumid' => $data['forumid'],
2972 'userid' => $data['userid'],
2973 'moderation_type' => $data['moderation_type'],
2974 'score' => $data['score'],
2975 'is_flagged' => $data['is_flagged'],
2976 'confidence' => $data['confidence'],
2977 'action_taken' => $data['action_taken'],
2978 'action_reason' => $data['action_reason'],
2979 'indicators' => $data['indicators'],
2980 'analysis_summary' => $data['analysis_summary'],
2981 'quality_tier' => $data['quality_tier'],
2982 'credits_used' => $data['credits_used'],
2983 'context_used' => $data['context_used'],
2984 'indexed_topics_count' => $data['indexed_topics_count'],
2985 'detection_time_ms' => $data['detection_time_ms'],
2986 'content_preview' => $data['content_preview'],
2987 'created' => $data['created'],
2988 ],
2989 [ '%s', '%d', '%d', '%d', '%d', '%s', '%d', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s' ]
2990 );
2991
2992 if ( $result === false ) {
2993 return false;
2994 }
2995
2996 // Auto-schedule cleanup cron if not already scheduled
2997 $this->schedule_moderation_cleanup();
2998
2999 return $wpdb->insert_id;
3000 }
3001
3002 /**
3003 * Update pending moderation log with actual content ID
3004 *
3005 * Called after topic/post is saved to update the log entry that was
3006 * created with content_id = 0 during the pre-save filter.
3007 *
3008 * @param string $content_type Content type (topic or post)
3009 * @param array $content Topic or post data with actual ID
3010 * @param array $forum Forum data
3011 */
3012 protected function update_pending_moderation_log( $content_type, $content, $forum ) {
3013 global $wpdb;
3014
3015 // Get the actual content ID
3016 $content_id = 0;
3017 $topicid = 0;
3018
3019 if ( $content_type === self::CONTENT_TOPIC ) {
3020 $content_id = (int) ( $content['topicid'] ?? 0 );
3021 $topicid = $content_id;
3022 } else {
3023 $content_id = (int) ( $content['postid'] ?? 0 );
3024 $topicid = (int) ( $content['topicid'] ?? 0 );
3025 }
3026
3027 if ( ! $content_id ) {
3028 return; // No valid content ID
3029 }
3030
3031 $userid = (int) ( $content['userid'] ?? 0 );
3032 $forumid = (int) ( $content['forumid'] ?? $forum['forumid'] ?? 0 );
3033
3034 // Find the most recent pending log entry for this user in this forum
3035 // (content_id = 0 means it was created during pre-save filter)
3036 $log_id = $wpdb->get_var(
3037 $wpdb->prepare(
3038 "SELECT id FROM " . WPF()->tables->ai_moderation . "
3039 WHERE content_type = %s
3040 AND content_id = 0
3041 AND userid = %d
3042 AND forumid = %d
3043 ORDER BY created DESC
3044 LIMIT 1",
3045 $content_type,
3046 $userid,
3047 $forumid
3048 )
3049 );
3050
3051 if ( ! $log_id ) {
3052 return; // No pending log entry found
3053 }
3054
3055 // Update the log entry with actual content ID
3056 $wpdb->update(
3057 WPF()->tables->ai_moderation,
3058 [
3059 'content_id' => $content_id,
3060 'topicid' => $topicid,
3061 ],
3062 [ 'id' => $log_id ],
3063 [ '%d', '%d' ],
3064 [ '%d' ]
3065 );
3066 }
3067
3068 /**
3069 * Get moderation logs for content
3070 *
3071 * @param string $content_type Content type (topic or post)
3072 * @param int $content_id Content ID
3073 * @return array Moderation logs
3074 */
3075 public function get_moderation_logs( $content_type, $content_id ) {
3076 global $wpdb;
3077
3078 $results = $wpdb->get_results(
3079 $wpdb->prepare(
3080 "SELECT * FROM " . WPF()->tables->ai_moderation . "
3081 WHERE content_type = %s AND content_id = %d
3082 ORDER BY created DESC",
3083 $content_type,
3084 $content_id
3085 ),
3086 ARRAY_A
3087 );
3088
3089 // Decode indicators JSON
3090 foreach ( $results as &$row ) {
3091 if ( ! empty( $row['indicators'] ) ) {
3092 $row['indicators'] = json_decode( $row['indicators'], true );
3093 }
3094 }
3095
3096 return $results;
3097 }
3098
3099 /**
3100 * Get latest moderation log for content
3101 *
3102 * @param string $content_type Content type (topic or post)
3103 * @param int $content_id Content ID
3104 * @param string $moderation_type Moderation type (spam, toxicity, etc.)
3105 * @return array|null Moderation log or null
3106 */
3107 public function get_latest_moderation( $content_type, $content_id, $moderation_type = null ) {
3108 global $wpdb;
3109
3110 // If no specific type requested, get the latest log regardless of type
3111 if ( $moderation_type === null ) {
3112 $result = $wpdb->get_row(
3113 $wpdb->prepare(
3114 "SELECT * FROM " . WPF()->tables->ai_moderation . "
3115 WHERE content_type = %s AND content_id = %d
3116 ORDER BY created DESC
3117 LIMIT 1",
3118 $content_type,
3119 $content_id
3120 ),
3121 ARRAY_A
3122 );
3123 } else {
3124 $result = $wpdb->get_row(
3125 $wpdb->prepare(
3126 "SELECT * FROM " . WPF()->tables->ai_moderation . "
3127 WHERE content_type = %s AND content_id = %d AND moderation_type = %s
3128 ORDER BY created DESC
3129 LIMIT 1",
3130 $content_type,
3131 $content_id,
3132 $moderation_type
3133 ),
3134 ARRAY_A
3135 );
3136 }
3137
3138 if ( $result && ! empty( $result['indicators'] ) ) {
3139 $result['indicators'] = json_decode( $result['indicators'], true );
3140 }
3141
3142 return $result;
3143 }
3144
3145 /**
3146 * Get flagged content for review
3147 *
3148 * @param array $args Query arguments
3149 * @return array Flagged content
3150 */
3151 public function get_flagged_content( $args = [] ) {
3152 global $wpdb;
3153
3154 $defaults = [
3155 'moderation_type' => null,
3156 'forumid' => null,
3157 'min_score' => 50,
3158 'reviewed' => false, // false = unreviewed only
3159 'limit' => 50,
3160 'offset' => 0,
3161 'order_by' => 'score',
3162 'order' => 'DESC',
3163 ];
3164
3165 $args = wp_parse_args( $args, $defaults );
3166
3167 $where = [ 'is_flagged = 1' ];
3168 $params = [];
3169
3170 if ( $args['moderation_type'] ) {
3171 $where[] = 'moderation_type = %s';
3172 $params[] = $args['moderation_type'];
3173 }
3174
3175 if ( $args['forumid'] ) {
3176 $where[] = 'forumid = %d';
3177 $params[] = $args['forumid'];
3178 }
3179
3180 if ( $args['min_score'] > 0 ) {
3181 $where[] = 'score >= %d';
3182 $params[] = $args['min_score'];
3183 }
3184
3185 if ( $args['reviewed'] === false ) {
3186 $where[] = 'reviewed_by IS NULL';
3187 } elseif ( $args['reviewed'] === true ) {
3188 $where[] = 'reviewed_by IS NOT NULL';
3189 }
3190
3191 $where_sql = implode( ' AND ', $where );
3192 $order_by = in_array( $args['order_by'], [ 'score', 'created', 'confidence' ], true )
3193 ? $args['order_by']
3194 : 'score';
3195 $order = $args['order'] === 'ASC' ? 'ASC' : 'DESC';
3196
3197 $sql = "SELECT * FROM " . WPF()->tables->ai_moderation . "
3198 WHERE $where_sql
3199 ORDER BY $order_by $order
3200 LIMIT %d OFFSET %d";
3201
3202 $params[] = $args['limit'];
3203 $params[] = $args['offset'];
3204
3205 $results = $wpdb->get_results(
3206 $wpdb->prepare( $sql, ...$params ),
3207 ARRAY_A
3208 );
3209
3210 foreach ( $results as &$row ) {
3211 if ( ! empty( $row['indicators'] ) ) {
3212 $row['indicators'] = json_decode( $row['indicators'], true );
3213 }
3214 }
3215
3216 return $results;
3217 }
3218
3219 /**
3220 * Mark moderation as reviewed
3221 *
3222 * @param int $id Moderation log ID
3223 * @param int $reviewer_id Reviewer user ID
3224 * @param string $action Action taken (override)
3225 * @param string $notes Review notes
3226 * @return bool Success
3227 */
3228 public function mark_as_reviewed( $id, $reviewer_id, $action = null, $notes = '' ) {
3229 global $wpdb;
3230
3231 $result = $wpdb->update(
3232 WPF()->tables->ai_moderation,
3233 [
3234 'reviewed_by' => $reviewer_id,
3235 'reviewed_at' => current_time( 'mysql' ),
3236 'review_action' => $action,
3237 'review_notes' => $notes,
3238 ],
3239 [ 'id' => $id ],
3240 [ '%d', '%s', '%s', '%s' ],
3241 [ '%d' ]
3242 );
3243
3244 return $result !== false;
3245 }
3246
3247 /**
3248 * Get moderation statistics
3249 *
3250 * @param array $args Query arguments
3251 * @return array Statistics
3252 */
3253 public function get_moderation_stats( $args = [] ) {
3254 global $wpdb;
3255
3256 $defaults = [
3257 'moderation_type' => null,
3258 'forumid' => null,
3259 'days' => 30,
3260 ];
3261
3262 $args = wp_parse_args( $args, $defaults );
3263
3264 $where = [ '1=1' ];
3265 $params = [];
3266
3267 if ( $args['moderation_type'] ) {
3268 $where[] = 'moderation_type = %s';
3269 $params[] = $args['moderation_type'];
3270 }
3271
3272 if ( $args['forumid'] ) {
3273 $where[] = 'forumid = %d';
3274 $params[] = $args['forumid'];
3275 }
3276
3277 if ( $args['days'] > 0 ) {
3278 $where[] = 'created >= DATE_SUB(NOW(), INTERVAL %d DAY)';
3279 $params[] = $args['days'];
3280 }
3281
3282 $where_sql = implode( ' AND ', $where );
3283
3284 $sql = "SELECT
3285 COUNT(*) as total_checks,
3286 SUM(is_flagged) as total_flagged,
3287 SUM(CASE WHEN action_taken = 'approve' THEN 1 ELSE 0 END) as auto_approved,
3288 SUM(CASE WHEN action_taken = 'hold' THEN 1 ELSE 0 END) as auto_held,
3289 SUM(CASE WHEN action_taken = 'delete' THEN 1 ELSE 0 END) as auto_deleted,
3290 SUM(CASE WHEN action_taken = 'ban_user' THEN 1 ELSE 0 END) as auto_banned,
3291 AVG(score) as avg_score,
3292 SUM(credits_used) as total_credits,
3293 AVG(detection_time_ms) as avg_detection_time,
3294 SUM(CASE WHEN reviewed_by IS NOT NULL THEN 1 ELSE 0 END) as reviewed_count
3295 FROM " . WPF()->tables->ai_moderation . "
3296 WHERE $where_sql";
3297
3298 if ( ! empty( $params ) ) {
3299 $result = $wpdb->get_row( $wpdb->prepare( $sql, ...$params ), ARRAY_A );
3300 } else {
3301 $result = $wpdb->get_row( $sql, ARRAY_A );
3302 }
3303
3304 return $result ?: [
3305 'total_checks' => 0,
3306 'total_flagged' => 0,
3307 'auto_approved' => 0,
3308 'auto_held' => 0,
3309 'auto_deleted' => 0,
3310 'auto_banned' => 0,
3311 'avg_score' => 0,
3312 'total_credits' => 0,
3313 'avg_detection_time' => 0,
3314 'reviewed_count' => 0,
3315 ];
3316 }
3317
3318 /**
3319 * Delete old moderation logs
3320 *
3321 * @param int $days Delete logs older than this many days
3322 * @return int Number of deleted rows
3323 */
3324 public function cleanup_old_logs( $days = 90 ) {
3325 global $wpdb;
3326
3327 $result = $wpdb->query(
3328 $wpdb->prepare(
3329 "DELETE FROM " . WPF()->tables->ai_moderation . "
3330 WHERE created < DATE_SUB(NOW(), INTERVAL %d DAY)",
3331 $days
3332 )
3333 );
3334
3335 return $result !== false ? $result : 0;
3336 }
3337
3338 /**
3339 * Cron callback for moderation log cleanup
3340 *
3341 * Runs daily to remove old moderation log entries.
3342 * Retention period is controlled by 'wpforo_ai_moderation_log_retention_days' filter (default 90).
3343 */
3344 public function cron_moderation_cleanup() {
3345 $retention_days = apply_filters( 'wpforo_ai_moderation_log_retention_days', 90 );
3346 $deleted = $this->cleanup_old_logs( $retention_days );
3347
3348 if ( $deleted > 0 ) {
3349 \wpforo_ai_log( 'info', "Cron cleanup: deleted {$deleted} logs older than {$retention_days} days", 'Moderation' );
3350 }
3351 }
3352
3353 /**
3354 * Schedule moderation log cleanup cron job
3355 *
3356 * Should be called on plugin activation or when moderation logs are created.
3357 */
3358 public function schedule_moderation_cleanup() {
3359 if ( ! wp_next_scheduled( 'wpforo_ai_moderation_cleanup' ) ) {
3360 // Schedule to run at 4 AM server time (off-peak hours)
3361 $next_run = strtotime( 'tomorrow 4:00am' );
3362 wp_schedule_event( $next_run, 'daily', 'wpforo_ai_moderation_cleanup' );
3363 }
3364 }
3365
3366 /**
3367 * Unschedule moderation log cleanup cron job
3368 *
3369 * Should be called on plugin deactivation.
3370 */
3371 public function unschedule_moderation_cleanup() {
3372 $timestamp = wp_next_scheduled( 'wpforo_ai_moderation_cleanup' );
3373 if ( $timestamp ) {
3374 wp_unschedule_event( $timestamp, 'wpforo_ai_moderation_cleanup' );
3375 }
3376 }
3377
3378 // =========================================================================
3379 // MODERATION REPORT DISPLAY (Admin View)
3380 // =========================================================================
3381
3382 /**
3383 * Display moderation report under posts for authorized users
3384 *
3385 * Shows AI moderation analysis results to users with 'au' (approve/unapprove)
3386 * permission for the current forum.
3387 *
3388 * @param array $post Post data
3389 * @param array $topic Topic data
3390 * @param array $forum Forum data
3391 * @param int $layout_id Layout ID
3392 */
3393 public function display_moderation_report( $post, $topic, $forum, $layout_id ) {
3394 // Check if user has 'au' permission for this forum
3395 // Use $forum parameter (more reliable) with fallback to $post['forumid']
3396 $forumid = (int) ( $forum['forumid'] ?? $post['forumid'] ?? 0 );
3397 if ( ! $forumid || ! WPF()->perm->forum_can( 'au', $forumid ) ) {
3398 return;
3399 }
3400
3401 // Post authors should NEVER see their own moderation reports
3402 $current_userid = WPF()->current_userid;
3403 $post_userid = (int) ( $post['userid'] ?? 0 );
3404 if ( $current_userid && $current_userid === $post_userid ) {
3405 return;
3406 }
3407
3408 // Determine content type and ID
3409 $is_first_post = ! empty( $post['is_first_post'] );
3410 $content_type = $is_first_post ? self::CONTENT_TOPIC : self::CONTENT_POST;
3411 $content_id = $is_first_post ? ( $post['topicid'] ?? $post['postid'] ) : $post['postid'];
3412
3413 // Get the latest moderation log for this content
3414 $moderation = $this->get_latest_moderation( $content_type, (int) $content_id );
3415
3416 // If no moderation log exists, don't display anything
3417 if ( empty( $moderation ) ) {
3418 return;
3419 }
3420
3421 // Render the moderation report
3422 $this->render_moderation_report( $moderation, $post );
3423 }
3424
3425 /**
3426 * Render the moderation report HTML
3427 *
3428 * @param array $moderation Moderation log data
3429 * @param array $post Post data
3430 */
3431 protected function render_moderation_report( $moderation, $post ) {
3432 $score = (int) ( $moderation['score'] ?? 0 );
3433 $is_flagged = (bool) ( $moderation['is_flagged'] ?? false );
3434 $confidence = (float) ( $moderation['confidence'] ?? 0 );
3435 $action = $moderation['action_taken'] ?? 'none';
3436 $summary = $moderation['analysis_summary'] ?? '';
3437 $indicators = $moderation['indicators'] ?? [];
3438 $quality = $moderation['quality_tier'] ?? 'fast';
3439 $credits = (int) ( $moderation['credits_used'] ?? 0 );
3440 $created = $moderation['created'] ?? '';
3441 $mod_type = $moderation['moderation_type'] ?? 'spam';
3442 $is_ai = ( $quality !== 'rule_based' );
3443
3444 // Decode indicators if string
3445 if ( is_string( $indicators ) && ! empty( $indicators ) ) {
3446 $indicators = json_decode( $indicators, true ) ?: [];
3447 }
3448
3449 // Determine status color
3450 $status_class = 'wpf-ai-mod-clean';
3451 $status_label = wpforo_phrase( 'Clean', false );
3452 if ( $score >= 85 ) {
3453 $status_class = 'wpf-ai-mod-detected';
3454 $status_label = wpforo_phrase( 'Detected', false );
3455 } elseif ( $score >= 70 ) {
3456 $status_class = 'wpf-ai-mod-suspected';
3457 $status_label = wpforo_phrase( 'Suspected', false );
3458 } elseif ( $score >= 51 ) {
3459 $status_class = 'wpf-ai-mod-uncertain';
3460 $status_label = wpforo_phrase( 'Uncertain', false );
3461 }
3462
3463 // Action label
3464 $action_labels = [
3465 'none' => wpforo_phrase( 'No action', false ),
3466 'approve' => wpforo_phrase( 'Auto-approved', false ),
3467 'auto_approve' => wpforo_phrase( 'Auto-approved', false ),
3468 'unapprove' => wpforo_phrase( 'Unapproved', false ),
3469 'unapprove_ban' => wpforo_phrase( 'Unapproved + Banned', false ),
3470 'delete_author' => wpforo_phrase( 'Deleted + Banned', false ),
3471 ];
3472 $action_label = $action_labels[ $action ] ?? $action;
3473
3474 // Moderation type label (short for row display)
3475 if ( $is_ai ) {
3476 $type_labels = [
3477 'spam' => wpforo_phrase( 'Spam Detection', false ),
3478 'toxicity' => wpforo_phrase( 'Toxicity Detection', false ),
3479 'compliance' => wpforo_phrase( 'Policy Compliance', false ),
3480 ];
3481 $type_label = $type_labels[ $mod_type ] ?? ucfirst( $mod_type );
3482 } else {
3483 $type_label = wpforo_phrase( 'Auto Moderation', false );
3484 }
3485
3486 // Feature name (full name for footer)
3487 if ( $is_ai ) {
3488 $feature_names = [
3489 'spam' => wpforo_phrase( 'AI Spam Detection', false ),
3490 'toxicity' => wpforo_phrase( 'AI Content Safety & Toxicity Detection', false ),
3491 'compliance' => wpforo_phrase( 'AI Policy Compliance', false ),
3492 ];
3493 $feature_name = $feature_names[ $mod_type ] ?? wpforo_phrase( 'AI Content Moderation', false );
3494 } else {
3495 $feature_name = wpforo_phrase( 'Auto Moderation', false );
3496 }
3497
3498 // Quality tier label
3499 $quality_labels = [
3500 'fast' => wpforo_phrase( 'Fast', false ),
3501 'balanced' => wpforo_phrase( 'Balanced', false ),
3502 'advanced' => wpforo_phrase( 'Advanced', false ),
3503 'premium' => wpforo_phrase( 'Premium', false ),
3504 'rule_based' => wpforo_phrase( 'Rule-based', false ),
3505 ];
3506 $quality_label = $quality_labels[ $quality ] ?? $quality;
3507
3508 ?>
3509 <div class="wpf-ai-moderation-report <?php echo esc_attr( $status_class ); ?>">
3510 <div class="wpf-ai-mod-header">
3511 <span class="wpf-ai-mod-icon">
3512 <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>
3513 </span>
3514 <span class="wpf-ai-mod-title"><?php $is_ai ? wpforo_phrase( 'AI Moderation Report' ) : wpforo_phrase( 'Moderation Report' ); ?></span>
3515 <span class="wpf-ai-mod-status"><?php echo esc_html( $status_label ); ?></span>
3516 </div>
3517
3518 <div class="wpf-ai-mod-body">
3519 <div class="wpf-ai-mod-row">
3520 <span class="wpf-ai-mod-label"><?php wpforo_phrase( 'Type:' ); ?></span>
3521 <span class="wpf-ai-mod-value"><?php echo esc_html( $type_label ); ?></span>
3522 </div>
3523
3524 <div class="wpf-ai-mod-row">
3525 <span class="wpf-ai-mod-label"><?php wpforo_phrase( 'Score:' ); ?></span>
3526 <span class="wpf-ai-mod-value">
3527 <?php if ( $is_ai ) : ?>
3528 <span class="wpf-ai-mod-score"><?php echo esc_html( $score ); ?>%</span>
3529 <span class="wpf-ai-mod-confidence">(<?php printf( wpforo_phrase( '%d%% confidence', false ), round( $confidence * 100 ) ); ?>)</span>
3530 <?php else : ?>
3531 <span class="wpf-ai-mod-score">-</span>
3532 <?php endif; ?>
3533 </span>
3534 </div>
3535
3536 <div class="wpf-ai-mod-row">
3537 <span class="wpf-ai-mod-label"><?php wpforo_phrase( 'Action:' ); ?></span>
3538 <span class="wpf-ai-mod-value"><?php echo esc_html( $action_label ); ?></span>
3539 </div>
3540
3541 <?php if ( ! empty( $summary ) ) : ?>
3542 <div class="wpf-ai-mod-row wpf-ai-mod-summary">
3543 <span class="wpf-ai-mod-label"><?php wpforo_phrase( 'Summary:' ); ?></span>
3544 <span class="wpf-ai-mod-value"><?php echo esc_html( $summary ); ?></span>
3545 </div>
3546 <?php endif; ?>
3547
3548 <?php if ( ! empty( $indicators ) && is_array( $indicators ) ) : ?>
3549 <div class="wpf-ai-mod-indicators">
3550 <span class="wpf-ai-mod-label"><?php wpforo_phrase( 'Indicators:' ); ?></span>
3551 <ul class="wpf-ai-mod-indicator-list">
3552 <?php foreach ( $indicators as $indicator ) : ?>
3553 <li class="wpf-ai-mod-indicator wpf-ai-mod-severity-<?php echo esc_attr( strtolower( $indicator['severity'] ?? 'medium' ) ); ?>">
3554 <span class="wpf-ai-mod-indicator-cat"><?php echo esc_html( $indicator['category'] ?? '' ); ?></span>
3555 <?php if ( ! empty( $indicator['description'] ) ) : ?>
3556 <span class="wpf-ai-mod-indicator-desc"><?php echo esc_html( $indicator['description'] ); ?></span>
3557 <?php endif; ?>
3558 </li>
3559 <?php endforeach; ?>
3560 </ul>
3561 </div>
3562 <?php endif; ?>
3563
3564 <div class="wpf-ai-mod-meta">
3565 <?php if ( $is_ai ) : ?>
3566 <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>
3567 <?php endif; ?>
3568 <span class="wpf-ai-mod-feature"><?php echo esc_html( $feature_name ); ?></span>
3569 </div>
3570 </div>
3571 </div>
3572 <?php
3573 }
3574
3575 /**
3576 * Output moderation report styles in wp_head
3577 *
3578 * Only outputs styles on wpForo pages.
3579 */
3580 public function output_moderation_report_styles() {
3581 // Only output on wpForo pages
3582 if ( ! function_exists( 'is_wpforo_page' ) || ! is_wpforo_page() ) {
3583 return;
3584 }
3585
3586 // Only output if user might see moderation reports
3587 // (checking here would be too slow, so we always output on wpForo pages)
3588 echo '<style id="wpforo-ai-moderation-report-styles">' . self::get_moderation_report_styles() . '</style>';
3589 }
3590
3591 /**
3592 * Get CSS styles for moderation report
3593 *
3594 * @return string CSS styles
3595 */
3596 public static function get_moderation_report_styles() {
3597 return '
3598 .wpf-ai-moderation-report {
3599 margin: 15px 0;
3600 padding: 12px 15px;
3601 border-radius: 6px;
3602 border: 1px solid #e0e0e0;
3603 background: #f8f9fa;
3604 font-size: 13px;
3605 }
3606 .wpf-ai-moderation-report.wpf-ai-mod-clean {
3607 border-color: #c3e6cb;
3608 background: #d4edda;
3609 }
3610 .wpf-ai-moderation-report.wpf-ai-mod-uncertain {
3611 border-color: #ffeeba;
3612 background: #fff3cd;
3613 }
3614 .wpf-ai-moderation-report.wpf-ai-mod-suspected {
3615 border-color: #ffcc80;
3616 background: #ffe0b2;
3617 }
3618 .wpf-ai-moderation-report.wpf-ai-mod-detected {
3619 border-color: #f5c6cb;
3620 background: #f8d7da;
3621 }
3622 .wpf-ai-mod-header {
3623 display: flex;
3624 align-items: center;
3625 gap: 8px;
3626 margin-bottom: 10px;
3627 padding-bottom: 8px;
3628 border-bottom: 1px solid rgba(0,0,0,0.1);
3629 }
3630 .wpf-ai-mod-icon svg {
3631 display: block;
3632 }
3633 .wpf-ai-mod-title {
3634 font-weight: 600;
3635 flex-grow: 1;
3636 }
3637 .wpf-ai-mod-status {
3638 font-size: 11px;
3639 font-weight: 500;
3640 text-transform: uppercase;
3641 padding: 2px 8px;
3642 border-radius: 3px;
3643 background: rgba(0,0,0,0.1);
3644 }
3645 .wpf-ai-mod-body {
3646 display: flex;
3647 flex-direction: column;
3648 gap: 6px;
3649 }
3650 .wpf-ai-mod-row {
3651 display: flex;
3652 gap: 8px;
3653 }
3654 .wpf-ai-mod-label {
3655 font-weight: 500;
3656 color: #555;
3657 min-width: 70px;
3658 }
3659 .wpf-ai-mod-value {
3660 color: #333;
3661 }
3662 .wpf-ai-mod-score {
3663 font-weight: 600;
3664 }
3665 .wpf-ai-mod-confidence {
3666 color: #666;
3667 font-size: 12px;
3668 }
3669 .wpf-ai-mod-summary {
3670 flex-direction: column;
3671 }
3672 .wpf-ai-mod-summary .wpf-ai-mod-value {
3673 margin-top: 2px;
3674 font-style: italic;
3675 }
3676 .wpf-ai-mod-indicators {
3677 margin-top: 6px;
3678 }
3679 .wpf-ai-mod-indicator-list {
3680 list-style: none;
3681 margin: 4px 0 0 0;
3682 padding: 0;
3683 }
3684 .wpf-ai-mod-indicator {
3685 display: flex;
3686 gap: 6px;
3687 padding: 4px 8px;
3688 margin: 2px 0;
3689 border-radius: 3px;
3690 font-size: 12px;
3691 }
3692 .wpf-ai-mod-indicator.wpf-ai-mod-severity-high {
3693 background: rgba(220, 53, 69, 0.15);
3694 }
3695 .wpf-ai-mod-indicator.wpf-ai-mod-severity-medium {
3696 background: rgba(255, 193, 7, 0.15);
3697 }
3698 .wpf-ai-mod-indicator.wpf-ai-mod-severity-low {
3699 background: rgba(108, 117, 125, 0.1);
3700 }
3701 .wpf-ai-mod-indicator-cat {
3702 font-weight: 500;
3703 text-transform: capitalize;
3704 }
3705 .wpf-ai-mod-indicator-desc {
3706 color: #666;
3707 }
3708 .wpf-ai-mod-meta {
3709 display: flex;
3710 justify-content: space-between;
3711 margin-top: 8px;
3712 padding-top: 8px;
3713 border-top: 1px solid rgba(0,0,0,0.1);
3714 font-size: 11px;
3715 color: #888;
3716 }
3717 ';
3718 }
3719 }
3720