PluginProbe
AI / trunk
AI vtrunk
1.3.0 1.2.0 1.1.0 1.0.2 1.0.1 1.0.0 0.9.0 trunk 0.1.1 0.2.0 0.2.1 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0
ai / includes / Experiments / Comment_Moderation / Comment_Moderation.php

Comment_Moderation.php in AI trunk, at includes/Experiments/Comment_Moderation/Comment_Moderation.php

1,181 lines 31.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Comment Moderation experiment implementation.
4 *
5 * @package WordPress\AI
6 */
7
8 declare( strict_types=1 );
9
10 namespace WordPress\AI\Experiments\Comment_Moderation;
11
12 use WordPress\AI\Abilities\Comment_Moderation\Comment_Analysis as Comment_Analysis_Ability;
13 use WordPress\AI\Abstracts\Abstract_Feature;
14 use WordPress\AI\Asset_Loader;
15 use WordPress\AI\Experiments\Experiment_Category;
16 use WordPress\AI\Settings\Settings_Registration;
17
18 use function WordPress\AI\get_provider_availability_data;
19 use function WordPress\AI\has_ai_credentials;
20
21 // Exit if accessed directly.
22 defined( 'ABSPATH' ) || exit;
23
24 /**
25 * Comment moderation experiment.
26 *
27 * Provides toxicity detection, sentiment analysis, and moderation
28 * for WordPress comments.
29 *
30 * @since 0.9.0
31 */
32 class Comment_Moderation extends Abstract_Feature {
33
34 /**
35 * Comment meta key for toxicity score.
36 *
37 * @var string
38 */
39 public const META_TOXICITY_SCORE = '_wpai_toxicity_score';
40
41 /**
42 * Comment meta key for sentiment.
43 *
44 * @var string
45 */
46 public const META_SENTIMENT = '_wpai_sentiment';
47
48 /**
49 * Comment meta key for analysis status.
50 *
51 * @var string
52 */
53 public const META_ANALYSIS_STATUS = '_wpai_analysis_status';
54
55 /**
56 * Comment meta key for analysis timestamp.
57 *
58 * @var string
59 */
60 public const META_ANALYZED_AT = '_wpai_analyzed_at';
61
62 /**
63 * Analysis status: pending.
64 *
65 * @var string
66 */
67 public const STATUS_PENDING = 'pending';
68
69 /**
70 * Analysis status: processing.
71 *
72 * @var string
73 */
74 public const STATUS_PROCESSING = 'processing';
75
76 /**
77 * Analysis status: complete.
78 *
79 * @var string
80 */
81 public const STATUS_COMPLETE = 'complete';
82
83 /**
84 * Analysis status: failed.
85 *
86 * @var string
87 */
88 public const STATUS_FAILED = 'failed';
89
90 /**
91 * Sentiment: positive.
92 *
93 * @var string
94 */
95 public const SENTIMENT_POSITIVE = 'positive';
96
97 /**
98 * Sentiment: neutral.
99 *
100 * @var string
101 */
102 public const SENTIMENT_NEUTRAL = 'neutral';
103
104 /**
105 * Sentiment: negative.
106 *
107 * @var string
108 */
109 public const SENTIMENT_NEGATIVE = 'negative';
110
111 /**
112 * Toxicity level: low.
113 *
114 * @var string
115 */
116 public const TOXICITY_LOW = 'low';
117
118 /**
119 * Toxicity level: medium.
120 *
121 * @var string
122 */
123 public const TOXICITY_MEDIUM = 'medium';
124
125 /**
126 * Toxicity level: high.
127 *
128 * @var string
129 */
130 public const TOXICITY_HIGH = 'high';
131
132 /**
133 * Default value for moderate guests setting.
134 *
135 * @var bool
136 */
137 public const DEFAULT_MODERATE_GUESTS = true;
138
139 /**
140 * One-shot query args the bulk action redirect uses to show its notice.
141 *
142 * @since 1.3.0
143 *
144 * @var list<string>
145 */
146 private const BULK_NOTICE_QUERY_ARGS = array( 'wpai_analysis_queued', 'wpai_no_provider' ); // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const.
147
148 /**
149 * Gets the configuration for sentiment levels.
150 *
151 * @since 1.0.0
152 *
153 * @return array<string, array{label: string, filterLabel: string, class: string, icon: string}> The sentiment configuration.
154 */
155 public static function get_sentiment_config(): array {
156 return array(
157 self::SENTIMENT_POSITIVE => array(
158 'label' => __( 'Positive', 'ai' ),
159 'filterLabel' => __( 'Positive', 'ai' ),
160 'class' => 'ai-badge--positive',
161 'icon' => '😊',
162 ),
163 self::SENTIMENT_NEUTRAL => array(
164 'label' => __( 'Neutral', 'ai' ),
165 'filterLabel' => __( 'Neutral', 'ai' ),
166 'class' => 'ai-badge--neutral',
167 'icon' => '😐',
168 ),
169 self::SENTIMENT_NEGATIVE => array(
170 'label' => __( 'Negative', 'ai' ),
171 'filterLabel' => __( 'Negative', 'ai' ),
172 'class' => 'ai-badge--negative',
173 'icon' => '😟',
174 ),
175 );
176 }
177
178 /**
179 * Gets the configuration for toxicity levels.
180 *
181 * @since 1.0.0
182 *
183 * @return array<string, array{label: string, filterLabel: string, class: string, icon: string, min: float, max: float}> The toxicity configuration.
184 */
185 public static function get_toxicity_config(): array {
186 return array(
187 self::TOXICITY_LOW => array(
188 'label' => __( 'Low', 'ai' ),
189 'filterLabel' => __( 'Low Toxicity (<40%)', 'ai' ),
190 'class' => 'ai-badge--low-toxicity',
191 'icon' => '',
192 'min' => 0.0,
193 'max' => 0.4,
194 ),
195 self::TOXICITY_MEDIUM => array(
196 'label' => __( 'Medium', 'ai' ),
197 'filterLabel' => __( 'Medium Toxicity (40%-69%)', 'ai' ),
198 'class' => 'ai-badge--medium-toxicity',
199 'icon' => '',
200 'min' => 0.4,
201 'max' => 0.7,
202 ),
203 self::TOXICITY_HIGH => array(
204 'label' => __( 'High', 'ai' ),
205 'filterLabel' => __( 'High Toxicity (>=70%)', 'ai' ),
206 'class' => 'ai-badge--high-toxicity',
207 'icon' => '⚠️',
208 'min' => 0.7,
209 'max' => 1.0,
210 ),
211 );
212 }
213
214 /**
215 * Comment analysis ability.
216 *
217 * @since 0.9.0
218 *
219 * @var \WordPress\AI\Abilities\Comment_Moderation\Comment_Analysis|null
220 */
221 private $comment_analysis_ability = null;
222
223 /**
224 * {@inheritDoc}
225 *
226 * @since 0.9.0
227 */
228 public static function get_id(): string {
229 return 'comment-moderation';
230 }
231
232 /**
233 * {@inheritDoc}
234 *
235 * @since 0.9.0
236 */
237 protected function load_metadata(): array {
238 return array(
239 'label' => __( 'Comment Moderation', 'ai' ),
240 'description' => __( 'Automatically moderate comments based on toxicity detection and sentiment analysis. Requires an AI connector that includes support for text generation models.', 'ai' ),
241 'category' => Experiment_Category::ADMIN,
242 );
243 }
244
245 /**
246 * {@inheritDoc}
247 *
248 * @since 0.9.0
249 */
250 public function register(): void {
251 // Register abilities.
252 add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
253
254 // Moderate new comments.
255 add_action( 'wp_insert_comment', array( $this, 'moderate_comment' ) );
256
257 // Add columns to comments list table.
258 add_filter( 'manage_edit-comments_columns', array( $this, 'add_columns' ) );
259 add_action( 'manage_comments_custom_column', array( $this, 'render_column' ), 10, 2 );
260
261 // Add bulk action.
262 add_filter( 'bulk_actions-edit-comments', array( $this, 'add_bulk_actions' ) );
263 add_filter( 'handle_bulk_actions-edit-comments', array( $this, 'handle_bulk_action' ), 10, 3 );
264 add_action( 'admin_notices', array( $this, 'show_bulk_action_notice' ) );
265 add_filter( 'removable_query_args', array( $this, 'register_removable_query_args' ) );
266 add_action( 'load-edit-comments.php', array( $this, 'remove_bulk_notice_query_args' ) );
267
268 // Add inline action.
269 add_filter( 'comment_row_actions', array( $this, 'add_inline_action' ), 10, 2 );
270 add_action( 'load-edit-comments.php', array( $this, 'handle_inline_action' ) );
271
272 // Add sortable columns.
273 add_filter( 'manage_edit-comments_sortable_columns', array( $this, 'add_sortable_columns' ) );
274
275 // Add custom sorting and filtering.
276 add_action( 'restrict_manage_comments', array( $this, 'add_filter_dropdowns' ) );
277 add_action( 'pre_get_comments', array( $this, 'handle_sorting_and_filtering' ) );
278
279 // Enqueue assets.
280 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
281
282 // Add inline styles for badges.
283 add_action( 'admin_head-edit-comments.php', array( $this, 'add_inline_styles' ) );
284 add_action( 'admin_head-index.php', array( $this, 'add_inline_styles' ) );
285
286 // Add dashboard pills.
287 add_filter( 'get_comment_excerpt', array( $this, 'add_dashboard_pills' ), 10, 3 );
288 }
289
290 /**
291 * Registers the comment moderation abilities.
292 *
293 * @since 0.9.0
294 */
295 public function register_abilities(): void {
296 wp_register_ability(
297 'ai/comment-analysis',
298 array(
299 'label' => __( 'Comment Analysis', 'ai' ),
300 'description' => __( 'Analyzes a comment for toxicity and sentiment.', 'ai' ),
301 'ability_class' => Comment_Analysis_Ability::class,
302 )
303 );
304 }
305
306 /**
307 * Registers experiment-specific settings.
308 *
309 * @since 1.1.0
310 */
311 public function register_settings(): void {
312 register_setting(
313 Settings_Registration::OPTION_GROUP,
314 static::get_field_option_name( 'moderate_guests' ),
315 array(
316 'type' => 'boolean',
317 'default' => self::DEFAULT_MODERATE_GUESTS,
318 'sanitize_callback' => 'rest_sanitize_boolean',
319 'show_in_rest' => array(
320 'schema' => array(
321 'type' => 'boolean',
322 ),
323 ),
324 )
325 );
326 }
327
328 /**
329 * {@inheritDoc}
330 */
331 public function get_settings_fields(): array {
332 return array(
333 array(
334 'id' => 'moderate_guests',
335 'label' => __( 'Automatically moderate guest comments', 'ai' ),
336 'type' => 'boolean',
337 'default' => self::DEFAULT_MODERATE_GUESTS,
338 ),
339 );
340 }
341
342 /**
343 * Checks whether guest comments should be automatically moderated.
344 *
345 * @since 1.1.0
346 *
347 * @return bool Whether guest comments should be automatically moderated.
348 */
349 public function should_moderate_guests(): bool {
350 $should_moderate = (bool) get_option( static::get_field_option_name( 'moderate_guests' ), self::DEFAULT_MODERATE_GUESTS );
351
352 /**
353 * Filters whether to moderate guest comments.
354 *
355 * @since 1.1.0
356 *
357 * @param bool $should_moderate Whether guest comments should be moderated.
358 */
359 return (bool) apply_filters( 'wpai_comment_moderation_moderate_guests', $should_moderate );
360 }
361
362 /**
363 * Moderate newly added comments.
364 *
365 * @since 0.9.0
366 *
367 * @param int $comment_id Comment ID.
368 */
369 public function moderate_comment( $comment_id ): void {
370 if ( ! has_ai_credentials() ) {
371 return;
372 }
373
374 $comment = get_comment( (int) $comment_id );
375 if ( ! $comment || ! is_a( $comment, '\WP_Comment' ) ) {
376 return;
377 }
378
379 // Skip moderation if the comment is already marked as spam or trash.
380 if ( in_array( $comment->comment_approved, array( 'spam', 'trash' ), true ) ) {
381 return;
382 }
383
384 // Prevent running on anonymous comments if setting is disabled.
385 if ( 0 === (int) $comment->user_id && ! $this->should_moderate_guests() ) {
386 return;
387 }
388
389 $analysis = $this->get_comment_analysis_ability()->analyze_comment_by_id( (int) $comment_id );
390 if ( is_wp_error( $analysis ) ) {
391 return;
392 }
393
394 // Moderate the comment if it is above the toxicity threshold and has a negative sentiment.
395 $should_moderate = $analysis['toxicity_score'] >= 0.7 && 'negative' === $analysis['sentiment'];
396
397 /**
398 * Filters whether the comment should be moderated.
399 *
400 * @since 0.9.0
401 *
402 * @param bool $should_moderate Whether the comment should be moderated.
403 * @param array $analysis The analysis results.
404 * @param int $comment_id The comment ID.
405 */
406 $should_moderate = (bool) apply_filters( 'wpai_comment_moderation_should_moderate', $should_moderate, $analysis, $comment_id );
407
408 if ( ! $should_moderate ) {
409 return;
410 }
411
412 wp_update_comment(
413 array(
414 'comment_ID' => $comment_id,
415 'comment_approved' => '0',
416 )
417 );
418 }
419
420 /**
421 * Gets the comment analysis ability for trusted internal use.
422 *
423 * @since 0.9.0
424 *
425 * @return \WordPress\AI\Abilities\Comment_Moderation\Comment_Analysis Comment analysis ability.
426 */
427 private function get_comment_analysis_ability(): Comment_Analysis_Ability {
428 if ( ! $this->comment_analysis_ability ) {
429 $this->comment_analysis_ability = new Comment_Analysis_Ability(
430 'ai/comment-analysis',
431 array(
432 'label' => __( 'Comment Analysis', 'ai' ),
433 'description' => __( 'Analyzes a comment for toxicity and sentiment.', 'ai' ),
434 )
435 );
436 }
437
438 return $this->comment_analysis_ability;
439 }
440
441 /**
442 * Adds custom columns to the comments list table.
443 *
444 * @since 0.9.0
445 *
446 * @param array<string, string> $columns The existing columns.
447 * @return array<string, string> The modified columns.
448 */
449 public function add_columns( $columns ): array {
450 $new_columns = array();
451
452 foreach ( (array) $columns as $key => $value ) {
453 $new_columns[ $key ] = $value;
454
455 // Insert our columns after the 'comment' column.
456 if ( 'comment' !== $key ) {
457 continue;
458 }
459
460 $new_columns['wpai_sentiment'] = __( 'Sentiment', 'ai' );
461 $new_columns['wpai_toxicity'] = __( 'Toxicity', 'ai' );
462 }
463
464 return $new_columns;
465 }
466
467 /**
468 * Adds sentiment and toxicity pills to the dashboard recent comments widget.
469 *
470 * @since 1.0.0
471 *
472 * @param string $comment_excerpt The comment excerpt.
473 * @param string $comment_id The comment ID.
474 * @param \WP_Comment $comment The comment object.
475 * @return string The modified comment excerpt.
476 */
477 public function add_dashboard_pills( $comment_excerpt, $comment_id, $comment ): string {
478 if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) {
479 return $comment_excerpt;
480 }
481
482 $screen = get_current_screen();
483 if ( ! $screen || 'dashboard' !== $screen->id ) {
484 return $comment_excerpt;
485 }
486
487 $comment_id = (int) $comment_id;
488
489 /**
490 * Filters whether to show AI sentiment and toxicity pills in the dashboard.
491 *
492 * @since 1.0.0
493 *
494 * @param bool $show Whether to show the pills. Default true.
495 * @param int $comment_id The comment ID.
496 * @param \WP_Comment $comment The comment object.
497 */
498 if ( ! apply_filters( 'wpai_comment_moderation_show_dashboard_pills', true, $comment_id, $comment ) ) {
499 return $comment_excerpt;
500 }
501
502 $status = get_comment_meta( $comment_id, self::META_ANALYSIS_STATUS, true );
503 if ( self::STATUS_COMPLETE !== $status ) {
504 return $comment_excerpt;
505 }
506
507 $sentiment = get_comment_meta( $comment_id, self::META_SENTIMENT, true );
508 $score = (float) get_comment_meta( $comment_id, self::META_TOXICITY_SCORE, true );
509
510 // Capture the pills HTML in an output buffer.
511 ob_start();
512 ?>
513 <div class="ai-dashboard-pills">
514 <?php
515 $this->render_sentiment_badge( (string) $sentiment );
516 $this->render_toxicity_badge( $score );
517 ?>
518 </div>
519 <?php
520 $pills = ob_get_clean();
521
522 return $comment_excerpt . $pills;
523 }
524
525 /**
526 * Adds filter dropdowns for sentiment and toxicity.
527 *
528 * @since 1.0.0
529 */
530 public function add_filter_dropdowns(): void {
531 if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) {
532 return;
533 }
534
535 $screen = get_current_screen();
536 if ( ! $screen || 'edit-comments' !== $screen->id ) {
537 return;
538 }
539
540 $current_sentiment = isset( $_GET['wpai_sentiment'] ) ? sanitize_text_field( wp_unslash( $_GET['wpai_sentiment'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
541 $current_toxicity = isset( $_GET['wpai_toxicity'] ) ? sanitize_text_field( wp_unslash( $_GET['wpai_toxicity'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
542
543 // Sentiment Dropdown.
544 $sentiments = self::get_sentiment_config();
545 ?>
546 <label class="screen-reader-text" for="wpai-filter-sentiment"><?php esc_html_e( 'Filter by Sentiment', 'ai' ); ?></label>
547 <select name="wpai_sentiment" id="wpai-filter-sentiment">
548 <option value=""><?php esc_html_e( 'All Sentiments', 'ai' ); ?></option>
549 <?php foreach ( $sentiments as $value => $config ) : ?>
550 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $current_sentiment, $value ); ?>>
551 <?php echo esc_html( $config['filterLabel'] ); ?>
552 </option>
553 <?php endforeach; ?>
554 </select>
555
556 <?php
557 // Toxicity Dropdown.
558 $toxicities = self::get_toxicity_config();
559 ?>
560 <label class="screen-reader-text" for="wpai-filter-toxicity"><?php esc_html_e( 'Filter by Toxicity', 'ai' ); ?></label>
561 <select name="wpai_toxicity" id="wpai-filter-toxicity">
562 <option value=""><?php esc_html_e( 'All Toxicities', 'ai' ); ?></option>
563 <?php foreach ( $toxicities as $value => $config ) : ?>
564 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $current_toxicity, $value ); ?>>
565 <?php echo esc_html( $config['filterLabel'] ); ?>
566 </option>
567 <?php endforeach; ?>
568 </select>
569 <?php
570 }
571
572 /**
573 * Adds sortable columns to the comments list table.
574 *
575 * @since 1.0.0
576 *
577 * @param array<string, string> $columns The existing sortable columns.
578 * @return array<string, string> The modified sortable columns.
579 */
580 public function add_sortable_columns( $columns ): array {
581 $columns['wpai_sentiment'] = 'wpai_sentiment';
582 $columns['wpai_toxicity'] = 'wpai_toxicity';
583 return $columns;
584 }
585
586 /**
587 * Handles the custom sorting and filtering for comments.
588 *
589 * @since 1.0.0
590 *
591 * @param \WP_Comment_Query $query The comment query object.
592 */
593 public function handle_sorting_and_filtering( $query ): void {
594 if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) {
595 return;
596 }
597
598 $screen = get_current_screen();
599 if ( ! $screen || 'edit-comments' !== $screen->id ) {
600 return;
601 }
602
603 $meta_query = $query->query_vars['meta_query'] ?? array();
604 if ( ! is_array( $meta_query ) ) { // Handle empty strings.
605 $meta_query = array();
606 }
607
608 // Handle filtering.
609 $sentiment = isset( $_GET['wpai_sentiment'] ) ? sanitize_text_field( wp_unslash( $_GET['wpai_sentiment'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
610 $toxicity = isset( $_GET['wpai_toxicity'] ) ? sanitize_text_field( wp_unslash( $_GET['wpai_toxicity'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
611
612 $sentiments = self::get_sentiment_config();
613 if ( ! empty( $sentiment ) && array_key_exists( $sentiment, $sentiments ) ) {
614 $meta_query[] = array(
615 'key' => self::META_SENTIMENT,
616 'value' => $sentiment,
617 );
618 }
619
620 $toxicities = self::get_toxicity_config();
621 if ( ! empty( $toxicity ) && array_key_exists( $toxicity, $toxicities ) ) {
622 $config = $toxicities[ $toxicity ];
623 $min = $config['min'];
624 $max = $config['max'];
625
626 $meta_query[] = array(
627 'relation' => 'AND',
628 array(
629 'key' => self::META_TOXICITY_SCORE,
630 'value' => $min,
631 'type' => 'DECIMAL(10, 5)',
632 'compare' => '>=',
633 ),
634 array(
635 'key' => self::META_TOXICITY_SCORE,
636 'value' => $max,
637 'type' => 'DECIMAL(10, 5)',
638 'compare' => 1.0 === $max ? '<=' : '<', // For the end boundary of 1.0 to be included.
639 ),
640 );
641 }
642
643 // Handle sorting.
644 $orderby = $query->query_vars['orderby'] ?? '';
645
646 // Use named meta queries so comments without analysis metadata remain visible when sorted.
647 if ( 'wpai_sentiment' === $orderby ) {
648 $meta_query[] = array(
649 'relation' => 'OR',
650 'wpai_sentiment_sort' => array(
651 'key' => self::META_SENTIMENT,
652 'compare' => 'EXISTS',
653 ),
654 'wpai_sentiment_empty' => array(
655 'key' => self::META_SENTIMENT,
656 'compare' => 'NOT EXISTS',
657 ),
658 );
659
660 $query->query_vars['orderby'] = 'wpai_sentiment_sort';
661 } elseif ( 'wpai_toxicity' === $orderby ) {
662 $meta_query[] = array(
663 'relation' => 'OR',
664 'wpai_toxicity_sort' => array(
665 'key' => self::META_TOXICITY_SCORE,
666 'compare' => 'EXISTS',
667 'type' => 'DECIMAL(10, 5)',
668 ),
669 'wpai_toxicity_empty' => array(
670 'key' => self::META_TOXICITY_SCORE,
671 'compare' => 'NOT EXISTS',
672 ),
673 );
674
675 $query->query_vars['orderby'] = 'wpai_toxicity_sort';
676 }
677
678 if ( empty( $meta_query ) ) {
679 return;
680 }
681
682 $query->query_vars['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
683 }
684
685 /**
686 * Renders the custom column content.
687 *
688 * @since 0.9.0
689 *
690 * @param string $column_name The column name.
691 * @param int $comment_id The comment ID.
692 */
693 public function render_column( $column_name, $comment_id ): void {
694 $status = get_comment_meta( (int) $comment_id, self::META_ANALYSIS_STATUS, true );
695
696 if ( 'wpai_sentiment' === (string) $column_name ) {
697 $this->render_sentiment_column( (int) $comment_id, $status );
698 } elseif ( 'wpai_toxicity' === (string) $column_name ) {
699 $this->render_toxicity_column( (int) $comment_id, $status );
700 }
701 }
702
703 /**
704 * Renders the sentiment column content.
705 *
706 * @since 0.9.0
707 *
708 * @param int $comment_id The comment ID.
709 * @param string $status The analysis status.
710 */
711 private function render_sentiment_column( int $comment_id, string $status ): void {
712 if ( self::STATUS_COMPLETE === $status ) {
713 $sentiment = get_comment_meta( $comment_id, self::META_SENTIMENT, true );
714 $this->render_sentiment_badge( $sentiment );
715 } elseif ( self::STATUS_PENDING === $status ) {
716 $this->render_pending_badge( $comment_id );
717 } elseif ( self::STATUS_PROCESSING === $status ) {
718 $this->render_processing_badge( $comment_id );
719 } elseif ( self::STATUS_FAILED === $status ) {
720 $this->render_failed_badge();
721 } else {
722 // Empty or not analyzed - show dash.
723 echo '<span class="ai-badge ai-badge--empty">—</span>';
724 }
725 }
726
727 /**
728 * Renders the toxicity column content.
729 *
730 * @since 0.9.0
731 *
732 * @param int $comment_id The comment ID.
733 * @param string $status The analysis status.
734 */
735 private function render_toxicity_column( int $comment_id, string $status ): void {
736 if ( self::STATUS_COMPLETE === $status ) {
737 $score = (float) get_comment_meta( $comment_id, self::META_TOXICITY_SCORE, true );
738 $this->render_toxicity_badge( $score );
739 } elseif ( self::STATUS_PENDING === $status ) {
740 $this->render_pending_badge( $comment_id );
741 } elseif ( self::STATUS_PROCESSING === $status ) {
742 $this->render_processing_badge( $comment_id );
743 } elseif ( self::STATUS_FAILED === $status ) {
744 $this->render_failed_badge();
745 } else {
746 // Empty or not analyzed - show dash.
747 echo '<span class="ai-badge ai-badge--empty">—</span>';
748 }
749 }
750
751 /**
752 * Renders a sentiment badge.
753 *
754 * @since 0.9.0
755 *
756 * @param string $sentiment The sentiment value.
757 */
758 private function render_sentiment_badge( string $sentiment ): void {
759 $badges = self::get_sentiment_config();
760
761 $badge = $badges[ $sentiment ] ?? $badges['neutral'];
762
763 printf(
764 '<span class="ai-badge %s" title="%s">%s %s</span>',
765 esc_attr( $badge['class'] ),
766 esc_attr( $badge['label'] ),
767 esc_html( $badge['icon'] ),
768 esc_html( $badge['label'] )
769 );
770 }
771
772 /**
773 * Renders a toxicity badge.
774 *
775 * @since 0.9.0
776 *
777 * @param float $score The toxicity score (0-1).
778 */
779 private function render_toxicity_badge( float $score ): void {
780 $config = self::get_toxicity_config();
781 $badge = $config[ self::TOXICITY_LOW ];
782
783 foreach ( $config as $tier ) {
784 if ( $score >= $tier['min'] && ( $score < $tier['max'] || 1.0 === $tier['max'] ) ) {
785 $badge = $tier;
786 break;
787 }
788 }
789
790 $label = $badge['label'];
791 $class = $badge['class'];
792 $icon = $badge['icon'];
793
794 printf(
795 '<span class="ai-badge %s" title="%s (%d%%)">%s %s</span>',
796 esc_attr( $class ),
797 esc_attr( $label ),
798 absint( $score * 100 ),
799 esc_html( $icon ),
800 esc_html( $label )
801 );
802 }
803
804 /**
805 * Renders a pending badge for comments queued for analysis.
806 *
807 * @since 0.9.0
808 *
809 * @param int $comment_id The comment ID.
810 */
811 private function render_pending_badge( int $comment_id ): void {
812 printf(
813 '<span class="ai-badge ai-badge--pending" data-comment-id="%d" data-ai-status="pending">%s</span>',
814 absint( $comment_id ),
815 esc_html__( 'Queued', 'ai' )
816 );
817 }
818
819 /**
820 * Renders a processing badge.
821 *
822 * @since 0.9.0
823 *
824 * @param int $comment_id The comment ID.
825 */
826 private function render_processing_badge( int $comment_id ): void {
827 printf(
828 '<span class="ai-badge ai-badge--processing" data-comment-id="%d" data-ai-status="processing">%s</span>',
829 absint( $comment_id ),
830 esc_html__( 'Analyzing…', 'ai' )
831 );
832 }
833
834 /**
835 * Renders a failed analysis badge.
836 *
837 * @since 1.0.0
838 */
839 private function render_failed_badge(): void {
840 printf(
841 '<span class="ai-badge ai-badge--failed">%s</span>',
842 esc_html__( 'Failed', 'ai' )
843 );
844 }
845
846 /**
847 * Adds bulk actions to the comments list.
848 *
849 * @since 0.9.0
850 *
851 * @param array<string, string> $actions The existing bulk actions.
852 * @return array<string, string> The modified bulk actions.
853 */
854 public function add_bulk_actions( $actions ): array {
855 if ( ! is_array( $actions ) ) {
856 return $actions;
857 }
858
859 $actions['wpai_analyze'] = __( 'Analyze Sentiment and Toxicity', 'ai' );
860 return $actions;
861 }
862
863 /**
864 * Registers the bulk notice trigger params as removable query args.
865 *
866 * The bulk action redirect carries `wpai_analysis_queued` or
867 * `wpai_no_provider` in the URL so the notice can be shown once. Listing
868 * them here lets core clean them out of the address bar on the first paint,
869 * via the canonical URL it prints in `admin_head`, so reloading the results
870 * page does not re-show the notice. The sort and pagination links are
871 * handled by the request URI scrub in
872 * {@see Comment_Moderation::remove_bulk_notice_query_args()}.
873 *
874 * @since 1.3.0
875 *
876 * @param list<string> $args Query args removed from admin URLs.
877 * @return list<string> Args including the bulk notice trigger params.
878 */
879 public function register_removable_query_args( array $args ): array {
880 return array_merge( $args, self::BULK_NOTICE_QUERY_ARGS );
881 }
882
883 /**
884 * Scrubs the bulk notice trigger params from the request URI.
885 *
886 * The notice reads the params from `$_GET`, which this does not touch, so
887 * it still shows once on the redirect. Removing them from the request URI
888 * stops the sort header links the list table builds from it from carrying
889 * them, since those links only strip `paged`, not removable args. This
890 * mirrors what core does for its own one-shot params in wp-admin/edit-comments.php.
891 *
892 * @since 1.3.0
893 */
894 public function remove_bulk_notice_query_args(): void {
895 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
896 return;
897 }
898
899 $_SERVER['REQUEST_URI'] = remove_query_arg( self::BULK_NOTICE_QUERY_ARGS, (string) $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
900 }
901
902 /**
903 * Handles the bulk action for AI analysis.
904 *
905 * @since 0.9.0
906 *
907 * @param string $redirect_url The redirect URL.
908 * @param string $action The action being performed.
909 * @param array<int> $comment_ids The comment IDs.
910 * @return string The modified redirect URL.
911 */
912 public function handle_bulk_action( $redirect_url, $action, $comment_ids ): string {
913 if ( 'wpai_analyze' !== (string) $action ) {
914 return $redirect_url;
915 }
916
917 if ( ! has_ai_credentials() ) {
918 return add_query_arg( 'wpai_no_provider', 1, (string) $redirect_url );
919 }
920
921 // Mark selected comments as pending for analysis.
922 $queued = 0;
923 foreach ( (array) $comment_ids as $comment_id ) {
924 $comment_id = absint( $comment_id );
925 $comment = get_comment( $comment_id );
926 if ( ! $comment || ! is_a( $comment, '\WP_Comment' ) ) {
927 continue;
928 }
929
930 update_comment_meta( $comment_id, self::META_ANALYSIS_STATUS, self::STATUS_PENDING );
931 ++$queued;
932 }
933
934 // Add query arg to show notice.
935 return add_query_arg( 'wpai_analysis_queued', $queued, (string) $redirect_url );
936 }
937
938 /**
939 * Shows admin notice after bulk action.
940 *
941 * @since 0.9.0
942 */
943 public function show_bulk_action_notice(): void {
944 if ( isset( $_GET['wpai_no_provider'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
945 self::show_missing_provider_notice();
946 return;
947 }
948
949 if ( ! isset( $_GET['wpai_analysis_queued'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
950 return;
951 }
952
953 $count = absint( wp_unslash( $_GET['wpai_analysis_queued'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
954
955 if ( $count <= 0 ) {
956 return;
957 }
958
959 printf(
960 '<div class="notice notice-success is-dismissible"><p>%s</p></div>',
961 esc_html(
962 sprintf(
963 /* translators: %d: Number of comments queued for analysis. */
964 _n(
965 '%d comment queued for analysis.',
966 '%d comments queued for analysis.',
967 $count,
968 'ai'
969 ),
970 $count
971 )
972 )
973 );
974 }
975
976 /**
977 * Adds an inline action to the comment row actions.
978 *
979 * @since 0.9.0
980 *
981 * @param array<string, string> $actions The existing actions.
982 * @param \WP_Comment $comment The comment object.
983 * @return array<string, string> The modified actions.
984 */
985 public function add_inline_action( $actions, $comment ): array {
986 if (
987 ! is_array( $actions ) ||
988 ! $comment ||
989 ! is_a( $comment, '\WP_Comment' )
990 ) {
991 return $actions;
992 }
993
994 $url = add_query_arg(
995 array(
996 'wpai_analyze_comment' => (int) $comment->comment_ID,
997 ),
998 admin_url( 'edit-comments.php' )
999 );
1000 $url = wp_nonce_url( $url, 'wpai_analyze_comment_' . (int) $comment->comment_ID );
1001
1002 $actions['wpai_analyze'] = sprintf(
1003 '<a href="%s" aria-label="%s">%s</a>',
1004 esc_url( $url ),
1005 esc_attr__( 'Analyze this comment', 'ai' ),
1006 esc_html__( 'Analyze Sentiment and Toxicity', 'ai' )
1007 );
1008
1009 return $actions;
1010 }
1011
1012 /**
1013 * Shows an admin notice if the inline action is attempted without a provider.
1014 *
1015 * @since 1.0.0
1016 */
1017 private function show_missing_provider_notice(): void {
1018 $connectors_url = get_provider_availability_data()['connectorsUrl'];
1019 $notice_message = sprintf(
1020 /* translators: %s: Link to connectors settings page. */
1021 __( 'This feature requires a valid AI Connector to function properly. Please set up a provider to use this feature in %s.', 'ai' ),
1022 '<a href="' . esc_url( $connectors_url ) . '">' . esc_html__( 'Settings → Connectors', 'ai' ) . '</a>'
1023 );
1024
1025 printf(
1026 '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
1027 wp_kses(
1028 $notice_message,
1029 array(
1030 'a' => array(
1031 'href' => array(),
1032 ),
1033 )
1034 )
1035 );
1036 }
1037
1038 /**
1039 * Handles the inline analyze action from the comment row.
1040 *
1041 * @since 0.9.0
1042 */
1043 public function handle_inline_action(): void {
1044 if ( ! current_user_can( 'moderate_comments' ) ) {
1045 return;
1046 }
1047
1048 if ( ! isset( $_GET['wpai_analyze_comment'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1049 return;
1050 }
1051
1052 $comment_id = absint( wp_unslash( $_GET['wpai_analyze_comment'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1053 if ( ! $comment_id ) {
1054 return;
1055 }
1056
1057 check_admin_referer( 'wpai_analyze_comment_' . $comment_id );
1058
1059 $redirect_url = remove_query_arg(
1060 array(
1061 'wpai_analyze_comment',
1062 '_wpnonce',
1063 )
1064 );
1065 $redirect_url = $this->handle_bulk_action( $redirect_url, 'wpai_analyze', array( $comment_id ) );
1066
1067 wp_safe_redirect( $redirect_url );
1068 exit;
1069 }
1070
1071 /**
1072 * Enqueues admin assets for the comments screen.
1073 *
1074 * @since 0.9.0
1075 *
1076 * @param string $hook_suffix The current admin page hook suffix.
1077 */
1078 public function enqueue_assets( $hook_suffix ): void {
1079 if ( 'edit-comments.php' !== (string) $hook_suffix ) {
1080 return;
1081 }
1082
1083 Asset_Loader::enqueue_script( 'comment_moderation', 'experiments/comment-moderation', array( 'include_core_abilities' => true ) );
1084 Asset_Loader::localize_script(
1085 'comment_moderation',
1086 'CommentModerationData',
1087 array(
1088 'enabled' => $this->is_enabled(),
1089 'labels' => array(
1090 'sentiment' => self::get_sentiment_config(),
1091 'toxicity' => self::get_toxicity_config(),
1092 ),
1093 )
1094 );
1095 }
1096
1097 /**
1098 * Adds inline styles for the comment moderation badges.
1099 *
1100 * @since 0.9.0
1101 */
1102 public function add_inline_styles(): void {
1103 ?>
1104 <style>
1105 .edit-comments-php .column-wpai_sentiment,
1106 .edit-comments-php .column-wpai_toxicity {
1107 width: 100px;
1108 }
1109
1110 .ai-badge {
1111 display: inline-flex;
1112 align-items: center;
1113 gap: 4px;
1114 padding: 2px 8px;
1115 border-radius: 3px;
1116 font-size: 12px;
1117 font-weight: 500;
1118 line-height: 1.4;
1119 white-space: nowrap;
1120 }
1121
1122 .ai-badge--positive {
1123 background-color: #d4edda;
1124 color: #155724;
1125 }
1126
1127 .ai-badge--negative {
1128 background-color: #f8d7da;
1129 color: #721c24;
1130 }
1131
1132 .ai-badge--neutral {
1133 background-color: #e2e3e5;
1134 color: #383d41;
1135 }
1136
1137 .ai-badge--low-toxicity {
1138 background-color: #d4edda;
1139 color: #155724;
1140 }
1141
1142 .ai-badge--medium-toxicity {
1143 background-color: #fff3cd;
1144 color: #856404;
1145 }
1146
1147 .ai-badge--high-toxicity {
1148 background-color: #f8d7da;
1149 color: #721c24;
1150 }
1151
1152 .ai-badge--empty {
1153 background-color: transparent;
1154 color: #999;
1155 }
1156
1157 .ai-badge--pending {
1158 background-color: #f0f0f0;
1159 color: #666;
1160 }
1161
1162 .ai-badge--processing {
1163 background-color: #cce5ff;
1164 color: #004085;
1165 }
1166
1167 .ai-badge--failed {
1168 background-color: #f8d7da;
1169 color: #721c24;
1170 }
1171
1172 .dashboard-comment-wrap .ai-dashboard-pills {
1173 margin-top: 8px;
1174 display: flex;
1175 gap: 8px;
1176 }
1177 </style>
1178 <?php
1179 }
1180 }
1181