tables->ai_logs ) ) { return false; } $defaults = [ 'action_type' => '', 'userid' => get_current_user_id(), 'user_type' => self::USER_TYPE_USER, 'credits_used' => 0, 'status' => self::STATUS_SUCCESS, 'content_type' => null, 'content_id' => null, 'forumid' => null, 'topicid' => null, 'request_summary' => null, 'response_summary' => null, 'error_message' => null, 'duration_ms' => 0, 'ip_address' => $this->get_client_ip(), 'extra_data' => null, 'created' => current_time( 'mysql', true ), // UTC for timezone conversion ]; $data = wp_parse_args( $data, $defaults ); // Validate required field if ( empty( $data['action_type'] ) ) { return false; } // Determine user type if not explicitly set if ( $data['user_type'] === self::USER_TYPE_USER ) { if ( $data['userid'] == 0 ) { $data['user_type'] = self::USER_TYPE_GUEST; } if ( defined( 'DOING_CRON' ) && DOING_CRON ) { $data['user_type'] = self::USER_TYPE_CRON; } } // Encode extra_data if array if ( is_array( $data['extra_data'] ) ) { $data['extra_data'] = wp_json_encode( $data['extra_data'] ); } // Truncate long strings if ( $data['request_summary'] && strlen( $data['request_summary'] ) > 500 ) { $data['request_summary'] = substr( $data['request_summary'], 0, 497 ) . '...'; } if ( $data['response_summary'] && strlen( $data['response_summary'] ) > 500 ) { $data['response_summary'] = substr( $data['response_summary'], 0, 497 ) . '...'; } if ( $data['error_message'] && strlen( $data['error_message'] ) > 1000 ) { $data['error_message'] = substr( $data['error_message'], 0, 997 ) . '...'; } $result = $wpdb->insert( WPF()->tables->ai_logs, [ 'action_type' => $data['action_type'], 'userid' => $data['userid'], 'user_type' => $data['user_type'], 'credits_used' => $data['credits_used'], 'status' => $data['status'], 'content_type' => $data['content_type'], 'content_id' => $data['content_id'], 'forumid' => $data['forumid'], 'topicid' => $data['topicid'], 'request_summary' => $data['request_summary'], 'response_summary' => $data['response_summary'], 'error_message' => $data['error_message'], 'duration_ms' => $data['duration_ms'], 'ip_address' => $data['ip_address'], 'extra_data' => $data['extra_data'], 'created' => $data['created'], ], [ '%s', '%d', '%s', '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s' ] ); if ( $result === false ) { return false; } $this->schedule_cleanup(); return $wpdb->insert_id; } /** * Get logs with filtering * * @param array $args Query arguments * * @return array */ public function get_logs( $args = [] ) { global $wpdb; $defaults = [ 'action_type' => '', 'date_filter' => 'all', 'status' => '', 'user_type' => '', 'search' => '', 'limit' => 50, 'offset' => 0, 'orderby' => 'created', 'order' => 'DESC', ]; $args = wp_parse_args( $args, $defaults ); $table = WPF()->tables->ai_logs; $where = [ '1=1' ]; $prepare_values = []; // Action type filter if ( ! empty( $args['action_type'] ) ) { $where[] = 'action_type = %s'; $prepare_values[] = $args['action_type']; } // Status filter if ( ! empty( $args['status'] ) ) { $where[] = 'status = %s'; $prepare_values[] = $args['status']; } // User type filter if ( ! empty( $args['user_type'] ) ) { $where[] = 'user_type = %s'; $prepare_values[] = $args['user_type']; } // Date filter if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { $date_sql = $this->get_date_filter_sql( $args['date_filter'] ); if ( $date_sql ) { $where[] = $date_sql; } } // Search if ( ! empty( $args['search'] ) ) { $where[] = '(request_summary LIKE %s OR response_summary LIKE %s OR error_message LIKE %s)'; $search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $prepare_values[] = $search_term; $prepare_values[] = $search_term; $prepare_values[] = $search_term; } $where_clause = implode( ' AND ', $where ); // Sanitize order $allowed_orderby = [ 'id', 'created', 'action_type', 'status', 'credits_used', 'duration_ms' ]; $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'created'; $order = strtoupper( $args['order'] ) === 'ASC' ? 'ASC' : 'DESC'; $sql = "SELECT * FROM `{$table}` WHERE {$where_clause} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d"; $prepare_values[] = intval( $args['limit'] ); $prepare_values[] = intval( $args['offset'] ); if ( ! empty( $prepare_values ) ) { $sql = $wpdb->prepare( $sql, $prepare_values ); } $logs = $wpdb->get_results( $sql, ARRAY_A ); // Enrich logs with user display names if ( $logs ) { $logs = $this->enrich_logs_with_user_data( $logs ); } return $logs ?: []; } /** * Get total count for pagination * * @param array $args Query arguments * * @return int */ public function get_logs_count( $args = [] ) { global $wpdb; $table = WPF()->tables->ai_logs; $where = [ '1=1' ]; $prepare_values = []; if ( ! empty( $args['action_type'] ) ) { $where[] = 'action_type = %s'; $prepare_values[] = $args['action_type']; } if ( ! empty( $args['status'] ) ) { $where[] = 'status = %s'; $prepare_values[] = $args['status']; } if ( ! empty( $args['user_type'] ) ) { $where[] = 'user_type = %s'; $prepare_values[] = $args['user_type']; } if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { $date_sql = $this->get_date_filter_sql( $args['date_filter'] ); if ( $date_sql ) { $where[] = $date_sql; } } if ( ! empty( $args['search'] ) ) { $where[] = '(request_summary LIKE %s OR response_summary LIKE %s OR error_message LIKE %s)'; $search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $prepare_values[] = $search_term; $prepare_values[] = $search_term; $prepare_values[] = $search_term; } $where_clause = implode( ' AND ', $where ); $sql = "SELECT COUNT(*) FROM `{$table}` WHERE {$where_clause}"; if ( ! empty( $prepare_values ) ) { $sql = $wpdb->prepare( $sql, $prepare_values ); } return (int) $wpdb->get_var( $sql ); } /** * Get a single log by ID * * @param int $id Log ID * * @return array|null */ public function get_log( $id ) { global $wpdb; $sql = $wpdb->prepare( "SELECT * FROM `" . WPF()->tables->ai_logs . "` WHERE id = %d", $id ); $log = $wpdb->get_row( $sql, ARRAY_A ); if ( $log ) { $logs = $this->enrich_logs_with_user_data( [ $log ] ); $log = $logs[0]; } return $log; } /** * Delete specific logs * * @param array $ids Log IDs * * @return int|false Number of deleted rows or false on error */ public function delete_logs( $ids ) { global $wpdb; if ( empty( $ids ) || ! is_array( $ids ) ) { return false; } $ids = array_map( 'intval', $ids ); $ids = array_filter( $ids ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); return $wpdb->query( $wpdb->prepare( "DELETE FROM `" . WPF()->tables->ai_logs . "` WHERE id IN ({$placeholders})", $ids ) ); } /** * Empty all logs * * @return int|false Number of deleted rows or false on error */ public function empty_all_logs() { global $wpdb; return $wpdb->query( "TRUNCATE TABLE `" . WPF()->tables->ai_logs . "`" ); } /** * Cleanup old logs (called by cron) * * @param int $days Delete logs older than this many days (default 90) * * @return int Number of deleted rows */ public function cleanup_old_logs( $days = 90 ) { global $wpdb; $days = apply_filters( 'wpforo_ai_logs_retention_days', $days ); $result = $wpdb->query( $wpdb->prepare( "DELETE FROM `" . WPF()->tables->ai_logs . "` WHERE created < DATE_SUB(NOW(), INTERVAL %d DAY)", $days ) ); return $result !== false ? $result : 0; } /** * Cron callback for log cleanup */ public function cron_cleanup_old_logs() { $days = $this->get_cleanup_days(); // If 0, auto-cleanup is disabled if ( $days <= 0 ) { return; } $deleted = $this->cleanup_old_logs( $days ); if ( $deleted > 0 ) { \wpforo_ai_log( 'info', "Cron cleanup: deleted {$deleted} logs older than {$days} days", 'Logs' ); } } /** * Get cleanup days setting (board-specific) * * @return int Number of days (0 = keep forever) */ public function get_cleanup_days() { return (int) wpforo_get_option( 'ai_logs_cleanup_days', 90 ); } /** * Save cleanup days setting (board-specific) * * @param int $days Number of days (0 = keep forever) * * @return bool Success */ public function save_cleanup_days( $days ) { $days = max( 0, min( 365, intval( $days ) ) ); return wpforo_update_option( 'ai_logs_cleanup_days', $days ); } /** * Get per page setting (board-specific) * * @return int Number of logs per page */ public function get_per_page() { return (int) wpforo_get_option( 'ai_logs_per_page', 50 ); } /** * Save per page setting (board-specific) * * @param int $per_page Number of logs per page * * @return bool Success */ public function save_per_page( $per_page ) { $allowed = [ 25, 50, 100, 200 ]; $per_page = in_array( $per_page, $allowed, true ) ? $per_page : 50; return wpforo_update_option( 'ai_logs_per_page', $per_page ); } /** * Schedule cleanup cron if not already scheduled */ public function schedule_cleanup() { if ( ! wp_next_scheduled( 'wpforo_ai_logs_cleanup' ) ) { wp_schedule_event( strtotime( 'tomorrow 5:00am' ), 'daily', 'wpforo_ai_logs_cleanup' ); } } /** * Unschedule cleanup cron */ public function unschedule_cleanup() { $timestamp = wp_next_scheduled( 'wpforo_ai_logs_cleanup' ); if ( $timestamp ) { wp_unschedule_event( $timestamp, 'wpforo_ai_logs_cleanup' ); } } // ========================================================================= // AJAX HANDLERS // ========================================================================= /** * AJAX: Get logs with filtering */ public function ajax_get_logs() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); // Accept both 'date_filter' and 'date_range' parameter names (JS sends date_range) $date_filter = $this->get_post_param( 'date_filter', '' ) ?: $this->get_post_param( 'date_range', 'all' ); $page = $this->get_post_param( 'page', 1, 'int' ); $per_page = $this->get_post_param( 'per_page', 50, 'int' ); $args = [ 'action_type' => $this->get_post_param( 'action_type', '' ), 'date_filter' => $date_filter, 'status' => $this->get_post_param( 'status', '' ), 'user_type' => $this->get_post_param( 'user_type', '' ), 'search' => $this->get_post_param( 'search', '' ), 'limit' => $per_page, 'offset' => ( $page - 1 ) * $per_page, 'orderby' => $this->get_post_param( 'orderby', 'created' ), 'order' => $this->get_post_param( 'order', 'DESC' ), ]; $logs = $this->get_logs( $args ); $total = $this->get_logs_count( $args ); // Enrich logs with user data $logs = $this->enrich_logs_with_user_data( $logs ); // Render HTML for table rows $html = $this->render_logs_table_html( $logs ); $this->send_success( [ 'html' => $html, 'logs' => $logs, 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'total_pages' => ceil( $total / $per_page ), ] ); } /** * AJAX: Delete selected logs */ public function ajax_delete_logs() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); // Accept both 'log_ids' and 'ids' parameter names $ids = $this->get_post_param( 'log_ids', [], 'array_int' ); if ( empty( $ids ) ) { $ids = $this->get_post_param( 'ids', [], 'array_int' ); } if ( empty( $ids ) ) { $this->send_error( __( 'No logs selected', 'wpforo' ), 400 ); } $deleted = $this->delete_logs( $ids ); if ( $deleted === false ) { $this->send_error( __( 'Failed to delete logs', 'wpforo' ), 500 ); } $this->send_success( [ 'message' => sprintf( __( '%d log(s) deleted', 'wpforo' ), $deleted ), 'deleted' => $deleted, ] ); } /** * AJAX: Empty all logs */ public function ajax_empty_all_logs() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); $result = $this->empty_all_logs(); if ( $result === false ) { $this->send_error( __( 'Failed to empty logs', 'wpforo' ), 500 ); } $this->send_success( [ 'message' => __( 'All logs have been deleted', 'wpforo' ), ] ); } /** * AJAX: Get single log detail */ public function ajax_get_log_detail() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); // Accept both 'id' and 'log_id' parameter names $id = $this->get_post_param( 'log_id', 0, 'int' ); if ( ! $id ) { $id = $this->get_post_param( 'id', 0, 'int' ); } if ( ! $id ) { $this->send_error( __( 'Invalid log ID', 'wpforo' ), 400 ); } $log = $this->get_log( $id ); if ( ! $log ) { $this->send_error( __( 'Log not found', 'wpforo' ), 404 ); } // Enrich with user data $logs = $this->enrich_logs_with_user_data( [ $log ] ); $log = $logs[0]; // Decode extra_data for display $extra_data_decoded = null; if ( ! empty( $log['extra_data'] ) ) { $extra_data_decoded = json_decode( $log['extra_data'], true ); } // Build HTML for modal $html = $this->render_log_detail_html( $log, $extra_data_decoded ); $this->send_success( [ 'html' => $html, 'log' => $log ] ); } /** * AJAX: Save cleanup days setting */ public function ajax_save_cleanup_days() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); $days = $this->get_post_param( 'days', 90, 'int' ); if ( $days < 0 || $days > 365 ) { $this->send_error( __( 'Days must be between 0 and 365', 'wpforo' ), 400 ); } $result = $this->save_cleanup_days( $days ); if ( ! $result ) { $this->send_error( __( 'Failed to save setting', 'wpforo' ), 500 ); } if ( $days > 0 ) { $message = sprintf( __( 'Logs older than %d days will be automatically deleted', 'wpforo' ), $days ); } else { $message = __( 'Auto-cleanup disabled. Logs will be kept forever.', 'wpforo' ); } $this->send_success( [ 'message' => $message, 'days' => $days ] ); } /** * AJAX: Save per page setting */ public function ajax_save_per_page() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); $per_page = $this->get_post_param( 'per_page', 50, 'int' ); $allowed = [ 25, 50, 100, 200 ]; if ( ! in_array( $per_page, $allowed, true ) ) { $this->send_error( __( 'Invalid value', 'wpforo' ), 400 ); } $result = $this->save_per_page( $per_page ); if ( ! $result ) { $this->send_error( __( 'Failed to save setting', 'wpforo' ), 500 ); } $this->send_success( [ 'message' => __( 'Setting saved', 'wpforo' ), 'per_page' => $per_page, ] ); } /** * AJAX: Get AI Chatbot messages (displayed as logs) */ public function ajax_get_chat_messages() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); // Accept both 'date_filter' and 'date_range' parameter names $date_filter = $this->get_post_param( 'date_filter', '' ) ?: $this->get_post_param( 'date_range', 'all' ); $page = $this->get_post_param( 'page', 1, 'int' ); $per_page = $this->get_post_param( 'per_page', 50, 'int' ); $args = [ 'date_filter' => $date_filter, 'status' => $this->get_post_param( 'status', '' ), 'user_type' => $this->get_post_param( 'user_type', '' ), 'search' => $this->get_post_param( 'search', '' ), 'limit' => $per_page, 'offset' => ( $page - 1 ) * $per_page, ]; $messages = $this->get_chat_messages( $args ); $total = $this->get_chat_messages_count( $args ); // Render HTML for table rows $html = $this->render_chat_messages_table_html( $messages ); $this->send_success( [ 'html' => $html, 'messages' => $messages, 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'total_pages' => ceil( $total / $per_page ), ] ); } /** * AJAX: Get single chat message detail */ public function ajax_get_chat_message_detail() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); // Accept both 'message_id' and 'id' parameter names $message_id = $this->get_post_param( 'message_id', 0, 'int' ); if ( ! $message_id ) { $message_id = $this->get_post_param( 'id', 0, 'int' ); } if ( ! $message_id ) { $this->send_error( __( 'Invalid message ID', 'wpforo' ), 400 ); } $message = $this->get_chat_message( $message_id ); if ( ! $message ) { $this->send_error( __( 'Message not found', 'wpforo' ), 404 ); } // Build HTML for modal $html = $this->render_chat_message_detail_html( $message ); $this->send_success( [ 'html' => $html, 'message' => $message ] ); } /** * Get chat messages with filtering * * @param array $args Query arguments * * @return array */ public function get_chat_messages( $args = [] ) { global $wpdb; $defaults = [ 'date_filter' => 'all', 'status' => '', 'user_type' => '', 'search' => '', 'limit' => 50, 'offset' => 0, ]; $args = wp_parse_args( $args, $defaults ); $messages_table = WPF()->tables->ai_chat_messages; $conversations_table = WPF()->tables->ai_chat_conversations; $where = [ '1=1' ]; $prepare_values = []; // Date filter if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { $date_sql = $this->get_chat_date_filter_sql( $args['date_filter'] ); if ( $date_sql ) { $where[] = $date_sql; } } // Status filter - for chat messages, we interpret this as role // 'success' = assistant messages (responses), 'error' would be messages with no credits, etc. // Since chat messages don't have status, we skip this filter for now // User type filter - filter by conversation owner type if ( ! empty( $args['user_type'] ) ) { if ( $args['user_type'] === 'guest' ) { $where[] = 'c.userid = 0'; } elseif ( $args['user_type'] === 'user' ) { $where[] = 'c.userid > 0'; } // 'cron' and 'system' don't apply to chat messages } // Search in message content or conversation title if ( ! empty( $args['search'] ) ) { $where[] = '(m.content LIKE %s OR c.title LIKE %s)'; $search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $prepare_values[] = $search_term; $prepare_values[] = $search_term; } $where_clause = implode( ' AND ', $where ); $sql = "SELECT m.*, c.title as conversation_title, c.userid FROM `{$messages_table}` m LEFT JOIN `{$conversations_table}` c ON m.conversation_id = c.conversation_id WHERE {$where_clause} ORDER BY m.created_at DESC LIMIT %d OFFSET %d"; $prepare_values[] = intval( $args['limit'] ); $prepare_values[] = intval( $args['offset'] ); if ( ! empty( $prepare_values ) ) { $sql = $wpdb->prepare( $sql, $prepare_values ); } $messages = $wpdb->get_results( $sql, ARRAY_A ); // Enrich with user data if ( $messages ) { $messages = $this->enrich_chat_messages_with_user_data( $messages ); } return $messages ?: []; } /** * Get total count of chat messages for pagination * * @param array $args Query arguments * * @return int */ public function get_chat_messages_count( $args = [] ) { global $wpdb; $messages_table = WPF()->tables->ai_chat_messages; $conversations_table = WPF()->tables->ai_chat_conversations; $where = [ '1=1' ]; $prepare_values = []; if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { $date_sql = $this->get_chat_date_filter_sql( $args['date_filter'] ); if ( $date_sql ) { $where[] = $date_sql; } } if ( ! empty( $args['user_type'] ) ) { if ( $args['user_type'] === 'guest' ) { $where[] = 'c.userid = 0'; } elseif ( $args['user_type'] === 'user' ) { $where[] = 'c.userid > 0'; } } if ( ! empty( $args['search'] ) ) { $where[] = '(m.content LIKE %s OR c.title LIKE %s)'; $search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $prepare_values[] = $search_term; $prepare_values[] = $search_term; } $where_clause = implode( ' AND ', $where ); $sql = "SELECT COUNT(*) FROM `{$messages_table}` m LEFT JOIN `{$conversations_table}` c ON m.conversation_id = c.conversation_id WHERE {$where_clause}"; if ( ! empty( $prepare_values ) ) { $sql = $wpdb->prepare( $sql, $prepare_values ); } return (int) $wpdb->get_var( $sql ); } /** * Get a single chat message by ID * * @param int $message_id Message ID * * @return array|null */ public function get_chat_message( $message_id ) { global $wpdb; $messages_table = WPF()->tables->ai_chat_messages; $conversations_table = WPF()->tables->ai_chat_conversations; $sql = $wpdb->prepare( "SELECT m.*, c.title as conversation_title, c.userid, c.running_summary, c.message_count, c.total_credits FROM `{$messages_table}` m LEFT JOIN `{$conversations_table}` c ON m.conversation_id = c.conversation_id WHERE m.message_id = %d", $message_id ); $message = $wpdb->get_row( $sql, ARRAY_A ); if ( $message ) { $messages = $this->enrich_chat_messages_with_user_data( [ $message ] ); $message = $messages[0]; } return $message; } /** * Get date filter SQL for chat messages * * @param string $filter Date filter key * * @return string SQL condition */ private function get_chat_date_filter_sql( $filter ) { switch ( $filter ) { case 'last_hour': return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)'; case 'last_day': return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)'; case 'last_week': return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)'; case 'last_month': return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 MONTH)'; default: return ''; } } /** * Enrich chat messages with user display names * * @param array $messages Array of chat messages * * @return array Enriched messages */ private function enrich_chat_messages_with_user_data( $messages ) { return $this->enrich_items_with_user_data( $messages, 'userid', [ 'user_type_field' => 'user_type', 'output_field' => 'user_display', 'use_type_labels' => false, // Chat messages only have user or guest ] ); } /** * Render chat messages as log table rows HTML * * @param array $messages Array of chat messages * * @return string HTML content for table tbody */ private function render_chat_messages_table_html( $messages ) { if ( empty( $messages ) ) { ob_start(); ?>