* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Reporter class. */ class Reporter implements Maintenance { use Singleton; /** Report format version */ private const REPORT_VERSION = '1.0'; /** Maximum number of events to include in report */ private const MAX_EVENTS_IN_REPORT = 1000; /** Reports directory name under uploads */ private const REPORTS_DIR = 'agenistrator/reports'; /** Email address to send reports to */ private const REPORT_EMAIL = 'agenistrator-reports@maximum.software'; /** Cached upload directory base path */ private $upload_basedir = null; /** Cached upload directory base URL */ private $upload_baseurl = null; /** Cached reports directory path (null = not initialized, false = error, string = path) */ private $reports_dir = null; /** * Constructor * @since 1.0.0 */ protected function __construct() { $this->init_upload_dir(); } /** * Initialize upload directory cache */ private function init_upload_dir() { $upload_dir = wp_upload_dir( null, false ); if ( empty( $upload_dir['error'] ) ) { $this->upload_basedir = $upload_dir['basedir']; $this->upload_baseurl = $upload_dir['baseurl']; } } /** * Register hooks * @since 1.0.0 */ public function register_hooks() { // No hooks needed } /** * Plugin activation tasks * @since 1.0.0 */ public function activate_plugin() { // No activation tasks needed } /** * Plugin deactivation tasks * @since 1.0.0 */ public function deactivate_plugin() { // No deactivation tasks needed } /** * Uninstall - remove reports directory * @since 1.0.0 */ public function uninstall_plugin() { $this->delete_all_reports( true ); } /** * Get the reports directory path, creating it if needed * * @return string Absolute path to reports directory * @throws \Exception If directory cannot be created * @since 1.0.0 */ public function get_reports_directory() { // Return cached result if available if ( null !== $this->reports_dir ) { return $this->reports_dir; } // Check if upload dir is available if ( null === $this->upload_basedir ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Upload directory is not available', 'agenistrator' ) ); } $reports_dir = path_join( $this->upload_basedir, self::REPORTS_DIR ); // Create directory if it doesn't exist if ( ! file_exists( $reports_dir ) ) { if ( ! wp_mkdir_p( $reports_dir ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Failed to create reports directory', 'agenistrator' ) ); } } // Ensure index.php exists to prevent directory listing $index_file = path_join( $reports_dir, 'index.php' ); if ( ! file_exists( $index_file ) ) { WordPress_Utils::get_wp_filesystem()->put_contents( $index_file, 'reports_dir = $reports_dir; return $reports_dir; } /** * Generate a problem report * * @param int $user_id The user ID * @param int $thread_id The thread ID * @param string $description User's description of the issue * @return string The report ID (UUID) * @throws \Exception If report generation fails * @since 1.0.0 */ public function generate_report( $user_id, $thread_id, $description = '' ) { $reports_dir = $this->get_reports_directory(); $report_id = wp_generate_uuid4(); $filename = $this->get_filename_for_report_id( $report_id ); $final_path = path_join( $reports_dir, $filename ); // Create zip in temp directory first, then move via WP_Filesystem $temp_path = path_join( get_temp_dir(), $filename ); $report_data = $this->get_report_data( $user_id, $thread_id, $description ); $zip = new \ZipArchive(); $result = $zip->open( $temp_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ); if ( true !== $result ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Failed to create zip file', 'agenistrator' ) ); } $json = wp_json_encode( $report_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ); $zip->addFromString( 'report.json', $json ); $zip->close(); // Move from temp to final location using WP_Filesystem $wp_filesystem = WordPress_Utils::get_wp_filesystem(); if ( ! $wp_filesystem->move( $temp_path, $final_path, true ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Failed to move zip file to reports directory', 'agenistrator' ) ); } if ( ! $wp_filesystem->exists( $final_path ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Failed to write zip file', 'agenistrator' ) ); } return $report_id; } /** * Collect all data for the report * * @param int $user_id The user ID * @param int $thread_id The thread ID * @param string $description User's description of the issue * @return array Report data structure */ private function get_report_data( $user_id, $thread_id, $description = '' ) { $user = get_userdata( $user_id ); // Get chat log $chat_log_manager = Chat_Log_Manager::get_instance(); $messages = $chat_log_manager->get_chat_log( $thread_id ); $messages_array = array_map( function ( $msg ) { return $msg->to_array(); }, $messages ); return array( 'report_version' => self::REPORT_VERSION, 'generated_at' => gmdate( 'c' ), 'issue_description' => $description, 'reporter' => array( 'user_id' => $user_id, 'email' => $user ? $user->user_email : '', 'display_name' => $user ? $user->display_name : '', ), 'site' => array( 'name' => get_bloginfo( 'name' ), 'url' => home_url(), 'admin_email' => get_option( 'admin_email' ), 'is_multisite' => is_multisite(), ), 'agenistrator_config' => $this->get_agenistrator_config(), 'agenistrator_stats' => $this->get_agenistrator_stats(), 'agenistrator_events' => $this->get_agenistrator_events(), 'wordpress_stats' => $this->get_wordpress_stats(), 'error_log' => $this->get_error_log_data(), 'action_scheduler' => WordPress_Utils::get_action_scheduler_status(), 'site_health' => $this->get_site_health_data(), 'chat_log' => array( 'thread_id' => $thread_id, 'message_count' => count( $messages_array ), 'messages' => $messages_array, ), ); } /** * Get Agenistrator plugin configuration (sanitized - no API keys) * * @return array Sanitized plugin configuration */ private function get_agenistrator_config() { $provider_manager = Provider_Manager::get_instance(); $llm_manager = LLM_Manager::get_instance(); $tool_manager = Tool_Manager::get_instance(); $settings = Settings::get_instance(); // Get sanitized provider config (include only public parameters) $providers_config = array(); try { $all_providers = $provider_manager->get_all_providers(); foreach ( $all_providers as $provider_id => $provider ) { $providers_config[ $provider_id ] = array( 'type' => $provider->get_type_id(), 'display_name' => $provider->get_display_name(), 'enabled' => $provider_manager->is_provider_enabled( $provider_id ), 'parameters' => $this->get_public_provider_params( $provider ), ); } } catch ( \Throwable $e ) { $providers_config = array( 'error' => $e->getMessage() ); } // Get tool settings $tool_settings = array(); try { $tool_settings = $tool_manager->get_tool_settings(); } catch ( \Throwable $e ) { $tool_settings = array( 'error' => $e->getMessage() ); } return array( 'providers' => $providers_config, 'llm_settings' => array( 'chat_model' => $llm_manager->get_chat_model_id(), 'embeddings_model' => $llm_manager->get_embeddings_model_id(), 'chat_model_configured' => $llm_manager->is_chat_model_configured(), 'embeddings_model_configured' => $llm_manager->is_embeddings_model_configured(), 'default_context_window' => $settings->get_config( 'default_context_window', Model_Info::DEFAULT_CONTEXT_WINDOW ), 'default_max_output_tokens' => $settings->get_config( 'default_max_output_tokens', Model_Info::DEFAULT_MAX_OUTPUT ), ), 'tool_settings' => $tool_settings, ); } /** * Get public (non-private) parameters for a provider * * Filters out parameters marked as private or with type 'password'. * * @param Provider_Type $provider The provider instance * @return array Public parameters with their values */ private function get_public_provider_params( $provider ) { $definitions = $provider->get_parameter_definitions(); $values = $provider->get_params(); $public_params = array(); foreach ( $definitions as $param_name => $definition ) { // Skip password fields (always private) if ( isset( $definition['type'] ) && 'password' === $definition['type'] ) { continue; } // Skip explicitly private fields if ( ! empty( $definition['private'] ) ) { continue; } // Include the parameter value if it exists if ( array_key_exists( $param_name, $values ) ) { $public_params[ $param_name ] = $values[ $param_name ]; } } return $public_params; } /** * Get Agenistrator statistics (embeddings, cron, progress) * * @return array Agenistrator statistics */ private function get_agenistrator_stats() { $embeddings_manager = Embeddings_Manager::get_instance(); $stats = array( 'embeddings_count' => 0, 'current_embedding_model' => '', 'current_embedding_dimension' => 0, 'search_operational' => false, 'scheduled_cron_events' => array(), 'progress_trackers' => array(), ); try { $stats['embeddings_count'] = $embeddings_manager->get_embeddings_count(); $stats['current_embedding_model'] = $embeddings_manager->get_config( 'current_embedding_model', '' ); $stats['current_embedding_dimension'] = (int) $embeddings_manager->get_config( 'current_embedding_dimension', 0 ); $stats['search_operational'] = $embeddings_manager->is_search_operational(); // Get all running progress trackers $running_trackers = Progress_Tracker::get_running(); foreach ( $running_trackers as $tracker ) { $stats['progress_trackers'][] = array( 'operation' => $tracker->get_operation(), 'instance_id' => $tracker->get_instance_id(), 'status' => $tracker->get_status(), 'current' => $tracker->get_current(), 'total' => $tracker->get_total(), 'percent_complete' => $tracker->get_percent_complete(), ); } // Get scheduled Agenistrator cron events $agenistrator_cron_hooks = array( 'agenistrator_hourly_cleanup', 'agenistrator_sync_content_embeddings_batch', 'agenistrator_cleanup_orphan_embeddings_batch', 'agenistrator_sync_post_embeddings', ); foreach ( $agenistrator_cron_hooks as $hook ) { $next_run = wp_next_scheduled( $hook ); if ( $next_run ) { $stats['scheduled_cron_events'][ $hook ] = gmdate( 'c', $next_run ); } } } catch ( \Throwable $e ) { $stats['error'] = $e->getMessage(); } return $stats; } /** * Get recent events from the event queue * * @return array Recent events */ private function get_agenistrator_events() { global $wpdb; try { $table_name = $wpdb->prefix . 'agenistrator_events'; // Get recent events directly (all types) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name is hardcoded; caching not applicable. $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded "SELECT * FROM {$table_name} ORDER BY id DESC LIMIT %d", self::MAX_EVENTS_IN_REPORT ) ); if ( ! $rows ) { return array(); } $events = array(); foreach ( $rows as $row ) { $events[] = array( 'id' => (int) $row->id, 'user_id' => null === $row->user_id ? null : (int) $row->user_id, 'thread_id' => $row->thread_id, 'event_type' => $row->event_type, 'data' => json_decode( $row->event_data, true ), 'created_at' => (int) $row->created_at, ); } return $events; } catch ( \Throwable $e ) { return array( 'error' => $e->getMessage() ); } } /** * Get WordPress instance statistics * * @return array WordPress statistics */ private function get_wordpress_stats() { // Get post counts by type $post_types_objects = get_post_types( array(), 'objects' ); $post_counts = array(); $post_types_info = array(); foreach ( $post_types_objects as $post_type ) { $counts = wp_count_posts( $post_type->name ); $post_counts[ $post_type->name ] = array( 'publish' => (int) ( $counts->publish ?? 0 ), 'draft' => (int) ( $counts->draft ?? 0 ), 'total' => array_sum( (array) $counts ), ); $post_types_info[] = array( 'name' => $post_type->name, 'label' => $post_type->label, 'public' => $post_type->public, 'hierarchical' => $post_type->hierarchical, 'show_ui' => $post_type->show_ui, 'builtin' => $post_type->_builtin, ); } // Get user counts $user_counts = count_users(); // Get comment counts $comment_counts = wp_count_comments(); // Get media library statistics $media_stats = $this->get_media_stats(); // Get taxonomy statistics $taxonomy_stats = $this->get_taxonomy_stats(); // Get menu statistics $menu_stats = $this->get_menu_stats(); return array( 'post_types' => $post_types_info, 'posts' => $post_counts, 'users' => array( 'total' => $user_counts['total_users'], 'by_role' => $user_counts['avail_roles'], ), 'comments' => array( 'total' => (int) $comment_counts->total_comments, 'approved' => (int) $comment_counts->approved, 'pending' => (int) $comment_counts->moderated, 'spam' => (int) $comment_counts->spam, ), 'media' => $media_stats, 'taxonomies' => $taxonomy_stats, 'menus' => $menu_stats, ); } /** * Get media library statistics * * @return array Media statistics by mime type */ private function get_media_stats() { $counts = wp_count_attachments(); $by_type = array(); $total = 0; foreach ( (array) $counts as $mime_type => $count ) { if ( 'trash' === $mime_type ) { continue; } $by_type[ $mime_type ] = (int) $count; $total += (int) $count; } arsort( $by_type ); return array( 'total' => $total, 'by_type' => $by_type, ); } /** * Get taxonomy statistics * * @return array Taxonomy statistics with term counts */ private function get_taxonomy_stats() { $taxonomies = get_taxonomies( array(), 'objects' ); $stats = array(); foreach ( $taxonomies as $taxonomy ) { $term_count = wp_count_terms( array( 'taxonomy' => $taxonomy->name, 'hide_empty' => false, ) ); $stats[] = array( 'name' => $taxonomy->name, 'label' => $taxonomy->label, 'public' => $taxonomy->public, 'hierarchical' => $taxonomy->hierarchical, 'builtin' => $taxonomy->_builtin, 'object_types' => $taxonomy->object_type, 'term_count' => is_wp_error( $term_count ) ? 0 : (int) $term_count, ); } return $stats; } /** * Get menu statistics * * @return array Menu statistics */ private function get_menu_stats() { $menus = wp_get_nav_menus(); $menu_list = array(); foreach ( $menus as $menu ) { $menu_list[] = array( 'id' => $menu->term_id, 'name' => $menu->name, 'item_count' => $menu->count, ); } $locations = get_registered_nav_menus(); $assigned_locations = get_nav_menu_locations(); return array( 'total' => count( $menus ), 'menus' => $menu_list, 'registered_locations' => count( $locations ), 'assigned_locations' => count( array_filter( $assigned_locations ) ), ); } /** * Get error log data * * @return array Error log information */ private function get_error_log_data() { $data = array( 'debug_enabled' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'debug_log_enabled' => defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG, 'debug_display' => defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY, 'log_files' => array(), ); $log_files = WordPress_Utils::discover_log_files(); foreach ( $log_files as $log_path ) { if ( ! file_exists( $log_path ) || ! is_readable( $log_path ) ) { continue; } $data['log_files'][] = array( 'path' => $log_path, 'size' => filesize( $log_path ), 'modified' => gmdate( 'Y-m-d H:i:s e', filemtime( $log_path ) ), 'agenistrator_errors' => $this->extract_agenistrator_log_lines( $log_path ), ); } return $data; } /** * Extract log lines containing 'Agenistrator' (case-insensitive) * * Reads the log file from the end and returns up to 1000 matching lines * in chronological order (oldest first). * * @param string $log_path Path to log file * @return array Array of matching log lines (raw strings) */ private function extract_agenistrator_log_lines( $log_path ) { $max_lines = 1000; $matches = array(); try { $file = new \SplFileObject( $log_path, 'r' ); $file->setFlags( \SplFileObject::DROP_NEW_LINE ); $file->seek( PHP_INT_MAX ); $total_lines = $file->key(); $match_count = 0; for ( $i = $total_lines; $i >= 0 && $match_count < $max_lines; $i-- ) { $file->seek( $i ); $line = $file->current(); if ( '' !== $line && stripos( $line, 'agenistrator' ) !== false ) { $matches[] = $line; ++$match_count; } } } catch ( \Throwable $e ) { return array(); } return array_reverse( $matches ); } /** * Get WordPress Site Health debug data * * @return array Site health data */ private function get_site_health_data() { // Load the WP_Debug_Data class if not already loaded if ( ! class_exists( 'WP_Debug_Data' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-debug-data.php'; } try { $debug_data = \WP_Debug_Data::debug_data(); // Extract only the fields (values), not the full structure with labels $simplified = array(); foreach ( $debug_data as $section_key => $section ) { if ( ! isset( $section['fields'] ) ) { continue; } $simplified[ $section_key ] = array(); foreach ( $section['fields'] as $field_key => $field ) { // Skip private fields if ( ! empty( $field['private'] ) ) { continue; } // Get the value, preferring 'debug' over 'value' $value = $field['debug'] ?? $field['value'] ?? null; $simplified[ $section_key ][ $field_key ] = $value; } } return $simplified; } catch ( \Throwable $e ) { return array( 'error' => $e->getMessage() ); } } /** * List all reports * * @return array Array of report info: report_id, size, created timestamp * @since 1.0.0 */ public function list_reports() { // Check if reports directory exists without creating it if ( null === $this->upload_basedir ) { return array(); } $reports_dir = path_join( $this->upload_basedir, self::REPORTS_DIR ); if ( ! is_dir( $reports_dir ) ) { return array(); } $reports = array(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Intentional: graceful fallback if directory is unreadable $files = @scandir( $reports_dir ); if ( false === $files ) { return array(); } $files = array_diff( $files, array( '.', '..', 'index.php' ) ); foreach ( $files as $file ) { $report_id = $this->extract_report_id_from_filename( $file ); if ( ! $report_id ) { continue; } $filepath = path_join( $reports_dir, $file ); if ( ! is_file( $filepath ) ) { continue; } $reports[] = array( 'report_id' => $report_id, 'size' => filesize( $filepath ), 'created' => filemtime( $filepath ), ); } // Sort by created time descending (newest first) usort( $reports, function ( $a, $b ) { return $b['created'] - $a['created']; } ); return $reports; } /** Filename prefix for report files */ private const REPORT_PREFIX = 'report-'; /** Filename suffix for report files */ private const REPORT_SUFFIX = '.zip'; /** * Get filename for a report ID * * @param string $report_id The report UUID * @return string The filename */ private function get_filename_for_report_id( $report_id ) { return self::REPORT_PREFIX . strtolower( $report_id ) . self::REPORT_SUFFIX; } /** * Extract report ID from filename * * @param string $filename The report filename * @return string|false The report ID or false if invalid */ private function extract_report_id_from_filename( $filename ) { $prefix_len = strlen( self::REPORT_PREFIX ); $suffix_len = strlen( self::REPORT_SUFFIX ); if ( strncmp( $filename, self::REPORT_PREFIX, $prefix_len ) !== 0 ) { return false; } if ( substr( $filename, -$suffix_len ) !== self::REPORT_SUFFIX ) { return false; } $potential_id = substr( $filename, $prefix_len, -$suffix_len ); return wp_is_uuid( $potential_id ) ? strtolower( $potential_id ) : false; } /** * Get report file path * * @param string $report_id The report UUID * @return string File path * @throws \Exception If report ID is invalid or report not found * @since 1.0.0 */ public function get_report_filepath( $report_id ) { if ( ! wp_is_uuid( $report_id ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Invalid report ID', 'agenistrator' ) ); } $reports_dir = $this->get_reports_directory(); $filename = $this->get_filename_for_report_id( $report_id ); $filepath = path_join( $reports_dir, $filename ); if ( ! file_exists( $filepath ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Report not found', 'agenistrator' ) ); } return $filepath; } /** * Delete a report * * @param string $report_id The report UUID * @throws \Exception If report cannot be deleted * @since 1.0.0 */ public function delete_report( $report_id ) { $filepath = $this->get_report_filepath( $report_id ); wp_delete_file( $filepath ); } /** * Delete all report files * * @param bool $remove_directory Whether to also remove the reports directory (for uninstall) * @since 1.0.0 */ public function delete_all_reports( $remove_directory = false ) { // Nothing to do if upload directory is not available if ( null === $this->upload_basedir ) { return; } $reports_dir = path_join( $this->upload_basedir, self::REPORTS_DIR ); if ( ! is_dir( $reports_dir ) ) { return; } // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Intentional: graceful fallback if directory is unreadable $files = @scandir( $reports_dir ); if ( false !== $files ) { $wp_filesystem = WordPress_Utils::get_wp_filesystem(); $files = array_diff( $files, array( '.', '..', 'index.php' ) ); foreach ( $files as $file ) { $filepath = path_join( $reports_dir, $file ); if ( is_file( $filepath ) ) { $wp_filesystem->delete( $filepath ); } } } if ( $remove_directory ) { $wp_filesystem = WordPress_Utils::get_wp_filesystem(); // Remove index.php before removing directory $index_file = path_join( $reports_dir, 'index.php' ); if ( file_exists( $index_file ) ) { $wp_filesystem->delete( $index_file ); } $wp_filesystem->rmdir( $reports_dir ); // Reset cached directory path $this->reports_dir = null; } } /** * Send a report via email * * @param string $report_id The report UUID * @throws \Exception If report cannot be sent * @since 1.0.0 */ public function send_report( $report_id ) { $filepath = $this->get_report_filepath( $report_id ); $filename = $this->get_filename_for_report_id( $report_id ); $site_domain = wp_parse_url( home_url(), PHP_URL_HOST ); $subject = "[Agenistrator] Report from {$site_domain}"; $site_name = get_bloginfo( 'name' ); $admin_email = get_option( 'admin_email' ); $report_url = $this->get_report_url( $report_id ); $message = String_Utils::render( 'report-email', array( 'site_name' => $site_name, 'site_domain' => $site_domain, 'report_url' => $report_url, 'filename' => $filename, 'admin_email' => $admin_email, ) ); $headers = array( 'Content-Type: text/html; charset=UTF-8', "From: {$site_name} <{$admin_email}>", "Reply-To: {$admin_email}", ); $attachments = array( $filepath ); $result = wp_mail( self::REPORT_EMAIL, $subject, $message, $headers, $attachments ); if ( ! $result ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Failed to send report email', 'agenistrator' ) ); } } /** * Get download URL for a report * * @param string $report_id The report UUID * @return string Download URL * @throws \Exception If report ID is invalid or upload URL unavailable * @since 1.0.0 */ public function get_report_url( $report_id ) { if ( ! wp_is_uuid( $report_id ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Invalid report ID', 'agenistrator' ) ); } if ( null === $this->upload_baseurl ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Upload URL is not available', 'agenistrator' ) ); } $filename = $this->get_filename_for_report_id( $report_id ); return trailingslashit( $this->upload_baseurl ) . trailingslashit( self::REPORTS_DIR ) . $filename; } }