300, 2 => 900, 3 => 1800, 4 => 3600, 5 => 7200, 6 => 14400, 7 => 21600, 8 => 21600, ); /** * Maximum number of request records retained. */ const MAX_PENDING_RECORDS = 500; /** * Retention window for non-pending request records. */ const PENDING_RETENTION = 2592000; // 30 days. /** * Cooldown window for email invite duplicate protection. */ const EMAIL_INVITE_COOLDOWN = 60; /** * Allowed destination ports for outbound HTTP calls. */ const ALLOWED_REMOTE_PORTS = array( 80, 443 ); /** * Read history retention window from settings with safe bounds. * * @return int Seconds. */ private static function get_pending_retention_seconds() { $days = 30; if ( function_exists( 'carbon_get_theme_option' ) ) { $value = carbon_get_theme_option( 'aish_history_retention_days' ); if ( null !== $value && '' !== $value ) { $days = intval( $value ); } } $days = max( 1, min( 365, $days ) ); return $days * DAY_IN_SECONDS; } /** * Read history max records from settings with safe bounds. * * @return int */ private static function get_pending_max_records() { $max = self::MAX_PENDING_RECORDS; if ( function_exists( 'carbon_get_theme_option' ) ) { $value = carbon_get_theme_option( 'aish_history_max_records' ); if ( null !== $value && '' !== $value ) { $max = intval( $value ); } } return max( 50, min( 5000, $max ) ); } /** * Parse blocked domains from settings. * * @return array */ public static function get_blocked_domains() { if ( ! function_exists( 'carbon_get_theme_option' ) ) { return array(); } $raw = carbon_get_theme_option( 'aish_blocked_domains' ); if ( ! is_string( $raw ) || '' === trim( $raw ) ) { return array(); } $tokens = preg_split( '/[\r\n,]+/', $raw ); $domains = array(); foreach ( (array) $tokens as $token ) { $token = trim( (string) $token ); if ( '' === $token ) { continue; } $host = $token; if ( strpos( $token, '://' ) !== false ) { $parsed_host = wp_parse_url( $token, PHP_URL_HOST ); if ( is_string( $parsed_host ) && '' !== $parsed_host ) { $host = $parsed_host; } } $host = preg_replace( '/^www\./i', '', strtolower( $host ) ); $host = sanitize_text_field( (string) $host ); if ( '' !== $host ) { $domains[] = $host; } } $domains = array_values( array_unique( $domains ) ); sort( $domains ); return $domains; } /** * Check whether a URL/domain is in blacklist. * * @param string $url_or_domain URL or domain. * @return bool */ public static function is_domain_blacklisted( $url_or_domain ) { $blocked = self::get_blocked_domains(); if ( empty( $blocked ) ) { return false; } $candidate = self::normalize_url( (string) $url_or_domain ); if ( '' === $candidate ) { return false; } foreach ( $blocked as $domain ) { if ( $candidate === $domain ) { return true; } if ( substr( $candidate, -1 * ( strlen( $domain ) + 1 ) ) === '.' . $domain ) { return true; } } return false; } /** * Add a domain to blacklist. * * @param string $url_or_domain URL or domain. * @return bool */ public static function add_domain_to_blacklist( $url_or_domain ) { if ( ! function_exists( 'carbon_set_theme_option' ) ) { return false; } $domain = self::normalize_url( (string) $url_or_domain ); if ( '' === $domain ) { return false; } $domains = self::get_blocked_domains(); if ( ! in_array( $domain, $domains, true ) ) { $domains[] = $domain; sort( $domains ); carbon_set_theme_option( 'aish_blocked_domains', implode( "\n", $domains ) ); AISH_Logger::log( 'info', sprintf( 'Added domain to handshake blacklist: %s', $domain ) ); } return true; } /** * Retry callback sync for approved_pending_sync requests. * * @param int $limit Max rows to process per run. * @return int Number of attempted retries. */ public static function retry_pending_sync_requests( $limit = 10 ) { $pending = get_option( self::PENDING_OPTION, array() ); if ( ! is_array( $pending ) ) { return 0; } $partners = AISH_Partner_Store::get_all(); $max = max( 1, intval( $limit ) ); $attempted = 0; $updated = false; $targets = array(); foreach ( $pending as $idx => $request ) { if ( $attempted >= $max ) { break; } if ( ! is_array( $request ) ) { continue; } $status = isset( $request['status'] ) ? sanitize_key( (string) $request['status'] ) : ''; if ( 'approved_pending_sync' !== $status ) { continue; } $retries = isset( $request['sync_retry_count'] ) ? intval( $request['sync_retry_count'] ) : 0; if ( $retries >= self::SYNC_RETRY_LIMIT ) { $request['status'] = 'sync_exhausted'; if ( empty( $request['sync_last_error'] ) ) { $request['sync_last_error'] = 'retry_limit_exhausted'; } if ( empty( $request['sync_last_failed_at'] ) ) { $request['sync_last_failed_at'] = current_time( 'mysql' ); } $pending[ $idx ] = $request; $updated = true; continue; } $next_retry_ts = self::get_next_retry_timestamp( $request ); if ( $next_retry_ts > time() ) { continue; } $attempted++; $from_url = isset( $request['from_url'] ) ? esc_url_raw( (string) $request['from_url'] ) : ''; if ( '' === $from_url ) { self::set_last_approval_sync_error( 'missing_from_url' ); $pending[ $idx ] = self::apply_sync_result_to_pending_row( $request, false ); $updated = true; continue; } $request_domain = self::normalize_url( $from_url ); $my_relation_to_partner = 'collaboration'; $my_relation_note = ''; foreach ( $partners as $partner ) { if ( ! is_array( $partner ) || empty( $partner['url'] ) ) { continue; } if ( self::normalize_url( $partner['url'] ) !== $request_domain ) { continue; } $my_relation_to_partner = isset( $partner['my_relation_to_partner'] ) ? self::sanitize_relation_type( $partner['my_relation_to_partner'] ) : 'collaboration'; $my_relation_note = isset( $partner['relation_note'] ) ? sanitize_textarea_field( (string) $partner['relation_note'] ) : ''; break; } $sync_ok = self::notify_requester_approval( $request, $my_relation_to_partner, $my_relation_note ); $pending[ $idx ] = self::apply_sync_result_to_pending_row( $request, $sync_ok ); if ( $sync_ok ) { $pending[ $idx ]['status'] = 'approved'; } else { $retry_count_after = isset( $pending[ $idx ]['sync_retry_count'] ) ? intval( $pending[ $idx ]['sync_retry_count'] ) : 0; $pending[ $idx ]['status'] = $retry_count_after >= self::SYNC_RETRY_LIMIT ? 'sync_exhausted' : 'approved_pending_sync'; } $updated = true; $targets[] = $from_url; if ( $sync_ok ) { AISH_Logger::log( 'success', sprintf( 'Retry sync succeeded for %s', $from_url ) ); } else { AISH_Logger::log( 'warning', sprintf( 'Retry sync failed for %s', $from_url ) ); } } if ( $updated ) { update_option( self::PENDING_OPTION, $pending ); if ( ! empty( $targets ) ) { self::invalidate_state_caches( $targets ); } } return $attempted; } /** * Manually retry sync for a single pending row index. * * @param int $index Pending row index. * @param bool $force Whether to bypass backoff window and retry-limit precheck. * @return array{processed:bool,sync_ok:bool,row:array,error:string} */ public static function retry_sync_request_by_index( $index, $force = true ) { self::set_last_approval_sync_error( '' ); $index = intval( $index ); $pending = get_option( self::PENDING_OPTION, array() ); if ( ! isset( $pending[ $index ] ) || ! is_array( $pending[ $index ] ) ) { return array( 'processed' => false, 'sync_ok' => false, 'row' => array(), 'error' => 'request_not_found', ); } $request = $pending[ $index ]; $status = isset( $request['status'] ) ? sanitize_key( (string) $request['status'] ) : ''; if ( ! in_array( $status, array( 'approved_pending_sync', 'sync_exhausted' ), true ) ) { return array( 'processed' => false, 'sync_ok' => false, 'row' => $request, 'error' => 'invalid_status', ); } $retries = isset( $request['sync_retry_count'] ) ? intval( $request['sync_retry_count'] ) : 0; if ( ! $force && $retries >= self::SYNC_RETRY_LIMIT ) { $request['status'] = 'sync_exhausted'; if ( empty( $request['sync_last_error'] ) ) { $request['sync_last_error'] = 'retry_limit_exhausted'; } if ( empty( $request['sync_last_failed_at'] ) ) { $request['sync_last_failed_at'] = current_time( 'mysql' ); } $pending[ $index ] = $request; update_option( self::PENDING_OPTION, $pending ); return array( 'processed' => false, 'sync_ok' => false, 'row' => $request, 'error' => 'retry_limit_exhausted', ); } if ( ! $force ) { $next_retry_ts = self::get_next_retry_timestamp( $request ); if ( $next_retry_ts > time() ) { return array( 'processed' => false, 'sync_ok' => false, 'row' => $request, 'error' => 'backoff_not_elapsed', ); } } $from_url = isset( $request['from_url'] ) ? esc_url_raw( (string) $request['from_url'] ) : ''; if ( '' === $from_url ) { self::set_last_approval_sync_error( 'missing_from_url' ); $pending[ $index ] = self::apply_sync_result_to_pending_row( $request, false ); $pending[ $index ]['status'] = isset( $pending[ $index ]['sync_retry_count'] ) && intval( $pending[ $index ]['sync_retry_count'] ) >= self::SYNC_RETRY_LIMIT ? 'sync_exhausted' : 'approved_pending_sync'; update_option( self::PENDING_OPTION, $pending ); return array( 'processed' => true, 'sync_ok' => false, 'row' => $pending[ $index ], 'error' => self::get_last_approval_sync_error(), ); } $partners = AISH_Partner_Store::get_all(); $request_domain = self::normalize_url( $from_url ); $my_relation_to_partner = 'collaboration'; $my_relation_note = ''; foreach ( $partners as $partner ) { if ( ! is_array( $partner ) || empty( $partner['url'] ) ) { continue; } if ( self::normalize_url( $partner['url'] ) !== $request_domain ) { continue; } $my_relation_to_partner = isset( $partner['my_relation_to_partner'] ) ? self::sanitize_relation_type( $partner['my_relation_to_partner'] ) : 'collaboration'; $my_relation_note = isset( $partner['relation_note'] ) ? sanitize_textarea_field( (string) $partner['relation_note'] ) : ''; break; } $sync_ok = self::notify_requester_approval( $request, $my_relation_to_partner, $my_relation_note ); $pending[ $index ] = self::apply_sync_result_to_pending_row( $request, $sync_ok ); if ( $sync_ok ) { $pending[ $index ]['status'] = 'approved'; } else { $retry_count_after = isset( $pending[ $index ]['sync_retry_count'] ) ? intval( $pending[ $index ]['sync_retry_count'] ) : 0; $pending[ $index ]['status'] = $retry_count_after >= self::SYNC_RETRY_LIMIT ? 'sync_exhausted' : 'approved_pending_sync'; } update_option( self::PENDING_OPTION, $pending ); self::invalidate_state_caches( array( $from_url ) ); AISH_Logger::log( $sync_ok ? 'success' : 'warning', sprintf( $sync_ok ? 'Manual sync retry succeeded for %s' : 'Manual sync retry failed for %s', $from_url ) ); return array( 'processed' => true, 'sync_ok' => (bool) $sync_ok, 'row' => $pending[ $index ], 'error' => $sync_ok ? '' : self::get_last_approval_sync_error(), ); } /** * Reset sync diagnostics for a pending row and return it to pending-sync state. * * @param int $index Pending row index. * @return array{processed:bool,row:array,error:string} */ public static function reset_sync_request_by_index( $index ) { $index = intval( $index ); $pending = get_option( self::PENDING_OPTION, array() ); if ( ! isset( $pending[ $index ] ) || ! is_array( $pending[ $index ] ) ) { return array( 'processed' => false, 'row' => array(), 'error' => 'request_not_found', ); } $request = $pending[ $index ]; $status = isset( $request['status'] ) ? sanitize_key( (string) $request['status'] ) : ''; if ( ! in_array( $status, array( 'approved_pending_sync', 'sync_exhausted' ), true ) ) { return array( 'processed' => false, 'row' => $request, 'error' => 'invalid_status', ); } $request['status'] = 'approved_pending_sync'; $request['sync_retry_count'] = 0; $request['sync_last_error'] = ''; $request['sync_last_failed_at'] = ''; $request['sync_last_retry_at'] = ''; $request['sync_next_retry_at'] = ''; $request['sync_next_retry_ts'] = 0; $pending[ $index ] = $request; update_option( self::PENDING_OPTION, $pending ); $from_url = isset( $request['from_url'] ) ? esc_url_raw( (string) $request['from_url'] ) : ''; if ( '' !== $from_url ) { self::invalidate_state_caches( array( $from_url ) ); } AISH_Logger::log( 'info', sprintf( 'Manual sync state reset for %s', '' !== $from_url ? $from_url : '(unknown partner)' ) ); return array( 'processed' => true, 'row' => $request, 'error' => '', ); } /** * Remove a partner from the local partner network by URL/domain. * * @param string $partner_url Partner URL or domain. * @return bool True when a partner was removed. */ public static function remove_partner( $partner_url ) { $partner_domain = self::normalize_url( (string) $partner_url ); if ( '' === $partner_domain ) { return false; } $partners = AISH_Partner_Store::get_all(); if ( empty( $partners ) || ! is_array( $partners ) ) { return false; } $matched_url = ''; foreach ( $partners as $partner ) { if ( ! is_array( $partner ) || empty( $partner['url'] ) ) { continue; } if ( self::normalize_url( $partner['url'] ) === $partner_domain ) { $matched_url = (string) $partner['url']; break; } } if ( '' === $matched_url ) { return false; } if ( ! AISH_Partner_Store::remove_by_url( $matched_url ) ) { return false; } self::invalidate_state_caches( array( $matched_url ) ); AISH_Logger::log( 'info', sprintf( 'Removed partner from local network: %s', $matched_url ) ); return true; } /** * Cancel a local outbound pending request by URL/domain. * * @param string $partner_url Partner URL or domain. * @return bool True when the pending request was canceled locally. */ public static function cancel_outbound_request( $partner_url ) { $relationship_state = self::get_domain_relationship_state( $partner_url ); if ( 'outbound_pending' !== $relationship_state['state'] ) { return false; } $partner_domain = $relationship_state['domain']; if ( '' === $partner_domain ) { return false; } $partners = AISH_Partner_Store::get_all(); if ( empty( $partners ) || ! is_array( $partners ) ) { return false; } $matched_url = ''; foreach ( $partners as $partner ) { if ( ! is_array( $partner ) || empty( $partner['url'] ) ) { continue; } if ( self::normalize_url( $partner['url'] ) !== $partner_domain ) { continue; } $status = isset( $partner['status'] ) ? sanitize_key( (string) $partner['status'] ) : ''; $sync_state = isset( $partner['sync_state'] ) ? sanitize_key( (string) $partner['sync_state'] ) : ''; if ( 'pending' !== $status || 'pending_outbound' !== $sync_state ) { continue; } $matched_url = (string) $partner['url']; break; } if ( '' === $matched_url ) { return false; } if ( ! AISH_Partner_Store::remove_by_url( $matched_url ) ) { return false; } self::invalidate_state_caches( array( $matched_url ) ); AISH_Logger::log( 'info', sprintf( 'Canceled outbound pending request for %s', $matched_url ) ); return true; } /** * Resolve the current handshake relationship state for one normalized domain. * * @param string $url_or_domain Partner URL or domain. * @return array{state:string,domain:string,has_inbound_pending:bool,has_outbound_pending:bool,has_active_partner:bool,has_broken_partner:bool} */ public static function get_domain_relationship_state( $url_or_domain ) { $domain = self::normalize_url( (string) $url_or_domain ); $state = array( 'state' => 'none', 'domain' => $domain, 'has_inbound_pending' => false, 'has_outbound_pending' => false, 'has_active_partner' => false, 'has_broken_partner' => false, ); if ( '' === $domain ) { return $state; } $partners = AISH_Partner_Store::get_all(); if ( is_array( $partners ) ) { foreach ( $partners as $partner ) { if ( ! is_array( $partner ) || empty( $partner['url'] ) ) { continue; } if ( self::normalize_url( $partner['url'] ) !== $domain ) { continue; } $partner_status = isset( $partner['status'] ) ? sanitize_key( (string) $partner['status'] ) : ''; $sync_state = isset( $partner['sync_state'] ) ? sanitize_key( (string) $partner['sync_state'] ) : ''; if ( 'active' === $partner_status ) { $state['has_active_partner'] = true; continue; } if ( 'broken' === $partner_status ) { $state['has_broken_partner'] = true; continue; } if ( 'pending' === $partner_status || 'pending_outbound' === $sync_state ) { $state['has_outbound_pending'] = true; } } } $pending = get_option( self::PENDING_OPTION, array() ); if ( is_array( $pending ) ) { foreach ( $pending as $request ) { if ( ! is_array( $request ) ) { continue; } $request_status = isset( $request['status'] ) ? sanitize_key( (string) $request['status'] ) : ''; if ( 'pending' !== $request_status ) { continue; } $request_domain = ''; if ( ! empty( $request['normalized_url'] ) ) { $request_domain = sanitize_text_field( (string) $request['normalized_url'] ); } elseif ( ! empty( $request['from_url'] ) ) { $request_domain = self::normalize_url( $request['from_url'] ); } if ( $request_domain === $domain ) { $state['has_inbound_pending'] = true; break; } } } if ( $state['has_active_partner'] ) { $state['state'] = 'active'; } elseif ( $state['has_broken_partner'] ) { $state['state'] = 'broken'; } elseif ( $state['has_inbound_pending'] && $state['has_outbound_pending'] ) { $state['state'] = 'mutual_pending'; } elseif ( $state['has_inbound_pending'] ) { $state['state'] = 'inbound_pending'; } elseif ( $state['has_outbound_pending'] ) { $state['state'] = 'outbound_pending'; } return $state; } /** * Persist partners and verify required record exists after write. * * @param array $partners Partners payload to persist. * @param string $required_domain Required normalized domain. * @param string $required_status Required status for required domain. * @return bool */ private static function persist_partners( $partners, $required_domain = '', $required_status = '' ) { AISH_Partner_Store::set_all( $partners ); $saved = AISH_Partner_Store::get_all(); if ( ! is_array( $saved ) ) { return false; } $required_domain = sanitize_text_field( (string) $required_domain ); $required_status = sanitize_key( (string) $required_status ); if ( '' === $required_domain ) { return true; } foreach ( $saved as $row ) { if ( ! is_array( $row ) || empty( $row['url'] ) ) { continue; } if ( self::normalize_url( $row['url'] ) !== $required_domain ) { continue; } if ( '' !== $required_status && ( ! isset( $row['status'] ) || sanitize_key( (string) $row['status'] ) !== $required_status ) ) { continue; } return true; } return false; } /** * Constructor. */ public function __construct() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_filter( 'rest_post_dispatch', array( $this, 'add_no_cache_headers' ), 10, 3 ); } /** * Add no-cache headers for plugin REST responses. * * @param mixed $response Response object. * @param mixed $server REST server. * @param mixed $request Request object. * @return mixed */ public function add_no_cache_headers( $response, $server, $request ) { $route = ''; if ( is_object( $request ) && method_exists( $request, 'get_route' ) ) { $route = (string) $request->get_route(); } if ( 0 !== strpos( $route, '/aish/v1/' ) ) { return $response; } $headers = array( 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma' => 'no-cache', 'Expires' => 'Wed, 11 Jan 1984 05:00:00 GMT', ); if ( is_object( $response ) ) { if ( method_exists( $response, 'header' ) ) { foreach ( $headers as $key => $value ) { $response->header( $key, $value ); } } elseif ( method_exists( $response, 'set_headers' ) ) { $response->set_headers( $headers ); } } return $response; } /** * Register REST API routes. */ public function register_routes() { register_rest_route( 'aish/v1', '/request', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_request' ), 'permission_callback' => '__return_true', 'args' => array( 'from_url' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', 'validate_callback' => function( $value ) { return filter_var( $value, FILTER_VALIDATE_URL ) !== false; }, ), 'callback_url' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', 'default' => '', ), 'from_name' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ), 'from_public_id' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ), 'from_type' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'Organization', ), 'relation_to_target' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'collaboration', ), 'relation_note' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_textarea_field', 'default' => '', ), 'request_id' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'callback_token' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'intent_token' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'intent_ts' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'intent_sig' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( 'aish/v1', '/status', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_status' ), 'permission_callback' => '__return_true', ) ); register_rest_route( 'aish/v1', '/intent/verify', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_intent_verify' ), 'permission_callback' => '__return_true', 'args' => array( 'token' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'to_url' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', ), 'timestamp' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'signature' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( 'aish/v1', '/approve-callback', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_approve_callback' ), 'permission_callback' => '__return_true', 'args' => array( 'request_id' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'callback_token' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'approver_url' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', ), 'approver_name' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ), 'approver_public_id' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ), 'approver_type' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'Organization', ), 'approver_relation_to_requester' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'collaboration', ), 'approver_note' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_textarea_field', 'default' => '', ), 'requester_relation_to_approver' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'collaboration', ), 'requester_note' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_textarea_field', 'default' => '', ), ), ) ); } /** * Handle incoming handshake request. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response */ public function handle_request( $request ) { $from_url_raw = $request->get_param( 'from_url' ); $callback_url = esc_url_raw( (string) $request->get_param( 'callback_url' ) ); $from_name = sanitize_text_field( (string) $request->get_param( 'from_name' ) ); $from_public_id = sanitize_text_field( (string) $request->get_param( 'from_public_id' ) ); $from_type = sanitize_text_field( (string) $request->get_param( 'from_type' ) ); $relation_to_target = self::sanitize_relation_type( $request->get_param( 'relation_to_target' ) ); $relation_note = sanitize_textarea_field( (string) $request->get_param( 'relation_note' ) ); $request_id = sanitize_text_field( (string) $request->get_param( 'request_id' ) ); $callback_token = sanitize_text_field( (string) $request->get_param( 'callback_token' ) ); $intent_token = $request->get_param( 'intent_token' ); $intent_ts = intval( $request->get_param( 'intent_ts' ) ); $intent_sig = $request->get_param( 'intent_sig' ); if ( empty( $request_id ) || empty( $callback_token ) ) { return new \WP_REST_Response( array( 'success' => false, 'message' => __( 'Missing request metadata.', 'ai-semantic-handshake' ), ), 400 ); } if ( ! self::is_safe_public_url( $from_url_raw ) ) { return new \WP_REST_Response( array( 'success' => false, 'message' => __( 'Invalid source URL.', 'ai-semantic-handshake' ), ), 400 ); } $from_url = self::canonicalize_site_url( (string) $from_url_raw ); if ( '' === $from_url ) { return new \WP_REST_Response( array( 'success' => false, 'message' => __( 'Invalid source URL.', 'ai-semantic-handshake' ), ), 400 ); } if ( '' !== $callback_url && ! self::is_safe_public_url( $callback_url ) ) { return new \WP_REST_Response( array( 'success' => false, 'message' => __( 'Invalid callback URL.', 'ai-semantic-handshake' ), ), 400 ); } if ( self::is_domain_blacklisted( $from_url ) ) { AISH_Logger::log( 'warning', sprintf( 'Blocked inbound request from blacklisted domain: %s', $from_url ) ); return new \WP_REST_Response( array( 'success' => false, 'message' => __( 'Source domain is blocked.', 'ai-semantic-handshake' ), ), 403 ); } // Verify outbound intent with callback to the declared source site. if ( ! self::verify_remote_intent( $from_url, $intent_token, $intent_ts, $intent_sig ) ) { AISH_Logger::log( 'warning', sprintf( 'Rejected handshake request with invalid proof from %s', $from_url ) ); return new \WP_REST_Response( array( 'success' => false, 'message' => __( 'Handshake proof validation failed.', 'ai-semantic-handshake' ), ), 403 ); } // Normalize URL for rate limiting and dedup. $normalized_url = self::normalize_url( $from_url ); $now = time(); $pending = get_option( self::PENDING_OPTION, array() ); $pending = self::cleanup_pending_requests( $pending, $now ); // Rate limit check. $recent_count = 0; foreach ( $pending as $req ) { if ( isset( $req['normalized_url'], $req['status'] ) && $req['normalized_url'] === $normalized_url && 'pending' === $req['status'] ) { if ( isset( $req['timestamp'] ) && ( $now - intval( $req['timestamp'] ) ) < self::COOLDOWN ) { $recent_count++; } } } if ( $recent_count >= self::RATE_LIMIT ) { update_option( self::PENDING_OPTION, $pending ); return new \WP_REST_Response( array( 'success' => false, 'message' => __( 'Rate limit exceeded. Please try again later.', 'ai-semantic-handshake' ), ), 429 ); } $relationship_state = self::get_domain_relationship_state( $normalized_url ); if ( 'active' === $relationship_state['state'] ) { update_option( self::PENDING_OPTION, $pending ); return new \WP_REST_Response( array( 'success' => true, 'code' => 'already_partner', 'message' => __( 'Partnership already exists.', 'ai-semantic-handshake' ), ), 200 ); } // Check for duplicate pending request. foreach ( $pending as $req ) { if ( isset( $req['normalized_url'] ) && $req['normalized_url'] === $normalized_url && 'pending' === $req['status'] ) { update_option( self::PENDING_OPTION, $pending ); return new \WP_REST_Response( array( 'success' => true, 'code' => 'already_pending', 'message' => __( 'Request already pending review.', 'ai-semantic-handshake' ), ), 200 ); } } // Score the requesting site. $score_result = AISH_Scorer::score_url( $from_url ); // Add to pending requests. $pending[] = array( 'from_url' => $from_url, 'normalized_url' => $normalized_url, 'from_name' => $from_name, 'from_public_id' => $from_public_id, 'from_type' => in_array( $from_type, array( 'Organization', 'Person' ), true ) ? $from_type : 'Organization', 'relation_to_target' => $relation_to_target, 'relation_note' => substr( $relation_note, 0, 240 ), 'request_id' => $request_id, 'callback_token' => $callback_token, 'callback_url' => $callback_url, 'status' => 'pending', 'timestamp' => $now, 'received_at' => current_time( 'mysql' ), 'score' => $score_result['total'], 'grade' => $score_result['grade'], ); $max_records = self::get_pending_max_records(); if ( count( $pending ) > $max_records ) { $pending = array_slice( $pending, -1 * $max_records ); } update_option( self::PENDING_OPTION, $pending ); AISH_Logger::log( 'info', sprintf( 'Received handshake request from %1$s (Score: %2$d)', $from_url, $score_result['total'] ) ); return new \WP_REST_Response( array( 'success' => true, 'code' => 'queued', 'message' => __( 'Handshake request received and pending review.', 'ai-semantic-handshake' ), ), 201 ); } /** * Handle status check - returns a site identity card. * * @return WP_REST_Response */ public function handle_status() { $business_name = carbon_get_theme_option( 'aish_my_business_name' ); $industry = carbon_get_theme_option( 'aish_my_industry' ); $schema_type = carbon_get_theme_option( 'aish_schema_type' ); // Get cached self-score. $cached_score = get_transient( 'aish_self_score' ); $score = $cached_score ? $cached_score['total'] : null; $grade = $cached_score ? $cached_score['grade'] : null; // Get site icon. $site_icon_id = get_option( 'site_icon' ); $site_icon_url = $site_icon_id ? wp_get_attachment_image_url( $site_icon_id, 'full' ) : ''; return new \WP_REST_Response( array( 'plugin' => 'ai-semantic-handshake', 'version' => AISH_VERSION, 'name' => $business_name ? $business_name : get_bloginfo( 'name' ), 'url' => get_site_url(), 'description' => get_bloginfo( 'description' ), 'industry' => $industry ? $industry : '', 'type' => $schema_type ? $schema_type : 'Organization', 'logo' => $site_icon_url, 'score' => $score, 'grade' => $grade, ), 200 ); } /** * REST callback for remote intent verification. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function handle_intent_verify( $request ) { $token = sanitize_text_field( (string) $request->get_param( 'token' ) ); $to_url = esc_url_raw( (string) $request->get_param( 'to_url' ) ); $timestamp = intval( $request->get_param( 'timestamp' ) ); $signature = sanitize_text_field( (string) $request->get_param( 'signature' ) ); if ( empty( $token ) || empty( $to_url ) || $timestamp <= 0 || empty( $signature ) ) { return new \WP_REST_Response( array( 'verified' => false ), 400 ); } $intents = get_option( self::OUTBOUND_INTENTS_OPTION, array() ); $intents = self::cleanup_outbound_intents( $intents ); if ( empty( $intents[ $token ] ) ) { update_option( self::OUTBOUND_INTENTS_OPTION, $intents, false ); return new \WP_REST_Response( array( 'verified' => false ), 403 ); } $intent = $intents[ $token ]; if ( ! empty( $intent['used'] ) ) { unset( $intents[ $token ] ); update_option( self::OUTBOUND_INTENTS_OPTION, $intents, false ); return new \WP_REST_Response( array( 'verified' => false ), 403 ); } $to_domain = self::normalize_url( $to_url ); $from_domain = self::normalize_url( get_site_url() ); if ( empty( $intent['to_domain'] ) || $intent['to_domain'] !== $to_domain ) { return new \WP_REST_Response( array( 'verified' => false ), 403 ); } if ( abs( time() - intval( $intent['timestamp'] ) ) > self::INTENT_TTL ) { unset( $intents[ $token ] ); update_option( self::OUTBOUND_INTENTS_OPTION, $intents, false ); return new \WP_REST_Response( array( 'verified' => false ), 403 ); } if ( intval( $intent['timestamp'] ) !== $timestamp ) { return new \WP_REST_Response( array( 'verified' => false ), 403 ); } $canonical = self::build_intent_payload( $from_domain, $to_domain, $timestamp, $token ); $expected = hash_hmac( 'sha256', $canonical, get_option( 'aish_site_secret', '' ) ); if ( ! hash_equals( $expected, $signature ) ) { return new \WP_REST_Response( array( 'verified' => false ), 403 ); } unset( $intents[ $token ] ); update_option( self::OUTBOUND_INTENTS_OPTION, $intents, false ); return new \WP_REST_Response( array( 'verified' => true ), 200 ); } /** * Handle approval callback from the approver site. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function handle_approve_callback( $request ) { $request_id = sanitize_text_field( (string) $request->get_param( 'request_id' ) ); $callback_token = sanitize_text_field( (string) $request->get_param( 'callback_token' ) ); $approver_url = esc_url_raw( (string) $request->get_param( 'approver_url' ) ); $approver_name = sanitize_text_field( (string) $request->get_param( 'approver_name' ) ); $approver_id = sanitize_text_field( (string) $request->get_param( 'approver_public_id' ) ); $approver_type = sanitize_text_field( (string) $request->get_param( 'approver_type' ) ); $approver_rel = self::sanitize_relation_type( $request->get_param( 'approver_relation_to_requester' ) ); $approver_note = substr( sanitize_textarea_field( (string) $request->get_param( 'approver_note' ) ), 0, 240 ); $requester_rel = self::sanitize_relation_type( $request->get_param( 'requester_relation_to_approver' ) ); $requester_note = substr( sanitize_textarea_field( (string) $request->get_param( 'requester_note' ) ), 0, 240 ); if ( empty( $request_id ) || empty( $callback_token ) || ! self::is_safe_public_url( $approver_url ) ) { return new \WP_REST_Response( array( 'success' => false, 'message' => __( 'Invalid approval callback payload.', 'ai-semantic-handshake' ), ), 400 ); } $ok = self::apply_approved_callback( array( 'request_id' => $request_id, 'callback_token' => $callback_token, 'approver_url' => $approver_url, 'approver_name' => $approver_name, 'approver_public_id' => $approver_id, 'approver_type' => in_array( $approver_type, array( 'Organization', 'Person' ), true ) ? $approver_type : 'Organization', 'approver_relation_to_requester' => $approver_rel, 'approver_note' => $approver_note, 'requester_relation_to_approver' => $requester_rel, 'requester_note' => $requester_note, ) ); if ( ! $ok ) { $reason = self::get_last_approve_callback_error(); if ( ! empty( $reason ) ) { AISH_Logger::log( 'warning', sprintf( 'Approve callback apply failed for request %1$s: %2$s', $request_id, $reason ) ); } $response_data = array( 'success' => false, 'message' => __( 'No matching outbound request found.', 'ai-semantic-handshake' ), ); if ( self::should_expose_debug_reason() ) { $response_data['reason'] = $reason; } return new \WP_REST_Response( $response_data, 404 ); } return new \WP_REST_Response( array( 'success' => true ), 200 ); } /** * Apply approval callback to local outbound pending partner. * * @param array $payload Callback payload. * @return bool */ private static function apply_approved_callback( $payload ) { self::set_last_approve_callback_error( '' ); $partners = AISH_Partner_Store::get_all(); if ( ! is_array( $partners ) ) { self::set_last_approve_callback_error( 'partners_not_array' ); return false; } $approver_domain = self::normalize_url( $payload['approver_url'] ); $request_id = sanitize_text_field( (string) $payload['request_id'] ); $callback_token = sanitize_text_field( (string) $payload['callback_token'] ); $token_tail = substr( $callback_token, -8 ); $stats = array( 'rows' => 0, 'domain_matches' => 0, 'request_matches'=> 0, 'token_matches' => 0, 'id_token' => 0, ); $strict_index = null; $fallback_index = null; foreach ( $partners as $idx => $partner ) { if ( ! is_array( $partner ) ) { continue; } $stats['rows']++; $partner_domain = ! empty( $partner['url'] ) ? self::normalize_url( $partner['url'] ) : ''; $same_domain = '' !== $partner_domain && $partner_domain === $approver_domain; $same_req = isset( $partner['request_id'] ) && sanitize_text_field( (string) $partner['request_id'] ) === $request_id; $same_token = isset( $partner['callback_token'] ) && sanitize_text_field( (string) $partner['callback_token'] ) === $callback_token; if ( $same_domain ) { $stats['domain_matches']++; } if ( $same_req ) { $stats['request_matches']++; } if ( $same_token ) { $stats['token_matches']++; } if ( $same_req && $same_token ) { $stats['id_token']++; if ( null === $fallback_index ) { $fallback_index = $idx; } } if ( $same_domain && $same_req && $same_token ) { $strict_index = $idx; break; } } $target_index = null !== $strict_index ? $strict_index : $fallback_index; if ( null === $target_index ) { self::set_last_approve_callback_error( sprintf( 'match_not_found:domain=%s,request_id=%s,token_tail=%s,counts(rows=%d,domain=%d,request=%d,token=%d,id_token=%d)', $approver_domain, $request_id, $token_tail, intval( $stats['rows'] ), intval( $stats['domain_matches'] ), intval( $stats['request_matches'] ), intval( $stats['token_matches'] ), intval( $stats['id_token'] ) ) ); return false; } $partner = $partners[ $target_index ]; $partner_domain = ! empty( $partner['url'] ) ? self::normalize_url( $partner['url'] ) : $approver_domain; $used_fallback = null === $strict_index && null !== $fallback_index; if ( $used_fallback ) { AISH_Logger::log( 'warning', sprintf( 'Approval callback domain drift accepted: callback=%1$s, local=%2$s, request_id=%3$s', $approver_domain, $partner_domain, $request_id ) ); } $partners[ $target_index ] = array_merge( $partner, array( 'status' => 'active', 'partner_name' => $payload['approver_name'], 'partner_public_id' => $payload['approver_public_id'], 'partner_type' => $payload['approver_type'], 'my_relation_to_partner' => $payload['requester_relation_to_approver'], 'partner_relation_to_me' => $payload['approver_relation_to_requester'], 'relation_note' => $payload['requester_note'], 'partner_claim' => $payload['approver_note'], 'sync_state' => 'active_synced', 'sync_retry_count' => 0, 'sync_last_failed_at' => '', 'sync_last_error' => '', ) ); if ( ! self::persist_partners( $partners, $partner_domain, 'active' ) ) { self::set_last_approve_callback_error( sprintf( 'local_persist_failed:domain=%s,request_id=%s', $partner_domain, $request_id ) ); return false; } AISH_Logger::log( 'info', sprintf( 'Approval callback applied for request %1$s on %2$s', $request_id, $partner_domain ) ); self::invalidate_state_caches( array( $payload['approver_url'] ) ); return true; } /** * Build a short reason message from callback response body. * * @param string $raw_body Raw response body. * @return string */ private static function extract_callback_response_reason( $raw_body ) { $raw_body = (string) $raw_body; if ( '' === trim( $raw_body ) ) { return ''; } $decoded = json_decode( $raw_body, true ); if ( is_array( $decoded ) ) { if ( isset( $decoded['reason'] ) ) { return sanitize_text_field( (string) $decoded['reason'] ); } if ( isset( $decoded['message'] ) ) { return sanitize_text_field( (string) $decoded['message'] ); } } return substr( sanitize_text_field( $raw_body ), 0, 180 ); } /** * Canonicalize a site URL for storage/display. * Keeps scheme/host/port/path, strips query/hash, trims trailing slash. * * @param string $url Raw URL. * @return string */ public static function canonicalize_site_url( $url ) { $url = esc_url_raw( (string) $url ); if ( '' === $url ) { return ''; } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return ''; } $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : 'https'; $host = strtolower( (string) $parts['host'] ); $port = isset( $parts['port'] ) ? intval( $parts['port'] ) : 0; $path = isset( $parts['path'] ) ? (string) $parts['path'] : ''; if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) { return ''; } $path = preg_replace( '#/+#', '/', $path ); $path = rtrim( (string) $path, '/' ); $canonical = $scheme . '://' . $host; if ( $port > 0 ) { $canonical .= ':' . $port; } if ( '' !== $path ) { $canonical .= $path; } return $canonical; } /** * Normalize a URL for comparison. * Extracts domain and removes www. prefix. * * @param string $url The URL to normalize. * @return string Normalized domain string. */ public static function normalize_url( $url ) { $parsed = wp_parse_url( $url ); $host = isset( $parsed['host'] ) ? strtolower( $parsed['host'] ) : strtolower( $url ); // Remove www. prefix. $host = preg_replace( '/^www\./', '', $host ); return $host; } /** * List of supported relationship types. * * @return array */ public static function get_relation_types() { return array( array( 'value' => 'collaboration', 'label' => __( 'Collaboration', 'ai-semantic-handshake' ), ), array( 'value' => 'client', 'label' => __( 'Client', 'ai-semantic-handshake' ), ), array( 'value' => 'vendor', 'label' => __( 'Vendor', 'ai-semantic-handshake' ), ), array( 'value' => 'sister_site', 'label' => __( 'Sister Site', 'ai-semantic-handshake' ), ), array( 'value' => 'media_mention', 'label' => __( 'Media Mention', 'ai-semantic-handshake' ), ), array( 'value' => 'friendly_recommendation', 'label' => __( 'Friendly Recommendation', 'ai-semantic-handshake' ), ), array( 'value' => 'strategic_partner', 'label' => __( 'Strategic Partner', 'ai-semantic-handshake' ), ), array( 'value' => 'community_peer', 'label' => __( 'Community Peer', 'ai-semantic-handshake' ), ), ); } /** * Get the protocol support target URL. * * @return string */ public static function get_protocol_support_target_url() { return 'https://aeosub.com'; } /** * Get the protocol support target display name. * * @return string */ public static function get_protocol_support_target_name() { return 'aeosub.com'; } /** * List of supported relationship type values. * * @return array */ private static function get_relation_type_values() { $values = array(); foreach ( self::get_relation_types() as $type ) { if ( isset( $type['value'] ) && is_string( $type['value'] ) ) { $values[] = $type['value']; } } return $values; } /** * Sanitize a relationship type. * * @param string $value Raw value. * @return string */ public static function sanitize_relation_type( $value ) { $value = sanitize_key( (string) $value ); return in_array( $value, self::get_relation_type_values(), true ) ? $value : 'collaboration'; } /** * Public URL safety check to avoid obvious local/private destinations. * * @param string $url URL to validate. * @return bool */ public static function is_safe_public_url( $url ) { if ( ! wp_http_validate_url( $url ) ) { return false; } $parts = wp_parse_url( $url ); $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : ''; $host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : ''; $port = isset( $parts['port'] ) ? intval( $parts['port'] ) : 0; if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || empty( $host ) ) { return false; } if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return false; } if ( $port > 0 && ! in_array( $port, self::ALLOWED_REMOTE_PORTS, true ) ) { return false; } if ( 'localhost' === $host || '127.0.0.1' === $host || '::1' === $host ) { return false; } if ( preg_match( '/(^|\.)localhost$/', $host ) ) { return false; } if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return self::is_public_ip( $host ); } // Best-effort DNS validation to avoid private/reserved network destinations. if ( ! self::should_skip_host_resolution_check() ) { $resolved_ips = self::resolve_host_ips( $host ); if ( ! empty( $resolved_ips ) ) { foreach ( $resolved_ips as $ip ) { if ( ! self::is_public_ip( $ip ) ) { return false; } } } } return true; } /** * Build secure defaults for outbound HTTP requests. * * @param int $timeout Request timeout. * @param bool $sslverify Whether SSL verification is enabled. * @param array $extra Extra request args. * @return array */ public static function build_remote_request_args( $timeout = 10, $sslverify = true, $extra = array() ) { $defaults = array( 'timeout' => max( 1, intval( $timeout ) ), 'sslverify' => (bool) $sslverify, 'redirection' => 0, 'reject_unsafe_urls' => true, 'headers' => array( 'User-Agent' => 'AI-Semantic-Handshake/' . AISH_VERSION . ' (WordPress; ' . get_site_url() . ')', ), ); return array_replace_recursive( $defaults, is_array( $extra ) ? $extra : array() ); } /** * Invalidate plugin runtime caches and bridge to common cache plugins. * * @param array $urls Related URLs. * @return void */ public static function invalidate_state_caches( $urls = array() ) { $targets = array(); foreach ( (array) $urls as $url ) { if ( ! is_string( $url ) ) { continue; } $url = trim( $url ); if ( '' === $url ) { continue; } $targets[] = $url; } $targets = array_values( array_unique( $targets ) ); if ( class_exists( 'AISH_Verifier' ) ) { foreach ( $targets as $url ) { AISH_Verifier::clear_cache( $url ); } } if ( class_exists( 'AISH_Scorer' ) ) { foreach ( $targets as $url ) { AISH_Scorer::clear_score_cache( $url ); } AISH_Scorer::clear_score_cache( get_site_url() ); } delete_transient( 'aish_self_score' ); if ( function_exists( 'rocket_clean_domain' ) ) { rocket_clean_domain(); } if ( function_exists( 'wp_cache_clear_cache' ) ) { wp_cache_clear_cache(); } if ( function_exists( 'do_action' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party cache plugin hooks. do_action( 'w3tc_flush_all' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party cache plugin hooks. do_action( 'wpfc_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party cache plugin hooks. do_action( 'litespeed_purge_all' ); do_action( 'aish_cache_invalidated', $targets ); } } /** * Determine whether host resolution checks should be skipped. * * @return bool */ private static function should_skip_host_resolution_check() { return defined( 'AISH_SKIP_HOST_RESOLUTION_CHECK' ) && AISH_SKIP_HOST_RESOLUTION_CHECK; } /** * Resolve A/AAAA records for a hostname. * * @param string $host Hostname. * @return array */ private static function resolve_host_ips( $host ) { $ips = array(); if ( function_exists( 'gethostbynamel' ) ) { $ipv4 = gethostbynamel( $host ); if ( is_array( $ipv4 ) ) { $ips = array_merge( $ips, $ipv4 ); } } if ( function_exists( 'dns_get_record' ) && defined( 'DNS_AAAA' ) ) { $records = @dns_get_record( $host, DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( is_array( $records ) ) { foreach ( $records as $record ) { if ( isset( $record['ipv6'] ) ) { $ips[] = $record['ipv6']; } } } } return array_values( array_unique( array_filter( $ips, 'is_string' ) ) ); } /** * Check whether an IP is publicly routable. * * @param string $ip IP address. * @return bool */ private static function is_public_ip( $ip ) { if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return false; } return false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } /** * Generate a one-time outbound intent payload. * * @param string $target_url Target site URL. * @return array */ public static function create_outbound_intent( $target_url ) { $target_url = self::canonicalize_site_url( $target_url ); if ( '' === $target_url ) { return array(); } $token = wp_generate_password( 24, false, false ); $timestamp = time(); $from_domain = self::normalize_url( get_site_url() ); $target_domain = self::normalize_url( $target_url ); $canonical = self::build_intent_payload( $from_domain, $target_domain, $timestamp, $token ); $site_secret = get_option( 'aish_site_secret', '' ); $signature = hash_hmac( 'sha256', $canonical, $site_secret ); $intents = get_option( self::OUTBOUND_INTENTS_OPTION, array() ); $intents = self::cleanup_outbound_intents( $intents ); $intents[ $token ] = array( 'to_domain' => $target_domain, 'timestamp' => $timestamp, 'used' => false, ); update_option( self::OUTBOUND_INTENTS_OPTION, $intents, false ); return array( 'intent_token' => $token, 'intent_ts' => $timestamp, 'intent_sig' => $signature, ); } /** * Verify remote intent by calling source site's intent endpoint. * * @param string $from_url Source site URL. * @param string $token Intent token. * @param int $ts Intent timestamp. * @param string $sig Intent signature. * @return bool */ private static function verify_remote_intent( $from_url, $token, $ts, $sig ) { $token = sanitize_text_field( (string) $token ); $sig = sanitize_text_field( (string) $sig ); if ( empty( $token ) || empty( $sig ) || $ts <= 0 ) { return false; } if ( abs( time() - $ts ) > self::INTENT_TTL ) { return false; } $verify_url = trailingslashit( $from_url ) . 'wp-json/aish/v1/intent/verify'; if ( ! self::is_safe_public_url( $verify_url ) ) { return false; } $response = wp_remote_post( $verify_url, self::build_remote_request_args( 10, true, array( 'body' => array( 'token' => $token, 'to_url' => get_site_url(), 'timestamp' => $ts, 'signature' => $sig, ), ) ) ); if ( is_wp_error( $response ) ) { return false; } if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); return is_array( $body ) && ! empty( $body['verified'] ); } /** * Build canonical payload used for intent signatures. * * @param string $from_domain Source domain. * @param string $to_domain Target domain. * @param int $timestamp Unix timestamp. * @param string $token Intent token. * @return string */ private static function build_intent_payload( $from_domain, $to_domain, $timestamp, $token ) { return implode( '|', array( strtolower( trim( $from_domain ) ), strtolower( trim( $to_domain ) ), intval( $timestamp ), sanitize_text_field( (string) $token ), ) ); } /** * Generate a handshake proof for a partner. * * @param string $partner_url The partner's URL. * @return string The SHA-256 hash proof. */ public static function generate_proof( $partner_url ) { $site_secret = get_option( 'aish_site_secret', '' ); $my_domain = self::normalize_url( get_site_url() ); $their_domain = self::normalize_url( $partner_url ); return hash( 'sha256', $my_domain . '|' . $their_domain . '|' . $site_secret ); } /** * Get pending requests. * * @return array */ public static function get_pending_requests() { $pending = get_option( self::PENDING_OPTION, array() ); $pending = self::cleanup_pending_requests( $pending, time() ); update_option( self::PENDING_OPTION, $pending ); // Filter to only pending status. return array_filter( $pending, function( $req ) { return isset( $req['status'] ) && 'pending' === $req['status']; } ); } /** * Approve a pending request by index. * * @param int $index The index in the pending array. * @return bool True on success. */ public static function approve_request( $index, $context = array() ) { self::set_last_approve_request_error( '' ); self::set_last_approval_sync_error( '' ); $pending = get_option( self::PENDING_OPTION, array() ); if ( ! isset( $pending[ $index ] ) ) { self::set_last_approve_request_error( 'request_not_found' ); return false; } $request = $pending[ $index ]; if ( ! isset( $request['status'] ) || 'pending' !== $request['status'] ) { self::set_last_approve_request_error( 'not_pending' ); return false; } $my_relation_to_partner = isset( $context['my_relation_to_partner'] ) ? self::sanitize_relation_type( $context['my_relation_to_partner'] ) : 'collaboration'; $my_relation_note = isset( $context['my_relation_note'] ) ? sanitize_textarea_field( (string) $context['my_relation_note'] ) : ''; // Add to partners. $partners = AISH_Partner_Store::get_all(); $request_domain = self::normalize_url( $request['from_url'] ); $existing_index = null; foreach ( $partners as $idx => $partner ) { if ( ! empty( $partner['url'] ) && self::normalize_url( $partner['url'] ) === $request_domain ) { $existing_index = $idx; break; } } $partner_data = array( 'url' => $request['from_url'], 'status' => 'active', 'last_verified' => '', 'error_detail' => '', 'partner_name' => isset( $request['from_name'] ) ? sanitize_text_field( (string) $request['from_name'] ) : '', 'partner_public_id' => isset( $request['from_public_id'] ) ? sanitize_text_field( (string) $request['from_public_id'] ) : '', 'partner_type' => isset( $request['from_type'] ) ? sanitize_text_field( (string) $request['from_type'] ) : 'Organization', 'my_relation_to_partner' => $my_relation_to_partner, 'partner_relation_to_me' => isset( $request['relation_to_target'] ) ? self::sanitize_relation_type( $request['relation_to_target'] ) : 'collaboration', 'relation_note' => substr( $my_relation_note, 0, 240 ), 'partner_claim' => isset( $request['relation_note'] ) ? substr( sanitize_textarea_field( (string) $request['relation_note'] ), 0, 240 ) : '', ); if ( null === $existing_index ) { $partners[] = $partner_data; } else { $partners[ $existing_index ] = array_merge( $partners[ $existing_index ], $partner_data ); } if ( ! self::persist_partners( $partners, $request_domain, 'active' ) ) { self::set_last_approve_request_error( 'local_persist_failed' ); AISH_Logger::log( 'error', sprintf( 'Approval aborted: local partner persistence failed for %s', $request['from_url'] ) ); return false; } $sync_ok = self::notify_requester_approval( $request, $my_relation_to_partner, $my_relation_note ); $pending[ $index ]['status'] = $sync_ok ? 'approved' : 'approved_pending_sync'; $pending[ $index ] = self::apply_sync_result_to_pending_row( $pending[ $index ], $sync_ok ); update_option( self::PENDING_OPTION, $pending ); self::invalidate_state_caches( array( $request['from_url'] ) ); AISH_Logger::log( 'success', sprintf( 'Approved handshake request from %s', $request['from_url'] ) ); if ( ! $sync_ok ) { AISH_Logger::log( 'warning', sprintf( 'Approved request from %s but sync callback failed.', $request['from_url'] ) ); } return true; } /** * Upsert local outbound pending partner entry before remote approval callback. * * @param array $payload Outbound handshake payload. * @return bool True if local persistence succeeded. */ public static function upsert_outbound_pending_partner( $payload ) { $target_url = isset( $payload['target_url'] ) ? self::canonicalize_site_url( (string) $payload['target_url'] ) : ''; if ( empty( $target_url ) ) { return false; } $partners = AISH_Partner_Store::get_all(); $target_domain = self::normalize_url( $target_url ); $relation = isset( $payload['relation_to_target'] ) ? self::sanitize_relation_type( $payload['relation_to_target'] ) : 'collaboration'; $note = isset( $payload['relation_note'] ) ? substr( sanitize_textarea_field( (string) $payload['relation_note'] ), 0, 240 ) : ''; $entry = array( 'url' => $target_url, 'status' => 'pending', 'last_verified' => '', 'error_detail' => '', 'my_relation_to_partner' => $relation, 'partner_relation_to_me' => 'collaboration', 'relation_note' => $note, 'partner_claim' => '', 'request_id' => isset( $payload['request_id'] ) ? sanitize_text_field( (string) $payload['request_id'] ) : '', 'callback_token' => isset( $payload['callback_token'] ) ? sanitize_text_field( (string) $payload['callback_token'] ) : '', 'sync_state' => 'pending_outbound', 'sync_retry_count' => 0, 'sync_last_failed_at' => '', 'sync_last_error' => '', ); $updated = false; foreach ( $partners as $idx => $partner ) { if ( ! empty( $partner['url'] ) && self::normalize_url( $partner['url'] ) === $target_domain ) { $existing_status = isset( $partner['status'] ) ? sanitize_key( (string) $partner['status'] ) : ''; if ( 'active' === $existing_status ) { // Avoid active->pending regression on resend/duplicate-accept flows. foreach ( array( 'status', 'last_verified', 'error_detail', 'sync_state', 'sync_retry_count', 'sync_last_failed_at', 'sync_last_error', 'sync_last_retry_at', 'sync_next_retry_at', 'sync_next_retry_ts', 'request_id', 'callback_token', 'callback_url', ) as $preserve_key ) { if ( array_key_exists( $preserve_key, $partner ) ) { $entry[ $preserve_key ] = $partner[ $preserve_key ]; } } } $partners[ $idx ] = array_merge( $partner, $entry ); $updated = true; break; } } if ( ! $updated ) { $partners[] = $entry; } $required_status = 'pending'; foreach ( $partners as $partner_row ) { if ( ! is_array( $partner_row ) || empty( $partner_row['url'] ) ) { continue; } if ( self::normalize_url( $partner_row['url'] ) !== $target_domain ) { continue; } $required_status = isset( $partner_row['status'] ) ? sanitize_key( (string) $partner_row['status'] ) : 'pending'; if ( '' === $required_status ) { $required_status = 'pending'; } break; } $persisted = self::persist_partners( $partners, $target_domain, $required_status ); if ( $persisted ) { self::invalidate_state_caches( array( $target_url ) ); return true; } AISH_Logger::log( 'error', sprintf( 'Outbound request accepted, but local partner persistence failed for %s', $target_url ) ); return false; } /** * Notify requester site after local approval to complete remote sync. * * @param array $request Pending request row. * @param string $my_relation_to_partner Approver relation to requester. * @param string $my_relation_note Approver note. * @return bool */ private static function notify_requester_approval( $request, $my_relation_to_partner, $my_relation_note ) { self::set_last_approval_sync_error( '' ); if ( empty( $request['request_id'] ) || empty( $request['callback_token'] ) ) { // Legacy records created before callback sync support. AISH_Logger::log( 'warning', 'Approval callback skipped: missing request_id or callback_token.' ); return true; } if ( empty( $request['from_url'] ) ) { self::set_last_approval_sync_error( 'missing_from_url' ); return false; } $callback_url_source = 'derived'; $callback_url = ''; if ( ! empty( $request['callback_url'] ) ) { $candidate = esc_url_raw( (string) $request['callback_url'] ); if ( self::is_safe_public_url( $candidate ) ) { $callback_url = $candidate; $callback_url_source = 'provided'; } else { AISH_Logger::log( 'warning', sprintf( 'Approval callback URL in pending payload is unsafe and ignored: %s', $candidate ) ); } } if ( '' === $callback_url ) { $callback_url = trailingslashit( $request['from_url'] ) . 'wp-json/aish/v1/approve-callback'; } if ( ! self::is_safe_public_url( $callback_url ) ) { self::set_last_approval_sync_error( 'callback_url_blocked' ); AISH_Logger::log( 'warning', sprintf( 'Approval callback blocked by outbound URL safety policy: %s', $callback_url ) ); return false; } AISH_Logger::log( 'info', sprintf( 'Approval callback dispatching to %1$s (request_id=%2$s, source=%3$s)', $callback_url, sanitize_text_field( (string) $request['request_id'] ), $callback_url_source ) ); $response = wp_remote_post( $callback_url, self::build_remote_request_args( 15, true, array( 'body' => array( 'request_id' => sanitize_text_field( (string) $request['request_id'] ), 'callback_token' => sanitize_text_field( (string) $request['callback_token'] ), 'approver_url' => get_site_url(), 'approver_name' => carbon_get_theme_option( 'aish_my_business_name' ) ? carbon_get_theme_option( 'aish_my_business_name' ) : get_bloginfo( 'name' ), 'approver_public_id' => substr( hash( 'sha256', get_option( 'aish_site_secret', '' ) ), 0, 16 ), 'approver_type' => carbon_get_theme_option( 'aish_schema_type' ) ? carbon_get_theme_option( 'aish_schema_type' ) : 'Organization', 'approver_relation_to_requester' => self::sanitize_relation_type( $my_relation_to_partner ), 'approver_note' => substr( sanitize_textarea_field( (string) $my_relation_note ), 0, 240 ), 'requester_relation_to_approver' => isset( $request['relation_to_target'] ) ? self::sanitize_relation_type( $request['relation_to_target'] ) : 'collaboration', 'requester_note' => isset( $request['relation_note'] ) ? substr( sanitize_textarea_field( (string) $request['relation_note'] ), 0, 240 ) : '', ), ) ) ); if ( is_wp_error( $response ) ) { self::set_last_approval_sync_error( 'transport:' . sanitize_text_field( (string) $response->get_error_message() ) ); AISH_Logger::log( 'warning', sprintf( 'Approval callback transport error to %1$s: %2$s', $callback_url, $response->get_error_message() ) ); return false; } $code = wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { $raw_body = wp_remote_retrieve_body( $response ); $reason = self::extract_callback_response_reason( $raw_body ); self::set_last_approval_sync_error( 'http_' . intval( $code ) . ( '' !== $reason ? ':' . sanitize_text_field( $reason ) : '' ) ); AISH_Logger::log( 'warning', sprintf( 'Approval callback HTTP failure to %1$s (HTTP %2$d)%3$s', $callback_url, intval( $code ), '' !== $reason ? ': ' . $reason : '' ) ); return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || empty( $body['success'] ) ) { $reason = self::extract_callback_response_reason( wp_remote_retrieve_body( $response ) ); self::set_last_approval_sync_error( 'unexpected_response' . ( '' !== $reason ? ':' . sanitize_text_field( $reason ) : '' ) ); AISH_Logger::log( 'warning', sprintf( 'Approval callback unexpected response from %1$s%2$s', $callback_url, '' !== $reason ? ': ' . $reason : '' ) ); return false; } AISH_Logger::log( 'success', sprintf( 'Approval callback success to %1$s (request_id=%2$s)', $callback_url, sanitize_text_field( (string) $request['request_id'] ) ) ); return true; } /** * Apply callback sync result diagnostics to a pending row. * * @param array $row Pending row. * @param bool $sync_ok Whether sync succeeded. * @return array */ private static function apply_sync_result_to_pending_row( $row, $sync_ok ) { if ( ! is_array( $row ) ) { $row = array(); } $row['sync_last_retry_at'] = current_time( 'mysql' ); if ( $sync_ok ) { $row['sync_last_error'] = ''; $row['sync_last_failed_at'] = ''; $row['sync_next_retry_at'] = ''; $row['sync_next_retry_ts'] = 0; if ( ! isset( $row['sync_retry_count'] ) ) { $row['sync_retry_count'] = 0; } return $row; } $row['sync_retry_count'] = isset( $row['sync_retry_count'] ) ? intval( $row['sync_retry_count'] ) + 1 : 1; $row['sync_last_error'] = self::get_last_approval_sync_error(); $row['sync_last_failed_at'] = current_time( 'mysql' ); $delay = self::get_sync_retry_backoff_seconds( $row['sync_retry_count'] ); $row['sync_next_retry_ts'] = time() + $delay; $row['sync_next_retry_at'] = gmdate( 'Y-m-d H:i:s', intval( $row['sync_next_retry_ts'] ) ); return $row; } /** * Resolve the next retry timestamp from a pending row. * * @param array $request Pending row. * @return int */ private static function get_next_retry_timestamp( $request ) { if ( ! is_array( $request ) ) { return 0; } if ( isset( $request['sync_next_retry_ts'] ) ) { $next_retry_ts = intval( $request['sync_next_retry_ts'] ); if ( $next_retry_ts > 0 ) { return $next_retry_ts; } } return self::parse_datetime_to_timestamp( isset( $request['sync_next_retry_at'] ) ? $request['sync_next_retry_at'] : '' ); } /** * Get retry backoff seconds for a given attempt number. * * @param int $attempt Attempt number (1-based). * @return int */ private static function get_sync_retry_backoff_seconds( $attempt ) { $attempt = max( 1, intval( $attempt ) ); if ( isset( self::SYNC_RETRY_BACKOFF[ $attempt ] ) ) { return intval( self::SYNC_RETRY_BACKOFF[ $attempt ] ); } return intval( end( self::SYNC_RETRY_BACKOFF ) ); } /** * Parse datetime string to unix timestamp. * * @param string $datetime Datetime. * @return int */ private static function parse_datetime_to_timestamp( $datetime ) { $datetime = trim( sanitize_text_field( (string) $datetime ) ); if ( '' === $datetime ) { return 0; } try { $dt = new \DateTimeImmutable( $datetime, new \DateTimeZone( 'UTC' ) ); return intval( $dt->getTimestamp() ); } catch ( \Exception $e ) { // Fall through to strtotime() for legacy/unexpected formats. } $ts = strtotime( $datetime ); return false === $ts ? 0 : intval( $ts ); } /** * Set last outbound approval-sync error. * * @param string $reason Reason. * @return void */ private static function set_last_approval_sync_error( $reason ) { self::$last_approval_sync_error = substr( sanitize_text_field( (string) $reason ), 0, 180 ); } /** * Get last outbound approval-sync error. * * @return string */ private static function get_last_approval_sync_error() { return (string) self::$last_approval_sync_error; } /** * Set last approve_request error reason. * * @param string $reason Reason. * @return void */ private static function set_last_approve_request_error( $reason ) { self::$last_approve_request_error = sanitize_key( (string) $reason ); } /** * Get last approve_request error reason. * * @return string */ public static function get_last_approve_request_error() { return (string) self::$last_approve_request_error; } /** * Set last approve callback apply error reason. * * @param string $reason Reason. * @return void */ private static function set_last_approve_callback_error( $reason ) { self::$last_approve_callback_error = sanitize_text_field( (string) $reason ); } /** * Get last approve callback apply error reason. * * @return string */ private static function get_last_approve_callback_error() { return (string) self::$last_approve_callback_error; } /** * Whether detailed callback reasons should be exposed via API responses. * * @return bool */ private static function should_expose_debug_reason() { if ( defined( 'AISH_DEBUG_VERBOSE_API' ) ) { return (bool) AISH_DEBUG_VERBOSE_API; } return defined( 'WP_DEBUG' ) && WP_DEBUG; } /** * Reject a pending request by index. * * @param int $index The index in the pending array. * @return bool True on success. */ public static function reject_request( $index ) { $pending = get_option( self::PENDING_OPTION, array() ); if ( ! isset( $pending[ $index ] ) ) { return false; } $pending[ $index ]['status'] = 'rejected'; update_option( self::PENDING_OPTION, $pending ); if ( ! empty( $pending[ $index ]['from_url'] ) ) { self::invalidate_state_caches( array( $pending[ $index ]['from_url'] ) ); } AISH_Logger::log( 'info', sprintf( 'Rejected handshake request from %s', $pending[ $index ]['from_url'] ) ); return true; } /** * Cleanup pending requests by age/status and cap list size. * * @param array $pending Pending records. * @param int $now Current unix timestamp. * @return array */ private static function cleanup_pending_requests( $pending, $now ) { if ( ! is_array( $pending ) ) { return array(); } $clean = array(); $retention_seconds = self::get_pending_retention_seconds(); $max_records = self::get_pending_max_records(); foreach ( $pending as $req ) { if ( ! is_array( $req ) ) { continue; } $ts = isset( $req['timestamp'] ) ? intval( $req['timestamp'] ) : 0; $status = isset( $req['status'] ) ? $req['status'] : 'pending'; if ( 'pending' !== $status && $ts > 0 && ( $now - $ts ) > $retention_seconds ) { continue; } $clean[] = $req; } if ( count( $clean ) > $max_records ) { $clean = array_slice( $clean, -1 * $max_records ); } return $clean; } /** * Cleanup consumed/expired outbound intents. * * @param array $intents Intent records. * @return array */ private static function cleanup_outbound_intents( $intents ) { if ( ! is_array( $intents ) ) { return array(); } $now = time(); $clean = array(); foreach ( $intents as $token => $intent ) { if ( ! is_array( $intent ) ) { continue; } $ts = isset( $intent['timestamp'] ) ? intval( $intent['timestamp'] ) : 0; $used = ! empty( $intent['used'] ); if ( $used ) { continue; } if ( $ts <= 0 || ( $now - $ts ) > self::INTENT_TTL ) { continue; } $clean[ $token ] = $intent; } return $clean; } /** * Send an email invitation to a site that doesn't have the plugin installed. * * @param string $email Recipient email. * @param string $site_url The target site URL (for context). * @param string $custom_body Optional custom message body. * @return bool Whether the email was sent successfully. */ public static function send_email_invite( $email, $site_url, $custom_body = '' ) { self::set_last_email_invite_error( '', '' ); if ( empty( $email ) || ! is_email( $email ) ) { self::set_last_email_invite_error( 'invalid_email', __( 'Invalid recipient email.', 'ai-semantic-handshake' ) ); return false; } $my_name = carbon_get_theme_option( 'aish_my_business_name' ); if ( empty( $my_name ) ) { $my_name = get_bloginfo( 'name' ); } $my_url = get_site_url(); $cooldown_key = self::get_email_invite_cooldown_key( $email, $site_url ); if ( get_transient( $cooldown_key ) ) { self::set_last_email_invite_error( 'cooldown', __( 'Duplicate invite blocked by cooldown window.', 'ai-semantic-handshake' ) ); AISH_Logger::log( 'info', sprintf( 'Skipped duplicate email invitation to %1$s for %2$s within cooldown window', $email, $site_url ) ); return false; } $subject = sprintf( /* translators: %s: sender site name */ __( '%s invites you to an AI Semantic Handshake', 'ai-semantic-handshake' ), $my_name ); $custom_body = is_string( $custom_body ) ? wp_unslash( $custom_body ) : ''; $custom_body = str_replace( array( "\r\n", "\r" ), "\n", $custom_body ); $custom_body = wp_kses( $custom_body, array() ); $custom_body = trim( $custom_body ); if ( '' !== $custom_body ) { $body = function_exists( 'mb_substr' ) ? mb_substr( $custom_body, 0, 4000 ) : substr( $custom_body, 0, 4000 ); } else { $body = self::build_default_email_invite_body( $my_name, $my_url ); } $headers = array( 'Content-Type: text/plain; charset=UTF-8', ); $mail_failed_error = null; $mail_succeeded = false; $failed_handler = static function ( $wp_error ) use ( &$mail_failed_error ) { if ( $wp_error instanceof WP_Error ) { $mail_failed_error = $wp_error; return; } $mail_failed_error = new WP_Error( 'mail_failed', __( 'Mail transport reported a failure.', 'ai-semantic-handshake' ) ); }; $succeeded_handler = static function () use ( &$mail_succeeded ) { $mail_succeeded = true; }; add_action( 'wp_mail_failed', $failed_handler, 10, 1 ); add_action( 'wp_mail_succeeded', $succeeded_handler, 10, 1 ); try { $sent = wp_mail( $email, $subject, $body, $headers ); } catch ( Throwable $e ) { remove_action( 'wp_mail_failed', $failed_handler, 10 ); remove_action( 'wp_mail_succeeded', $succeeded_handler, 10 ); AISH_Logger::log( 'error', sprintf( 'Email invite exception for %1$s (%2$s): %3$s', $email, $site_url, $e->getMessage() ) ); self::set_last_email_invite_error( 'mail_exception', $e->getMessage() ); return false; } remove_action( 'wp_mail_failed', $failed_handler, 10 ); remove_action( 'wp_mail_succeeded', $succeeded_handler, 10 ); if ( $mail_failed_error instanceof WP_Error ) { $detail = $mail_failed_error->get_error_message(); $detail = is_string( $detail ) ? trim( $detail ) : ''; if ( '' === $detail ) { $detail = __( 'Mail transport reported a failure.', 'ai-semantic-handshake' ); } AISH_Logger::log( 'warning', sprintf( 'Email invitation failed for %1$s (%2$s): %3$s', $email, $site_url, $detail ) ); self::set_last_email_invite_error( 'mail_failed_hook', $detail ); return false; } if ( $sent || $mail_succeeded ) { set_transient( $cooldown_key, 1, self::EMAIL_INVITE_COOLDOWN ); AISH_Logger::log( 'info', sprintf( 'Sent email invitation to %1$s for %2$s', $email, $site_url ) ); return true; } AISH_Logger::log( 'warning', sprintf( 'Email invite delivery state unconfirmed for %1$s (%2$s): wp_mail returned false without explicit failure hook.', $email, $site_url ) ); // Treat unconfirmed sends as duplicate-sensitive for a short window. set_transient( $cooldown_key, 1, self::EMAIL_INVITE_COOLDOWN ); self::set_last_email_invite_error( 'unconfirmed', __( 'Mail send action was triggered, but delivery confirmation is unavailable in this mail configuration.', 'ai-semantic-handshake' ) ); return false; } /** * Build default invite email body text. * * @param string $my_name Sender site name. * @param string $my_url Sender site URL. * @return string */ private static function build_default_email_invite_body( $my_name, $my_url ) { return sprintf( __( "Hello,\n\n%1\$s (%2\$s) would like to establish a semantic handshake with your website.\n\nWhat is AI Semantic Handshake?\nIt is a WordPress plugin that creates structured data (JSON-LD) partnerships between websites, helping AI search engines understand trusted relationships and strengthening E-E-A-T signals for both parties.\n\nHow it works:\n1. Install the AI Semantic Handshake plugin on your WordPress site\n2. Complete the plugin setup in your WordPress admin\n3. Send a handshake request to %2\$s from the plugin interface\n4. Once the request is approved, both sites can publish the relationship in structured data\n\nProject page: https://aeosub.com/ai-semantic-handshake/\n\nBest regards,\n%1\$s", 'ai-semantic-handshake' ), $my_name, $my_url ); } /** * Get default invite email body for admin UI prefill. * * @return string */ public static function get_default_email_invite_body() { $my_name = carbon_get_theme_option( 'aish_my_business_name' ); if ( empty( $my_name ) ) { $my_name = get_bloginfo( 'name' ); } $my_url = get_site_url(); return self::build_default_email_invite_body( $my_name, $my_url ); } /** * Build transient key for invite duplicate-cooldown checks. * * @param string $email Recipient email. * @param string $site_url Target site URL. * @return string */ private static function get_email_invite_cooldown_key( $email, $site_url ) { $email = strtolower( trim( (string) $email ) ); $url = self::normalize_url( (string) $site_url ); return 'aish_invite_cd_' . md5( $email . '|' . $url ); } /** * Set last email invite failure reason. * * @param string $reason Reason code. * @return void */ private static function set_last_email_invite_error( $reason, $message = '' ) { self::$last_email_invite_error = sanitize_key( (string) $reason ); self::$last_email_invite_error_message = sanitize_text_field( (string) $message ); } /** * Get last email invite failure reason. * * @return string */ public static function get_last_email_invite_error() { return self::$last_email_invite_error; } /** * Get last email invite failure detail message. * * @return string */ public static function get_last_email_invite_error_message() { return self::$last_email_invite_error_message; } }