* @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; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Event_Queue class. */ class Event_Queue implements Maintenance { use Singleton; /** * Get the table name for events. * * @return string The full table name with WordPress prefix */ private function get_table() { global $wpdb; return $wpdb->prefix . 'agenistrator_events'; } /** * Register hooks. * @since 1.0.0 */ public function register_hooks() { // Register activation hook for creating the database table add_action( 'agenistrator_activate', array( $this, 'activate_plugin' ) ); // Register cleanup task to run hourly add_action( 'agenistrator_hourly_cleanup', array( $this, 'cleanup_old_events' ) ); // Schedule the hourly cleanup task if not already scheduled if ( ! wp_next_scheduled( 'agenistrator_hourly_cleanup' ) ) { wp_schedule_event( time(), 'hourly', 'agenistrator_hourly_cleanup' ); } // Suppress alternate cron redirect on SSE endpoint as early as possible on init. if ( defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON ) { add_action( 'init', array( $this, 'suppress_alternate_cron_on_sse' ), 0 ); } // Handle SSE endpoint before rewrite rules are parsed so it works with any permalink structure. add_filter( 'do_parse_request', array( $this, 'maybe_handle_sse_request' ) ); // Cascade delete user events when user is deleted add_action( 'delete_user', array( $this, 'deleted_user' ) ); } /** * Handle user deletion - cascade delete user's events * * @param int $user_id The user ID being deleted. * @since 1.0.0 */ public function deleted_user( $user_id ) { try { $this->delete_events_by_user( (int) $user_id ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Silently fail - cascade deletion should not block user deletion } } /** * Determine whether the current request targets the SSE endpoint. * * Matches both the pretty-permalink path form (/agenistrator/sse/) and the * plain-permalink query form (?agenistrator_sse=1). * * @return bool True when the request is for the SSE endpoint. * @since 1.0.4 */ private function is_sse_request() { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only checking for presence, not using the value. if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( $_SERVER['REQUEST_URI'], '/agenistrator/sse' ) !== false ) { return true; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only checking for presence; the nonce is verified in SSE_Connection::handle_request() via the X-WP-Nonce header. return isset( $_GET['agenistrator_sse'] ); } /** * Suppress alternate WP-Cron redirect on SSE endpoint requests. * * Runs on the init hook with priority 0, before the default wp_cron() callback. * Only runs when ALTERNATE_WP_CRON is enabled. * @since 1.0.0 */ public function suppress_alternate_cron_on_sse() { if ( ! $this->is_sse_request() ) { return; } if ( ! defined( 'DISABLE_WP_CRON' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- DISABLE_WP_CRON is a WordPress core constant (not a plugin constant); we're defining it conditionally to prevent alternate cron from interfering with SSE connections. define( 'DISABLE_WP_CRON', true ); } } /** * Handle SSE requests before rewrite rules are parsed. * * Intercepts the SSE endpoint on do_parse_request so it resolves regardless * of permalink structure (plain or pretty) and even when the rewrite_rules * option has been overwritten by another plugin. * * @param bool $do_parse Whether to parse the request. * @return bool Whether to parse the request. * @since 1.0.4 */ public function maybe_handle_sse_request( $do_parse ) { if ( ! $this->is_sse_request() ) { return $do_parse; } SSE_Connection::get_instance()->handle_request(); exit; } /** * Plugin activation. * @since 1.0.0 */ public function activate_plugin() { $this->create_database_tables(); } /** * Plugin deactivation. * @since 1.0.0 */ public function deactivate_plugin() { // Clear the scheduled cleanup task $timestamp = wp_next_scheduled( 'agenistrator_hourly_cleanup' ); if ( $timestamp ) { wp_unschedule_event( $timestamp, 'agenistrator_hourly_cleanup' ); } } /** * Plugin uninstall. * @since 1.0.0 */ public function uninstall_plugin() { // Clear scheduled cleanup event (also done on deactivate, but ensure it's gone on uninstall) wp_clear_scheduled_hook( 'agenistrator_hourly_cleanup' ); $this->drop_tables(); } /** * Create database tables. */ private function create_database_tables() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); $table_name = $this->get_table(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is a hardcoded string literal with a trusted prefix, not user input. $sql = "CREATE TABLE IF NOT EXISTS {$table_name} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NULL, thread_id BIGINT UNSIGNED NULL, event_type VARCHAR(32) NOT NULL, event_data JSON NOT NULL, created_at BIGINT(20) NOT NULL, PRIMARY KEY (id), KEY user_id (user_id, id), KEY thread_id (thread_id, id), KEY created_at (created_at) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } /** * Drop database tables. */ private function drop_tables() { global $wpdb; $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table DDL; table name is a hardcoded string literal with a trusted prefix, not user input; caching not applicable. $wpdb->query( "DROP TABLE IF EXISTS {$table_name}" ); } /** * Push an event to the queue. * * @param Event $event The event to push. * @return int|false The ID of the inserted event or false on failure. * @since 1.0.0 */ public function push_event( $event ) { global $wpdb; $user_id = $event instanceof User_Event ? $event->get_user_id() : null; $thread_id = $event instanceof Thread_Event ? $event->get_thread_id() : null; $data = array( 'user_id' => $user_id, 'thread_id' => $thread_id, 'event_type' => $event->get_type(), 'event_data' => wp_json_encode( $event->get_data() ), 'created_at' => time(), ); // Build format array based on whether values are null $format = array( null === $user_id ? null : '%d', null === $thread_id ? null : '%d', '%s', '%s', '%d', ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table. $result = $wpdb->insert( $this->get_table(), $data, $format ); if ( $result ) { return $wpdb->insert_id; } return false; } /** * Get events since the last ID. * * Returns both system-wide events (user_id IS NULL) and events for the specified user * (across all their threads). Frontend is responsible for filtering by thread_id. * * @param int $user_id The user ID. * @param int $last_id The last event ID received by the client. * @param int $after_timestamp Optional. Only get events after this timestamp. Default 0. * @return Event[] Array of Event objects. * @since 1.0.0 */ public function get_events( $user_id, $last_id = 0, $after_timestamp = 0 ) { global $wpdb; $table_name = $this->get_table(); // Get system-wide events (user_id IS NULL) and user-specific events (user_id = $user_id) $query = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded "SELECT * FROM {$table_name} WHERE (user_id IS NULL OR user_id = %d) AND id > %d AND created_at >= %d ORDER BY id ASC", $user_id, $last_id, $after_timestamp ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; query built with $wpdb->prepare() above; table name from get_table() is hardcoded; caching not applicable. $results = $wpdb->get_results( $query ); $events = array(); if ( $results ) { foreach ( $results as $row ) { // Build data array for Event::from_array() factory $data = array( 'id' => (int) $row->id, 'user_id' => null === $row->user_id ? null : (int) $row->user_id, 'thread_id' => null === $row->thread_id ? null : (int) $row->thread_id, 'event_type' => $row->event_type, 'data' => json_decode( $row->event_data, true ), 'created_at' => (int) $row->created_at, ); // Use Event factory to reconstruct the proper Event subclass $event = Event::from_array( $data ); if ( null !== $event ) { $events[] = $event; } } } return $events; } /** * Get the latest event ID visible to a user. * * Returns the maximum ID from system-wide events (user_id IS NULL) or user's events. * * @param int $user_id The user ID. * @return int The latest event ID, or the table's next AUTO_INCREMENT - 1 if no events exist. * @since 1.0.0 */ public function get_latest_event_id( $user_id ) { global $wpdb; $table_name = $this->get_table(); // Get latest ID from system-wide events or user's events // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable. $latest_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded "SELECT MAX(id) FROM {$table_name} WHERE user_id IS NULL OR user_id = %d", $user_id ) ); if ( $latest_id ) { return (int) $latest_id; } // Table is empty for this user - use AUTO_INCREMENT to get the current sequence position. // This handles the case where events were added and then cleaned up. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- information_schema query; caching not applicable. $auto_increment = $wpdb->get_var( $wpdb->prepare( 'SELECT AUTO_INCREMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s', $wpdb->dbname, $table_name ) ); // AUTO_INCREMENT is the next ID, so subtract 1 to get the last used ID return $auto_increment ? (int) $auto_increment - 1 : 0; } /** * Get recent events of a specific type. * * @param string $event_type The event type to filter by. * @param int $limit Maximum number of events to return. * @return Event[] Array of Event instances. * @since 1.0.0 */ public function get_events_by_type( $event_type, $limit = 1000 ) { global $wpdb; $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable. $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded "SELECT id, event_type, event_data, created_at FROM {$table_name} WHERE event_type = %s ORDER BY id DESC LIMIT %d", $event_type, $limit ) ); if ( ! $rows ) { return array(); } $events = array(); foreach ( $rows as $row ) { $data = json_decode( $row->event_data, true ); if ( is_array( $data ) ) { $event = Event::from_array( array( 'id' => (int) $row->id, 'event_type' => $row->event_type, 'data' => $data, 'created_at' => (int) $row->created_at, ) ); if ( null !== $event ) { $events[] = $event; } } } return $events; } /** * Check if a thread is currently processing. * * Uses SQL-level JSON filtering to find the most recent terminal state event * (ready or processing). * * @param int $thread_id The thread ID. * @return bool True if thread is currently processing. * @since 1.0.0 */ public function is_thread_processing( $thread_id ) { global $wpdb; $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable. $action = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded "SELECT JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.action')) AS action FROM {$table_name} WHERE thread_id = %d AND event_type = 'control' AND JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.action')) IN (%s, %s) ORDER BY id DESC LIMIT 1", $thread_id, Events\Control_Event::ACTION_READY, Events\Control_Event::ACTION_PROCESSING ) ); return Events\Control_Event::ACTION_PROCESSING === $action; } /** * Check if rate limit wait is currently active (unresumed). * * Looks for the most recent rate_limit_wait or rate_limit_resume control event. * Rate limit is active if the most recent one is rate_limit_wait. * * @param int $thread_id The thread ID. * @return bool True if rate limit wait is active (not yet resumed). * @since 1.0.0 */ public function is_rate_limit_active( $thread_id ) { return $this->get_rate_limit_remaining_time( $thread_id ) !== null; } /** * Get remaining rate limit wait time for a thread. * * Uses SQL-level JSON filtering to find the most recent relevant control event * (rate_limit_wait, rate_limit_resume, ready, or processing). * * @param int $thread_id The thread ID. * @return int|null Remaining wait time in seconds (can be 0 if elapsed), or null if not in rate limit wait. * @since 1.0.0 */ public function get_rate_limit_remaining_time( $thread_id ) { global $wpdb; $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable. $row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded "SELECT JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.action')) AS action, JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.params.wait_time')) AS wait_time, created_at FROM {$table_name} WHERE thread_id = %d AND event_type = 'control' AND JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.action')) IN (%s, %s, %s, %s) ORDER BY id DESC LIMIT 1", $thread_id, Events\Control_Event::ACTION_RATE_LIMIT_WAIT, Events\Control_Event::ACTION_RATE_LIMIT_RESUME, Events\Control_Event::ACTION_READY, Events\Control_Event::ACTION_PROCESSING ), \ARRAY_A ); if ( empty( $row ) || Events\Control_Event::ACTION_RATE_LIMIT_WAIT !== $row['action'] ) { return null; } $wait_time = isset( $row['wait_time'] ) ? (int) $row['wait_time'] : 0; $created_at = (int) $row['created_at']; $elapsed = time() - $created_at; $remaining = $wait_time - $elapsed; return max( 0, $remaining ); } /** * Clean up old events. * @since 1.0.0 */ public function cleanup_old_events() { global $wpdb; // Delete events older than 1 hour $cutoff_time = time() - 3600; // 1 hour ago $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable. $wpdb->query( $wpdb->prepare( "DELETE FROM {$table_name} WHERE created_at < %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded $cutoff_time ) ); } /** * Delete all events for a thread (silent cascade delete) * * @param int $thread_id The thread ID. * @since 1.0.0 */ public function delete_events_by_thread( $thread_id ) { global $wpdb; $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; caching not applicable. $wpdb->delete( $table_name, array( 'thread_id' => $thread_id ), array( '%d' ) ); } /** * Delete all events for a user (silent cascade delete) * * @param int $user_id The user ID. * @since 1.0.0 */ public function delete_events_by_user( $user_id ) { global $wpdb; $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; caching not applicable. $wpdb->delete( $table_name, array( 'user_id' => $user_id ), array( '%d' ) ); } }