prefix . 'aiadvisors_actions' ): the identifier is prefix-derived // (never user input), all values are prepared, and results are request-cached in // a transient. Plugin Check ignores phpcs.xml.dist, so silence the expected // own-table notices for this file. // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL -- Own custom table; see note above. class Inbox { private $settings; private $actions_table; private $cache_key = 'aiadvisors_inbox_data'; private $cache_ttl = 300; // 5 minutes private $prefs_cache_key = 'aiadvisors_automation_prefs'; public function __construct( $settings ) { $this->settings = $settings; global $wpdb; $this->actions_table = $wpdb->prefix . 'aiadvisors_actions'; } /** * Fetch pending actions from the platform API (cached). * * @param bool $force_refresh Skip cache. * @return array Array of action items. */ public function fetch_actions( $force_refresh = false ) { if ( ! $force_refresh ) { $cached = get_transient( $this->cache_key ); if ( $cached !== false ) { return $cached; } } $domain_key = $this->settings->get_domain_key(); // Consent for platform calls is granted by connecting a domain key. if ( empty( $domain_key ) ) { return array(); } $response = wp_remote_get( $this->authed_api_url( '/api/wordpress/pending-actions', $domain_key ), array( 'timeout' => 8, 'sslverify' => true, 'headers' => array( 'Authorization' => 'Bearer ' . $domain_key ), 'user-agent' => 'AI-Advisors WordPress Plugin/' . AIADVISORS_VERSION, ) ); if ( is_wp_error( $response ) ) { // Cache an empty result briefly so the inbox page (and the badge) // don't make a fresh blocking request on every render while the // platform is unreachable. A manual Refresh forces a re-fetch. set_transient( $this->cache_key, array(), 2 * MINUTE_IN_SECONDS ); return array(); } $code = wp_remote_retrieve_response_code( $response ); if ( $code !== 200 ) { set_transient( $this->cache_key, array(), 2 * MINUTE_IN_SECONDS ); return array(); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $raw_actions = ( is_array( $data ) && isset( $data['actions'] ) && is_array( $data['actions'] ) ) ? $data['actions'] : array(); // Keep only well-formed items (array with a non-empty string id) that // have not already been handled locally. $handled_ids = $this->get_handled_action_ids(); $prefs = ! empty( $raw_actions ) ? $this->get_automation_prefs() : array(); $actions = array(); $action_cache = array(); foreach ( $raw_actions as $a ) { if ( ! is_array( $a ) || empty( $a['id'] ) || ! is_string( $a['id'] ) ) { continue; } if ( in_array( $a['id'], $handled_ids, true ) ) { continue; } // Respect a platform "do nothing" preference for this fix type. $a_type = ( isset( $a['type'] ) && is_string( $a['type'] ) ) ? $a['type'] : ''; if ( $a_type !== '' && isset( $prefs[ $a_type ] ) && $prefs[ $a_type ] === 'do_nothing' ) { continue; } $actions[] = $a; $action_cache[ $a['id'] ] = $a; } // Cache the full action data keyed by ID so Fixer can access payloads. set_transient( 'aiadvisors_action_cache', $action_cache, $this->cache_ttl ); set_transient( $this->cache_key, $actions, $this->cache_ttl ); return $actions; } /** * Fetch per-type recommendation preferences from the platform (cached). * * Returns a map of fix type => preference. The plugin never changes the site * on its own — every recommendation is shown in the inbox for you to review * and apply yourself. The only preference that has any effect here is * 'do_nothing', which hides that fix type from the inbox. A missing or * malformed response means "show everything". * * @return array */ public function get_automation_prefs() { $cached = get_transient( $this->prefs_cache_key ); if ( $cached !== false ) { return is_array( $cached ) ? $cached : array(); } $domain_key = $this->settings->get_domain_key(); if ( empty( $domain_key ) ) { return array(); } $response = wp_remote_get( $this->authed_api_url( '/api/wordpress/automation-prefs', $domain_key ), array( 'timeout' => 8, 'sslverify' => true, 'headers' => array( 'Authorization' => 'Bearer ' . $domain_key ), 'user-agent' => 'AI-Advisors WordPress Plugin/' . AIADVISORS_VERSION, ) ); if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) { // Cache an empty map briefly so a down endpoint can't block the inbox. set_transient( $this->prefs_cache_key, array(), 2 * MINUTE_IN_SECONDS ); return array(); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); // The API returns the map under "prefs"; some responses use "preferences". // Accept both, plus a bare map. if ( is_array( $data ) && isset( $data['prefs'] ) && is_array( $data['prefs'] ) ) { $prefs = $data['prefs']; } elseif ( is_array( $data ) && isset( $data['preferences'] ) && is_array( $data['preferences'] ) ) { $prefs = $data['preferences']; } elseif ( is_array( $data ) ) { $prefs = $data; } else { $prefs = array(); } $prefs = $this->normalize_pref_types( $prefs ); set_transient( $this->prefs_cache_key, $prefs, $this->cache_ttl ); return $prefs; } /** * Map the platform's preference type names onto the plugin's fix types. * * The API uses plural keys for the content types (heading_rewrites, * meta_descriptions, blog_drafts) while actions and the Fixer use singular * names. Non-string entries are dropped so a malformed value can never * match 'do_nothing'. * * @param array $prefs Raw type => preference map from the API. * @return array */ private function normalize_pref_types( $prefs ) { $aliases = array( 'heading_rewrites' => 'heading_rewrite', 'meta_descriptions' => 'meta_description', 'blog_drafts' => 'blog_draft', ); $normalized = array(); foreach ( $prefs as $type => $pref ) { if ( ! is_string( $type ) || ! is_string( $pref ) ) { continue; } $normalized[ $aliases[ $type ] ?? $type ] = $pref; } return $normalized; } /** * Get full cached action data by ID (includes payload). * * @param string $action_id Action UUID. * @return array|null Full action data or null. */ public function get_cached_action( $action_id ) { $cache = get_transient( 'aiadvisors_action_cache' ); if ( $cache && isset( $cache[ $action_id ] ) ) { return $cache[ $action_id ]; } return null; } /** * Get recently applied/dismissed actions from local table. * * @param int $limit Max items. * @return array Array of local action records. */ public function get_recent_history( $limit = 10 ) { global $wpdb; $table = esc_sql( $this->actions_table ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $results = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM `$table` WHERE status IN ('applied','dismissed') ORDER BY applied_at DESC LIMIT %d", $limit ), ARRAY_A ); return $results ?: array(); } /** * Get IDs of actions already handled locally (to filter from API results). * * @return array Array of action_id strings. */ private function get_handled_action_ids() { global $wpdb; $table = esc_sql( $this->actions_table ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $ids = $wpdb->get_col( "SELECT action_id FROM `$table` WHERE status IN ('applied','dismissed')" ); return $ids ?: array(); } /** * Apply an action: execute the fix on the site, report to platform, store locally. * * @param string $action_id Recommendation UUID from platform. * @param array $action Full action data (title, type, etc). * @return array Result with success boolean and message. */ public function apply_action( $action_id, $action = array() ) { // Idempotency guard: never apply the same recommendation twice — a // re-apply can overwrite an undo backup or duplicate content. if ( $this->is_already_applied( $action_id ) ) { return array( 'success' => false, 'message' => 'This recommendation has already been applied.', ); } // Merge with cached full action data (includes payload from API). $cached = $this->get_cached_action( $action_id ); if ( $cached ) { $action = array_merge( $cached, $action ); } // Attempt to apply the fix on the WordPress site. $type = $action['type'] ?? ''; $fixer = new Fixer(); $fix_result = $fixer->apply( $type, $action ); if ( empty( $fix_result['success'] ) ) { return $fix_result; // Nothing applied - nothing to record or report. } // Record locally BEFORE reporting. If the platform is unreachable, the // change is still on the site and the item is filtered from the inbox, // so it cannot be re-applied. Local state is authoritative. $this->store_local_action( $action_id, $action, 'applied' ); delete_transient( $this->cache_key ); // Best-effort report to the platform. $report = $this->report_to_platform( $action_id, 'applied' ); $result = array( 'success' => true, 'message' => $fix_result['message'], 'fix_message' => $fix_result['message'], 'synced' => ! empty( $report['success'] ), ); if ( empty( $report['success'] ) ) { $result['message'] .= ' (Applied locally; will sync to AI-Advisors shortly.)'; } if ( ! empty( $fix_result['edit_link'] ) ) { $result['edit_link'] = $fix_result['edit_link']; } if ( ! empty( $report['annotation_id'] ) ) { $result['annotation_id'] = $report['annotation_id']; } return $result; } /** * Dismiss an action: store locally + report to platform (best-effort). */ public function dismiss_action( $action_id, $action = array() ) { $this->store_local_action( $action_id, $action, 'dismissed' ); delete_transient( $this->cache_key ); $report = $this->report_to_platform( $action_id, 'dismissed' ); return array( 'success' => true, 'message' => 'Recommendation dismissed.', 'synced' => ! empty( $report['success'] ), ); } /** * Whether an action has already been applied locally (idempotency guard). * * @param string $action_id Action UUID. * @return bool */ private function is_already_applied( $action_id ) { global $wpdb; $table = esc_sql( $this->actions_table ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $status = $wpdb->get_var( $wpdb->prepare( "SELECT status FROM `$table` WHERE action_id = %s", $action_id ) ); return $status === 'applied'; } /** * Build a platform API URL from the configured endpoint. * * @param string $path Leading-slash API path. * @return string */ private function api_url( $path ) { $endpoint = rtrim( $this->settings->get( 'endpoint', AIADVISORS_API_ENDPOINT ), '/' ); $base_url = preg_replace( '#/api/collect$#', '', $endpoint ); return $base_url . $path; } /** * Build a platform GET URL carrying the domain key as a query parameter. * * The /api/wordpress/* endpoints authenticate via the domain_key query * parameter. Callers also send the key as a Bearer header for forward * compatibility. * * @param string $path Leading-slash API path. * @param string $domain_key Domain key. * @return string */ private function authed_api_url( $path, $domain_key ) { return add_query_arg( 'domain_key', rawurlencode( $domain_key ), $this->api_url( $path ) ); } /** * Undo a previously applied/dismissed action. */ public function undo_action( $action_id ) { // Get the local action record to know the type for undo. global $wpdb; $table = esc_sql( $this->actions_table ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $local = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `$table` WHERE action_id = %s", $action_id ), ARRAY_A ); // Reverse the fix on the WordPress site. if ( $local && ! empty( $local['type'] ) ) { $fixer = new Fixer(); $fixer->undo( $local['type'], json_decode( $local['payload'] ?? '{}', true ) ?: array() ); } // Best-effort report; remove the local record regardless so the undo is // reflected in the inbox even if the platform is unreachable (and so // local-only tools, which never sync, can still be undone). $report = $this->report_to_platform( $action_id, 'undone' ); $wpdb->delete( $this->actions_table, array( 'action_id' => $action_id ), array( '%s' ) ); delete_transient( $this->cache_key ); return array( 'success' => true, 'message' => 'Action undone.', 'synced' => ! empty( $report['success'] ), ); } /** * Apply a free, local-only fix generated from site data — no platform * account or domain key required. Used by the "Free tools" inbox card. * * @param string $tool One of: robots, llms, org_schema. * @return array Result with success boolean and message. */ public function apply_local_tool( $tool ) { $map = array( 'robots' => array( 'type' => 'robots_txt', 'title' => 'AI bot rules (robots.txt)', ), 'llms' => array( 'type' => 'llms_txt', 'title' => 'llms.txt file', ), 'org_schema' => array( 'type' => 'org_schema', 'title' => 'Organization schema', ), ); if ( ! isset( $map[ $tool ] ) ) { return array( 'success' => false, 'message' => 'Unknown tool.', ); } $type = $map[ $tool ]['type']; $action_id = 'local-' . $type; if ( $this->is_already_applied( $action_id ) ) { return array( 'success' => false, 'message' => 'This is already applied. Use Undo first to re-generate.', ); } // Empty payload → the Fixer generates content from local site data. $fixer = new Fixer(); $fix_result = $fixer->apply( $type, array() ); if ( empty( $fix_result['success'] ) ) { return $fix_result; } $this->store_local_action( $action_id, array( 'type' => $type, 'title' => $map[ $tool ]['title'], ), 'applied' ); delete_transient( $this->cache_key ); return array( 'success' => true, 'message' => $fix_result['message'], ); } /** * Report action completion to the platform API. * * @param string $action_id Action/recommendation UUID. * @param string $status applied|dismissed|undone * @return array Result with success and message. */ private function report_to_platform( $action_id, $status ) { // Local-only tools (robots/llms/org generated from site data) are never // issued by the platform, so there is nothing to report back. if ( strpos( (string) $action_id, 'local-' ) === 0 ) { return array( 'success' => true, 'message' => 'Local tool — no platform sync needed.', ); } $domain_key = $this->settings->get_domain_key(); if ( empty( $domain_key ) ) { return array( 'success' => false, 'message' => 'No domain key configured.', ); } $response = wp_remote_post( $this->api_url( '/api/wordpress/action-complete' ), array( 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $domain_key, ), 'body' => wp_json_encode( array( 'domain_key' => $domain_key, // Also in the Authorization header; kept here for compatibility. 'action_id' => $action_id, 'status' => $status, 'completed_at' => gmdate( 'c' ), ) ), 'timeout' => 10, 'sslverify' => true, 'user-agent' => 'AI-Advisors WordPress Plugin/' . AIADVISORS_VERSION, ) ); if ( is_wp_error( $response ) ) { return array( 'success' => false, 'message' => 'Network error: ' . $response->get_error_message(), ); } $code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $code === 200 && ! empty( $body['ok'] ) ) { return array( 'success' => true, 'message' => 'Action reported successfully.', 'annotation_id' => $body['annotation_id'] ?? null, ); } return array( 'success' => false, 'message' => $body['error'] ?? "Server responded with HTTP $code", ); } /** * Store an action in the local history table. */ private function store_local_action( $action_id, $action, $status ) { global $wpdb; $wpdb->replace( $this->actions_table, array( 'action_id' => $action_id, 'type' => $action['type'] ?? 'unknown', 'title' => $action['title'] ?? '', 'status' => $status, 'payload' => ! empty( $action ) ? wp_json_encode( $action ) : null, 'applied_at' => current_time( 'mysql', true ), 'created_at' => current_time( 'mysql', true ), ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); } /** * Get count of pending actions (for menu badge). */ public function get_pending_count() { $actions = $this->fetch_actions(); return count( $actions ); } /** * Pending count from cache only (no network call) — used for the menu * badge and admin notices so they never block admin page rendering. * * @return int */ public function get_cached_pending_count() { $cached = get_transient( $this->cache_key ); return is_array( $cached ) ? count( $cached ) : 0; } /** * Clear the inbox cache. */ public function clear_cache() { delete_transient( $this->cache_key ); } }