PluginProbe
wpForo Forum / 3.0.8
wpForo Forum v3.0.8
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.8, at classes/TaskManager.php

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