get( 'enable_chat_history', 1 ) ) { return true; } return SessionLock::run( $session_id, static function () use ( $session_id, $question, $answer, $provider, $model, $outcome ) { return self::append_log( $session_id, $question, $answer, $provider, $model, $outcome ); } ); } private static function append_log( string $session_id, string $question, string $answer, string $provider, string $model, string $outcome ): bool { if ( ! \AISupportAssistant\Admin\Settings::get_instance()->get( 'enable_chat_history', 1 ) ) { return true; } self::purge_expired(); global $wpdb; DatabaseManager::maybe_upgrade_chat_table(); $table = DatabaseManager::get_chat_history_table(); $now = current_time( 'mysql' ); $existing = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE session_id = %s LIMIT 1', $table, $session_id ) ); if ( $existing ) { $messages = array(); if ( ! empty( $existing->messages ) ) { $decoded = json_decode( $existing->messages, true ); if ( is_array( $decoded ) ) { $messages = $decoded; } } // If messages array is empty, populate from the existing initial question/answer. if ( empty( $messages ) && ( ! empty( $existing->question ) || ! empty( $existing->answer ) ) ) { if ( ! empty( $existing->question ) ) { $messages[] = array( 'role' => 'user', 'content' => $existing->question, 'time' => $existing->created_at, ); } if ( ! empty( $existing->answer ) ) { $messages[] = array( 'role' => 'assistant', 'content' => $existing->answer, 'time' => $existing->created_at, ); } } // Append new user turn and AI response. $messages[] = array( 'role' => 'user', 'content' => $question, 'time' => $now, ); $messages = array_slice( $messages, -100 ); $messages[] = array( 'role' => 'assistant', 'content' => $answer, 'outcome' => $outcome, 'provider' => $provider, 'model' => $model, 'time' => $now, ); $result = $wpdb->update( $table, array( 'question' => $question, 'answer' => $answer, 'messages' => wp_json_encode( $messages ), 'provider' => $provider, 'model' => $model, 'status' => 'completed', 'error_message' => null, 'updated_at' => $now, ), array( 'id' => $existing->id ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ), array( '%d' ) ); return false !== $result; } $messages = array( array( 'role' => 'user', 'content' => $question, 'time' => $now, ), array( 'role' => 'assistant', 'content' => $answer, 'outcome' => $outcome, 'provider' => $provider, 'model' => $model, 'time' => $now, ), ); $result = $wpdb->insert( $table, array( 'session_id' => $session_id, 'question' => $question, 'answer' => $answer, 'messages' => wp_json_encode( $messages ), 'provider' => $provider, 'model' => $model, 'status' => 'completed', 'error_message' => null, 'created_at' => $now, 'updated_at' => $now, ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); return false !== $result; } /** * Record a failed chat request for administrator diagnostics. * * @param string $session_id User session ID. * @param string $question User query. * @param string $provider AI provider. * @param string $model AI model. * @param string $error Internal failure detail. * @return bool True if the failure record was saved. */ public static function log_failure( string $session_id, string $question, string $provider, string $model, string $error ): bool { if ( ! \AISupportAssistant\Admin\Settings::get_instance()->get( 'enable_chat_history', 1 ) ) { return true; } return SessionLock::run( $session_id, static function () use ( $session_id, $question, $provider, $model, $error ) { return self::append_failure( $session_id, $question, $provider, $model, $error ); } ); } private static function append_failure( string $session_id, string $question, string $provider, string $model, string $error ): bool { if ( ! \AISupportAssistant\Admin\Settings::get_instance()->get( 'enable_chat_history', 1 ) ) { return true; } self::purge_expired(); global $wpdb; DatabaseManager::maybe_upgrade_chat_table(); $table = DatabaseManager::get_chat_history_table(); $now = current_time( 'mysql' ); $error = self::redact_api_keys( sanitize_textarea_field( $error ) ); $error = substr( $error, 0, 4000 ); $existing = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE session_id = %s LIMIT 1', $table, $session_id ) ); $messages = array(); if ( $existing && ! empty( $existing->messages ) ) { $decoded = json_decode( $existing->messages, true ); if ( is_array( $decoded ) ) { $messages = $decoded; } } if ( $existing && empty( $messages ) ) { if ( ! empty( $existing->question ) ) { $messages[] = array( 'role' => 'user', 'content' => $existing->question, 'time' => $existing->created_at, ); } if ( ! empty( $existing->answer ) ) { $messages[] = array( 'role' => 'assistant', 'content' => $existing->answer, 'time' => $existing->created_at, ); } } $messages[] = array( 'role' => 'user', 'content' => $question, 'outcome' => 'failed', 'provider' => $provider, 'model' => $model, 'time' => $now, ); $messages = array_slice( $messages, -100 ); $data = array( 'question' => $question, 'answer' => '', 'messages' => wp_json_encode( $messages ), 'provider' => $provider, 'model' => $model, 'status' => 'failed', 'error_message' => $error, 'updated_at' => $now, ); if ( $existing ) { $result = $wpdb->update( $table, $data, array( 'id' => $existing->id ) ); } else { $data['session_id'] = $session_id; $data['created_at'] = $now; $result = $wpdb->insert( $table, $data ); } if ( false === $result ) { Logger::error( 'Unable to save failed chat request to admin history.', array( 'error' => $wpdb->last_error ) ); return false; } return true; } /** * Remove configured API credentials from provider error details. * * @param string $message Error detail. * @return string Redacted error detail. */ private static function redact_api_keys( string $message ): string { $settings = \AISupportAssistant\Admin\Settings::get_instance(); $keys = array( (string) $settings->get_secret( 'openai_api_key' ), (string) $settings->get_secret( 'gemini_api_key' ), (string) \AISupportAssistant\Helpers\ConnectorHelper::get_connector_api_key( 'openai' ), (string) \AISupportAssistant\Helpers\ConnectorHelper::get_connector_api_key( 'google' ), (string) \AISupportAssistant\Helpers\ConnectorHelper::get_connector_api_key( 'gemini' ), ); foreach ( array_filter( $keys ) as $key ) { $message = str_replace( $key, '[redacted]', $message ); } return $message; } /** * Log a chat conversation turn in the database (alias for add_log). * * @param string $session_id User session ID. * @param string $question User query. * @param string $answer AI response. * @param string $provider AI provider (openai, gemini). * @param string $model AI model. * @return bool True on success. */ public static function log( string $session_id, string $question, string $answer, string $provider, string $model, string $outcome = 'completed' ): bool { return self::add_log( $session_id, $question, $answer, $provider, $model, $outcome ); } /** * Retrieve dialogue history for a session ID. * * @param string $session_id User session ID. * @param int $limit Max turns to fetch. * @return array Dialogue turns. */ public static function get_history( string $session_id, int $limit = 5 ): array { self::purge_expired(); global $wpdb; $table = DatabaseManager::get_chat_history_table(); $existing = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE session_id = %s LIMIT 1', $table, $session_id ) ); if ( ! $existing ) { return array(); } $history = array(); if ( ! empty( $existing->messages ) ) { $messages = json_decode( $existing->messages, true ); if ( is_array( $messages ) ) { $temp_user = ''; foreach ( $messages as $msg ) { if ( ( $msg['role'] ?? '' ) === 'user' ) { $temp_user = $msg['content'] ?? ''; } elseif ( ( $msg['role'] ?? '' ) === 'assistant' && ! empty( $temp_user ) ) { $history[] = array( 'question' => $temp_user, 'answer' => $msg['content'] ?? '', ); $temp_user = ''; } } } } if ( empty( $history ) && ! empty( $existing->question ) ) { $history[] = array( 'question' => $existing->question, 'answer' => $existing->answer, ); } if ( count( $history ) > $limit ) { $history = array_slice( $history, -$limit ); } return $history; } /** * Stream chat logs as a download CSV file. * * @return void */ public static function export_csv(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized access.', 'ai-support-assistant' ) ); } self::purge_expired(); global $wpdb; $table = DatabaseManager::get_chat_history_table(); $logs = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY created_at DESC', $table ), ARRAY_A ); // Clean headers. header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="ai-support-assistant-history-' . gmdate( 'Y-m-d' ) . '.csv"' ); header( 'Pragma: no-cache' ); header( 'Expires: 0' ); $output = fopen( 'php://output', 'w' ); if ( false === $output ) { exit; } // Write column headers. fputcsv( $output, array( 'ID', 'Session ID', 'Question', 'Answer', 'Provider', 'Model', 'Status', 'Error', 'Asked At' ), ',', '"', '' ); if ( ! empty( $logs ) ) { foreach ( $logs as $row ) { fputcsv( $output, array_map( array( self::class, 'sanitize_csv_cell' ), array( $row['id'], $row['session_id'], $row['question'], $row['answer'], $row['provider'], $row['model'], $row['status'] ?? 'completed', $row['error_message'] ?? '', $row['created_at'], ) ), ',', '"', '' ); } } fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose exit; } /** Remove inactive conversations after 30 days. */ public static function purge_expired(): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE COALESCE(updated_at, created_at) < %s', DatabaseManager::get_chat_history_table(), wp_date( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS, wp_timezone() ) ) ); } /** * Keep untrusted CSV values from being evaluated as spreadsheet formulas. * * @param mixed $value Exported cell value. * @return string Safe spreadsheet cell. */ public static function sanitize_csv_cell( $value ): string { $value = (string) $value; return preg_match( '/^[\s\x00-\x20]*[=+@-]/u', $value ) ? "'" . $value : $value; } }