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

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

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