PluginProbe
wpForo Forum / 3.0.5
wpForo Forum v3.0.5
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 / TaskManager.php

TaskManager.php in wpForo Forum 3.0.5, at classes/TaskManager.php

3,159 lines 99.4 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 * wpForo AI Task Manager
7 *
8 * Handles AI task management including:
9 * - Task CRUD operations (create, read, update, delete)
10 * - Task scheduling via WordPress cron
11 * - Task execution coordination with backend API
12 * - Task logging and statistics
13 *
14 * @since 3.0.0
15 */
16 class TaskManager {
17
18 /**
19 * Task types and their labels
20 *
21 * @var array
22 */
23 public static $task_types = [
24 'topic_generator' => 'AI Topic Generator',
25 'reply_generator' => 'AI Reply Generator',
26 'tag_maintenance' => 'AI Topic Tag Generator and Cleaning',
27 ];
28
29 /**
30 * Task statuses
31 *
32 * @var array
33 */
34 public static $statuses = [
35 'draft' => 'Draft',
36 'active' => 'Active',
37 'paused' => 'Paused',
38 'error' => 'Error',
39 ];
40
41 /**
42 * Quality tiers and their credit costs
43 *
44 * @var array
45 */
46 public static $quality_tiers = [
47 'fast' => [ 'label' => 'Fast', 'credits' => 1, 'model' => 'Fast Model' ],
48 'balanced' => [ 'label' => 'Balanced', 'credits' => 2, 'model' => 'Balanced Model' ],
49 'advanced' => [ 'label' => 'Advanced', 'credits' => 3, 'model' => 'Advanced Model' ],
50 'premium' => [ 'label' => 'Premium', 'credits' => 4, 'model' => 'Premium Model' ],
51 ];
52
53 /**
54 * AIClient instance (lazy loaded)
55 *
56 * @var AIClient|null
57 */
58 private $ai_client = null;
59
60 /**
61 * Constructor
62 */
63 public function __construct() {
64 // Note: ai_client is lazy loaded via get_ai_client() to avoid initialization order issues
65
66 // Register AJAX handlers (admin only)
67 if ( is_admin() ) {
68 add_action( 'wp_ajax_wpforo_ai_save_task', [ $this, 'ajax_save_task' ] );
69 add_action( 'wp_ajax_wpforo_ai_get_task', [ $this, 'ajax_get_task' ] );
70 add_action( 'wp_ajax_wpforo_ai_delete_task', [ $this, 'ajax_delete_task' ] );
71 add_action( 'wp_ajax_wpforo_ai_update_task_status', [ $this, 'ajax_update_task_status' ] );
72 add_action( 'wp_ajax_wpforo_ai_run_task', [ $this, 'ajax_run_task' ] );
73 add_action( 'wp_ajax_wpforo_ai_bulk_task_action', [ $this, 'ajax_bulk_action' ] );
74 add_action( 'wp_ajax_wpforo_ai_get_task_logs', [ $this, 'ajax_get_task_logs' ] );
75 add_action( 'wp_ajax_wpforo_ai_search_users', [ $this, 'ajax_search_users' ] );
76 add_action( 'wp_ajax_wpforo_ai_duplicate_task', [ $this, 'ajax_duplicate_task' ] );
77 add_action( 'wp_ajax_wpforo_ai_get_task_stats', [ $this, 'ajax_get_task_stats' ] );
78 }
79
80 // Register cron hooks
81 add_action( 'wpforo_ai_execute_task', [ $this, 'cron_execute_task' ], 10, 1 );
82 add_action( 'wpforo_ai_check_scheduled_tasks', [ $this, 'cron_check_scheduled_tasks' ] );
83
84 // Schedule the task checker if not already scheduled
85 if ( ! wp_next_scheduled( 'wpforo_ai_check_scheduled_tasks' ) ) {
86 wp_schedule_event( time(), 'hourly', 'wpforo_ai_check_scheduled_tasks' );
87 }
88
89 // Check and reschedule overdue tasks when admin page loads
90 add_action( 'admin_init', [ $this, 'reschedule_overdue_tasks' ] );
91
92 // Hook into topic/post approval for run_on_approval tasks
93 add_action( 'wpforo_topic_approve', [ $this, 'on_topic_approved' ], 20, 1 );
94 add_action( 'wpforo_post_approve', [ $this, 'on_post_approved' ], 20, 1 );
95
96 // Also hook into topic/post creation for run_on_approval tasks (for content created with status=0)
97 add_action( 'wpforo_after_add_topic', [ $this, 'on_topic_created' ], 20, 2 );
98 add_action( 'wpforo_after_add_post', [ $this, 'on_post_created' ], 20, 3 );
99 }
100
101 /**
102 * Reschedule overdue active tasks
103 * Called on admin_init to catch tasks that missed their cron execution
104 */
105 public function reschedule_overdue_tasks() {
106 // Only run on wpForo AI admin page
107 if ( ! isset( $_GET['page'] ) || $_GET['page'] !== 'wpforo-ai' ) {
108 return;
109 }
110
111 global $wpdb;
112 $table = $this->get_tasks_table();
113 $now = current_time( 'mysql' );
114
115 // Find active tasks that are overdue (more than 5 minutes past next_run_time)
116 $five_mins_ago = date( 'Y-m-d H:i:s', strtotime( '-5 minutes' ) );
117 $overdue_tasks = $wpdb->get_results(
118 $wpdb->prepare(
119 "SELECT task_id FROM {$table} WHERE status = 'active' AND next_run_time IS NOT NULL AND next_run_time <= %s",
120 $five_mins_ago
121 ),
122 ARRAY_A
123 );
124
125 foreach ( $overdue_tasks as $task ) {
126 $task_id = (int) $task['task_id'];
127 // Only reschedule if not already scheduled
128 if ( ! wp_next_scheduled( 'wpforo_ai_execute_task', [ $task_id ] ) ) {
129 // Schedule for immediate execution
130 wp_schedule_single_event( time() + 10, 'wpforo_ai_execute_task', [ $task_id ] );
131 }
132 }
133 }
134
135 /**
136 * Get AIClient instance (lazy loaded)
137 *
138 * @return AIClient|null
139 */
140 private function get_ai_client() {
141 if ( $this->ai_client === null && isset( WPF()->ai_client ) ) {
142 $this->ai_client = WPF()->ai_client;
143 }
144 return $this->ai_client;
145 }
146
147 // =========================================================================
148 // TASK CRUD OPERATIONS
149 // =========================================================================
150
151 /**
152 * Get tasks table name
153 *
154 * @return string Table name with prefix
155 */
156 private function get_tasks_table() {
157 global $wpdb;
158 return $wpdb->prefix . 'wpforo_ai_tasks';
159 }
160
161 /**
162 * Get task logs table name
163 *
164 * @return string Table name with prefix
165 */
166 private function get_logs_table() {
167 global $wpdb;
168 return $wpdb->prefix . 'wpforo_ai_task_logs';
169 }
170
171 /**
172 * Get all tasks
173 *
174 * @param array $args Query arguments
175 * @return array Tasks list
176 */
177 public function get_tasks( $args = [] ) {
178 global $wpdb;
179
180 $defaults = [
181 'board_id' => null,
182 'status' => null,
183 'type' => null,
184 'orderby' => 'created_at',
185 'order' => 'DESC',
186 'limit' => 50,
187 'offset' => 0,
188 ];
189
190 $args = wp_parse_args( $args, $defaults );
191 $table = $this->get_tasks_table();
192
193 $where = [];
194 $values = [];
195
196 if ( $args['board_id'] !== null ) {
197 $where[] = 'board_id = %d';
198 $values[] = intval( $args['board_id'] );
199 }
200
201 if ( $args['status'] !== null ) {
202 $where[] = 'status = %s';
203 $values[] = sanitize_text_field( $args['status'] );
204 }
205
206 if ( $args['type'] !== null ) {
207 $where[] = 'task_type = %s';
208 $values[] = sanitize_text_field( $args['type'] );
209 }
210
211 $where_sql = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : '';
212
213 // Validate orderby and order
214 $allowed_orderby = [ 'task_id', 'task_name', 'task_type', 'status', 'created_at', 'last_run_time', 'next_run_time' ];
215 $orderby = in_array( $args['orderby'], $allowed_orderby ) ? $args['orderby'] : 'created_at';
216 $order = strtoupper( $args['order'] ) === 'ASC' ? 'ASC' : 'DESC';
217
218 $sql = "SELECT * FROM {$table} {$where_sql} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";
219 $values[] = intval( $args['limit'] );
220 $values[] = intval( $args['offset'] );
221
222 if ( ! empty( $values ) ) {
223 $sql = $wpdb->prepare( $sql, $values );
224 }
225
226 $results = $wpdb->get_results( $sql, ARRAY_A );
227
228 // Decode JSON config for each task
229 foreach ( $results as &$task ) {
230 if ( ! empty( $task['config'] ) ) {
231 $task['config'] = json_decode( $task['config'], true );
232 }
233 }
234
235 return $results;
236 }
237
238 /**
239 * Get a single task by ID
240 *
241 * @param int $task_id Task ID
242 * @return array|null Task data or null if not found
243 */
244 public function get_task( $task_id ) {
245 global $wpdb;
246
247 $table = $this->get_tasks_table();
248 $task = $wpdb->get_row(
249 $wpdb->prepare( "SELECT * FROM {$table} WHERE task_id = %d", intval( $task_id ) ),
250 ARRAY_A
251 );
252
253 if ( $task && ! empty( $task['config'] ) ) {
254 $task['config'] = json_decode( $task['config'], true );
255 }
256
257 return $task;
258 }
259
260 /**
261 * Create a new task
262 *
263 * @param array $data Task data
264 * @return int|false Task ID on success, false on failure
265 */
266 public function create_task( $data ) {
267 global $wpdb;
268
269 $table = $this->get_tasks_table();
270 $current_user_id = get_current_user_id();
271
272 // Sanitize and validate data
273 $insert_data = [
274 'task_name' => sanitize_text_field( $data['task_name'] ?? '' ),
275 'task_type' => sanitize_key( $data['task_type'] ?? '' ),
276 'status' => sanitize_key( $data['status'] ?? 'draft' ),
277 'board_id' => intval( $data['board_id'] ?? 0 ),
278 'config' => is_array( $data['config'] ?? null ) ? wp_json_encode( $data['config'] ) : ( $data['config'] ?? '{}' ),
279 'created_by' => $current_user_id,
280 'created_at' => current_time( 'mysql' ),
281 'updated_at' => current_time( 'mysql' ),
282 ];
283
284 // Validate task type
285 if ( ! array_key_exists( $insert_data['task_type'], self::$task_types ) ) {
286 return false;
287 }
288
289 // Validate status
290 if ( ! array_key_exists( $insert_data['status'], self::$statuses ) ) {
291 $insert_data['status'] = 'draft';
292 }
293
294 $result = $wpdb->insert( $table, $insert_data, [
295 '%s', '%s', '%s', '%d', '%s', '%d', '%s', '%s'
296 ] );
297
298 if ( $result === false ) {
299 return false;
300 }
301
302 $task_id = $wpdb->insert_id;
303
304 // Schedule task if active AND not run_on_approval
305 // run_on_approval tasks trigger on topic/post approval, not cron
306 $config = is_array( $data['config'] ?? null ) ? $data['config'] : json_decode( $data['config'] ?? '{}', true );
307 $run_on_approval = ! empty( $config['run_on_approval'] );
308
309 if ( $insert_data['status'] === 'active' && ! $run_on_approval ) {
310 $this->schedule_next_run( $task_id );
311 }
312
313 return $task_id;
314 }
315
316 /**
317 * Update an existing task
318 *
319 * @param int $task_id Task ID
320 * @param array $data Task data to update
321 * @return bool True on success
322 */
323 public function update_task( $task_id, $data ) {
324 global $wpdb;
325
326 $table = $this->get_tasks_table();
327 $task_id = intval( $task_id );
328
329 // Get existing task
330 $existing = $this->get_task( $task_id );
331 if ( ! $existing ) {
332 return false;
333 }
334
335 $update_data = [];
336 $formats = [];
337
338 // Only update provided fields
339 if ( isset( $data['task_name'] ) ) {
340 $update_data['task_name'] = sanitize_text_field( $data['task_name'] );
341 $formats[] = '%s';
342 }
343
344 if ( isset( $data['task_type'] ) && array_key_exists( $data['task_type'], self::$task_types ) ) {
345 $update_data['task_type'] = sanitize_key( $data['task_type'] );
346 $formats[] = '%s';
347 }
348
349 if ( isset( $data['status'] ) && array_key_exists( $data['status'], self::$statuses ) ) {
350 $update_data['status'] = sanitize_key( $data['status'] );
351 $formats[] = '%s';
352 }
353
354 if ( isset( $data['board_id'] ) ) {
355 $update_data['board_id'] = intval( $data['board_id'] );
356 $formats[] = '%d';
357 }
358
359 if ( isset( $data['config'] ) ) {
360 $update_data['config'] = is_array( $data['config'] ) ? wp_json_encode( $data['config'] ) : $data['config'];
361 $formats[] = '%s';
362 }
363
364 if ( empty( $update_data ) ) {
365 return true; // Nothing to update
366 }
367
368 $update_data['updated_at'] = current_time( 'mysql' );
369 $formats[] = '%s';
370
371 $result = $wpdb->update(
372 $table,
373 $update_data,
374 [ 'task_id' => $task_id ],
375 $formats,
376 [ '%d' ]
377 );
378
379 // Handle scheduling based on status change or config update
380 $new_status = $data['status'] ?? $existing['status'];
381 $config_changed = isset( $data['config'] );
382
383 // Check if run_on_approval is enabled in the new config
384 $new_config = isset( $data['config'] )
385 ? ( is_array( $data['config'] ) ? $data['config'] : json_decode( $data['config'], true ) )
386 : ( $existing['config'] ?? [] );
387 $run_on_approval = ! empty( $new_config['run_on_approval'] );
388
389 if ( $new_status === 'active' ) {
390 // If run_on_approval is enabled, unschedule any existing cron and skip scheduling
391 if ( $run_on_approval ) {
392 $this->unschedule_task( $task_id );
393 } elseif ( $existing['status'] !== 'active' || $config_changed ) {
394 // Reschedule if: status changed to active OR config changed while active
395 // Clear old schedule first
396 $this->unschedule_task( $task_id );
397 // Schedule with new config
398 $this->schedule_next_run( $task_id );
399 }
400 } elseif ( $new_status !== 'active' && $existing['status'] === 'active' ) {
401 // Status changed from active to non-active
402 $this->unschedule_task( $task_id );
403 }
404
405 return $result !== false;
406 }
407
408 /**
409 * Delete a task
410 *
411 * @param int $task_id Task ID
412 * @return bool True on success
413 */
414 public function delete_task( $task_id ) {
415 global $wpdb;
416
417 $task_id = intval( $task_id );
418
419 // Unschedule any pending runs
420 $this->unschedule_task( $task_id );
421
422 // Delete logs first (foreign key constraint)
423 $logs_table = $this->get_logs_table();
424 $wpdb->delete( $logs_table, [ 'task_id' => $task_id ], [ '%d' ] );
425
426 // Delete task
427 $table = $this->get_tasks_table();
428 $result = $wpdb->delete( $table, [ 'task_id' => $task_id ], [ '%d' ] );
429
430 return $result !== false;
431 }
432
433 // =========================================================================
434 // TASK SCHEDULING
435 // =========================================================================
436
437 /**
438 * Schedule the next run for a task
439 *
440 * @param int $task_id Task ID
441 * @return bool True if scheduled
442 */
443 public function schedule_next_run( $task_id ) {
444 $task = $this->get_task( $task_id );
445 if ( ! $task || $task['status'] !== 'active' ) {
446 return false;
447 }
448
449 $config = $task['config'];
450
451 // run_on_approval tasks don't use cron scheduling - they trigger on topic/post approval
452 if ( ! empty( $config['run_on_approval'] ) ) {
453 return false;
454 }
455 $next_run = $this->calculate_next_run_time( $config );
456
457 if ( $next_run ) {
458 // Update next_run_time in database
459 global $wpdb;
460 $table = $this->get_tasks_table();
461 $wpdb->update(
462 $table,
463 [ 'next_run_time' => date( 'Y-m-d H:i:s', $next_run ) ],
464 [ 'task_id' => $task_id ],
465 [ '%s' ],
466 [ '%d' ]
467 );
468
469 // Schedule WordPress cron event
470 wp_schedule_single_event( $next_run, 'wpforo_ai_execute_task', [ $task_id ] );
471
472 return true;
473 }
474
475 return false;
476 }
477
478 /**
479 * Unschedule a task
480 *
481 * @param int $task_id Task ID
482 */
483 public function unschedule_task( $task_id ) {
484 wp_clear_scheduled_hook( 'wpforo_ai_execute_task', [ $task_id ] );
485 }
486
487 /**
488 * Calculate next run time based on task config
489 *
490 * @param array $config Task configuration
491 * @return int|false Unix timestamp or false
492 */
493 private function calculate_next_run_time( $config ) {
494 $schedule_type = $config['schedule_type'] ?? 'recurring';
495
496 if ( $schedule_type === 'once' ) {
497 // One-time execution - schedule immediately if in active hours
498 return $this->get_next_active_time( $config );
499 }
500
501 // Recurring execution - parse frequency
502 // Support combined frequency format (e.g., "hourly", "3hours", "daily", "weekly")
503 // and legacy separate frequency_value/frequency_unit format
504 $interval = 0;
505
506 if ( isset( $config['frequency'] ) ) {
507 // Parse combined frequency format
508 $frequency = $config['frequency'];
509 switch ( $frequency ) {
510 case 'hourly':
511 $interval = HOUR_IN_SECONDS;
512 break;
513 case '3hours':
514 $interval = 3 * HOUR_IN_SECONDS;
515 break;
516 case '6hours':
517 $interval = 6 * HOUR_IN_SECONDS;
518 break;
519 case 'daily':
520 $interval = DAY_IN_SECONDS;
521 break;
522 case '3days':
523 $interval = 3 * DAY_IN_SECONDS;
524 break;
525 case 'weekly':
526 $interval = WEEK_IN_SECONDS;
527 break;
528 }
529 }
530
531 // Fallback to legacy format if combined frequency not set or invalid
532 if ( $interval === 0 ) {
533 $frequency_value = intval( $config['frequency_value'] ?? 1 );
534 $frequency_unit = $config['frequency_unit'] ?? 'day';
535
536 switch ( $frequency_unit ) {
537 case 'hour':
538 $interval = $frequency_value * HOUR_IN_SECONDS;
539 break;
540 case 'day':
541 $interval = $frequency_value * DAY_IN_SECONDS;
542 break;
543 case 'week':
544 $interval = $frequency_value * WEEK_IN_SECONDS;
545 break;
546 }
547 }
548
549 if ( $interval === 0 ) {
550 return false;
551 }
552
553 $next_time = time() + $interval;
554
555 // Adjust to active hours if configured
556 return $this->get_next_active_time( $config, $next_time );
557 }
558
559 /**
560 * Get the next time within active hours
561 *
562 * @param array $config Task configuration
563 * @param int $from Starting timestamp (default: now)
564 * @return int Unix timestamp
565 */
566 private function get_next_active_time( $config, $from = null ) {
567 $from = $from ?? time();
568
569 // Check active days - default to all days if not specified
570 $active_days = $config['active_days'] ?? [ 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun' ];
571 if ( empty( $active_days ) ) {
572 $active_days = [ 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun' ];
573 }
574
575 $day_map = [
576 'sun' => 0, 'mon' => 1, 'tue' => 2, 'wed' => 3,
577 'thu' => 4, 'fri' => 5, 'sat' => 6
578 ];
579
580 // Check active time window - default to 24/7 if not specified
581 $time_start = $config['active_time_start'] ?? '00:00';
582 $time_end = $config['active_time_end'] ?? '23:59';
583
584 // Parse times
585 $start_parts = explode( ':', $time_start );
586 $end_parts = explode( ':', $time_end );
587 $start_hour = intval( $start_parts[0] );
588 $start_min = intval( $start_parts[1] ?? 0 );
589 $end_hour = intval( $end_parts[0] );
590 $end_min = intval( $end_parts[1] ?? 0 );
591
592 // Find next valid time
593 $check_time = $from;
594 for ( $i = 0; $i < 14; $i++ ) { // Check up to 2 weeks ahead
595 $day_of_week = date( 'w', $check_time );
596 $day_abbrev = array_search( $day_of_week, $day_map );
597
598 if ( in_array( $day_abbrev, $active_days ) ) {
599 // Check time window
600 $current_hour = intval( date( 'G', $check_time ) );
601 $current_min = intval( date( 'i', $check_time ) );
602
603 // If before start time, set to start time
604 if ( $current_hour < $start_hour || ( $current_hour === $start_hour && $current_min < $start_min ) ) {
605 return strtotime( date( 'Y-m-d', $check_time ) . ' ' . sprintf( '%02d:%02d:00', $start_hour, $start_min ) );
606 }
607
608 // If within time window, return as is
609 if ( $current_hour < $end_hour || ( $current_hour === $end_hour && $current_min <= $end_min ) ) {
610 return $check_time;
611 }
612 }
613
614 // Move to next day at start time
615 $check_time = strtotime( date( 'Y-m-d', $check_time ) . ' +1 day ' . sprintf( '%02d:%02d:00', $start_hour, $start_min ) );
616 }
617
618 // Fallback to from time
619 return $from;
620 }
621
622 /**
623 * Cron handler to check and execute scheduled tasks
624 */
625 public function cron_check_scheduled_tasks() {
626 global $wpdb;
627
628 $table = $this->get_tasks_table();
629 $now = current_time( 'mysql' );
630
631 // Find active tasks with next_run_time in the past
632 $tasks = $wpdb->get_results(
633 $wpdb->prepare(
634 "SELECT task_id FROM {$table} WHERE status = 'active' AND next_run_time <= %s",
635 $now
636 ),
637 ARRAY_A
638 );
639
640 foreach ( $tasks as $task ) {
641 // Schedule immediate execution if not already scheduled
642 if ( ! wp_next_scheduled( 'wpforo_ai_execute_task', [ $task['task_id'] ] ) ) {
643 wp_schedule_single_event( time(), 'wpforo_ai_execute_task', [ $task['task_id'] ] );
644 }
645 }
646 }
647
648 /**
649 * Cron handler to execute a task
650 *
651 * @param int $task_id Task ID
652 */
653 public function cron_execute_task( $task_id ) {
654 $task = $this->get_task( $task_id );
655 if ( ! $task || $task['status'] !== 'active' ) {
656 return;
657 }
658
659 // Execute the task
660 $result = $this->execute_task( $task_id );
661
662 // Schedule next run
663 $this->schedule_next_run( $task_id );
664 }
665
666 // =========================================================================
667 // TASK EXECUTION
668 // =========================================================================
669
670 /**
671 * Execute a task
672 *
673 * @param int $task_id Task ID
674 * @return array Execution result
675 */
676 public function execute_task( $task_id ) {
677 $task = $this->get_task( $task_id );
678 if ( ! $task ) {
679 return [ 'success' => false, 'error' => 'Task not found' ];
680 }
681
682 $start_time = microtime( true );
683
684 // Check credit threshold
685 if ( ! $this->check_credit_threshold( $task ) ) {
686 return $this->log_execution( $task_id, [
687 'status' => 'skipped',
688 'error_message' => 'Credit threshold reached',
689 ] );
690 }
691
692 // Execute based on task type
693 $result = [];
694 switch ( $task['task_type'] ) {
695 case 'topic_generator':
696 $result = $this->execute_topic_generator( $task );
697 break;
698 case 'reply_generator':
699 $result = $this->execute_reply_generator( $task );
700 break;
701 case 'tag_maintenance':
702 $result = $this->execute_tag_maintenance( $task );
703 break;
704 default:
705 $result = [ 'success' => false, 'error' => 'Unknown task type' ];
706 }
707
708 // Calculate execution time
709 $execution_time = microtime( true ) - $start_time;
710
711 // Log the execution
712 return $this->log_execution( $task_id, [
713 'status' => $result['success'] ? 'completed' : 'error',
714 'items_created' => $result['items_created'] ?? 0,
715 'credits_used' => $result['credits_used'] ?? 0,
716 'execution_duration' => $execution_time,
717 'error_message' => $result['error'] ?? null,
718 'result_data' => $result['data'] ?? null,
719 ] );
720 }
721
722 /**
723 * Resolve the response language from task config.
724 *
725 * Converts a 2-letter language code (or empty for auto) to an English language name
726 * that the backend LLM can understand (e.g., "Spanish", "French").
727 *
728 * @param array $config Task configuration array
729 * @return string English language name
730 */
731 private function resolve_response_language( $config ) {
732 $code = $config['response_language'] ?? '';
733
734 if ( ! function_exists( 'wpforo_get_ai_languages' ) ) {
735 return $code ?: 'English';
736 }
737
738 $languages = wpforo_get_ai_languages();
739
740 if ( ! empty( $code ) ) {
741 // Map 2-letter code to English name
742 foreach ( $languages as $lang ) {
743 if ( $lang['code'] === $code ) {
744 return $lang['name'];
745 }
746 }
747 return $code; // Fallback: return the code itself
748 }
749
750 // Auto: resolve from board locale
751 $board_locale = WPF()->board->get_current( 'locale' );
752 foreach ( $languages as $lang ) {
753 if ( $lang['locale'] === $board_locale ) {
754 return $lang['name'];
755 }
756 }
757
758 return 'English'; // Final fallback
759 }
760
761 /**
762 * Execute topic generator task
763 *
764 * @param array $task Task data
765 * @return array Execution result
766 */
767 private function execute_topic_generator( $task ) {
768 $config = $task['config'];
769
770 // Call backend API for topic generation
771 $ai_client = $this->get_ai_client();
772 $api_key = $ai_client ? $ai_client->get_stored_api_key() : '';
773 if ( empty( $api_key ) ) {
774 return [ 'success' => false, 'error' => 'AI service not connected' ];
775 }
776
777 $topics_per_run = intval( $config['topics_per_run'] ?? 1 );
778 $quality_tier = $config['quality_tier'] ?? 'balanced';
779 $target_forums = $config['target_forums'] ?? [];
780
781 // Get forum details for context
782 $forum_context = [];
783 foreach ( $target_forums as $forum_id ) {
784 $forum = WPF()->forum->get_forum( $forum_id );
785 if ( $forum ) {
786 $forum_context[] = [
787 'id' => $forum['forumid'],
788 'title' => $forum['title'],
789 'slug' => $forum['slug'],
790 ];
791 }
792 }
793
794 // Build custom instructions from topic_theme and content options
795 $custom_instructions = $this->build_topic_custom_instructions( $config );
796
797 // Build API request
798 $request_data = [
799 'task_type' => 'topic_generator',
800 'topics_count' => $topics_per_run,
801 'quality' => $quality_tier,
802 'target_forums' => $forum_context,
803 'topic_style' => $config['topic_style'] ?? 'neutral',
804 'custom_instructions' => $custom_instructions,
805 'board_id' => $task['board_id'],
806 'response_language' => $this->resolve_response_language( $config ),
807 ];
808
809 // Make API call
810 $response = $this->call_tasks_api( 'generate', $request_data, $api_key );
811
812 if ( is_wp_error( $response ) ) {
813 return [ 'success' => false, 'error' => $response->get_error_message() ];
814 }
815
816 // Process response and create topics
817 $topics_created = 0;
818 $topics_skipped = 0;
819 $credits_used = 0;
820
821 // Check if duplicate prevention is enabled
822 $duplicate_prevention = ! empty( $config['duplicate_prevention'] );
823 $similarity_threshold = intval( $config['similarity_threshold'] ?? 75 );
824 $check_days = intval( $config['duplicate_check_days'] ?? 90 );
825
826 if ( ! empty( $response['topics'] ) ) {
827 foreach ( $response['topics'] as $topic_data ) {
828 // Check for duplicates if enabled
829 if ( $duplicate_prevention ) {
830 $is_duplicate = $this->check_duplicate_topic(
831 $topic_data['title'] ?? '',
832 $api_key,
833 $similarity_threshold,
834 $check_days
835 );
836
837 if ( $is_duplicate ) {
838 $topics_skipped++;
839 continue; // Skip this topic
840 }
841 }
842
843 $created = $this->create_forum_topic( $task, $topic_data );
844 if ( $created ) {
845 $topics_created++;
846 }
847 }
848 $credits_used = $response['credits_used'] ?? 0;
849 }
850
851 // Update task statistics
852 $this->update_task_statistics( $task['task_id'], $topics_created, $credits_used );
853
854 return [
855 'success' => true,
856 'items_created' => $topics_created,
857 'items_skipped' => $topics_skipped,
858 'credits_used' => $credits_used,
859 'data' => $response,
860 ];
861 }
862
863 /**
864 * Check if a topic title is a duplicate using semantic search
865 *
866 * Uses VectorStorageManager for semantic search (supports both local and cloud modes).
867 *
868 * @param string $title Topic title to check
869 * @param string $api_key API key for search (kept for compatibility, unused)
870 * @param int $similarity_threshold Threshold percentage (0-100)
871 * @param int $check_days Days to look back (0 = all topics) - reserved for future use
872 * @return bool True if duplicate found
873 */
874 private function check_duplicate_topic( $title, $api_key, $similarity_threshold = 75, $check_days = 90 ) {
875 if ( empty( $title ) ) {
876 return false;
877 }
878
879 // Check if vector storage is available
880 if ( ! isset( WPF()->vector_storage ) ) {
881 return false; // Can't check, allow topic creation
882 }
883
884 // Use VectorStorageManager for semantic search (handles both local and cloud modes)
885 // Note: check_days filtering is reserved for future implementation
886 $response = WPF()->vector_storage->semantic_search( $title, 10 );
887
888 if ( is_wp_error( $response ) ) {
889 return false; // On error, allow topic creation
890 }
891
892 // Check results for similarity above threshold
893 $threshold_decimal = $similarity_threshold / 100;
894
895 if ( ! empty( $response['results'] ) ) {
896 foreach ( $response['results'] as $result ) {
897 $score = floatval( $result['score'] ?? 0 );
898 if ( $score >= $threshold_decimal ) {
899 // Found a similar topic
900 return true;
901 }
902 }
903 }
904
905 return false;
906 }
907
908 /**
909 * Execute reply generator task
910 *
911 * @param array $task Task data
912 * @return array Execution result
913 */
914 private function execute_reply_generator( $task ) {
915 $config = $task['config'];
916
917 // Call backend API for reply generation
918 $ai_client = $this->get_ai_client();
919 $api_key = $ai_client ? $ai_client->get_stored_api_key() : '';
920 if ( empty( $api_key ) ) {
921 return [ 'success' => false, 'error' => 'AI service not connected' ];
922 }
923
924 $replies_per_run = intval( $config['replies_per_run'] ?? 1 );
925 $quality_tier = $config['quality_tier'] ?? 'balanced';
926 $target_forums = $config['target_forums'] ?? [];
927
928 // Find topics to reply to based on selection criteria
929 $topics = $this->find_topics_for_reply( $task );
930 if ( empty( $topics ) ) {
931 return [ 'success' => true, 'items_created' => 0, 'credits_used' => 0, 'data' => [ 'message' => 'No eligible topics found' ] ];
932 }
933
934 // Build custom instructions from config
935 $custom_instructions = $this->build_reply_custom_instructions( $config );
936
937 // Get knowledge source setting (forum_only, forum_and_ai, forum_and_web, forum_and_web_and_ai, ai_only)
938 $knowledge_source = $config['knowledge_source'] ?? 'forum_only';
939
940 // Get reply strategy (determines context and where to place the reply)
941 $reply_strategy = $config['reply_strategy'] ?? 'first_post';
942
943 // Get local RAG context if using local storage mode and RAG is enabled
944 $rag_contexts = null;
945 $use_rag = in_array( $knowledge_source, [ 'forum_only', 'forum_and_ai', 'forum_and_web', 'forum_and_web_and_ai' ], true );
946 if ( $use_rag && WPF()->vector_storage && WPF()->vector_storage->is_local_mode() ) {
947 $rag_contexts = $this->get_local_rag_contexts( $topics );
948 }
949
950 // Build API request
951 $request_data = [
952 'task_type' => 'reply_generator',
953 'topics' => $topics,
954 'replies_count' => $replies_per_run,
955 'quality' => $quality_tier,
956 'reply_style' => $config['reply_style'] ?? 'helpful',
957 'reply_strategy' => $reply_strategy,
958 'custom_instructions' => $custom_instructions,
959 'knowledge_source' => $knowledge_source,
960 'board_id' => $task['board_id'],
961 'response_language' => $this->resolve_response_language( $config ),
962 ];
963
964 // Add local RAG contexts if available (for local storage mode)
965 if ( ! empty( $rag_contexts ) ) {
966 $request_data['rag_contexts'] = $rag_contexts;
967 }
968
969 // Build topics map for looking up context when processing replies
970 $topics_map = [];
971 foreach ( $topics as $topic ) {
972 $topics_map[ $topic['topic_id'] ] = $topic;
973 }
974
975 // Make API call
976 $response = $this->call_tasks_api( 'generate', $request_data, $api_key );
977
978 if ( is_wp_error( $response ) ) {
979 return [ 'success' => false, 'error' => $response->get_error_message() ];
980 }
981
982 // Process response and create replies
983 $replies_created = 0;
984 $credits_used = 0;
985
986 if ( ! empty( $response['replies'] ) ) {
987 foreach ( $response['replies'] as $reply_data ) {
988 $topic_id = intval( $reply_data['topic_id'] ?? 0 );
989 $topic_context = $topics_map[ $topic_id ] ?? [];
990 $created = $this->create_forum_reply( $task, $reply_data, $topic_context );
991 if ( $created ) {
992 $replies_created++;
993 }
994 }
995 $credits_used = $response['credits_used'] ?? 0;
996 }
997
998 // Update task statistics
999 $this->update_task_statistics( $task['task_id'], $replies_created, $credits_used );
1000
1001 return [
1002 'success' => true,
1003 'items_created' => $replies_created,
1004 'credits_used' => $credits_used,
1005 'data' => $response,
1006 ];
1007 }
1008
1009 /**
1010 * Execute tag maintenance task
1011 *
1012 * Generates and cleans tags for topics using AI.
1013 * All topics in one run are processed in a SINGLE LLM call.
1014 *
1015 * @param array $task Task data
1016 * @return array Execution result
1017 */
1018 private function execute_tag_maintenance( $task ) {
1019 $config = $task['config'];
1020
1021 // Call backend API for tag generation
1022 $ai_client = $this->get_ai_client();
1023 $api_key = $ai_client ? $ai_client->get_stored_api_key() : '';
1024 if ( empty( $api_key ) ) {
1025 return [ 'success' => false, 'error' => 'AI service not connected' ];
1026 }
1027
1028 $topics_per_run = intval( $config['topics_per_run'] ?? 20 );
1029 $quality_tier = $config['quality_tier'] ?? 'balanced';
1030 $max_tags = intval( $config['max_tags'] ?? 5 );
1031
1032 // Tag maintenance settings
1033 $preserve_existing = ! empty( $config['preserve_existing'] );
1034 $maintain_vocabulary = ! empty( $config['maintain_vocabulary'] );
1035 $remove_duplicates = ! empty( $config['remove_duplicates'] );
1036 $remove_irrelevant = ! empty( $config['remove_irrelevant'] );
1037 $lowercase = ! empty( $config['lowercase'] );
1038
1039 // Find topics to process
1040 $topics = $this->find_topics_for_tagging( $task );
1041 if ( empty( $topics ) ) {
1042 return [ 'success' => true, 'items_created' => 0, 'credits_used' => 0, 'data' => [ 'message' => 'No eligible topics found' ] ];
1043 }
1044
1045 // Get existing forum vocabulary for consistency
1046 $existing_vocabulary = [];
1047 if ( $maintain_vocabulary ) {
1048 $existing_vocabulary = $this->get_forum_tag_vocabulary( $task['board_id'] );
1049 }
1050
1051 // Build API request - ALL topics in ONE request
1052 $request_data = [
1053 'task_type' => 'tag_maintenance',
1054 'topics' => $topics,
1055 'max_tags' => $max_tags,
1056 'preserve_existing' => $preserve_existing,
1057 'maintain_vocabulary' => $maintain_vocabulary,
1058 'remove_duplicates' => $remove_duplicates,
1059 'remove_irrelevant' => $remove_irrelevant,
1060 'lowercase' => $lowercase,
1061 'quality' => $quality_tier,
1062 'existing_vocabulary' => $existing_vocabulary,
1063 'board_id' => $task['board_id'],
1064 ];
1065
1066 // Make API call (single call for all topics)
1067 $response = $this->call_tasks_api( 'generate', $request_data, $api_key );
1068
1069 if ( is_wp_error( $response ) ) {
1070 return [ 'success' => false, 'error' => $response->get_error_message() ];
1071 }
1072
1073 // Process response and update topic tags
1074 $topics_updated = 0;
1075 $credits_used = 0;
1076
1077 if ( ! empty( $response['results'] ) ) {
1078 foreach ( $response['results'] as $result_data ) {
1079 $topic_id = intval( $result_data['topic_id'] ?? 0 );
1080 $new_tags = $result_data['tags'] ?? [];
1081
1082 if ( $topic_id && ! empty( $new_tags ) ) {
1083 $updated = $this->update_topic_tags( $topic_id, $new_tags );
1084 if ( $updated ) {
1085 $topics_updated++;
1086 }
1087 }
1088 }
1089 $credits_used = $response['credits_used'] ?? 0;
1090 }
1091
1092 // Update task statistics
1093 $this->update_task_statistics( $task['task_id'], $topics_updated, $credits_used );
1094
1095 return [
1096 'success' => true,
1097 'items_created' => $topics_updated,
1098 'credits_used' => $credits_used,
1099 'data' => $response,
1100 ];
1101 }
1102
1103 /**
1104 * Find topics eligible for tag maintenance based on task config
1105 *
1106 * @param array $task Task data
1107 * @return array Topics for tagging
1108 */
1109 private function find_topics_for_tagging( $task ) {
1110 global $wpdb;
1111
1112 $config = $task['config'];
1113 $limit = intval( $config['topics_per_run'] ?? 20 );
1114 $only_not_tagged = ! empty( $config['only_not_tagged'] );
1115
1116 $all_topic_ids = [];
1117
1118 // 1. Get specific topic IDs if provided (filter out already processed)
1119 $target_topic_ids_raw = $config['target_topic_ids'] ?? '';
1120 if ( ! empty( $target_topic_ids_raw ) ) {
1121 $target_topic_ids_raw = str_replace( [ "\r\n", "\r", "\n" ], ',', $target_topic_ids_raw );
1122 $specific_ids = array_map( 'trim', explode( ',', $target_topic_ids_raw ) );
1123 $specific_ids = array_filter( $specific_ids, 'is_numeric' );
1124 $specific_ids = array_map( 'intval', $specific_ids );
1125
1126 // Filter out already processed topics (task_tag > 0)
1127 if ( ! empty( $specific_ids ) ) {
1128 $ids_str = implode( ',', $specific_ids );
1129 $unprocessed_ids = $wpdb->get_col(
1130 "SELECT topicid FROM " . WPF()->tables->topics . "
1131 WHERE topicid IN ($ids_str) AND task_tag = 0"
1132 );
1133 $all_topic_ids = array_merge( $all_topic_ids, $unprocessed_ids );
1134 }
1135 }
1136
1137 // 2. Get topics from target forums
1138 $target_forums = $config['tag_target_forum_ids'] ?? [];
1139 if ( ! empty( $target_forums ) ) {
1140 $forum_ids = array_map( 'intval', $target_forums );
1141 $forum_ids_str = implode( ',', $forum_ids );
1142
1143 $where_clauses = [ "t.forumid IN ($forum_ids_str)", "t.status = 0", "t.private = 0" ];
1144
1145 // Exclude already processed topics (task_tag > 0 means already processed)
1146 $where_clauses[] = "t.task_tag = 0";
1147
1148 // Only not tagged filter
1149 if ( $only_not_tagged ) {
1150 $where_clauses[] = "(t.tags IS NULL OR t.tags = '')";
1151 }
1152
1153 // Date range filters
1154 if ( ! empty( $config['date_range_from'] ) ) {
1155 $where_clauses[] = $wpdb->prepare( "t.created >= %s", $config['date_range_from'] );
1156 }
1157 if ( ! empty( $config['date_range_to'] ) ) {
1158 $where_clauses[] = $wpdb->prepare( "t.created <= %s", $config['date_range_to'] );
1159 }
1160
1161 $where_sql = implode( ' AND ', $where_clauses );
1162
1163 // Get random topics from forums
1164 $forum_topic_ids = $wpdb->get_col(
1165 "SELECT t.topicid FROM " . WPF()->tables->topics . " t
1166 WHERE $where_sql
1167 ORDER BY RAND()
1168 LIMIT $limit"
1169 );
1170
1171 if ( $forum_topic_ids ) {
1172 $all_topic_ids = array_merge( $all_topic_ids, $forum_topic_ids );
1173 }
1174 }
1175
1176 // Remove duplicates and limit
1177 $all_topic_ids = array_unique( $all_topic_ids );
1178 $all_topic_ids = array_slice( $all_topic_ids, 0, $limit );
1179
1180 if ( empty( $all_topic_ids ) ) {
1181 return [];
1182 }
1183
1184 // Build topics data with title, content, and existing tags
1185 $topics = [];
1186 foreach ( $all_topic_ids as $topic_id ) {
1187 $topic = WPF()->topic->get_topic( $topic_id );
1188 if ( ! $topic ) continue;
1189
1190 // Get first post content
1191 $first_post = WPF()->post->get_post( $topic['first_postid'] );
1192 $content = $first_post ? wp_strip_all_tags( $first_post['body'] ) : '';
1193 $content = mb_substr( $content, 0, 2000 ); // Limit content length
1194
1195 // Get existing tags
1196 $existing_tags = [];
1197 if ( ! empty( $topic['tags'] ) ) {
1198 $existing_tags = array_map( 'trim', explode( ',', $topic['tags'] ) );
1199 $existing_tags = array_filter( $existing_tags );
1200 }
1201
1202 $topics[] = [
1203 'topic_id' => intval( $topic_id ),
1204 'title' => $topic['title'],
1205 'content' => $content,
1206 'existing_tags' => $existing_tags,
1207 ];
1208 }
1209
1210 return $topics;
1211 }
1212
1213 /**
1214 * Get existing tag vocabulary from the forum
1215 *
1216 * @param int $board_id Board ID
1217 * @return array List of existing tags
1218 */
1219 private function get_forum_tag_vocabulary( $board_id = 0 ) {
1220 // Get most used tags from the tags table
1221 $tags = WPF()->topic->get_tags( [
1222 'orderby' => 'count',
1223 'order' => 'DESC',
1224 'row_count' => 200,
1225 ] );
1226
1227 $vocabulary = [];
1228 if ( ! empty( $tags ) ) {
1229 foreach ( $tags as $tag ) {
1230 $vocabulary[] = $tag['tag'];
1231 }
1232 }
1233
1234 return $vocabulary;
1235 }
1236
1237 /**
1238 * Update tags for a topic
1239 *
1240 * @param int $topic_id Topic ID
1241 * @param array $new_tags New tags array
1242 * @return bool Success
1243 */
1244 private function update_topic_tags( $topic_id, $new_tags ) {
1245 $topic = WPF()->topic->get_topic( $topic_id );
1246 if ( ! $topic ) {
1247 return false;
1248 }
1249
1250 // Sanitize and format tags
1251 $tags_string = WPF()->topic->sanitize_tags( $new_tags, false, true );
1252
1253 // Update topic tags using the Topics class method
1254 WPF()->topic->edit_tags( $tags_string, $topic );
1255
1256 // Update the topic record with tags and task_tag timestamp
1257 WPF()->db->update(
1258 WPF()->tables->topics,
1259 [
1260 'tags' => $tags_string,
1261 'task_tag' => time(), // Mark as processed with current timestamp
1262 ],
1263 [ 'topicid' => $topic_id ],
1264 [ '%s', '%d' ],
1265 [ '%d' ]
1266 );
1267
1268 // Clean topic cache to ensure fresh data
1269 wpforo_clean_cache( 'topic', $topic_id );
1270 wpforo_clean_cache( 'tag' );
1271
1272 return true;
1273 }
1274
1275 /**
1276 * Find topics eligible for reply based on task config
1277 *
1278 * Supports multiple targeting methods:
1279 * - target_topic_ids: Specific topic IDs (any status)
1280 * - reply_target_forums: Random topics from selected forums
1281 * - date_range_from/to: Filter by topic creation date
1282 *
1283 * @param array $task Task data
1284 * @return array Topics for reply
1285 */
1286 private function find_topics_for_reply( $task ) {
1287 global $wpdb;
1288
1289 $config = $task['config'];
1290 $reply_strategy = $config['reply_strategy'] ?? 'first_post';
1291 $limit = intval( $config['replies_per_run'] ?? 1 );
1292 $only_not_replied = ! empty( $config['only_not_replied'] );
1293
1294 $all_topic_ids = [];
1295
1296 // 1. Get specific topic IDs (always included regardless of status)
1297 $target_topic_ids_raw = $config['target_topic_ids'] ?? '';
1298 if ( ! empty( $target_topic_ids_raw ) ) {
1299 $target_topic_ids_raw = str_replace( [ "\r\n", "\r", "\n" ], ',', $target_topic_ids_raw );
1300 $specific_ids = array_map( 'trim', explode( ',', $target_topic_ids_raw ) );
1301 $specific_ids = array_filter( $specific_ids, function( $id ) {
1302 return is_numeric( $id ) && intval( $id ) > 0;
1303 } );
1304 $all_topic_ids = array_merge( $all_topic_ids, array_map( 'intval', $specific_ids ) );
1305 }
1306
1307 // 2. Get topics from forums/date range (random, excludes private/closed/unapproved)
1308 $target_forums = $config['reply_target_forums'] ?? [];
1309 $date_from = $config['date_range_from'] ?? '';
1310 $date_to = $config['date_range_to'] ?? '';
1311
1312 if ( ! empty( $target_forums ) || ! empty( $date_from ) || ! empty( $date_to ) ) {
1313 $forum_topic_ids = $this->find_random_forum_topics( $target_forums, $date_from, $date_to, $limit * 3, $only_not_replied );
1314 $all_topic_ids = array_merge( $all_topic_ids, $forum_topic_ids );
1315 }
1316
1317 // Remove duplicates
1318 $all_topic_ids = array_unique( $all_topic_ids );
1319
1320 if ( empty( $all_topic_ids ) ) {
1321 return [];
1322 }
1323
1324 // Shuffle and limit
1325 shuffle( $all_topic_ids );
1326 $all_topic_ids = array_slice( $all_topic_ids, 0, $limit );
1327
1328 // Fetch each topic and build context
1329 $filtered = [];
1330 foreach ( $all_topic_ids as $topic_id ) {
1331 $topic = WPF()->topic->get_topic( $topic_id );
1332 if ( ! $topic ) {
1333 continue;
1334 }
1335
1336 // Skip topics with replies if only_not_replied is enabled
1337 // posts = 1 means only the first post exists (no replies)
1338 if ( $only_not_replied && intval( $topic['posts'] ?? 0 ) > 1 ) {
1339 continue;
1340 }
1341
1342 // Build topic context based on reply strategy
1343 $topic_data = $this->build_topic_context_for_reply( $topic, $reply_strategy );
1344 if ( $topic_data ) {
1345 $filtered[] = $topic_data;
1346 }
1347 }
1348
1349 return $filtered;
1350 }
1351
1352 /**
1353 * Find random topics from forums with optional date filtering
1354 *
1355 * Excludes private, closed, and unapproved topics.
1356 *
1357 * @param array $forum_ids Forum IDs to search (empty = all forums)
1358 * @param string $date_from Start date (Y-m-d format)
1359 * @param string $date_to End date (Y-m-d format)
1360 * @param int $limit Maximum topics to return
1361 * @param bool $only_not_replied Only include topics without replies (posts = 1)
1362 * @return array Topic IDs
1363 */
1364 private function find_random_forum_topics( $forum_ids, $date_from, $date_to, $limit = 10, $only_not_replied = false ) {
1365 global $wpdb;
1366
1367 $topics_table = WPF()->tables->topics;
1368
1369 $where = [];
1370 $values = [];
1371
1372 // Exclude private topics
1373 $where[] = 'private = 0';
1374
1375 // Exclude closed topics
1376 $where[] = 'closed = 0';
1377
1378 // Exclude unapproved topics (status = 0 means approved)
1379 $where[] = 'status = 0';
1380
1381 // Only include topics without replies (posts = 1 means only first post, no replies)
1382 if ( $only_not_replied ) {
1383 $where[] = 'posts = 1';
1384 }
1385
1386 // Filter by forums
1387 if ( ! empty( $forum_ids ) ) {
1388 $forum_ids = array_map( 'intval', $forum_ids );
1389 $placeholders = implode( ',', array_fill( 0, count( $forum_ids ), '%d' ) );
1390 $where[] = "forumid IN ($placeholders)";
1391 $values = array_merge( $values, $forum_ids );
1392 }
1393
1394 // Filter by date range
1395 if ( ! empty( $date_from ) ) {
1396 $where[] = 'created >= %s';
1397 $values[] = $date_from . ' 00:00:00';
1398 }
1399 if ( ! empty( $date_to ) ) {
1400 $where[] = 'created <= %s';
1401 $values[] = $date_to . ' 23:59:59';
1402 }
1403
1404 $where_sql = implode( ' AND ', $where );
1405
1406 // Get random topics
1407 $query = "SELECT topicid FROM {$topics_table} WHERE {$where_sql} ORDER BY RAND() LIMIT %d";
1408 $values[] = $limit;
1409
1410 $results = $wpdb->get_col( $wpdb->prepare( $query, $values ) );
1411
1412 return $results ? array_map( 'intval', $results ) : [];
1413 }
1414
1415 /**
1416 * Build topic context for API based on reply strategy
1417 *
1418 * @param array $topic Topic data from wpForo
1419 * @param string $reply_strategy Strategy: first_post, whole_topic, or last_post
1420 * @return array|null Topic context for API
1421 */
1422 private function build_topic_context_for_reply( $topic, $reply_strategy ) {
1423 $topic_id = intval( $topic['topicid'] );
1424 $first_postid = intval( $topic['first_postid'] ?? 0 );
1425
1426 // Get first post (always needed)
1427 $first_post = WPF()->post->get_post( $first_postid );
1428 if ( ! $first_post ) {
1429 return null;
1430 }
1431
1432 $first_post_content = [
1433 'postid' => $first_postid,
1434 'author' => WPF()->member->get_member( $first_post['userid'] )['display_name'] ?? 'Unknown',
1435 'content' => wp_strip_all_tags( $first_post['body'] ),
1436 'parentid' => 0,
1437 'root' => -1,
1438 ];
1439
1440 $topic_data = [
1441 'topic_id' => $topic_id,
1442 'forum_id' => $topic['forumid'],
1443 'title' => $topic['title'],
1444 'first_postid' => $first_postid,
1445 'reply_strategy' => $reply_strategy,
1446 ];
1447
1448 switch ( $reply_strategy ) {
1449 case 'first_post':
1450 // Only include first post content
1451 $topic_data['posts'] = [ $first_post_content ];
1452 $topic_data['context_type'] = 'first_post_only';
1453 break;
1454
1455 case 'whole_topic':
1456 // Include first post + last 100 replies for full context
1457 $posts = WPF()->post->get_posts( [
1458 'topicid' => $topic_id,
1459 'row_count' => 100,
1460 'orderby' => 'created',
1461 'order' => 'ASC',
1462 ] );
1463 $post_contents = [];
1464 foreach ( $posts as $post ) {
1465 $post_contents[] = [
1466 'postid' => intval( $post['postid'] ),
1467 'author' => WPF()->member->get_member( $post['userid'] )['display_name'] ?? 'Unknown',
1468 'content' => wp_strip_all_tags( $post['body'] ),
1469 'parentid' => intval( $post['parentid'] ),
1470 'root' => intval( $post['root'] ),
1471 ];
1472 }
1473 $topic_data['posts'] = $post_contents;
1474 $topic_data['context_type'] = 'whole_topic';
1475 break;
1476
1477 case 'last_post':
1478 // Get last post and its sub-thread context
1479 $last_post = WPF()->post->get_posts( [
1480 'topicid' => $topic_id,
1481 'row_count' => 1,
1482 'orderby' => 'created',
1483 'order' => 'DESC',
1484 ] );
1485
1486 if ( empty( $last_post ) ) {
1487 // No replies, fall back to first post only
1488 $topic_data['posts'] = [ $first_post_content ];
1489 $topic_data['context_type'] = 'first_post_only';
1490 $topic_data['last_postid'] = $first_postid;
1491 $topic_data['reply_parentid'] = 0;
1492 $topic_data['reply_root'] = -1;
1493 break;
1494 }
1495
1496 $last = $last_post[0];
1497 $last_postid = intval( $last['postid'] );
1498 $last_parentid = intval( $last['parentid'] );
1499 $last_root = intval( $last['root'] );
1500
1501 // If the last post IS the first post, just use first post context
1502 if ( $last_postid === $first_postid ) {
1503 $topic_data['posts'] = [ $first_post_content ];
1504 $topic_data['context_type'] = 'first_post_only';
1505 $topic_data['last_postid'] = $first_postid;
1506 $topic_data['reply_parentid'] = 0;
1507 $topic_data['reply_root'] = -1;
1508 break;
1509 }
1510
1511 // Build sub-thread context: first post + thread branch leading to last post
1512 $post_contents = [ $first_post_content ];
1513
1514 // Find the sub-thread root (the top-level reply that started this thread)
1515 // root=-1 or root=0 means it's a top-level reply
1516 if ( $last_root <= 0 ) {
1517 // Last post is a top-level reply, use it directly
1518 $subthread_root_postid = $last_postid;
1519 } else {
1520 // Last post is nested, get its sub-thread root
1521 $subthread_root_postid = $last_root;
1522 }
1523
1524 // Get posts in this sub-thread (from subthread root to last post)
1525 if ( $subthread_root_postid !== $first_postid ) {
1526 // Get the sub-thread root post
1527 $subthread_root_post = WPF()->post->get_post( $subthread_root_postid );
1528 if ( $subthread_root_post ) {
1529 $post_contents[] = [
1530 'postid' => $subthread_root_postid,
1531 'author' => WPF()->member->get_member( $subthread_root_post['userid'] )['display_name'] ?? 'Unknown',
1532 'content' => wp_strip_all_tags( $subthread_root_post['body'] ),
1533 'parentid' => intval( $subthread_root_post['parentid'] ),
1534 'root' => intval( $subthread_root_post['root'] ),
1535 'is_subthread_root' => true,
1536 ];
1537 }
1538
1539 // Get nested replies in this sub-thread (up to 20)
1540 $subthread_posts = WPF()->post->get_posts( [
1541 'topicid' => $topic_id,
1542 'root' => $subthread_root_postid,
1543 'row_count' => 20,
1544 'orderby' => 'created',
1545 'order' => 'ASC',
1546 ] );
1547 foreach ( $subthread_posts as $post ) {
1548 $postid = intval( $post['postid'] );
1549 if ( $postid !== $subthread_root_postid ) {
1550 $post_contents[] = [
1551 'postid' => $postid,
1552 'author' => WPF()->member->get_member( $post['userid'] )['display_name'] ?? 'Unknown',
1553 'content' => wp_strip_all_tags( $post['body'] ),
1554 'parentid' => intval( $post['parentid'] ),
1555 'root' => intval( $post['root'] ),
1556 ];
1557 }
1558 }
1559 }
1560
1561 // Determine parentid and root for the AI reply
1562 // The reply should be nested under the last post
1563 if ( $last_root <= 0 ) {
1564 // Last post is a top-level reply, so AI reply's root is the last post
1565 $reply_parentid = $last_postid;
1566 $reply_root = $last_postid;
1567 } else {
1568 // Last post is already nested, AI reply's root is the sub-thread root
1569 $reply_parentid = $last_postid;
1570 $reply_root = $last_root;
1571 }
1572
1573 $topic_data['posts'] = $post_contents;
1574 $topic_data['context_type'] = 'last_post_subthread';
1575 $topic_data['last_postid'] = $last_postid;
1576 $topic_data['reply_parentid'] = $reply_parentid;
1577 $topic_data['reply_root'] = $reply_root;
1578 break;
1579
1580 default:
1581 // Fallback to first_post
1582 $topic_data['posts'] = [ $first_post_content ];
1583 $topic_data['context_type'] = 'first_post_only';
1584 break;
1585 }
1586
1587 return $topic_data;
1588 }
1589
1590 /**
1591 * Build custom instructions string from task config
1592 *
1593 * Combines topic_theme (the main theme/focus), topic_tone, content_length,
1594 * and include options into a comprehensive instruction string for the AI.
1595 *
1596 * @param array $config Task configuration
1597 * @return string Custom instructions for the AI
1598 */
1599 private function build_topic_custom_instructions( $config ) {
1600 $instructions = [];
1601
1602 // Primary theme/focus (required) - truncate to 120 chars (multibyte-safe)
1603 $topic_theme = trim( $config['topic_theme'] ?? '' );
1604 if ( ! empty( $topic_theme ) ) {
1605 // Multibyte-safe truncation to prevent prompt injection via long inputs
1606 $topic_theme = mb_substr( $topic_theme, 0, 120, 'UTF-8' );
1607 $instructions[] = "Topic theme/focus: {$topic_theme}";
1608 }
1609
1610 // Topic tone
1611 $topic_tone = $config['topic_tone'] ?? 'neutral';
1612 if ( $topic_tone !== 'neutral' ) {
1613 $tone_labels = [
1614 'professional' => 'Professional - Business-like and formal',
1615 'friendly' => 'Friendly - Warm and approachable',
1616 'casual' => 'Casual - Relaxed and informal',
1617 'technical' => 'Technical - Detailed and precise',
1618 'enthusiastic' => 'Enthusiastic - Excited and energetic',
1619 'helpful' => 'Helpful - Supportive and guiding',
1620 'authoritative' => 'Authoritative - Expert and confident',
1621 'conversational' => 'Conversational - Like chatting with a friend',
1622 'educational' => 'Educational - Teaching and informative',
1623 'inspirational' => 'Inspirational - Motivating and uplifting',
1624 'humorous' => 'Humorous - Light-hearted and witty',
1625 'serious' => 'Serious - Focused and earnest',
1626 'encouraging' => 'Encouraging - Positive and supportive',
1627 ];
1628 $tone_desc = $tone_labels[ $topic_tone ] ?? $topic_tone;
1629 $instructions[] = "Tone: {$tone_desc}";
1630 }
1631
1632 // Content length
1633 $content_length = $config['content_length'] ?? 'medium';
1634 $length_labels = [
1635 'brief' => 'Brief (100-200 words)',
1636 'medium' => 'Medium (200-400 words)',
1637 'detailed' => 'Detailed (400-800 words)',
1638 'comprehensive' => 'Comprehensive (800-1200 words)',
1639 ];
1640 $length_desc = $length_labels[ $content_length ] ?? $content_length;
1641 $instructions[] = "Content length: {$length_desc}";
1642
1643 // Content inclusion options
1644 $include_options = [];
1645 if ( ! empty( $config['include_code'] ) ) {
1646 $include_options[] = 'code examples';
1647 }
1648 if ( ! empty( $config['include_links'] ) ) {
1649 $include_options[] = 'relevant external links';
1650 }
1651 if ( ! empty( $config['include_steps'] ) ) {
1652 $include_options[] = 'step-by-step instructions';
1653 }
1654 if ( ! empty( $config['include_youtube'] ) ) {
1655 // Note: YouTube placeholders are handled separately below
1656 // to ensure proper formatting on new lines
1657 }
1658
1659 if ( ! empty( $include_options ) ) {
1660 $instructions[] = 'Include: ' . implode( ', ', $include_options );
1661 }
1662
1663 // YouTube video placeholders (special handling for proper formatting)
1664 if ( ! empty( $config['include_youtube'] ) ) {
1665 $instructions[] = 'YOUTUBE VIDEO REQUIREMENT: When suggesting a YouTube video, DO NOT invent fake URLs. ' .
1666 'Instead, add a placeholder on a new line in this exact format: [YOUTUBE_SEARCH: descriptive search term]. ' .
1667 'Example: [YOUTUBE_SEARCH: AWS Bedrock tutorial for beginners]. ' .
1668 'The placeholder must be on its own line, not embedded in a sentence. ' .
1669 'Only suggest videos when they would genuinely add value to the content.';
1670 }
1671
1672 // Web search placeholders (when links are requested)
1673 if ( ! empty( $config['include_links'] ) ) {
1674 $instructions[] = 'WEB SEARCH REQUIREMENT: When including external links or documentation references, add a placeholder in this exact format: [WEB_SEARCH: descriptive search term]. ' .
1675 'Example: [WEB_SEARCH: AWS Lambda best practices documentation]. ' .
1676 'The placeholder must be on its own line. Use this for documentation links, tutorials, or authoritative resources.';
1677 }
1678
1679 // Code search placeholders (when code examples are requested)
1680 if ( ! empty( $config['include_code'] ) ) {
1681 $instructions[] = 'CODE SEARCH REQUIREMENT: When providing code examples for common programming tasks, you may add a placeholder in this exact format: [CODE_SEARCH: programming question]. ' .
1682 'Example: [CODE_SEARCH: python list comprehension with condition]. ' .
1683 'The placeholder must be on its own line. This will fetch real code snippets from Stack Overflow. Only use when it adds value.';
1684 }
1685
1686 // Search keywords for inspiration (if set)
1687 $search_keywords = trim( $config['search_keywords'] ?? '' );
1688 if ( ! empty( $search_keywords ) ) {
1689 $instructions[] = "Related keywords for topic ideas: {$search_keywords}";
1690 }
1691
1692 return implode( "\n", $instructions );
1693 }
1694
1695 /**
1696 * Build custom instructions for reply generation
1697 *
1698 * @param array $config Task configuration
1699 * @return string Custom instructions for the AI
1700 */
1701 private function build_reply_custom_instructions( $config ) {
1702 $instructions = [];
1703
1704 // Response guidelines (primary instructions) - truncate to 120 chars (multibyte-safe)
1705 $response_guidelines = trim( $config['response_guidelines'] ?? '' );
1706 if ( ! empty( $response_guidelines ) ) {
1707 // Multibyte-safe truncation to prevent prompt injection via long inputs
1708 $response_guidelines = mb_substr( $response_guidelines, 0, 120, 'UTF-8' );
1709 $instructions[] = "Response Guidelines: {$response_guidelines}";
1710 }
1711
1712 // Reply tone
1713 $reply_tone = $config['reply_tone'] ?? 'neutral';
1714 if ( $reply_tone !== 'neutral' ) {
1715 $tone_labels = [
1716 'professional' => 'Professional - Business-like and formal',
1717 'friendly' => 'Friendly - Warm and approachable',
1718 'casual' => 'Casual - Relaxed and informal',
1719 'technical' => 'Technical - Detailed and precise',
1720 'enthusiastic' => 'Enthusiastic - Excited and energetic',
1721 'helpful' => 'Helpful - Supportive and guiding',
1722 'authoritative' => 'Authoritative - Expert and confident',
1723 'conversational' => 'Conversational - Like chatting with a friend',
1724 'educational' => 'Educational - Teaching and informative',
1725 'inspirational' => 'Inspirational - Motivating and uplifting',
1726 'humorous' => 'Humorous - Light-hearted and witty',
1727 'serious' => 'Serious - Focused and earnest',
1728 'encouraging' => 'Encouraging - Positive and supportive',
1729 'warm' => 'Warm - Caring and compassionate',
1730 'direct' => 'Direct - Straightforward and to the point',
1731 'thoughtful' => 'Thoughtful - Considerate and reflective',
1732 'empathetic' => 'Empathetic - Understanding and relatable',
1733 'confident' => 'Confident - Self-assured and decisive',
1734 'curious' => 'Curious - Inquisitive and engaging',
1735 'playful' => 'Playful - Fun and lighthearted',
1736 'sincere' => 'Sincere - Genuine and honest',
1737 ];
1738 $tone_desc = $tone_labels[ $reply_tone ] ?? $reply_tone;
1739 $instructions[] = "Tone: {$tone_desc}";
1740 }
1741
1742 // Reply length
1743 $reply_length = $config['reply_length'] ?? 'medium';
1744 $length_labels = [
1745 'brief' => 'Brief (100-200 words)',
1746 'medium' => 'Medium (200-400 words)',
1747 'detailed' => 'Detailed (400-800 words)',
1748 ];
1749 $length_desc = $length_labels[ $reply_length ] ?? $reply_length;
1750 $instructions[] = "Reply length: {$length_desc}";
1751
1752 // Content inclusion options
1753 $include_options = [];
1754 if ( ! empty( $config['reply_include_code'] ) ) {
1755 $include_options[] = 'code examples';
1756 }
1757 if ( ! empty( $config['reply_include_links'] ) ) {
1758 $include_options[] = 'relevant links to documentation';
1759 }
1760 if ( ! empty( $config['reply_include_steps'] ) ) {
1761 $include_options[] = 'step-by-step solutions';
1762 }
1763 if ( ! empty( $config['reply_include_followup'] ) ) {
1764 $include_options[] = 'follow-up questions';
1765 }
1766 if ( ! empty( $config['reply_include_greeting'] ) ) {
1767 $include_options[] = 'personalized greeting (e.g., "Hi John")';
1768 }
1769
1770 if ( ! empty( $include_options ) ) {
1771 $instructions[] = 'Include: ' . implode( ', ', $include_options );
1772 }
1773
1774 // YouTube video placeholders (special handling for proper formatting)
1775 if ( ! empty( $config['reply_include_youtube'] ) ) {
1776 $instructions[] = 'YOUTUBE VIDEO REQUIREMENT: When suggesting a YouTube video, DO NOT invent fake URLs. ' .
1777 'Instead, add a placeholder on a new line in this exact format: [YOUTUBE_SEARCH: descriptive search term]. ' .
1778 'Example: [YOUTUBE_SEARCH: AWS Bedrock tutorial for beginners]. ' .
1779 'The placeholder must be on its own line, not embedded in a sentence. ' .
1780 'Only suggest videos when they would genuinely add value to the reply.';
1781 }
1782
1783 // Knowledge source and fallback behavior
1784 $knowledge_source = $config['knowledge_source'] ?? 'forum_only';
1785 $no_content_action = $config['no_content_action'] ?? 'use_other_sources';
1786 $uses_web_search = in_array( $knowledge_source, [ 'forum_and_web', 'forum_and_web_and_ai' ], true );
1787
1788 if ( $knowledge_source === 'forum_only' ) {
1789 $instructions[] = 'KNOWLEDGE SOURCE: Use only information from the forum context provided. Do not use general AI knowledge.';
1790 } elseif ( $knowledge_source === 'forum_and_ai' ) {
1791 $instructions[] = 'KNOWLEDGE SOURCE: Primarily use forum context, but supplement with general AI knowledge when helpful.';
1792 } elseif ( $knowledge_source === 'forum_and_web' ) {
1793 $instructions[] = 'KNOWLEDGE SOURCE: Use forum context and supplement with web search results. Do not use general AI knowledge.';
1794 } elseif ( $knowledge_source === 'forum_and_web_and_ai' ) {
1795 $instructions[] = 'KNOWLEDGE SOURCE: Use forum context, web search results, and general AI knowledge to provide comprehensive responses.';
1796 } else {
1797 $instructions[] = 'KNOWLEDGE SOURCE: Use your general AI knowledge to provide helpful responses.';
1798 }
1799
1800 // Web search placeholders (when web search is enabled)
1801 if ( $uses_web_search || ! empty( $config['reply_include_links'] ) ) {
1802 $instructions[] = 'WEB SEARCH REQUIREMENT: When you need current information or external resources, add a placeholder in this exact format: [WEB_SEARCH: descriptive search term]. ' .
1803 'Example: [WEB_SEARCH: best practices for API rate limiting 2024]. ' .
1804 'The placeholder must be on its own line. Use this for documentation links, tutorials, or current information.';
1805 }
1806
1807 // Code search placeholders (when code examples are enabled)
1808 if ( ! empty( $config['reply_include_code'] ) ) {
1809 $instructions[] = 'CODE SEARCH REQUIREMENT: When providing code examples for common programming questions, you may add a placeholder in this exact format: [CODE_SEARCH: programming question]. ' .
1810 'Example: [CODE_SEARCH: python async await example]. ' .
1811 'The placeholder must be on its own line. This will fetch real code snippets from Stack Overflow. Only use when relevant.';
1812 }
1813
1814 // What to do when no relevant content is found in forum
1815 if ( $no_content_action === 'skip' ) {
1816 $instructions[] = 'NO FORUM CONTENT FALLBACK: If no relevant forum content is available, indicate that you cannot provide a helpful response and do not generate a reply.';
1817 } elseif ( $no_content_action === 'ask_details' ) {
1818 $instructions[] = 'NO FORUM CONTENT FALLBACK: If no relevant forum content is available, ask the user for more details about their question to better assist them.';
1819 } else {
1820 $instructions[] = 'NO FORUM CONTENT FALLBACK: If no relevant forum content is available, use other available knowledge sources (AI knowledge, web search if enabled) to provide a helpful response.';
1821 }
1822
1823 return implode( "\n", $instructions );
1824 }
1825
1826 /**
1827 * Process YouTube placeholders in content
1828 *
1829 * Converts [YOUTUBE_SEARCH: search term] placeholders to actual YouTube search links.
1830 * The links are placed on their own line so wpForo addons can replace them with embeds.
1831 *
1832 * @param string $content The content with placeholders
1833 * @return string Content with YouTube search links
1834 */
1835 private function process_youtube_placeholders( $content ) {
1836 // Pattern matches [YOUTUBE_SEARCH: any search term]
1837 $pattern = '/\[YOUTUBE_SEARCH:\s*([^\]]+)\]/i';
1838
1839 return preg_replace_callback( $pattern, function( $matches ) {
1840 $search_term = trim( $matches[1] );
1841 if ( empty( $search_term ) ) {
1842 return '';
1843 }
1844 // Create YouTube search URL
1845 $search_url = 'https://www.youtube.com/results?search_query=' . rawurlencode( $search_term );
1846 // Return as plain URL on its own line (not wrapped in HTML tags)
1847 // This allows wpForo video addons to detect and convert to embed
1848 return "\n" . $search_url . "\n";
1849 }, $content );
1850 }
1851
1852 /**
1853 * Process web search placeholders in content
1854 *
1855 * Removes any unresolved [WEB_SEARCH: search term] placeholders.
1856 * These should have been processed by the backend (Tavily), but may remain
1857 * if the Tavily API key is not configured or the service is unavailable.
1858 *
1859 * @param string $content The content with placeholders
1860 * @return string Content with placeholders removed
1861 */
1862 private function process_web_search_placeholders( $content ) {
1863 $pattern = '/\[WEB_SEARCH:\s*[^\]]+\]/i';
1864 return preg_replace( $pattern, '', $content );
1865 }
1866
1867 /**
1868 * Process code search placeholders in content
1869 *
1870 * Removes any unresolved [CODE_SEARCH: search term] placeholders.
1871 * These should have been processed by the backend (Stack Overflow), but may remain
1872 * if the service is unavailable.
1873 *
1874 * @param string $content The content with placeholders
1875 * @return string Content with placeholders removed
1876 */
1877 private function process_code_search_placeholders( $content ) {
1878 $pattern = '/\[CODE_SEARCH:\s*[^\]]+\]/i';
1879 return preg_replace( $pattern, '', $content );
1880 }
1881
1882 /**
1883 * Append AI disclosure notice to content.
1884 *
1885 * Adds a small disclaimer line at the end of AI-generated content
1886 * indicating the content was created by AI and may contain inaccuracies.
1887 *
1888 * @param string $content The content to append disclosure to
1889 * @return string Content with AI disclosure notice
1890 */
1891 private function append_ai_disclosure( $content ) {
1892 $disclosure = __( 'ℹ️ This content was generated by AI and may contain inaccuracies.', 'wpforo' );
1893 return $content . "\n\n" . $disclosure;
1894 }
1895
1896 /**
1897 * Create a forum topic from AI-generated content
1898 *
1899 * @param array $task Task data
1900 * @param array $topic_data Generated topic data
1901 * @return bool True on success
1902 */
1903 private function create_forum_topic( $task, $topic_data ) {
1904 $config = $task['config'];
1905 // Use author_userid (current name) with fallback to bot_user_id (legacy name)
1906 $author_user_id = intval( $config['author_userid'] ?? $config['bot_user_id'] ?? 0 );
1907 $author_groupid = intval( $config['author_groupid'] ?? 0 );
1908
1909 // If usergroup is set and no specific user, pick random member from group
1910 if ( $author_groupid > 0 && $author_user_id <= 0 ) {
1911 $author_user_id = $this->resolve_author_from_group( $author_groupid );
1912 }
1913
1914 $target_forums = $config['target_forums'] ?? [];
1915
1916 // Select forum (random from targets if multiple)
1917 $forum_id = ! empty( $target_forums ) ? $target_forums[ array_rand( $target_forums ) ] : 0;
1918 if ( ! $forum_id ) {
1919 return false;
1920 }
1921
1922 // Get forum details
1923 $forum = WPF()->forum->get_forum( $forum_id );
1924 if ( ! $forum ) {
1925 return false;
1926 }
1927
1928 // Get topic status from config (0 = published/approved, 1 = unapproved)
1929 $topic_status = intval( $config['topic_status'] ?? 0 );
1930
1931 // Handle topic prefix - either addon prefix ID or text prefix
1932 $topic_prefix_id = intval( $config['topic_prefix_id'] ?? 0 );
1933 $topic_prefix = trim( $config['topic_prefix'] ?? '' );
1934 $title = sanitize_text_field( $topic_data['title'] ?? 'AI Generated Topic' );
1935
1936 // If wpForo Topic Prefix addon is active and prefix ID is set, we'll pass it to the topic data
1937 // Otherwise, prepend text prefix to title (legacy behavior)
1938 if ( empty( $topic_prefix_id ) && ! empty( $topic_prefix ) ) {
1939 $title = $topic_prefix . ' ' . $title;
1940 }
1941
1942 // Merge auto tags with generated tags
1943 $auto_tags_str = $config['auto_tags'] ?? '';
1944 $auto_tags = array_filter( array_map( 'trim', explode( ',', $auto_tags_str ) ) );
1945 $generated_tags = $topic_data['tags'] ?? [];
1946 $all_tags = array_unique( array_merge( $generated_tags, $auto_tags ) );
1947
1948 // Process content - convert/strip tool placeholders
1949 $content = $topic_data['content'] ?? '';
1950 $content = $this->process_youtube_placeholders( $content );
1951 $content = $this->process_web_search_placeholders( $content );
1952 $content = $this->process_code_search_placeholders( $content );
1953
1954 // Add AI disclosure notice if enabled
1955 $show_ai_badge = ! empty( $config['show_ai_badge'] );
1956 if ( $show_ai_badge ) {
1957 $content = $this->append_ai_disclosure( $content );
1958 }
1959
1960 // Prepare topic data
1961 $topic = [
1962 'forumid' => $forum_id,
1963 'title' => $title,
1964 'body' => wp_kses_post( $content ),
1965 'userid' => $author_user_id > 0 ? $author_user_id : get_current_user_id(),
1966 'status' => $topic_status,
1967 'private' => 0,
1968 'name' => '',
1969 'email' => '',
1970 'tags' => $all_tags,
1971 'is_ai_generated' => true, // Skip all spam/moderation checks for AI-created content
1972 ];
1973
1974 // Add prefix ID if wpForo Topic Prefix addon is active and prefix ID is configured
1975 if ( $topic_prefix_id > 0 && function_exists( 'WPF_TOPIC_PREFIX' ) ) {
1976 $topic['prefix'] = $topic_prefix_id;
1977 }
1978
1979 // In cron context, we need to set the current user for wpForo permission checks
1980 // Save the original user and switch to the author user
1981 $original_user_id = get_current_user_id();
1982 $target_user_id = $author_user_id > 0 ? $author_user_id : 1; // Fallback to admin (ID 1)
1983
1984 // Set WordPress current user
1985 wp_set_current_user( $target_user_id );
1986
1987 // Re-initialize wpForo's current user context
1988 if ( isset( WPF()->current_userid ) ) {
1989 WPF()->current_userid = $target_user_id;
1990 }
1991
1992 // Add filter to enforce the intended topic status and userid from task config
1993 // This runs after auto_moderate (priority 10) to override its changes
1994 $enforce_topic_data = function( $args ) use ( $topic_status, $target_user_id ) {
1995 if ( ! empty( $args ) ) {
1996 $args['status'] = $topic_status;
1997 $args['userid'] = $target_user_id;
1998 }
1999 return $args;
2000 };
2001 add_filter( 'wpforo_add_topic_data_filter', $enforce_topic_data, 99 );
2002
2003 // Create topic using wpForo API
2004 $result = WPF()->topic->add( $topic );
2005
2006 $notices = WPF()->notice->get_notices();
2007 \wpforo_ai_log( 'debug', sprintf(
2008 'create_forum_topic: forum_id=%d, user_id=%d, result=%s, notices=%s',
2009 $forum_id,
2010 $target_user_id,
2011 wp_json_encode( $result ),
2012 wp_json_encode( $notices )
2013 ), 'Task' );
2014
2015 // Remove the temporary filter
2016 remove_filter( 'wpforo_add_topic_data_filter', $enforce_topic_data, 99 );
2017
2018 // Restore the original user context
2019 wp_set_current_user( $original_user_id );
2020 if ( isset( WPF()->current_userid ) ) {
2021 WPF()->current_userid = $original_user_id;
2022 }
2023
2024 return ! empty( $result );
2025 }
2026
2027 /**
2028 * Create a forum reply from AI-generated content
2029 *
2030 * @param array $task Task data
2031 * @param array $reply_data Generated reply data
2032 * @param array $topic_context Topic context with reply strategy info
2033 * @return bool True on success
2034 */
2035 private function create_forum_reply( $task, $reply_data, $topic_context = [] ) {
2036 $config = $task['config'];
2037
2038 $topic_id = intval( $reply_data['topic_id'] ?? 0 );
2039 if ( ! $topic_id ) {
2040 return false;
2041 }
2042
2043 // Get topic details
2044 $topic = WPF()->topic->get_topic( $topic_id );
2045 if ( ! $topic ) {
2046 return false;
2047 }
2048
2049 // Resolve author: specific user, or random from usergroup (excluding topic creator)
2050 $author_user_id = intval( $config['author_userid'] ?? $config['bot_user_id'] ?? 0 );
2051 $author_groupid = intval( $config['author_groupid'] ?? 0 );
2052 if ( $author_groupid > 0 && $author_user_id <= 0 ) {
2053 $topic_creator_id = intval( $topic['userid'] ?? 0 );
2054 $author_user_id = $this->resolve_author_from_group( $author_groupid, $topic_creator_id );
2055 }
2056
2057 // Get reply status from config (0 = published/approved, 1 = unapproved)
2058 $reply_status = intval( $config['reply_status'] ?? 0 );
2059
2060 // Determine parentid and root based on reply strategy
2061 $reply_strategy = $config['reply_strategy'] ?? 'first_post';
2062 $parentid = 0;
2063 $root = -1;
2064
2065 switch ( $reply_strategy ) {
2066 case 'first_post':
2067 case 'whole_topic':
2068 // Both strategies create a top-level reply (not nested under any post)
2069 // parentid=0 means not replying to any specific post
2070 // root=-1 means this is a top-level reply
2071 $parentid = 0;
2072 $root = -1;
2073 break;
2074
2075 case 'last_post':
2076 // Use parentid and root from topic context (calculated in build_topic_context_for_reply)
2077 // This nests the reply under the last post in the sub-thread
2078 if ( ! empty( $topic_context['reply_parentid'] ) || ! empty( $topic_context['reply_root'] ) ) {
2079 $parentid = intval( $topic_context['reply_parentid'] ?? 0 );
2080 $root = intval( $topic_context['reply_root'] ?? -1 );
2081 }
2082 // If context not available, fall back to top-level reply
2083 break;
2084
2085 default:
2086 // Unknown strategy, use top-level reply
2087 $parentid = 0;
2088 $root = -1;
2089 break;
2090 }
2091
2092 // Process content - convert/strip tool placeholders
2093 $content = $reply_data['content'] ?? '';
2094 $content = $this->process_youtube_placeholders( $content );
2095 $content = $this->process_web_search_placeholders( $content );
2096 $content = $this->process_code_search_placeholders( $content );
2097
2098 // Add AI disclosure notice if enabled
2099 $show_ai_badge = ! empty( $config['show_ai_badge'] );
2100 if ( $show_ai_badge ) {
2101 $content = $this->append_ai_disclosure( $content );
2102 }
2103
2104 // Prepare reply data
2105 $post = [
2106 'forumid' => $topic['forumid'],
2107 'topicid' => $topic_id,
2108 'parentid' => $parentid,
2109 'root' => $root,
2110 'body' => wp_kses_post( $content ),
2111 'userid' => $author_user_id > 0 ? $author_user_id : get_current_user_id(),
2112 'status' => $reply_status,
2113 'private' => 0,
2114 'name' => '',
2115 'email' => '',
2116 'is_ai_generated' => true, // Skip all spam/moderation checks for AI-created content
2117 ];
2118
2119 // In cron context, we need to set the current user for wpForo permission checks
2120 // Save the original user and switch to the author user
2121 $original_user_id = get_current_user_id();
2122 $target_user_id = $author_user_id > 0 ? $author_user_id : 1; // Fallback to admin (ID 1)
2123
2124 // Set WordPress current user
2125 wp_set_current_user( $target_user_id );
2126
2127 // Re-initialize wpForo's current user context
2128 if ( isset( WPF()->current_userid ) ) {
2129 WPF()->current_userid = $target_user_id;
2130 }
2131
2132 // Add filter to enforce the intended reply status and userid from task config
2133 // This runs after auto_moderate (priority 10) to override its changes
2134 $enforce_post_data = function( $args ) use ( $reply_status, $target_user_id ) {
2135 if ( ! empty( $args ) ) {
2136 $args['status'] = $reply_status;
2137 $args['userid'] = $target_user_id;
2138 }
2139 return $args;
2140 };
2141 add_filter( 'wpforo_add_post_data_filter', $enforce_post_data, 99 );
2142
2143 // Create reply using wpForo API
2144 $result = WPF()->post->add( $post );
2145
2146 $notices = WPF()->notice->get_notices();
2147 \wpforo_ai_log( 'debug', sprintf(
2148 'create_forum_reply: topic_id=%d, user_id=%d, result=%s, notices=%s',
2149 $topic_id,
2150 $target_user_id,
2151 wp_json_encode( $result ),
2152 wp_json_encode( $notices )
2153 ), 'Task' );
2154
2155 // Remove the temporary filter
2156 remove_filter( 'wpforo_add_post_data_filter', $enforce_post_data, 99 );
2157
2158 // Restore the original user context
2159 wp_set_current_user( $original_user_id );
2160 if ( isset( WPF()->current_userid ) ) {
2161 WPF()->current_userid = $original_user_id;
2162 }
2163
2164 return ! empty( $result );
2165 }
2166
2167 /**
2168 * Resolve author user ID from a usergroup by random selection.
2169 *
2170 * @param int $groupid Usergroup ID to pick a member from
2171 * @param int $exclude_userid User ID to exclude (e.g., topic creator for replies)
2172 * @return int Selected user ID, or 0 if no members found
2173 */
2174 private function resolve_author_from_group( $groupid, $exclude_userid = 0 ) {
2175 $members = WPF()->member->get_members( [ 'groupid' => $groupid ] );
2176 if ( empty( $members ) ) {
2177 return 0;
2178 }
2179
2180 // Filter out excluded user (e.g., topic creator for replies)
2181 $candidates = $members;
2182 if ( $exclude_userid > 0 ) {
2183 $candidates = array_filter( $members, function( $m ) use ( $exclude_userid ) {
2184 return intval( $m['userid'] ) !== $exclude_userid;
2185 } );
2186 // If filtering removed all candidates, fall back to full list
2187 if ( empty( $candidates ) ) {
2188 $candidates = $members;
2189 }
2190 }
2191
2192 $candidates = array_values( $candidates );
2193 $random = $candidates[ array_rand( $candidates ) ];
2194
2195 return intval( $random['userid'] );
2196 }
2197
2198 /**
2199 * Call the tasks API endpoint
2200 *
2201 * Uses AIClient's api_post() method for consistent HTTP handling.
2202 *
2203 * @param string $endpoint API endpoint
2204 * @param array $data Request data
2205 * @param string $api_key API key (kept for compatibility, AIClient handles auth)
2206 * @return array|WP_Error Response or error
2207 */
2208 private function call_tasks_api( $endpoint, $data, $api_key ) {
2209 $ai_client = $this->get_ai_client();
2210 if ( ! $ai_client ) {
2211 return new \WP_Error( 'no_ai_client', 'AI service not available' );
2212 }
2213
2214 // Use AIClient's api_post() method with 60 second timeout for LLM generation
2215 return $ai_client->api_post( '/tasks/' . $endpoint, $data, 60 );
2216 }
2217
2218 // =========================================================================
2219 // TASK STATISTICS & LOGGING
2220 // =========================================================================
2221
2222 /**
2223 * Check if credit threshold allows execution
2224 *
2225 * @param array $task Task data
2226 * @return bool True if execution allowed
2227 */
2228 private function check_credit_threshold( $task ) {
2229 $config = $task['config'];
2230 $max_daily = intval( $config['max_daily_credits'] ?? 0 );
2231
2232 if ( $max_daily <= 0 ) {
2233 return true; // No limit set
2234 }
2235
2236 // Get today's credit usage for this task
2237 global $wpdb;
2238 $logs_table = $this->get_logs_table();
2239 $today = date( 'Y-m-d' );
2240
2241 $used_today = $wpdb->get_var( $wpdb->prepare(
2242 "SELECT COALESCE(SUM(credits_used), 0) FROM {$logs_table} WHERE task_id = %d AND DATE(execution_time) = %s",
2243 $task['task_id'],
2244 $today
2245 ) );
2246
2247 if ( intval( $used_today ) >= $max_daily ) {
2248 // Check if we should pause the task
2249 if ( ! empty( $config['pause_on_threshold'] ) ) {
2250 $this->update_task( $task['task_id'], [ 'status' => 'paused' ] );
2251 }
2252 return false;
2253 }
2254
2255 return true;
2256 }
2257
2258 /**
2259 * Update task statistics after execution
2260 *
2261 * @param int $task_id Task ID
2262 * @param int $items_created Number of items created
2263 * @param int $credits_used Credits consumed
2264 */
2265 private function update_task_statistics( $task_id, $items_created, $credits_used ) {
2266 global $wpdb;
2267 $table = $this->get_tasks_table();
2268
2269 $wpdb->query( $wpdb->prepare(
2270 "UPDATE {$table} SET
2271 total_runs = total_runs + 1,
2272 items_created = items_created + %d,
2273 credits_used = credits_used + %d,
2274 last_run_time = %s
2275 WHERE task_id = %d",
2276 $items_created,
2277 $credits_used,
2278 current_time( 'mysql' ),
2279 $task_id
2280 ) );
2281 }
2282
2283 /**
2284 * Log task execution
2285 *
2286 * @param int $task_id Task ID
2287 * @param array $data Log data
2288 * @return array Execution result
2289 */
2290 private function log_execution( $task_id, $data ) {
2291 global $wpdb;
2292 $table = $this->get_logs_table();
2293
2294 $log_data = [
2295 'task_id' => intval( $task_id ),
2296 'execution_time' => current_time( 'mysql' ),
2297 'status' => sanitize_key( $data['status'] ?? 'unknown' ),
2298 'items_created' => intval( $data['items_created'] ?? 0 ),
2299 'credits_used' => intval( $data['credits_used'] ?? 0 ),
2300 'execution_duration' => floatval( $data['execution_duration'] ?? 0 ),
2301 'error_message' => sanitize_text_field( $data['error_message'] ?? '' ),
2302 'result_data' => ! empty( $data['result_data'] ) ? wp_json_encode( $data['result_data'] ) : null,
2303 ];
2304
2305 $wpdb->insert( $table, $log_data, [
2306 '%d', '%s', '%s', '%d', '%d', '%f', '%s', '%s'
2307 ] );
2308
2309 // Also log to central AI Logs for visibility in AI Logs tab
2310 $this->log_to_ai_logs( $task_id, $log_data, $data );
2311
2312 return [
2313 'success' => $data['status'] !== 'error',
2314 'log_id' => $wpdb->insert_id,
2315 'items_created' => $log_data['items_created'],
2316 'credits_used' => $log_data['credits_used'],
2317 'error' => $log_data['error_message'],
2318 ];
2319 }
2320
2321 /**
2322 * Log task execution to central AI Logs
2323 *
2324 * This ensures task executions appear in the wpForo > AI Features > AI Logs tab
2325 * alongside other AI actions like searches, translations, etc.
2326 *
2327 * @param int $task_id Task ID
2328 * @param array $log_data Processed log data
2329 * @param array $raw_data Original raw data
2330 */
2331 private function log_to_ai_logs( $task_id, $log_data, $raw_data ) {
2332 if ( ! isset( WPF()->ai_logs ) ) {
2333 return;
2334 }
2335
2336 // Get task info for better log context
2337 $task = $this->get_task( $task_id );
2338 $task_name = $task['task_name'] ?? 'Unknown Task';
2339 $task_type = $task['task_type'] ?? 'unknown';
2340
2341 // Map task status to AI Logs status
2342 $status_map = [
2343 'completed' => AILogs::STATUS_SUCCESS,
2344 'error' => AILogs::STATUS_ERROR,
2345 'skipped' => AILogs::STATUS_CACHED, // Use cached for skipped tasks
2346 ];
2347 $ai_log_status = $status_map[ $log_data['status'] ] ?? AILogs::STATUS_SUCCESS;
2348
2349 // Build request summary
2350 $request_summary = sprintf( '%s: %s', $this->get_task_type_label( $task_type ), $task_name );
2351
2352 // Build response summary
2353 if ( $log_data['status'] === 'completed' ) {
2354 $response_summary = sprintf(
2355 '%d items created, %d credits used',
2356 $log_data['items_created'],
2357 $log_data['credits_used']
2358 );
2359 } elseif ( $log_data['status'] === 'skipped' ) {
2360 $response_summary = 'Skipped: ' . ( $log_data['error_message'] ?: 'threshold reached' );
2361 } else {
2362 $response_summary = 'Error: ' . ( $log_data['error_message'] ?: 'unknown error' );
2363 }
2364
2365 // Log to central AI Logs
2366 WPF()->ai_logs->log( [
2367 'action_type' => AILogs::ACTION_TASK_EXECUTION,
2368 'userid' => 0, // Tasks run as system/cron
2369 'user_type' => defined( 'DOING_CRON' ) && DOING_CRON ? AILogs::USER_TYPE_CRON : AILogs::USER_TYPE_SYSTEM,
2370 'credits_used' => $log_data['credits_used'],
2371 'status' => $ai_log_status,
2372 'content_type' => 'task',
2373 'content_id' => $task_id,
2374 'request_summary' => $request_summary,
2375 'response_summary' => $response_summary,
2376 'error_message' => $log_data['status'] === 'error' ? $log_data['error_message'] : null,
2377 'duration_ms' => intval( $log_data['execution_duration'] * 1000 ),
2378 'extra_data' => [
2379 'task_id' => $task_id,
2380 'task_name' => $task_name,
2381 'task_type' => $task_type,
2382 'items_created' => $log_data['items_created'],
2383 'result_data' => $raw_data['result_data'] ?? null,
2384 ],
2385 ] );
2386 }
2387
2388 /**
2389 * Get human-readable label for task type
2390 *
2391 * @param string $task_type Task type slug
2392 * @return string Human-readable label
2393 */
2394 private function get_task_type_label( $task_type ) {
2395 $labels = [
2396 'topic_generator' => __( 'Topic Generator', 'wpforo' ),
2397 'reply_generator' => __( 'Reply Generator', 'wpforo' ),
2398 'tag_maintenance' => __( 'Tag Maintenance', 'wpforo' ),
2399 ];
2400 return $labels[ $task_type ] ?? ucwords( str_replace( '_', ' ', $task_type ) );
2401 }
2402
2403 /**
2404 * Get task execution logs
2405 *
2406 * @param int $task_id Task ID
2407 * @param array $args Query arguments
2408 * @return array Logs list
2409 */
2410 public function get_task_logs( $task_id, $args = [] ) {
2411 global $wpdb;
2412
2413 $defaults = [
2414 'limit' => 50,
2415 'offset' => 0,
2416 ];
2417
2418 $args = wp_parse_args( $args, $defaults );
2419 $table = $this->get_logs_table();
2420
2421 $logs = $wpdb->get_results( $wpdb->prepare(
2422 "SELECT * FROM {$table} WHERE task_id = %d ORDER BY execution_time DESC LIMIT %d OFFSET %d",
2423 intval( $task_id ),
2424 intval( $args['limit'] ),
2425 intval( $args['offset'] )
2426 ), ARRAY_A );
2427
2428 return $logs;
2429 }
2430
2431 // =========================================================================
2432 // AJAX HANDLERS
2433 // =========================================================================
2434
2435 /**
2436 * AJAX handler to save a task
2437 */
2438 public function ajax_save_task() {
2439 check_ajax_referer( 'wpforo_ai_task_nonce', '_wpnonce' );
2440
2441 if ( ! current_user_can( 'manage_options' ) ) {
2442 wp_send_json_error( [ 'message' => 'Permission denied' ] );
2443 }
2444
2445 $task_id = isset( $_POST['task_id'] ) ? intval( $_POST['task_id'] ) : 0;
2446
2447 $data = [
2448 'task_name' => sanitize_text_field( $_POST['task_name'] ?? '' ),
2449 'task_type' => sanitize_key( $_POST['task_type'] ?? '' ),
2450 'status' => sanitize_key( $_POST['status'] ?? 'draft' ),
2451 'board_id' => intval( $_POST['board_id'] ?? 0 ),
2452 'config' => $_POST['config'] ?? '{}',
2453 ];
2454
2455 // Decode config if it's a JSON string
2456 if ( is_string( $data['config'] ) ) {
2457 $data['config'] = json_decode( stripslashes( $data['config'] ), true );
2458 }
2459
2460 if ( $task_id > 0 ) {
2461 // Update existing task
2462 $result = $this->update_task( $task_id, $data );
2463 if ( $result ) {
2464 wp_send_json_success( [ 'message' => 'Task updated successfully', 'task_id' => $task_id ] );
2465 } else {
2466 wp_send_json_error( [ 'message' => 'Failed to update task' ] );
2467 }
2468 } else {
2469 // Create new task
2470 $new_id = $this->create_task( $data );
2471 if ( $new_id ) {
2472 wp_send_json_success( [ 'message' => 'Task created successfully', 'task_id' => $new_id ] );
2473 } else {
2474 wp_send_json_error( [ 'message' => 'Failed to create task' ] );
2475 }
2476 }
2477 }
2478
2479 /**
2480 * AJAX handler to get a task
2481 */
2482 public function ajax_get_task() {
2483 check_ajax_referer( 'wpforo_ai_task_nonce', '_wpnonce' );
2484
2485 if ( ! current_user_can( 'manage_options' ) ) {
2486 wp_send_json_error( [ 'message' => 'Permission denied' ] );
2487 }
2488
2489 $task_id = intval( $_POST['task_id'] ?? 0 );
2490 $task = $this->get_task( $task_id );
2491
2492 if ( $task ) {
2493 wp_send_json_success( [ 'task' => $task ] );
2494 } else {
2495 wp_send_json_error( [ 'message' => 'Task not found' ] );
2496 }
2497 }
2498
2499 /**
2500 * AJAX handler to delete a task
2501 */
2502 public function ajax_delete_task() {
2503 check_ajax_referer( 'wpforo_ai_task_nonce', '_wpnonce' );
2504
2505 if ( ! current_user_can( 'manage_options' ) ) {
2506 wp_send_json_error( [ 'message' => 'Permission denied' ] );
2507 }
2508
2509 $task_id = intval( $_POST['task_id'] ?? 0 );
2510 $result = $this->delete_task( $task_id );
2511
2512 if ( $result ) {
2513 wp_send_json_success( [ 'message' => 'Task deleted successfully' ] );
2514 } else {
2515 wp_send_json_error( [ 'message' => 'Failed to delete task' ] );
2516 }
2517 }
2518
2519 /**
2520 * AJAX handler to update task status
2521 */
2522 public function ajax_update_task_status() {
2523 check_ajax_referer( 'wpforo_ai_task_nonce', '_wpnonce' );
2524
2525 if ( ! current_user_can( 'manage_options' ) ) {
2526 wp_send_json_error( [ 'message' => 'Permission denied' ] );
2527 }
2528
2529 $task_id = intval( $_POST['task_id'] ?? 0 );
2530 $status = sanitize_key( $_POST['status'] ?? '' );
2531
2532 if ( ! $task_id ) {
2533 wp_send_json_error( [ 'message' => 'Invalid task ID' ] );
2534 }
2535
2536 if ( ! array_key_exists( $status, self::$statuses ) ) {
2537 wp_send_json_error( [ 'message' => 'Invalid status: ' . $status ] );
2538 }
2539
2540 // Check if task exists
2541 $task = $this->get_task( $task_id );
2542 if ( ! $task ) {
2543 wp_send_json_error( [ 'message' => 'Task not found: ' . $task_id ] );
2544 }
2545
2546 $result = $this->update_task( $task_id, [ 'status' => $status ] );
2547
2548 if ( $result ) {
2549 wp_send_json_success( [ 'message' => 'Task status updated', 'status' => $status ] );
2550 } else {
2551 global $wpdb;
2552 wp_send_json_error( [ 'message' => 'Failed to update status. DB error: ' . $wpdb->last_error ] );
2553 }
2554 }
2555
2556 /**
2557 * AJAX handler to run a task immediately
2558 */
2559 public function ajax_run_task() {
2560 check_ajax_referer( 'wpforo_ai_task_nonce', '_wpnonce' );
2561
2562 if ( ! current_user_can( 'manage_options' ) ) {
2563 wp_send_json_error( [ 'message' => 'Permission denied' ] );
2564 }
2565
2566 $task_id = intval( $_POST['task_id'] ?? 0 );
2567 $result = $this->execute_task( $task_id );
2568
2569 if ( $result['success'] ) {
2570 wp_send_json_success( [
2571 'message' => 'Task executed successfully',
2572 'items_created' => $result['items_created'] ?? 0,
2573 'credits_used' => $result['credits_used'] ?? 0,
2574 ] );
2575 } else {
2576 wp_send_json_error( [ 'message' => $result['error'] ?? 'Task execution failed' ] );
2577 }
2578 }
2579
2580 /**
2581 * AJAX handler for bulk actions
2582 */
2583 public function ajax_bulk_action() {
2584 check_ajax_referer( 'wpforo_ai_task_nonce', '_wpnonce' );
2585
2586 if ( ! current_user_can( 'manage_options' ) ) {
2587 wp_send_json_error( [ 'message' => 'Permission denied' ] );
2588 }
2589
2590 $action = sanitize_key( $_POST['bulk_action'] ?? '' );
2591 $task_ids = array_map( 'intval', $_POST['task_ids'] ?? [] );
2592
2593 if ( empty( $task_ids ) ) {
2594 wp_send_json_error( [ 'message' => 'No tasks selected' ] );
2595 }
2596
2597 $count = 0;
2598 foreach ( $task_ids as $task_id ) {
2599 switch ( $action ) {
2600 case 'delete':
2601 if ( $this->delete_task( $task_id ) ) {
2602 $count++;
2603 }
2604 break;
2605 case 'activate':
2606 if ( $this->update_task( $task_id, [ 'status' => 'active' ] ) ) {
2607 $count++;
2608 }
2609 break;
2610 case 'pause':
2611 if ( $this->update_task( $task_id, [ 'status' => 'paused' ] ) ) {
2612 $count++;
2613 }
2614 break;
2615 }
2616 }
2617
2618 wp_send_json_success( [ 'message' => sprintf( '%d task(s) updated', $count ) ] );
2619 }
2620
2621 /**
2622 * AJAX handler to get task logs
2623 */
2624 public function ajax_get_task_logs() {
2625 check_ajax_referer( 'wpforo_ai_task_nonce', '_wpnonce' );
2626
2627 if ( ! current_user_can( 'manage_options' ) ) {
2628 wp_send_json_error( [ 'message' => 'Permission denied' ] );
2629 }
2630
2631 $task_id = intval( $_POST['task_id'] ?? 0 );
2632 $limit = intval( $_POST['limit'] ?? 50 );
2633 $offset = intval( $_POST['offset'] ?? 0 );
2634
2635 $logs = $this->get_task_logs( $task_id, [
2636 'limit' => $limit,
2637 'offset' => $offset,
2638 ] );
2639
2640 // Format execution_time in user's timezone
2641 foreach ( $logs as &$log ) {
2642 if ( ! empty( $log->execution_time ) ) {
2643 $log->execution_time = wpforo_ai_format_datetime( $log->execution_time );
2644 }
2645 }
2646 unset( $log );
2647
2648 wp_send_json_success( [ 'logs' => $logs ] );
2649 }
2650
2651 /**
2652 * AJAX handler to search users for author selection
2653 * Only returns activated users (empty user_activation_key)
2654 */
2655 public function ajax_search_users() {
2656 check_ajax_referer( 'wpforo_ai_task_nonce', '_wpnonce' );
2657
2658 if ( ! current_user_can( 'manage_options' ) ) {
2659 wp_send_json_error( [ 'message' => 'Permission denied' ] );
2660 }
2661
2662 $search = sanitize_text_field( $_POST['search'] ?? '' );
2663 $user_id = intval( $_POST['user_id'] ?? 0 );
2664
2665 global $wpdb;
2666
2667 // If user_id is provided, look up that specific user
2668 if ( $user_id > 0 ) {
2669 $user = get_userdata( $user_id );
2670 if ( $user ) {
2671 $role = ! empty( $user->roles ) ? ucfirst( $user->roles[0] ) : '';
2672 wp_send_json_success( [
2673 'users' => [
2674 [
2675 'id' => $user->ID,
2676 'user_login' => $user->user_login,
2677 'display_name' => $user->display_name,
2678 'role' => $role,
2679 'label' => sprintf(
2680 '%s (%s)%s',
2681 $user->display_name,
2682 $user->user_login,
2683 $role ? ' - ' . $role : ''
2684 ),
2685 ]
2686 ]
2687 ] );
2688 } else {
2689 wp_send_json_success( [ 'users' => [] ] );
2690 }
2691 return;
2692 }
2693
2694 // Otherwise, search by text
2695 if ( strlen( $search ) < 2 ) {
2696 wp_send_json_success( [ 'users' => [] ] );
2697 }
2698
2699 // Search for activated users (empty user_activation_key) by login, display name, or email
2700 $like = '%' . $wpdb->esc_like( $search ) . '%';
2701 $users = $wpdb->get_results(
2702 $wpdb->prepare(
2703 "SELECT ID, user_login, display_name, user_email
2704 FROM {$wpdb->users}
2705 WHERE user_activation_key = ''
2706 AND (user_login LIKE %s OR display_name LIKE %s OR user_email LIKE %s)
2707 ORDER BY display_name ASC
2708 LIMIT 50",
2709 $like,
2710 $like,
2711 $like
2712 )
2713 );
2714
2715 $results = [];
2716 foreach ( $users as $user ) {
2717 $user_obj = get_userdata( $user->ID );
2718 $role = $user_obj && ! empty( $user_obj->roles ) ? ucfirst( $user_obj->roles[0] ) : '';
2719 $results[] = [
2720 'id' => $user->ID,
2721 'user_login' => $user->user_login,
2722 'display_name' => $user->display_name,
2723 'role' => $role,
2724 'label' => sprintf(
2725 '%s (%s)%s',
2726 $user->display_name,
2727 $user->user_login,
2728 $role ? ' - ' . $role : ''
2729 ),
2730 ];
2731 }
2732
2733 wp_send_json_success( [ 'users' => $results ] );
2734 }
2735
2736 /**
2737 * AJAX handler to duplicate a task
2738 */
2739 public function ajax_duplicate_task() {
2740 check_ajax_referer( 'wpforo_ai_task_nonce', '_wpnonce' );
2741
2742 if ( ! current_user_can( 'manage_options' ) ) {
2743 wp_send_json_error( [ 'message' => 'Permission denied' ] );
2744 }
2745
2746 $task_id = intval( $_POST['task_id'] ?? 0 );
2747
2748 // Get the original task
2749 $original = $this->get_task( $task_id );
2750 if ( ! $original ) {
2751 wp_send_json_error( [ 'message' => 'Task not found' ] );
2752 }
2753
2754 // Create a copy with modified name and paused status
2755 $new_data = [
2756 'task_name' => $original['task_name'] . ' (Copy)',
2757 'task_type' => $original['task_type'],
2758 'status' => 'paused',
2759 'board_id' => $original['board_id'],
2760 'config' => $original['config'],
2761 ];
2762
2763 $new_task_id = $this->create_task( $new_data );
2764
2765 if ( $new_task_id ) {
2766 wp_send_json_success( [
2767 'message' => 'Task duplicated successfully',
2768 'task_id' => $new_task_id,
2769 ] );
2770 } else {
2771 wp_send_json_error( [ 'message' => 'Failed to duplicate task' ] );
2772 }
2773 }
2774
2775 /**
2776 * AJAX handler to get task statistics
2777 */
2778 public function ajax_get_task_stats() {
2779 check_ajax_referer( 'wpforo_ai_task_nonce', '_wpnonce' );
2780
2781 if ( ! current_user_can( 'manage_options' ) ) {
2782 wp_send_json_error( [ 'message' => 'Permission denied' ] );
2783 }
2784
2785 $task_id = intval( $_POST['task_id'] ?? 0 );
2786
2787 // Get the task
2788 $task = $this->get_task( $task_id );
2789 if ( ! $task ) {
2790 wp_send_json_error( [ 'message' => 'Task not found' ] );
2791 }
2792
2793 // Get logs to calculate statistics
2794 global $wpdb;
2795 $logs_table = $this->get_logs_table();
2796
2797 // Total runs and success/error counts
2798 $run_stats = $wpdb->get_row(
2799 $wpdb->prepare(
2800 "SELECT
2801 COUNT(*) as total_runs,
2802 SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successful_runs,
2803 SUM(items_created) as items_created,
2804 SUM(credits_used) as credits_used
2805 FROM {$logs_table}
2806 WHERE task_id = %d",
2807 $task_id
2808 )
2809 );
2810
2811 $total_runs = intval( $run_stats->total_runs ?? 0 );
2812 $successful_runs = intval( $run_stats->successful_runs ?? 0 );
2813 $items_created = intval( $run_stats->items_created ?? 0 );
2814 $credits_used = intval( $run_stats->credits_used ?? 0 );
2815
2816 // Calculate success rate
2817 $success_rate = $total_runs > 0 ? round( ( $successful_runs / $total_runs ) * 100 ) . '%' : '0%';
2818
2819 // Calculate average items per run
2820 $avg_items = $total_runs > 0 ? round( $items_created / $total_runs, 1 ) : 0;
2821
2822 // Format last run time
2823 $last_run = ! empty( $task['last_run_time'] ) ? human_time_diff( strtotime( $task['last_run_time'] ), current_time( 'timestamp' ) ) . ' ago' : 'Never';
2824
2825 // Get next scheduled run
2826 $next_run = 'Not scheduled';
2827 if ( $task['status'] === 'active' ) {
2828 $next_scheduled = wp_next_scheduled( 'wpforo_ai_execute_task', [ $task_id ] );
2829 if ( $next_scheduled ) {
2830 $next_run = human_time_diff( current_time( 'timestamp' ), $next_scheduled ) . ' from now';
2831 }
2832 } elseif ( ! empty( $task['next_run_time'] ) ) {
2833 // Show stored next run time even if paused (convert to user timezone)
2834 $next_run = wpforo_ai_format_datetime( $task['next_run_time'] ) . ' (paused)';
2835 }
2836
2837 wp_send_json_success( [
2838 'stats' => [
2839 'total_runs' => $total_runs,
2840 'items_created' => $items_created,
2841 'credits_used' => $credits_used,
2842 'success_rate' => $success_rate,
2843 'last_run' => $last_run,
2844 'next_run' => $next_run,
2845 'avg_items_per_run' => $avg_items,
2846 ],
2847 ] );
2848 }
2849
2850 /**
2851 * Get local RAG contexts for topics
2852 *
2853 * When using local storage mode, performs semantic search for each topic
2854 * and formats the results as RAG context to be sent to the Lambda API.
2855 * This allows the Reply Generator to use forum context even in local storage mode.
2856 *
2857 * @param array $topics Array of topic data with topic_id and title
2858 * @return array Associative array of topic_id => rag_context_string
2859 */
2860 private function get_local_rag_contexts( $topics ) {
2861 $rag_contexts = [];
2862
2863 if ( ! WPF()->vector_storage ) {
2864 return $rag_contexts;
2865 }
2866
2867 foreach ( $topics as $topic ) {
2868 $topic_id = intval( $topic['topic_id'] ?? 0 );
2869 $title = $topic['title'] ?? '';
2870 $first_post_content = '';
2871
2872 // Get first post content for better context query
2873 if ( ! empty( $topic['posts'] ) && is_array( $topic['posts'] ) ) {
2874 $first_post_content = $topic['posts'][0]['content'] ?? '';
2875 } elseif ( ! empty( $topic['content'] ) ) {
2876 $first_post_content = $topic['content'];
2877 }
2878
2879 // Build search query from title + first post content
2880 $search_query = $title;
2881 if ( $first_post_content ) {
2882 $search_query .= ' ' . mb_substr( wp_strip_all_tags( $first_post_content ), 0, 200 );
2883 }
2884
2885 if ( empty( $search_query ) ) {
2886 continue;
2887 }
2888
2889 // Perform semantic search
2890 $results = WPF()->vector_storage->semantic_search( $search_query, 3 );
2891
2892 if ( is_wp_error( $results ) || empty( $results['results'] ) ) {
2893 continue;
2894 }
2895
2896 // Format results as RAG context (same format as Lambda _get_rag_context)
2897 $context_parts = [];
2898 foreach ( $results['results'] as $result ) {
2899 // Get topic title from result
2900 $result_title = '';
2901 if ( ! empty( $result['topic_id'] ) ) {
2902 // Local format - fetch topic title
2903 $result_topic = WPF()->topic->get_topic( intval( $result['topic_id'] ) );
2904 $result_title = $result_topic['title'] ?? '';
2905 } elseif ( ! empty( $result['metadata']['topic_title'] ) ) {
2906 // Cloud format
2907 $result_title = $result['metadata']['topic_title'];
2908 }
2909
2910 // Get excerpt
2911 $excerpt = '';
2912 if ( ! empty( $result['content'] ) ) {
2913 // Local format - use content_preview
2914 $excerpt = wp_strip_all_tags( $result['content'] );
2915 } elseif ( ! empty( $result['excerpt'] ) ) {
2916 // Cloud format
2917 $excerpt = wp_strip_all_tags( $result['excerpt'] );
2918 }
2919 $excerpt = mb_substr( $excerpt, 0, 300 );
2920
2921 if ( $result_title || $excerpt ) {
2922 $context_parts[] = "- {$result_title}: {$excerpt}";
2923 }
2924 }
2925
2926 if ( ! empty( $context_parts ) ) {
2927 $rag_contexts[ strval( $topic_id ) ] = "Relevant existing content:\n" . implode( "\n", $context_parts );
2928 }
2929 }
2930
2931 return $rag_contexts;
2932 }
2933
2934 /**
2935 * Handle topic creation for run_on_approval tasks
2936 *
2937 * Triggers Tag Generator tasks with run_on_approval enabled when topic is created with status=0
2938 *
2939 * @param array $topic Topic data
2940 * @param array $forum Forum data
2941 */
2942 public function on_topic_created( $topic, $forum ) {
2943 // Only trigger for approved topics (status = 0)
2944 // Unapproved topics will trigger via wpforo_topic_approve hook later
2945 if ( empty( $topic['topicid'] ) || intval( $topic['status'] ?? 1 ) !== 0 ) {
2946 return;
2947 }
2948
2949 $this->trigger_tag_maintenance_for_topic( $topic );
2950 }
2951
2952 /**
2953 * Handle topic approval for run_on_approval tasks
2954 *
2955 * Triggers Tag Generator tasks with run_on_approval enabled
2956 *
2957 * @param array $topic Topic data
2958 */
2959 public function on_topic_approved( $topic ) {
2960 if ( empty( $topic['topicid'] ) ) {
2961 return;
2962 }
2963
2964 $this->trigger_tag_maintenance_for_topic( $topic );
2965 }
2966
2967 /**
2968 * Trigger tag maintenance tasks for a topic
2969 *
2970 * @param array $topic Topic data
2971 */
2972 private function trigger_tag_maintenance_for_topic( $topic ) {
2973 $topicid = intval( $topic['topicid'] );
2974 $forumid = intval( $topic['forumid'] ?? 0 );
2975
2976 // Get active tag_maintenance tasks with run_on_approval enabled
2977 $tasks = $this->get_run_on_approval_tasks( 'tag_maintenance', $forumid );
2978
2979 foreach ( $tasks as $task ) {
2980 // Schedule task execution for this specific topic
2981 $this->execute_task_for_topic( $task, $topicid );
2982 }
2983 }
2984
2985 /**
2986 * Handle post creation for run_on_approval tasks
2987 *
2988 * Triggers Reply Generator tasks with run_on_approval enabled when post is created with status=0
2989 *
2990 * @param array $post Post data
2991 * @param array $topic Topic data
2992 * @param array $forum Forum data
2993 */
2994 public function on_post_created( $post, $topic, $forum ) {
2995 // Only trigger for approved posts (status = 0)
2996 // Unapproved posts will trigger via wpforo_post_approve hook later
2997 if ( empty( $post['postid'] ) || intval( $post['status'] ?? 1 ) !== 0 ) {
2998 return;
2999 }
3000
3001 // Skip first posts (topics themselves) - we only want replies
3002 if ( ! empty( $post['is_first_post'] ) ) {
3003 return;
3004 }
3005
3006 $this->trigger_reply_generator_for_post( $post );
3007 }
3008
3009 /**
3010 * Handle post approval for run_on_approval tasks
3011 *
3012 * Triggers Reply Generator tasks with run_on_approval enabled
3013 * Note: Only triggers for non-first posts (replies, not topics)
3014 *
3015 * @param array $post Post data
3016 */
3017 public function on_post_approved( $post ) {
3018 if ( empty( $post['postid'] ) || empty( $post['topicid'] ) ) {
3019 return;
3020 }
3021
3022 // Skip first posts (topics themselves) - we only want replies
3023 // is_first_post = 1 means this is the first post in a topic (the topic itself)
3024 if ( ! empty( $post['is_first_post'] ) ) {
3025 return;
3026 }
3027
3028 $this->trigger_reply_generator_for_post( $post );
3029 }
3030
3031 /**
3032 * Trigger reply generator tasks for a post
3033 *
3034 * @param array $post Post data
3035 */
3036 private function trigger_reply_generator_for_post( $post ) {
3037 $topicid = intval( $post['topicid'] );
3038 $forumid = intval( $post['forumid'] ?? 0 );
3039
3040 // Get active reply_generator tasks with run_on_approval enabled
3041 $tasks = $this->get_run_on_approval_tasks( 'reply_generator', $forumid );
3042
3043 foreach ( $tasks as $task ) {
3044 // Execute task to generate a reply to this topic
3045 $this->execute_task_for_topic( $task, $topicid );
3046 }
3047 }
3048
3049 /**
3050 * Get active tasks with run_on_approval enabled for a specific task type
3051 *
3052 * @param string $task_type Task type (reply_generator, tag_maintenance)
3053 * @param int $forum_id Forum ID to check target forums
3054 * @return array Array of tasks
3055 */
3056 private function get_run_on_approval_tasks( $task_type, $forum_id ) {
3057 global $wpdb;
3058 $table = $this->get_tasks_table();
3059
3060 // Get all active tasks of this type
3061 $tasks = $wpdb->get_results(
3062 $wpdb->prepare(
3063 "SELECT * FROM {$table} WHERE task_type = %s AND status = 'active'",
3064 $task_type
3065 ),
3066 ARRAY_A
3067 );
3068
3069 $matching_tasks = [];
3070
3071 foreach ( $tasks as $task ) {
3072 $config = ! empty( $task['config'] ) ? json_decode( $task['config'], true ) : [];
3073
3074 // Check if run_on_approval is enabled
3075 if ( empty( $config['run_on_approval'] ) ) {
3076 continue;
3077 }
3078
3079 // Check if forum is in target forums (if forum filtering is configured)
3080 $target_forums = [];
3081 if ( $task_type === 'reply_generator' ) {
3082 $target_forums = $config['reply_target_forums'] ?? [];
3083 } elseif ( $task_type === 'tag_maintenance' ) {
3084 $target_forums = $config['tag_target_forum_ids'] ?? [];
3085 }
3086
3087 // If target forums are specified, check if this forum is included
3088 if ( ! empty( $target_forums ) && $forum_id > 0 ) {
3089 if ( ! in_array( $forum_id, array_map( 'intval', $target_forums ) ) ) {
3090 continue;
3091 }
3092 }
3093
3094 $task['config'] = $config;
3095 $matching_tasks[] = $task;
3096 }
3097
3098 return $matching_tasks;
3099 }
3100
3101 /**
3102 * Execute a task for a specific topic (for run_on_approval tasks)
3103 *
3104 * @param array $task Task data with parsed config
3105 * @param int $topic_id Topic ID to process
3106 * @return array Execution result
3107 */
3108 private function execute_task_for_topic( $task, $topic_id ) {
3109 $task_id = intval( $task['task_id'] );
3110 $task_type = $task['task_type'];
3111 $config = $task['config'];
3112
3113 // Check credit threshold
3114 if ( ! $this->check_credit_threshold( $task ) ) {
3115 return [
3116 'success' => false,
3117 'error' => 'Credit threshold reached',
3118 ];
3119 }
3120
3121 $start_time = microtime( true );
3122 $result = [];
3123
3124 switch ( $task_type ) {
3125 case 'reply_generator':
3126 // Create a modified config to target only this topic
3127 $config['target_topic_ids'] = strval( $topic_id );
3128 $config['replies_per_run'] = 1;
3129 $task['config'] = $config;
3130 $result = $this->execute_reply_generator( $task );
3131 break;
3132
3133 case 'tag_maintenance':
3134 // Create a modified config to target only this topic
3135 $config['target_topic_ids'] = strval( $topic_id );
3136 $config['topics_per_run'] = 1;
3137 $task['config'] = $config;
3138 $result = $this->execute_tag_maintenance( $task );
3139 break;
3140
3141 default:
3142 return [ 'success' => false, 'error' => 'Unsupported task type for run_on_approval' ];
3143 }
3144
3145 // Calculate execution time
3146 $execution_time = microtime( true ) - $start_time;
3147
3148 // Log the execution
3149 return $this->log_execution( $task_id, [
3150 'status' => $result['success'] ? 'completed' : 'error',
3151 'items_created' => $result['items_created'] ?? 0,
3152 'credits_used' => $result['credits_used'] ?? 0,
3153 'execution_duration' => $execution_time,
3154 'error_message' => $result['error'] ?? null,
3155 'result_data' => array_merge( $result['data'] ?? [], [ 'trigger' => 'on_approval', 'topic_id' => $topic_id ] ),
3156 ] );
3157 }
3158 }
3159