get_relation_type_options(); return isset( $options[ $value ] ) ? $options[ $value ] : $options['collaboration']; } /** * Whether required local identity fields are ready for outbound handshakes. * * @return bool */ private function has_required_identity_profile() { $business_name = trim( (string) carbon_get_theme_option( 'aish_my_business_name' ) ); return '' !== $business_name; } /** * Constructor. */ public function __construct() { add_action( 'carbon_fields_register_fields', array( $this, 'create_options_page' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) ); add_action( 'wp_ajax_aish_verify_now', array( $this, 'ajax_verify_now' ) ); add_action( 'wp_ajax_aish_save_support_settings', array( $this, 'ajax_save_support_settings' ) ); add_action( 'wp_ajax_aish_cancel_outbound_request', array( $this, 'ajax_cancel_outbound_request' ) ); add_action( 'wp_ajax_aish_remove_partner', array( $this, 'ajax_remove_partner' ) ); add_action( 'wp_ajax_aish_approve_request', array( $this, 'ajax_approve_request' ) ); add_action( 'wp_ajax_aish_reject_request', array( $this, 'ajax_reject_request' ) ); add_action( 'wp_ajax_aish_reject_blacklist_request', array( $this, 'ajax_reject_blacklist_request' ) ); add_action( 'wp_ajax_aish_send_request', array( $this, 'ajax_send_request' ) ); add_action( 'wp_ajax_aish_send_email_invite', array( $this, 'ajax_send_email_invite' ) ); add_action( 'wp_ajax_aish_get_email_invite_status', array( $this, 'ajax_get_email_invite_status' ) ); add_action( 'wp_ajax_aish_retry_sync_request', array( $this, 'ajax_retry_sync_request' ) ); add_action( 'wp_ajax_aish_reset_sync_request', array( $this, 'ajax_reset_sync_request' ) ); add_action( 'wp_ajax_aish_bulk_sync_action', array( $this, 'ajax_bulk_sync_action' ) ); add_action( 'admin_notices', array( $this, 'pending_requests_notice' ) ); } /** * Register the options page and fields. */ public function create_options_page() { $container = Container::make( 'theme_options', 'aish_settings', __( 'AI Semantic Handshake', 'ai-semantic-handshake' ) ) ->set_page_file( 'ai-semantic-handshake' ) ->set_page_menu_position( 30 ) ->set_icon( 'dashicons-share' ) ->add_tab( __( 'My Identity', 'ai-semantic-handshake' ), array( Field::make( 'text', 'aish_my_business_name', __( 'Business / Site Name', 'ai-semantic-handshake' ) ) ->set_required( true ) ->set_help_text( __( 'Used as your display name and Schema identity. Required for sending handshake requests.', 'ai-semantic-handshake' ) ), Field::make( 'text', 'aish_my_industry', __( 'Industry / Keywords', 'ai-semantic-handshake' ) ) ->set_help_text( __( 'Optional. Used in your own status card and fallback Schema description. Example: Web Development, SEO, e-Commerce.', 'ai-semantic-handshake' ) ), Field::make( 'select', 'aish_schema_type', __( 'Schema Entity Type', 'ai-semantic-handshake' ) ) ->set_options( array( 'Organization' => __( 'Organization', 'ai-semantic-handshake' ), 'Person' => __( 'Person', 'ai-semantic-handshake' ), ) ) ->set_default_value( 'Organization' ) ->set_help_text( __( 'Used as your @type in Schema output and handshake metadata sent to partners.', 'ai-semantic-handshake' ) ), Field::make( 'html', 'aish_site_id_display' ) ->set_html( $this->render_site_id_info() ), ) ) ->add_tab( __( 'Partner Network', 'ai-semantic-handshake' ), array( Field::make( 'html', 'aish_send_request_ui' ) ->set_html( $this->render_send_request_ui() ), // Keep underlying partner data field registered for Carbon persistence, but hide it from UI. Field::make( 'complex', 'aish_partners', __( 'Partners', 'ai-semantic-handshake' ) ) ->set_layout( 'tabbed-horizontal' ) ->set_classes( 'aish-partners-field aish-partners-field--hidden' ) ->set_conditional_logic( array( array( 'field' => 'aish_output_scope', 'value' => '__never_show_partners_storage__', ), ) ) ->add_fields( array( Field::make( 'text', 'url', __( 'Partner URL', 'ai-semantic-handshake' ) ) ->set_attribute( 'placeholder', 'https://example.com' ) ->set_attribute( 'readOnly', true ) ->set_classes( 'aish-partner-readonly-input' ), Field::make( 'text', 'status', __( 'Handshake Status', 'ai-semantic-handshake' ) ) ->set_default_value( 'pending' ) ->set_attribute( 'readOnly', true ) ->set_classes( 'aish-partner-readonly-input' ), Field::make( 'text', 'last_verified', __( 'Last Verified', 'ai-semantic-handshake' ) ) ->set_attribute( 'readOnly', true ) ->set_classes( 'aish-partner-readonly-input' ), Field::make( 'text', 'error_detail', __( 'Details', 'ai-semantic-handshake' ) ) ->set_attribute( 'readOnly', true ) ->set_classes( 'aish-partner-readonly-input' ), Field::make( 'select', 'my_relation_to_partner', __( 'My Relation To Partner', 'ai-semantic-handshake' ) ) ->set_options( $this->get_relation_type_options() ) ->set_default_value( 'collaboration' ) ->set_classes( 'aish-partner-readonly-select' ), Field::make( 'text', 'partner_relation_to_me', __( 'Partner Relation To Me', 'ai-semantic-handshake' ) ) ->set_attribute( 'readOnly', true ) ->set_classes( 'aish-partner-readonly-input' ), Field::make( 'textarea', 'relation_note', __( 'Relation Note', 'ai-semantic-handshake' ) ) ->set_attribute( 'readOnly', true ) ->set_classes( 'aish-partner-readonly-input' ), Field::make( 'textarea', 'partner_claim', __( 'Partner Claim', 'ai-semantic-handshake' ) ) ->set_attribute( 'readOnly', true ) ->set_classes( 'aish-partner-readonly-input' ), Field::make( 'text', 'request_id', __( 'Request ID', 'ai-semantic-handshake' ) ) ->set_classes( 'aish-partner-meta-field' ) ->set_attribute( 'type', 'hidden' ), Field::make( 'text', 'callback_token', __( 'Callback Token', 'ai-semantic-handshake' ) ) ->set_classes( 'aish-partner-meta-field' ) ->set_attribute( 'type', 'hidden' ), Field::make( 'text', 'sync_state', __( 'Sync State', 'ai-semantic-handshake' ) ) ->set_classes( 'aish-partner-meta-field' ) ->set_attribute( 'type', 'hidden' ), Field::make( 'text', 'callback_url', __( 'Callback URL', 'ai-semantic-handshake' ) ) ->set_classes( 'aish-partner-meta-field' ) ->set_attribute( 'type', 'hidden' ), ) ) ->set_header_template( '<%- status ? status.toUpperCase() : "" %> - <%- url %> | <%- my_relation_to_partner ? my_relation_to_partner : "collaboration" %> -> <%- partner_relation_to_me ? partner_relation_to_me : "-" %>' ), Field::make( 'html', 'aish_partners_readonly' ) ->set_html( $this->render_partners_readonly() ), Field::make( 'html', 'aish_verify_now_btn' ) ->set_html( $this->render_verify_button() ), ) ) ->add_tab( __( 'Incoming Requests', 'ai-semantic-handshake' ), array( Field::make( 'html', 'aish_incoming_requests' ) ->set_html( $this->render_incoming_requests() ), ) ) // History tab is hidden by default and can be enabled via AISH_ENABLE_HISTORY_TAB. ->add_tab( __( 'Settings', 'ai-semantic-handshake' ), array( Field::make( 'select', 'aish_output_scope', __( 'Schema Output Scope', 'ai-semantic-handshake' ) ) ->set_options( array( 'front_page' => __( 'Front Page Only', 'ai-semantic-handshake' ), 'all_pages' => __( 'All Pages', 'ai-semantic-handshake' ), ) ) ->set_default_value( 'all_pages' ) ->set_help_text( __( 'Where to inject the handshake Schema markup.', 'ai-semantic-handshake' ) ), Field::make( 'checkbox', 'aish_ssl_verify', __( 'Enable SSL Verification', 'ai-semantic-handshake' ) ) ->set_default_value( true ) ->set_help_text( __( 'Verify SSL certificates when checking partner sites. Disable only for debugging.', 'ai-semantic-handshake' ) ), Field::make( 'checkbox', 'aish_delete_data_on_uninstall', __( 'Delete all plugin data on uninstall', 'ai-semantic-handshake' ) ) ->set_default_value( false ) ->set_help_text( __( 'Default: off. If enabled, uninstalling the plugin will permanently remove all options and transients prefixed with aish_.', 'ai-semantic-handshake' ) ), Field::make( 'text', 'aish_history_retention_days', __( 'History Retention (days)', 'ai-semantic-handshake' ) ) ->set_attribute( 'type', 'number' ) ->set_attribute( 'min', 1 ) ->set_default_value( 30 ) ->set_help_text( __( 'How long non-pending request history should be kept. Default: 30 days.', 'ai-semantic-handshake' ) ), Field::make( 'text', 'aish_history_max_records', __( 'History Max Records', 'ai-semantic-handshake' ) ) ->set_attribute( 'type', 'number' ) ->set_attribute( 'min', 50 ) ->set_default_value( 500 ) ->set_help_text( __( 'Maximum number of request records retained in history. Default: 500.', 'ai-semantic-handshake' ) ), Field::make( 'textarea', 'aish_blocked_domains', __( 'Blocked Domains', 'ai-semantic-handshake' ) ) ->set_help_text( __( 'One domain per line. Requests from these domains will be blocked before entering queue.', 'ai-semantic-handshake' ) ), Field::make( 'checkbox', 'aish_support_schema_enabled', __( 'Enable Semantic Support', 'ai-semantic-handshake' ) ) ->set_default_value( false ) ->set_help_text( __( 'Output an independent protocol support Schema node pointing to aeosub.com on the front end.', 'ai-semantic-handshake' ) ) ->set_classes( 'aish-support-sidebar-field aish-support-schema-enabled-field' ), Field::make( 'checkbox', 'aish_support_public_listing_consent', __( 'Allow Future Public Display of This Site\'s Protocol Information', 'ai-semantic-handshake' ) ) ->set_default_value( false ) ->set_help_text( __( 'Future-facing consent only. A later version may use your public site identity to display protocol adopters on aeosub.com or related Schema. Nothing is submitted automatically in the current version.', 'ai-semantic-handshake' ) ) ->set_classes( 'aish-support-sidebar-field aish-support-public-listing-field' ), ) ) ->add_tab( __( 'Logs', 'ai-semantic-handshake' ), array( Field::make( 'html', 'aish_logs_display' ) ->set_html( $this->render_logs() ), ) ); if ( $this->is_history_tab_enabled() ) { $container->add_tab( __( 'History', 'ai-semantic-handshake' ), array( Field::make( 'html', 'aish_history_view' ) ->set_html( $this->render_history_view() ), ) ); } } /** * Whether the History tab should be displayed. * * @return bool */ private function is_history_tab_enabled() { return defined( 'AISH_ENABLE_HISTORY_TAB' ) && (bool) AISH_ENABLE_HISTORY_TAB; } /** * Render site identity info (read-only display). * * @return string */ private function render_site_id_info() { $secret = get_option( 'aish_site_secret', '' ); $public_id = $secret ? substr( hash( 'sha256', $secret ), 0, 16 ) : __( 'Not generated yet. Please deactivate and reactivate the plugin.', 'ai-semantic-handshake' ); $site_url = get_site_url(); // Self score and advisory network guidance. $capacity = AISH_Scorer::can_add_partner(); $score_badge = AISH_Scorer::render_score_badge( $capacity['score'] ); $limit_text = sprintf( '%d / %d', $capacity['current'], $capacity['limit'] ); $score_badge_safe = wp_kses( $score_badge, array( 'span' => array( 'class' => array(), 'style' => array(), ), ) ); return sprintf( '
%s
%s
%s
%s
%s
%s
%s
%s
%s
%s
', esc_html__( 'Site URL (auto-detected):', 'ai-semantic-handshake' ), esc_html( $site_url ), esc_html__( 'This URL is derived from your current WordPress install and cannot be manually edited.', 'ai-semantic-handshake' ), esc_html__( 'Site Public ID (auto-generated):', 'ai-semantic-handshake' ), esc_html( $public_id ), esc_html__( 'This is a truncated hash of your private site secret. It is safe to share.', 'ai-semantic-handshake' ), esc_html__( 'Self Score', 'ai-semantic-handshake' ), $score_badge_safe, esc_html__( 'Suggested Handshake Capacity (Current / Suggested)', 'ai-semantic-handshake' ), esc_html( $limit_text ) ); } /** * Render "Send Request" UI. * * @return string */ private function render_send_request_ui() { // Advisory handshake guidance only. Do not block sends. $capacity = AISH_Scorer::can_add_partner(); $limit_warning = ''; if ( ! empty( $capacity['recommended_exceeded'] ) ) { $limit_warning_text = sprintf( /* translators: 1: current handshake count, 2: suggested handshake capacity */ __( 'Your current network is above the suggested handshake capacity (%1$d/%2$d). This is guidance only and will not block new handshakes.', 'ai-semantic-handshake' ), intval( $capacity['current'] ), intval( $capacity['limit'] ) ); $limit_warning = sprintf( '
' . '%s
', esc_html( $limit_warning_text ) ); } $identity_warning = ''; $identity_ready = $this->has_required_identity_profile(); if ( ! $identity_ready ) { $identity_warning = sprintf( '
%s
', esc_html__( 'Please complete My Identity (Business / Site Name) and save settings before sending handshake requests.', 'ai-semantic-handshake' ) ); } $disabled = $identity_ready ? '' : ' disabled'; $default_invite_body = AISH_REST::get_default_email_invite_body(); $relation_options_html = $this->render_relation_options_html( 'collaboration' ); return sprintf( '

%s

%s %s

%s

', esc_html__( 'Send Handshake Request', 'ai-semantic-handshake' ), wp_kses_post( $limit_warning ), wp_kses_post( $identity_warning ), esc_html__( 'Enter the URL of a site you want to partner with. The plugin will check if they have AI Semantic Handshake installed.', 'ai-semantic-handshake' ), $disabled, $disabled, esc_html__( 'Send Request', 'ai-semantic-handshake' ), esc_html__( 'Relation Type', 'ai-semantic-handshake' ), $disabled, $relation_options_html, esc_html__( 'Note', 'ai-semantic-handshake' ), esc_attr__( 'Optional note visible to partner (max 240 chars)', 'ai-semantic-handshake' ), $disabled, esc_html__( 'The target site does not have this plugin installed. Enter their email to send an invitation.', 'ai-semantic-handshake' ), esc_html__( 'Send Invitation Email', 'ai-semantic-handshake' ), esc_html__( 'Invitation Email Content (Editable)', 'ai-semantic-handshake' ), esc_textarea( $default_invite_body ) ); } /** * Render relation options html for generic select controls. * * @param string $selected_value Selected relation value. * @return string */ private function render_relation_options_html( $selected_value ) { $selected_value = AISH_REST::sanitize_relation_type( $selected_value ); $options = $this->get_relation_type_options(); $html = ''; foreach ( $options as $value => $label ) { $html .= sprintf( '', esc_attr( $value ), selected( $selected_value, $value, false ), esc_html( $label ) ); } return $html; } /** * Render read-only partners table. * * @return string */ private function render_partners_readonly() { $partners = AISH_Partner_Store::get_all(); $tip = esc_html__( 'Partner entries are created by Send Request or approved incoming requests. Canceling a pending outbound request or removing a partner only affects this site.', 'ai-semantic-handshake' ); if ( ! is_array( $partners ) || empty( $partners ) ) { return sprintf( '

%s

%s

', $tip, esc_html__( 'No partner entries yet.', 'ai-semantic-handshake' ) ); } $rows = ''; foreach ( $partners as $partner ) { if ( ! is_array( $partner ) || empty( $partner['url'] ) ) { continue; } $status = isset( $partner['status'] ) ? sanitize_key( (string) $partner['status'] ) : 'pending'; $status_badge_html = $this->render_history_status_badge( $status ); $last_verified = isset( $partner['last_verified'] ) ? sanitize_text_field( (string) $partner['last_verified'] ) : ''; $details = isset( $partner['error_detail'] ) ? sanitize_text_field( (string) $partner['error_detail'] ) : ''; $my_relation = isset( $partner['my_relation_to_partner'] ) ? AISH_REST::sanitize_relation_type( $partner['my_relation_to_partner'] ) : 'collaboration'; $partner_relation = isset( $partner['partner_relation_to_me'] ) ? AISH_REST::sanitize_relation_type( $partner['partner_relation_to_me'] ) : ''; $relation_note = isset( $partner['relation_note'] ) ? sanitize_textarea_field( (string) $partner['relation_note'] ) : ''; $relationship_state = AISH_REST::get_domain_relationship_state( $partner['url'] ); $actions_html = '-'; if ( 'outbound_pending' === $relationship_state['state'] ) { $actions_html = sprintf( '
', esc_attr( $partner['url'] ), esc_html__( 'Cancel Request', 'ai-semantic-handshake' ) ); } elseif ( in_array( $status, array( 'active', 'broken' ), true ) ) { $actions_html = sprintf( '
', esc_attr( $partner['url'] ), esc_html__( 'Remove Partner', 'ai-semantic-handshake' ) ); } $rows .= sprintf( ' %3$s %4$s %5$s %6$s %7$s %8$s %9$s %10$s ', esc_attr( $partner['url'] ), esc_url( $partner['url'] ), esc_html( $partner['url'] ), $status_badge_html, esc_html( '' !== $last_verified ? $last_verified : __( 'N/A', 'ai-semantic-handshake' ) ), esc_html( '' !== $details ? $details : '-' ), esc_html( $this->get_relation_label( $my_relation ) ), esc_html( '' !== $partner_relation ? $this->get_relation_label( $partner_relation ) : '-' ), esc_html( '' !== $relation_note ? $relation_note : '-' ), $actions_html ); } if ( '' === $rows ) { return sprintf( '

%s

%s

', $tip, esc_html__( 'No partner entries yet.', 'ai-semantic-handshake' ) ); } return sprintf( '

%s

%s
%s %s %s %s %s %s %s %s
', $tip, esc_html__( 'Partner URL', 'ai-semantic-handshake' ), esc_html__( 'Handshake Status', 'ai-semantic-handshake' ), esc_html__( 'Last Verified', 'ai-semantic-handshake' ), esc_html__( 'Details', 'ai-semantic-handshake' ), esc_html__( 'My Relation To Partner', 'ai-semantic-handshake' ), esc_html__( 'Partner Relation To Me', 'ai-semantic-handshake' ), esc_html__( 'Relation Note', 'ai-semantic-handshake' ), esc_html__( 'Actions', 'ai-semantic-handshake' ), $rows ); } /** * Render the "Verify Now" button. * * @return string */ private function render_verify_button() { return sprintf( '
', __( 'Verify All Partners Now', 'ai-semantic-handshake' ) ); } /** * Render the incoming requests list. * * @return string */ private function render_incoming_requests() { $pending = AISH_REST::get_pending_requests(); if ( empty( $pending ) ) { return sprintf( '

%s

', __( 'No pending handshake requests.', 'ai-semantic-handshake' ) ); } $html = '

' . esc_html__( 'Review incoming requests and decide whether to connect, decline, or block the domain.', 'ai-semantic-handshake' ) . '

'; $html .= '
'; $all_pending = get_option( AISH_REST::PENDING_OPTION, array() ); foreach ( $all_pending as $i => $req ) { if ( ! isset( $req['status'] ) || 'pending' !== $req['status'] ) { continue; } $score = isset( $req['score'] ) ? intval( $req['score'] ) : 0; $grade = isset( $req['grade'] ) ? $req['grade'] : ''; $score_html = AISH_Scorer::render_score_badge( $score, $grade ); $partner_relation_to_me = isset( $req['relation_to_target'] ) ? AISH_REST::sanitize_relation_type( $req['relation_to_target'] ) : 'collaboration'; $partner_note = isset( $req['relation_note'] ) ? sanitize_textarea_field( (string) $req['relation_note'] ) : ''; $html .= sprintf( '', esc_url( $req['from_url'] ), esc_html( $req['from_url'] ), esc_html( isset( $req['from_name'] ) && '' !== $req['from_name'] ? $req['from_name'] : '-' ), esc_html( isset( $req['from_public_id'] ) && '' !== $req['from_public_id'] ? $req['from_public_id'] : '-' ), esc_html( $this->get_relation_label( $partner_relation_to_me ) ), esc_html( '' !== $partner_note ? $partner_note : '-' ), $score_html, $i, __( 'Approve', 'ai-semantic-handshake' ), $i, __( 'Reject', 'ai-semantic-handshake' ), $i, __( 'Reject & Blacklist', 'ai-semantic-handshake' ) ); } $html .= '
' . __( 'Site URL', 'ai-semantic-handshake' ) . ' ' . __( 'Site Name', 'ai-semantic-handshake' ) . ' ' . __( 'Public ID', 'ai-semantic-handshake' ) . ' ' . __( 'Partner Declared Relation', 'ai-semantic-handshake' ) . ' ' . __( 'Partner Note', 'ai-semantic-handshake' ) . ' ' . __( 'Snapshot Score', 'ai-semantic-handshake' ) . ' ' . __( 'Actions', 'ai-semantic-handshake' ) . '
%s %s %s %s %s %s
'; return $html; } /** * Render inbound/outbound history table. * * @return string */ private function render_history_view() { $rows = array(); $all_pending = get_option( AISH_REST::PENDING_OPTION, array() ); $partners = AISH_Partner_Store::get_all(); $raw_status_filter = filter_input( INPUT_GET, 'aish_history_status', FILTER_UNSAFE_RAW ); $status_filter = sanitize_key( is_string( $raw_status_filter ) ? $raw_status_filter : 'all' ); $allowed = array( 'all', 'pending', 'approved', 'rejected', 'approved_pending_sync', 'sync_exhausted', 'broken' ); if ( ! in_array( $status_filter, $allowed, true ) ) { $status_filter = 'all'; } if ( is_array( $all_pending ) ) { foreach ( $all_pending as $idx => $req ) { if ( ! is_array( $req ) || empty( $req['from_url'] ) ) { continue; } $status = isset( $req['status'] ) ? sanitize_key( (string) $req['status'] ) : 'pending'; if ( 'all' !== $status_filter && $status !== $status_filter ) { continue; } $timestamp = isset( $req['timestamp'] ) ? intval( $req['timestamp'] ) : 0; $rows[] = array( 'direction' => 'inbound', 'url' => esc_url_raw( (string) $req['from_url'] ), 'name' => isset( $req['from_name'] ) ? sanitize_text_field( (string) $req['from_name'] ) : '', 'relation' => isset( $req['relation_to_target'] ) ? $this->get_relation_label( AISH_REST::sanitize_relation_type( $req['relation_to_target'] ) ) : $this->get_relation_label( 'collaboration' ), 'note' => isset( $req['relation_note'] ) ? substr( sanitize_textarea_field( (string) $req['relation_note'] ), 0, 240 ) : '', 'status' => $status, 'score' => isset( $req['score'] ) ? intval( $req['score'] ) : null, 'time' => $this->format_history_time( isset( $req['received_at'] ) ? (string) $req['received_at'] : '', $timestamp ), 'diag' => $this->format_sync_diagnostics( isset( $req['sync_last_error'] ) ? (string) $req['sync_last_error'] : '', isset( $req['sync_retry_count'] ) ? intval( $req['sync_retry_count'] ) : 0, isset( $req['sync_last_failed_at'] ) ? (string) $req['sync_last_failed_at'] : '', isset( $req['sync_last_retry_at'] ) ? (string) $req['sync_last_retry_at'] : '', isset( $req['sync_next_retry_at'] ) ? (string) $req['sync_next_retry_at'] : '' ), 'ts' => $timestamp, 'pending_index' => intval( $idx ), ); } } if ( is_array( $partners ) ) { foreach ( $partners as $partner ) { if ( ! is_array( $partner ) || empty( $partner['url'] ) ) { continue; } $sync_state = isset( $partner['sync_state'] ) ? sanitize_key( (string) $partner['sync_state'] ) : ''; $is_outbound_tracked = in_array( $sync_state, array( 'pending_outbound', 'active_synced' ), true ); if ( ! $is_outbound_tracked ) { continue; } $status = $this->map_outbound_history_status( $partner ); if ( 'all' !== $status_filter && $status !== $status_filter ) { continue; } $last_verified = isset( $partner['last_verified'] ) ? sanitize_text_field( (string) $partner['last_verified'] ) : ''; $rows[] = array( 'direction' => 'outbound', 'url' => esc_url_raw( (string) $partner['url'] ), 'name' => isset( $partner['partner_name'] ) ? sanitize_text_field( (string) $partner['partner_name'] ) : '', 'relation' => isset( $partner['my_relation_to_partner'] ) ? $this->get_relation_label( AISH_REST::sanitize_relation_type( $partner['my_relation_to_partner'] ) ) : $this->get_relation_label( 'collaboration' ), 'note' => isset( $partner['relation_note'] ) ? substr( sanitize_textarea_field( (string) $partner['relation_note'] ), 0, 240 ) : '', 'status' => $status, 'score' => null, 'time' => '' !== $last_verified ? $last_verified : __( 'N/A', 'ai-semantic-handshake' ), 'diag' => $this->format_sync_diagnostics( isset( $partner['sync_last_error'] ) ? (string) $partner['sync_last_error'] : ( isset( $partner['error_detail'] ) ? (string) $partner['error_detail'] : '' ), isset( $partner['sync_retry_count'] ) ? intval( $partner['sync_retry_count'] ) : 0, isset( $partner['sync_last_failed_at'] ) ? (string) $partner['sync_last_failed_at'] : '', isset( $partner['sync_last_retry_at'] ) ? (string) $partner['sync_last_retry_at'] : '', isset( $partner['sync_next_retry_at'] ) ? (string) $partner['sync_next_retry_at'] : '' ), 'ts' => 0, 'pending_index' => -1, ); } } usort( $rows, function( $a, $b ) { return intval( $b['ts'] ) <=> intval( $a['ts'] ); } ); $options_html = ''; foreach ( $allowed as $value ) { $options_html .= sprintf( '', esc_attr( $value ), selected( $status_filter, $value, false ), esc_html( $this->get_history_status_label( $value ) ) ); } $html = '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= ''; $html .= '
'; $html .= '

' . esc_html__( 'Tip: use "Sync Required" or "Sync Needs Attention" to quickly find rows that need manual action.', 'ai-semantic-handshake' ) . ' ' . esc_html__( 'Snapshot Score is captured at request time; My Identity score is your current self score for capacity.', 'ai-semantic-handshake' ) . '

'; $html .= ''; if ( empty( $rows ) ) { $html .= '

' . esc_html__( 'No history records found for the selected filter.', 'ai-semantic-handshake' ) . '

'; $html .= '
'; return $html; } $html .= '
'; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; foreach ( $rows as $row ) { $score_html = null === $row['score'] ? '-' : AISH_Scorer::render_score_badge( intval( $row['score'] ) ); $select_html = $this->render_history_selection_cell( $row ); $actions_html = $this->render_history_actions_cell( $row ); $status_badge_html = $this->render_history_status_badge( $row['status'] ); $html .= sprintf( '', esc_attr( $row['status'] ), intval( isset( $row['pending_index'] ) ? $row['pending_index'] : -1 ), $select_html, esc_html( 'inbound' === $row['direction'] ? __( 'Inbound', 'ai-semantic-handshake' ) : __( 'Outbound', 'ai-semantic-handshake' ) ), esc_url( $row['url'] ), esc_html( $row['url'] ), esc_html( '' !== $row['name'] ? $row['name'] : '-' ), esc_html( '' !== $row['relation'] ? $row['relation'] : '-' ), esc_html( '' !== $row['note'] ? $row['note'] : '-' ), $status_badge_html, esc_html( '' !== $row['diag'] ? $row['diag'] : '-' ), $score_html, esc_html( $row['time'] ), $actions_html ); } $html .= '
' . esc_html__( 'Direction', 'ai-semantic-handshake' ) . '' . esc_html__( 'Site URL', 'ai-semantic-handshake' ) . '' . esc_html__( 'Site Name', 'ai-semantic-handshake' ) . '' . esc_html__( 'Relation', 'ai-semantic-handshake' ) . '' . esc_html__( 'Note', 'ai-semantic-handshake' ) . '' . esc_html__( 'Status', 'ai-semantic-handshake' ) . '' . esc_html__( 'Sync', 'ai-semantic-handshake' ) . '' . esc_html__( 'Snapshot Score', 'ai-semantic-handshake' ) . '' . esc_html__( 'Time', 'ai-semantic-handshake' ) . '' . esc_html__( 'Actions', 'ai-semantic-handshake' ) . '
%s %s %s %s %s %s %s %s %s %s %s
'; return $html; } /** * Format history time from mysql/timestamp values. * * @param string $mysql_time Mysql datetime string. * @param int $timestamp Unix timestamp. * @return string */ private function format_history_time( $mysql_time, $timestamp ) { if ( ! empty( $mysql_time ) ) { return sanitize_text_field( $mysql_time ); } if ( intval( $timestamp ) > 0 ) { return gmdate( 'Y-m-d H:i:s', intval( $timestamp ) ); } return __( 'N/A', 'ai-semantic-handshake' ); } /** * Format sync diagnostics text for history rows. * * @param string $error Last error text. * @param int $retries Retry count. * @param string $failed_at Last failure time. * @param string $retry_at Last retry time. * @return string */ private function format_sync_diagnostics( $error, $retries, $failed_at, $retry_at = '', $next_retry_at = '' ) { $parts = array(); $error = sanitize_text_field( (string) $error ); if ( '' !== $error ) { $parts[] = sprintf( /* translators: %s: sync error reason */ __( 'Latest issue: %s', 'ai-semantic-handshake' ), $error ); } $retries = max( 0, intval( $retries ) ); if ( $retries > 0 ) { $parts[] = sprintf( /* translators: %d: retry attempts */ __( 'Attempts: %d', 'ai-semantic-handshake' ), $retries ); } $failed_at = sanitize_text_field( (string) $failed_at ); if ( '' !== $failed_at ) { $parts[] = sprintf( /* translators: %s: last failure datetime */ __( 'Last failure: %s', 'ai-semantic-handshake' ), $failed_at ); } $retry_at = sanitize_text_field( (string) $retry_at ); if ( '' !== $retry_at ) { $parts[] = sprintf( /* translators: %s: last retry datetime */ __( 'Last retry: %s', 'ai-semantic-handshake' ), $retry_at ); } $next_retry_at = sanitize_text_field( (string) $next_retry_at ); if ( '' !== $next_retry_at ) { $parts[] = sprintf( /* translators: %s: next retry datetime */ __( 'Next retry: %s', 'ai-semantic-handshake' ), $next_retry_at ); } if ( empty( $parts ) ) { return __( 'No sync issues recorded.', 'ai-semantic-handshake' ); } return implode( ' | ', $parts ); } /** * Render action buttons for a history row. * * @param array $row History row. * @return string */ private function render_history_actions_cell( $row ) { $status = isset( $row['status'] ) ? sanitize_key( (string) $row['status'] ) : ''; $index = isset( $row['pending_index'] ) ? intval( $row['pending_index'] ) : -1; if ( $index < 0 || ! in_array( $status, array( 'approved_pending_sync', 'sync_exhausted' ), true ) ) { return '-'; } return sprintf( '
', $index, esc_html__( 'Retry Now', 'ai-semantic-handshake' ), esc_html__( 'Reset Sync', 'ai-semantic-handshake' ) ); } /** * Render row selection checkbox for sync-eligible history rows. * * @param array $row History row. * @return string */ private function render_history_selection_cell( $row ) { $status = isset( $row['status'] ) ? sanitize_key( (string) $row['status'] ) : ''; $index = isset( $row['pending_index'] ) ? intval( $row['pending_index'] ) : -1; if ( $index < 0 || ! in_array( $status, array( 'approved_pending_sync', 'sync_exhausted' ), true ) ) { return '-'; } return sprintf( '', $index, esc_attr__( 'Select this sync row', 'ai-semantic-handshake' ) ); } /** * Map outbound partner fields to a history status. * * @param array $partner Partner record. * @return string */ private function map_outbound_history_status( $partner ) { $sync_state = isset( $partner['sync_state'] ) ? sanitize_key( (string) $partner['sync_state'] ) : ''; $status = isset( $partner['status'] ) ? sanitize_key( (string) $partner['status'] ) : ''; if ( 'pending_outbound' === $sync_state || 'pending' === $status ) { return 'pending'; } if ( 'active_synced' === $sync_state || 'active' === $status ) { return 'approved'; } if ( 'sync_exhausted' === $status ) { return 'sync_exhausted'; } if ( 'broken' === $status ) { return 'broken'; } return 'approved'; } /** * Human label for history status. * * @param string $status Status key. * @return string */ private function get_history_status_label( $status ) { $labels = array( 'all' => __( 'All', 'ai-semantic-handshake' ), 'pending' => __( 'Awaiting Review', 'ai-semantic-handshake' ), 'active' => __( 'Active', 'ai-semantic-handshake' ), 'approved' => __( 'Connected', 'ai-semantic-handshake' ), 'rejected' => __( 'Declined', 'ai-semantic-handshake' ), 'approved_pending_sync'=> __( 'Sync Required', 'ai-semantic-handshake' ), 'sync_exhausted' => __( 'Sync Needs Attention', 'ai-semantic-handshake' ), 'broken' => __( 'Verification Failed', 'ai-semantic-handshake' ), ); $status = sanitize_key( (string) $status ); return isset( $labels[ $status ] ) ? $labels[ $status ] : $status; } /** * Status badge tone. * * @param string $status Status key. * @return string */ private function get_history_status_tone( $status ) { $status = sanitize_key( (string) $status ); $tones = array( 'pending' => 'neutral', 'active' => 'success', 'approved' => 'success', 'rejected' => 'neutral', 'approved_pending_sync' => 'warning', 'sync_exhausted' => 'error', 'broken' => 'error', ); return isset( $tones[ $status ] ) ? $tones[ $status ] : 'neutral'; } /** * Render status badge markup. * * @param string $status Status key. * @return string */ private function render_history_status_badge( $status ) { $tone = $this->get_history_status_tone( $status ); $label = $this->get_history_status_label( $status ); return sprintf( '%2$s', esc_attr( $tone ), esc_html( $label ) ); } /** * Render logs. * * @return string */ private function render_logs() { $logs = AISH_Logger::get_logs( 50 ); if ( empty( $logs ) ) { return '

' . __( 'No log entries yet.', 'ai-semantic-handshake' ) . '

'; } $type_colors = array( 'success' => '#00a32a', 'error' => '#d63638', 'warning' => '#dba617', 'info' => '#2271b1', ); $html = '
'; foreach ( $logs as $entry ) { $color = isset( $type_colors[ $entry['type'] ] ) ? $type_colors[ $entry['type'] ] : '#666'; $html .= sprintf( '
%s [%s] %s
', esc_html( $entry['time'] ), $color, esc_html( $entry['type'] ), esc_html( $entry['message'] ) ); } $html .= '
'; return $html; } /** * Enqueue admin scripts. * * @param string $hook The current admin page hook. */ public function enqueue_admin_scripts( $hook ) { // Only load on our settings page. if ( strpos( $hook, 'ai-semantic-handshake' ) === false && strpos( $hook, 'ai-handshake' ) === false && strpos( $hook, 'crb_carbon_fields_container' ) === false ) { return; } wp_enqueue_script( 'aish-admin', AISH_PLUGIN_URL . 'assets/admin.js', array( 'jquery' ), AISH_VERSION . '.' . filemtime( AISH_PLUGIN_DIR . 'assets/admin.js' ), true ); wp_enqueue_style( 'aish-admin', AISH_PLUGIN_URL . 'assets/admin.css', array(), AISH_VERSION . '.' . filemtime( AISH_PLUGIN_DIR . 'assets/admin.css' ) ); wp_localize_script( 'aish-admin', 'aish_ajax', array( 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'aish_admin_nonce' ), 'support_target_url' => AISH_REST::get_protocol_support_target_url(), 'support_target_name' => AISH_REST::get_protocol_support_target_name(), 'i18n' => array( 'verifying' => __( 'Verifying...', 'ai-semantic-handshake' ), 'done' => __( 'Done! Refresh the page to see results.', 'ai-semantic-handshake' ), 'error' => __( 'Error occurred.', 'ai-semantic-handshake' ), 'server_error_check_mail' => __( 'Server returned an internal error. Check inbox/logs before retrying to avoid duplicate emails.', 'ai-semantic-handshake' ), 'sending' => __( 'Sending...', 'ai-semantic-handshake' ), 'sent_success' => __( 'Request sent successfully!', 'ai-semantic-handshake' ), 'no_plugin' => __( 'Plugin not detected. You can send an email invitation instead.', 'ai-semantic-handshake' ), 'email_sending' => __( 'Sending invitation...', 'ai-semantic-handshake' ), 'email_sent' => __( 'Invitation email sent!', 'ai-semantic-handshake' ), 'email_confirming' => __( 'Server response failed. Verifying actual delivery status...', 'ai-semantic-handshake' ), 'email_sent_confirmed' => __( 'Invitation email sent (confirmed after status check).', 'ai-semantic-handshake' ), 'email_unconfirmed' => __( 'Send action completed, but confirmation is unavailable in your current mail setup. Please ask the recipient to check inbox/spam before retrying.', 'ai-semantic-handshake' ), 'email_status_unknown' => __( 'Delivery status could not be confirmed. Check inbox/logs before retrying to avoid duplicate emails.', 'ai-semantic-handshake' ), 'confirm_approve'=> __( 'Approve this handshake request?', 'ai-semantic-handshake' ), 'confirm_reject' => __( 'Reject this handshake request?', 'ai-semantic-handshake' ), 'confirm_reject_blacklist' => __( 'Reject this request and add its domain to blacklist?', 'ai-semantic-handshake' ), 'confirm_retry_sync' => __( 'Retry this sync now?', 'ai-semantic-handshake' ), 'confirm_reset_sync' => __( 'Reset sync diagnostics for this request?', 'ai-semantic-handshake' ), 'confirm_cancel_request' => __( 'Cancel this outbound handshake request on this site?', 'ai-semantic-handshake' ), 'confirm_remove_partner' => __( 'Remove this partner from your local network? This only affects this site.', 'ai-semantic-handshake' ), 'confirm_bulk_retry_sync' => __( 'Retry sync for all selected rows now?', 'ai-semantic-handshake' ), 'confirm_bulk_reset_sync' => __( 'Reset sync diagnostics for all selected rows?', 'ai-semantic-handshake' ), 'retry_now' => __( 'Retry Now', 'ai-semantic-handshake' ), 'reset_sync' => __( 'Reset Sync', 'ai-semantic-handshake' ), 'canceling_request' => __( 'Canceling request...', 'ai-semantic-handshake' ), 'removing_partner' => __( 'Removing partner...', 'ai-semantic-handshake' ), 'retry_selected' => __( 'Retry Selected', 'ai-semantic-handshake' ), 'reset_selected' => __( 'Reset Selected', 'ai-semantic-handshake' ), 'select_history_row' => __( 'Select this sync row', 'ai-semantic-handshake' ), 'no_history_selection' => __( 'Select at least one eligible history row.', 'ai-semantic-handshake' ), 'retry_sync_success' => __( 'Sync retry succeeded.', 'ai-semantic-handshake' ), 'retry_sync_attempt_failed' => __( 'Sync retry attempted, but still failed.', 'ai-semantic-handshake' ), 'reset_sync_success' => __( 'Sync state reset.', 'ai-semantic-handshake' ), 'enter_url' => __( 'Please enter a URL.', 'ai-semantic-handshake' ), 'enter_email' => __( 'Please enter an email address.', 'ai-semantic-handshake' ), 'no_pending_requests' => __( 'No pending handshake requests.', 'ai-semantic-handshake' ), 'approve_failed' => __( 'Failed to approve request.', 'ai-semantic-handshake' ), 'reject_failed' => __( 'Failed to reject request.', 'ai-semantic-handshake' ), 'reject_blacklist_failed' => __( 'Failed to reject and blacklist request.', 'ai-semantic-handshake' ), 'cancel_request_failed' => __( 'Failed to cancel request.', 'ai-semantic-handshake' ), 'remove_partner_failed' => __( 'Failed to remove partner.', 'ai-semantic-handshake' ), 'retry_sync_failed' => __( 'Failed to retry sync request.', 'ai-semantic-handshake' ), 'reset_sync_failed' => __( 'Failed to reset sync request.', 'ai-semantic-handshake' ), 'bulk_sync_failed' => __( 'Failed to process selected rows.', 'ai-semantic-handshake' ), 'bulk_failure_title' => __( 'Bulk failure details', 'ai-semantic-handshake' ), 'bulk_failure_domain' => __( 'Domain', 'ai-semantic-handshake' ), 'bulk_failure_reason' => __( 'Reason', 'ai-semantic-handshake' ), 'bulk_failure_suggestion' => __( 'Suggested Action', 'ai-semantic-handshake' ), 'bulk_failure_unknown_domain' => __( '(unknown domain)', 'ai-semantic-handshake' ), 'partners_label' => __( 'Partners', 'ai-semantic-handshake' ), 'no_partner_entries' => __( 'No partner entries yet.', 'ai-semantic-handshake' ), 'support_sidebar_title' => __( 'Semantic Support', 'ai-semantic-handshake' ), 'support_sidebar_intro' => __( 'Enable an independent protocol-support Schema node for AI Semantic Handshake. This is separate from partner handshakes and does not affect partner status, verification, or capacity.', 'ai-semantic-handshake' ), 'support_sidebar_target' => __( 'Support target', 'ai-semantic-handshake' ), 'support_sidebar_status_on' => __( 'Front-end semantic support is enabled.', 'ai-semantic-handshake' ), 'support_sidebar_status_off' => __( 'Front-end semantic support is not enabled.', 'ai-semantic-handshake' ), 'support_sidebar_save' => __( 'Save Support Settings', 'ai-semantic-handshake' ), 'support_sidebar_save_hint' => __( 'Only saves the two support options in this card.', 'ai-semantic-handshake' ), 'support_sidebar_dirty' => __( 'You have unsaved support-setting changes.', 'ai-semantic-handshake' ), 'support_sidebar_saving' => __( 'Saving support settings...', 'ai-semantic-handshake' ), 'support_sidebar_saved' => __( 'Support settings saved.', 'ai-semantic-handshake' ), 'support_sidebar_save_failed' => __( 'Support settings could not be saved. Please try again.', 'ai-semantic-handshake' ), 'global_save_note' => __( 'Changes in My Identity or Settings apply after you click Save Changes here.', 'ai-semantic-handshake' ), 'tab_incoming_requests' => __( 'Incoming Requests', 'ai-semantic-handshake' ), ), ) ); } /** * Get admin page URL for AI Semantic Handshake settings. * * @return string */ private function get_admin_page_url( $target_tab = '' ) { $slugs = array( 'ai-semantic-handshake', 'ai-handshake', 'crb_aish_settings.php', 'crb_carbon_fields_container_aish_settings', 'crb_carbon_fields_container_aish_settings.php', 'crb_carbon_fields_container_ai_handshake', 'crb_carbon_fields_container_ai_handshake.php', ); $url = ''; if ( function_exists( 'menu_page_url' ) ) { foreach ( $slugs as $slug ) { $candidate = menu_page_url( $slug, false ); if ( is_string( $candidate ) && '' !== $candidate ) { $url = $candidate; break; } } } if ( '' === $url ) { $url = admin_url( 'admin.php?page=ai-semantic-handshake' ); } if ( '' !== $target_tab ) { $url = add_query_arg( 'aish_tab', sanitize_key( $target_tab ), $url ); } return $url; } /** * Show admin notice when there are pending requests. */ public function pending_requests_notice() { // Pending requests notice. $pending = AISH_REST::get_pending_requests(); if ( ! empty( $pending ) ) { $count = count( $pending ); /* translators: %d: number of pending requests */ $pending_template = _n( 'AI Semantic Handshake: You have %d pending handshake request.', 'AI Semantic Handshake: You have %d pending handshake requests.', $count, 'ai-semantic-handshake' ); if ( '' === trim( (string) $pending_template ) ) { $pending_template = 1 === $count ? 'AI Semantic Handshake: You have %d pending handshake request.' : 'AI Semantic Handshake: You have %d pending handshake requests.'; } $pending_message = sprintf( $pending_template, $count ); printf( '

%s %s

', esc_html( $pending_message ), esc_url( $this->get_admin_page_url( 'incoming-requests' ) ), esc_html__( 'Review requests', 'ai-semantic-handshake' ) ); } // Status change notifications (active -> broken). $notifications = get_option( 'aish_status_notifications', array() ); if ( ! empty( $notifications ) ) { foreach ( $notifications as $note ) { printf( '

AI Semantic Handshake: %s

', esc_html( $note['msg'] ) ); } // Clear after displaying. delete_option( 'aish_status_notifications' ); } } /** * AJAX: Verify all partners now. */ public function ajax_verify_now() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } AISH_Verifier::clear_cache(); AISH_Scorer::clear_score_cache( get_site_url() ); delete_transient( 'aish_self_score' ); $cron = AISH_AI_Semantic_Handshake::instance()->get_module( 'cron' ); if ( $cron ) { $cron->run_verification( true ); } wp_send_json_success( array( 'message' => __( 'Verification complete.', 'ai-semantic-handshake' ) ) ); } /** * Save semantic support settings without touching unrelated fields. * * @param bool $enabled Whether semantic support output is enabled. * @param bool $consent Whether future public listing consent is granted. * @return array|WP_Error */ public static function save_support_settings( $enabled, $consent ) { if ( ! function_exists( 'carbon_set_theme_option' ) || ! function_exists( 'carbon_get_theme_option' ) ) { return new WP_Error( 'support_settings_unavailable', __( 'Support settings are unavailable in the current environment.', 'ai-semantic-handshake' ) ); } carbon_set_theme_option( 'aish_support_schema_enabled', (bool) $enabled ); carbon_set_theme_option( 'aish_support_public_listing_consent', (bool) $consent ); $saved_enabled = (bool) carbon_get_theme_option( 'aish_support_schema_enabled' ); $saved_consent = (bool) carbon_get_theme_option( 'aish_support_public_listing_consent' ); if ( $saved_enabled !== (bool) $enabled || $saved_consent !== (bool) $consent ) { return new WP_Error( 'support_settings_save_failed', __( 'Support settings could not be saved. Please try again.', 'ai-semantic-handshake' ) ); } return array( 'enabled' => $saved_enabled, 'consent' => $saved_consent, ); } /** * AJAX: Save semantic support settings independently from the main form. */ public function ajax_save_support_settings() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } $enabled = ! empty( $_POST['support_enabled'] ); $consent = ! empty( $_POST['support_public_listing_consent'] ); $result = self::save_support_settings( $enabled, $consent ); if ( is_wp_error( $result ) ) { wp_send_json_error( $result->get_error_message() ); } wp_send_json_success( array( 'enabled' => (bool) $result['enabled'], 'consent' => (bool) $result['consent'], 'message' => __( 'Support settings saved.', 'ai-semantic-handshake' ), ) ); } /** * AJAX: Cancel a local outbound pending request. */ public function ajax_cancel_outbound_request() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } $partner_url_raw = isset( $_POST['partner_url'] ) ? sanitize_text_field( wp_unslash( $_POST['partner_url'] ) ) : ''; $partner_url = AISH_REST::canonicalize_site_url( (string) $partner_url_raw ); if ( '' === $partner_url ) { wp_send_json_error( __( 'Partner URL is invalid.', 'ai-semantic-handshake' ) ); } if ( ! AISH_REST::cancel_outbound_request( $partner_url ) ) { wp_send_json_error( __( 'This outbound pending request can no longer be canceled. Refresh the page and try again.', 'ai-semantic-handshake' ) ); } wp_send_json_success( array( 'partner_url' => $partner_url, ) ); } /** * AJAX: Remove a partner from the local network. */ public function ajax_remove_partner() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } $partner_url_raw = isset( $_POST['partner_url'] ) ? sanitize_text_field( wp_unslash( $_POST['partner_url'] ) ) : ''; $partner_url = AISH_REST::canonicalize_site_url( (string) $partner_url_raw ); if ( '' === $partner_url ) { wp_send_json_error( __( 'Partner URL is invalid.', 'ai-semantic-handshake' ) ); } $partners = AISH_Partner_Store::get_all(); $status = ''; if ( is_array( $partners ) ) { foreach ( $partners as $partner ) { if ( ! is_array( $partner ) || empty( $partner['url'] ) ) { continue; } if ( AISH_REST::normalize_url( $partner['url'] ) !== AISH_REST::normalize_url( $partner_url ) ) { continue; } $status = isset( $partner['status'] ) ? sanitize_key( (string) $partner['status'] ) : ''; break; } } if ( ! in_array( $status, array( 'active', 'broken' ), true ) ) { wp_send_json_error( __( 'Only active or broken partners can be removed from this list.', 'ai-semantic-handshake' ) ); } if ( ! AISH_REST::remove_partner( $partner_url ) ) { wp_send_json_error( __( 'Partner could not be removed. Refresh the page and try again.', 'ai-semantic-handshake' ) ); } wp_send_json_success( array( 'partner_url' => $partner_url, ) ); } /** * AJAX: Approve a pending request. */ public function ajax_approve_request() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } $index = isset( $_POST['index'] ) ? intval( $_POST['index'] ) : -1; $context = array( 'my_relation_to_partner' => isset( $_POST['my_relation_to_partner'] ) ? sanitize_text_field( wp_unslash( $_POST['my_relation_to_partner'] ) ) : 'collaboration', 'my_relation_note' => isset( $_POST['my_relation_note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['my_relation_note'] ) ) : '', ); $result = AISH_REST::approve_request( $index, $context ); if ( $result ) { wp_send_json_success(); } else { $approve_error = AISH_REST::get_last_approve_request_error(); if ( 'not_pending' === $approve_error ) { wp_send_json_error( __( 'This request is no longer pending. Refresh the page and try again.', 'ai-semantic-handshake' ) ); } if ( 'local_persist_failed' === $approve_error ) { wp_send_json_error( __( 'Approval failed while saving local partner data. Please save settings and retry.', 'ai-semantic-handshake' ) ); } wp_send_json_error( __( 'Request not found or could not be approved.', 'ai-semantic-handshake' ) ); } } /** * AJAX: Reject a pending request. */ public function ajax_reject_request() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } $index = isset( $_POST['index'] ) ? intval( $_POST['index'] ) : -1; $result = AISH_REST::reject_request( $index ); if ( $result ) { wp_send_json_success(); } else { wp_send_json_error( __( 'Request not found.', 'ai-semantic-handshake' ) ); } } /** * AJAX: Reject a pending request and blacklist its domain. */ public function ajax_reject_blacklist_request() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } $index = isset( $_POST['index'] ) ? intval( $_POST['index'] ) : -1; $pending = get_option( AISH_REST::PENDING_OPTION, array() ); if ( ! isset( $pending[ $index ] ) || empty( $pending[ $index ]['from_url'] ) ) { wp_send_json_error( __( 'Request not found.', 'ai-semantic-handshake' ) ); } $from_url = esc_url_raw( (string) $pending[ $index ]['from_url'] ); $blocked = AISH_REST::add_domain_to_blacklist( $from_url ); $rejected = AISH_REST::reject_request( $index ); if ( ! $blocked || ! $rejected ) { wp_send_json_error( __( 'Could not complete reject-and-blacklist action.', 'ai-semantic-handshake' ) ); } wp_send_json_success(); } /** * AJAX: Retry callback sync for one request row. */ public function ajax_retry_sync_request() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } $index = isset( $_POST['index'] ) ? intval( $_POST['index'] ) : -1; $result = AISH_REST::retry_sync_request_by_index( $index, true ); if ( empty( $result['processed'] ) ) { wp_send_json_error( __( 'Request not found or not eligible for retry.', 'ai-semantic-handshake' ) ); } $row = isset( $result['row'] ) && is_array( $result['row'] ) ? $result['row'] : array(); $payload = $this->build_history_sync_action_payload( $index, $row ); $payload['sync_ok'] = ! empty( $result['sync_ok'] ); $payload['message'] = ! empty( $result['sync_ok'] ) ? __( 'Sync retry succeeded.', 'ai-semantic-handshake' ) : __( 'Sync retry attempted, but still failed.', 'ai-semantic-handshake' ); wp_send_json_success( $payload ); } /** * AJAX: Reset callback sync diagnostics for one request row. */ public function ajax_reset_sync_request() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } $index = isset( $_POST['index'] ) ? intval( $_POST['index'] ) : -1; $result = AISH_REST::reset_sync_request_by_index( $index ); if ( empty( $result['processed'] ) ) { wp_send_json_error( __( 'Request not found or not eligible for reset.', 'ai-semantic-handshake' ) ); } $row = isset( $result['row'] ) && is_array( $result['row'] ) ? $result['row'] : array(); $payload = $this->build_history_sync_action_payload( $index, $row ); $payload['sync_ok'] = null; $payload['message'] = __( 'Sync state reset.', 'ai-semantic-handshake' ); wp_send_json_success( $payload ); } /** * AJAX: Bulk retry/reset callback sync requests. */ public function ajax_bulk_sync_action() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } $operation = isset( $_POST['operation'] ) ? sanitize_key( wp_unslash( $_POST['operation'] ) ) : ''; if ( ! in_array( $operation, array( 'retry', 'reset' ), true ) ) { wp_send_json_error( __( 'Invalid bulk action.', 'ai-semantic-handshake' ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below per token. $raw_indexes = isset( $_POST['indexes'] ) ? wp_unslash( $_POST['indexes'] ) : ''; if ( is_array( $raw_indexes ) ) { $index_tokens = array_map( 'sanitize_text_field', $raw_indexes ); } else { $index_tokens = explode( ',', sanitize_text_field( (string) $raw_indexes ) ); } $indexes = array(); foreach ( $index_tokens as $token ) { $idx = intval( $token ); if ( $idx >= 0 ) { $indexes[] = $idx; } } $indexes = array_values( array_unique( $indexes ) ); if ( empty( $indexes ) ) { wp_send_json_error( __( 'No valid rows selected.', 'ai-semantic-handshake' ) ); } $rows = array(); $processed = 0; $success = 0; $failed = 0; $selected = count( $indexes ); $failure_details = array(); foreach ( $indexes as $idx ) { if ( 'retry' === $operation ) { $result = AISH_REST::retry_sync_request_by_index( $idx, true ); if ( empty( $result['processed'] ) ) { $failed++; $failure_details[] = $this->build_bulk_failure_detail( $idx, 'retry', isset( $result['error'] ) ? (string) $result['error'] : '', array() ); continue; } $processed++; if ( ! empty( $result['sync_ok'] ) ) { $success++; } else { $failed++; $row = isset( $result['row'] ) && is_array( $result['row'] ) ? $result['row'] : array(); $failure_reason = isset( $result['error'] ) && '' !== (string) $result['error'] ? (string) $result['error'] : ( isset( $row['sync_last_error'] ) ? (string) $row['sync_last_error'] : '' ); $failure_details[] = $this->build_bulk_failure_detail( $idx, 'retry', $failure_reason, $row ); } $row = isset( $result['row'] ) && is_array( $result['row'] ) ? $result['row'] : array(); $rows[ $idx ] = $this->build_history_sync_action_payload( $idx, $row ); continue; } $result = AISH_REST::reset_sync_request_by_index( $idx ); if ( empty( $result['processed'] ) ) { $failed++; $failure_details[] = $this->build_bulk_failure_detail( $idx, 'reset', isset( $result['error'] ) ? (string) $result['error'] : '', array() ); continue; } $processed++; $success++; $row = isset( $result['row'] ) && is_array( $result['row'] ) ? $result['row'] : array(); $rows[ $idx ] = $this->build_history_sync_action_payload( $idx, $row ); } if ( 0 === $processed ) { wp_send_json_error( array( 'message' => __( 'No selected rows could be processed.', 'ai-semantic-handshake' ), 'failure_details' => $failure_details, ) ); } $message = 'retry' === $operation ? sprintf( /* translators: 1: selected rows, 2: processed rows, 3: success rows, 4: failed rows */ __( 'Bulk retry done: selected %1$d, processed %2$d, succeeded %3$d, failed %4$d.', 'ai-semantic-handshake' ), intval( $selected ), intval( $processed ), intval( $success ), intval( $failed ) ) : sprintf( /* translators: 1: selected rows, 2: processed rows, 3: failed rows */ __( 'Bulk reset done: selected %1$d, processed %2$d, failed %3$d.', 'ai-semantic-handshake' ), intval( $selected ), intval( $processed ), intval( $failed ) ); wp_send_json_success( array( 'operation' => $operation, 'selected' => intval( $selected ), 'processed' => intval( $processed ), 'success' => intval( $success ), 'failed' => intval( $failed ), 'rows' => $rows, 'failure_details' => $failure_details, 'message' => $message, ) ); } /** * Build one failure detail row for bulk sync actions. * * @param int $index Pending row index. * @param string $operation Bulk operation key. * @param string $error Error reason key/message. * @param array $row Optional request row payload. * @return array */ private function build_bulk_failure_detail( $index, $operation, $error, $row = array() ) { $operation = sanitize_key( (string) $operation ); $error = sanitize_text_field( (string) $error ); $row = is_array( $row ) ? $row : array(); if ( empty( $row ) ) { $pending = get_option( AISH_REST::PENDING_OPTION, array() ); if ( isset( $pending[ $index ] ) && is_array( $pending[ $index ] ) ) { $row = $pending[ $index ]; } } $domain = __( '(unknown domain)', 'ai-semantic-handshake' ); if ( ! empty( $row['from_url'] ) ) { $domain = sanitize_text_field( AISH_REST::normalize_url( (string) $row['from_url'] ) ); } $reason_label = __( 'Unknown failure', 'ai-semantic-handshake' ); $suggestion = __( 'Check the row diagnostics and plugin logs, then retry.', 'ai-semantic-handshake' ); if ( '' === $error ) { $error = 'unknown'; } if ( 'request_not_found' === $error ) { $reason_label = __( 'Request record not found', 'ai-semantic-handshake' ); $suggestion = __( 'Refresh the page and run the bulk action again.', 'ai-semantic-handshake' ); } elseif ( 'invalid_status' === $error ) { $reason_label = __( 'Row status is not eligible', 'ai-semantic-handshake' ); $suggestion = 'retry' === $operation ? __( 'Only "Approved (Pending Sync)" or "Sync Exhausted" rows can be retried.', 'ai-semantic-handshake' ) : __( 'Only "Approved (Pending Sync)" or "Sync Exhausted" rows can be reset.', 'ai-semantic-handshake' ); } elseif ( 'missing_from_url' === $error ) { $reason_label = __( 'Missing source URL in request data', 'ai-semantic-handshake' ); $suggestion = __( 'Open the row and ensure a valid partner URL is present before retrying.', 'ai-semantic-handshake' ); } elseif ( 'callback_url_blocked' === $error ) { $reason_label = __( 'Callback URL blocked by outbound safety policy', 'ai-semantic-handshake' ); $suggestion = __( 'Ensure partner callback URL is public and not private/local network.', 'ai-semantic-handshake' ); } elseif ( 0 === strpos( $error, 'transport:' ) ) { $detail = trim( substr( $error, strlen( 'transport:' ) ) ); $reason_label = '' !== $detail ? sprintf( /* translators: %s: transport error detail */ __( 'Network transport error: %s', 'ai-semantic-handshake' ), $detail ) : __( 'Network transport error', 'ai-semantic-handshake' ); $suggestion = __( 'Check partner availability/firewall and retry later.', 'ai-semantic-handshake' ); } elseif ( 0 === strpos( $error, 'http_' ) ) { $reason_label = sprintf( /* translators: %s: HTTP failure signature */ __( 'Remote callback returned %s', 'ai-semantic-handshake' ), $error ); $suggestion = __( 'Check partner callback endpoint and plugin compatibility.', 'ai-semantic-handshake' ); } elseif ( 0 === strpos( $error, 'unexpected_response' ) ) { $reason_label = __( 'Remote callback returned unexpected payload', 'ai-semantic-handshake' ); $suggestion = __( 'Verify partner is on a compatible plugin version and returns JSON success.', 'ai-semantic-handshake' ); } elseif ( 'retry_limit_exhausted' === $error ) { $reason_label = __( 'Retry limit exhausted', 'ai-semantic-handshake' ); $suggestion = __( 'Use "Reset Selected" first, then run "Retry Selected" again.', 'ai-semantic-handshake' ); } elseif ( 'unknown' !== $error ) { $reason_label = $error; } return array( 'pending_index' => intval( $index ), 'domain' => $domain, 'reason' => $reason_label, 'suggestion' => $suggestion, ); } /** * Build AJAX payload for history sync-action updates. * * @param int $index Pending index. * @param array $row Pending row. * @return array */ private function build_history_sync_action_payload( $index, $row ) { $status = isset( $row['status'] ) ? sanitize_key( (string) $row['status'] ) : ''; return array( 'pending_index' => intval( $index ), 'status' => $status, 'status_label' => $this->get_history_status_label( $status ), 'status_badge_html' => $this->render_history_status_badge( $status ), 'diag' => $this->format_sync_diagnostics( isset( $row['sync_last_error'] ) ? (string) $row['sync_last_error'] : '', isset( $row['sync_retry_count'] ) ? intval( $row['sync_retry_count'] ) : 0, isset( $row['sync_last_failed_at'] ) ? (string) $row['sync_last_failed_at'] : '', isset( $row['sync_last_retry_at'] ) ? (string) $row['sync_last_retry_at'] : '', isset( $row['sync_next_retry_at'] ) ? (string) $row['sync_next_retry_at'] : '' ), ); } /** * AJAX: Send a handshake request to another site. */ public function ajax_send_request() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } if ( ! $this->has_required_identity_profile() ) { wp_send_json_error( array( 'code' => 'identity_incomplete', 'message' => __( 'Please complete My Identity (Business / Site Name) and save settings before sending handshake requests.', 'ai-semantic-handshake' ), ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via sanitize_text_field before use. $target_url_raw = isset( $_POST['target_url'] ) ? sanitize_text_field( wp_unslash( $_POST['target_url'] ) ) : ''; $target_url = AISH_REST::canonicalize_site_url( (string) $target_url_raw ); if ( empty( $target_url ) || ! AISH_REST::is_safe_public_url( $target_url ) ) { wp_send_json_error( __( 'Please provide a valid public URL.', 'ai-semantic-handshake' ) ); } if ( AISH_REST::is_domain_blacklisted( $target_url ) ) { wp_send_json_error( __( 'This domain is blocked by your blacklist policy.', 'ai-semantic-handshake' ) ); } $my_url = get_site_url(); if ( AISH_REST::normalize_url( $my_url ) === AISH_REST::normalize_url( $target_url ) ) { wp_send_json_error( __( 'You cannot send a handshake request to your own site.', 'ai-semantic-handshake' ) ); } // Server-side plugin detection to avoid browser cross-site false negatives. $status_url = trailingslashit( $target_url ) . 'wp-json/aish/v1/status'; if ( ! AISH_REST::is_safe_public_url( $status_url ) ) { wp_send_json_error( __( 'Target URL blocked by outbound safety policy.', 'ai-semantic-handshake' ) ); } $status_response = wp_remote_get( $status_url, AISH_REST::build_remote_request_args( 10, true ) ); if ( is_wp_error( $status_response ) ) { wp_send_json_error( array( 'code' => 'plugin_not_detected', 'message' => __( 'Plugin not detected. You can send an email invitation instead.', 'ai-semantic-handshake' ), ) ); } $status_code = wp_remote_retrieve_response_code( $status_response ); $status_body = json_decode( wp_remote_retrieve_body( $status_response ), true ); if ( $status_code < 200 || $status_code >= 300 || ! is_array( $status_body ) || ! isset( $status_body['plugin'] ) || 'ai-semantic-handshake' !== $status_body['plugin'] ) { wp_send_json_error( array( 'code' => 'plugin_not_detected', 'message' => __( 'Plugin not detected. You can send an email invitation instead.', 'ai-semantic-handshake' ), ) ); } $relationship_state = AISH_REST::get_domain_relationship_state( $target_url ); if ( in_array( $relationship_state['state'], array( 'inbound_pending', 'mutual_pending' ), true ) ) { wp_send_json_error( array( 'code' => 'inbound_pending_exists', 'message' => __( 'This site has already sent you a handshake request. Review it in Incoming Requests instead of sending a new one.', 'ai-semantic-handshake' ), ) ); } if ( 'outbound_pending' === $relationship_state['state'] ) { wp_send_json_error( array( 'code' => 'outbound_pending_exists', 'message' => __( 'Handshake request already sent to this site. Please wait for their review.', 'ai-semantic-handshake' ), ) ); } if ( 'active' === $relationship_state['state'] ) { wp_send_json_error( array( 'code' => 'already_partner', 'message' => __( 'A partnership with this site already exists.', 'ai-semantic-handshake' ), ) ); } $my_name = carbon_get_theme_option( 'aish_my_business_name' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via sanitize_key before use. $relation_to_target_raw = isset( $_POST['relation_to_target'] ) ? sanitize_key( wp_unslash( $_POST['relation_to_target'] ) ) : 'collaboration'; $relation_to_target = AISH_REST::sanitize_relation_type( $relation_to_target_raw ); $relation_note = isset( $_POST['relation_note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['relation_note'] ) ) : ''; $request_id = wp_generate_password( 20, false, false ) . '-' . time(); $callback_token = wp_generate_password( 40, false, false ); $intent = AISH_REST::create_outbound_intent( $target_url ); if ( empty( $intent['intent_token'] ) || empty( $intent['intent_ts'] ) || empty( $intent['intent_sig'] ) ) { wp_send_json_error( __( 'Could not generate handshake proof. Try again.', 'ai-semantic-handshake' ) ); } // Send POST request to the target site's REST endpoint. $api_url = trailingslashit( $target_url ) . 'wp-json/aish/v1/request'; if ( ! AISH_REST::is_safe_public_url( $api_url ) ) { wp_send_json_error( __( 'Target URL blocked by outbound safety policy.', 'ai-semantic-handshake' ) ); } $response = wp_remote_post( $api_url, AISH_REST::build_remote_request_args( 15, true, array( 'body' => array( 'from_url' => $my_url, 'callback_url' => rest_url( 'aish/v1/approve-callback' ), 'from_name' => $my_name ? $my_name : get_bloginfo( 'name' ), 'from_public_id'=> substr( hash( 'sha256', get_option( 'aish_site_secret', '' ) ), 0, 16 ), 'from_type' => carbon_get_theme_option( 'aish_schema_type' ) ? carbon_get_theme_option( 'aish_schema_type' ) : 'Organization', 'relation_to_target' => $relation_to_target, 'relation_note'=> substr( $relation_note, 0, 240 ), 'request_id' => $request_id, 'callback_token'=> $callback_token, 'intent_token' => $intent['intent_token'], 'intent_ts' => $intent['intent_ts'], 'intent_sig' => $intent['intent_sig'], ), ) ) ); if ( is_wp_error( $response ) ) { wp_send_json_error( $response->get_error_message() ); } $code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $code >= 200 && $code < 300 && isset( $body['success'] ) && $body['success'] ) { $result_code = isset( $body['code'] ) ? sanitize_key( (string) $body['code'] ) : ''; $local_saved = true; if ( 'already_partner' !== $result_code ) { $local_saved = AISH_REST::upsert_outbound_pending_partner( array( 'target_url' => $target_url, 'relation_to_target' => $relation_to_target, 'relation_note' => $relation_note, 'request_id' => $request_id, 'callback_token' => $callback_token, ) ); } if ( ! $local_saved ) { wp_send_json_error( __( 'Request delivered, but local partner record could not be saved. Check logs and retry.', 'ai-semantic-handshake' ) ); } AISH_Logger::log( 'info', sprintf( 'Sent handshake request to %s', $target_url ) ); wp_send_json_success( $body ); } else { $msg = isset( $body['message'] ) ? $body['message'] : __( 'Target site did not accept the request.', 'ai-semantic-handshake' ); wp_send_json_error( $msg ); } } /** * Normalize operation ID used for email invite status lookups. * * @param string $op_id Raw operation id. * @return string */ private function normalize_email_invite_op_id( $op_id ) { $op_id = strtolower( trim( (string) $op_id ) ); $op_id = preg_replace( '/[^a-z0-9_-]/', '', $op_id ); return substr( (string) $op_id, 0, 64 ); } /** * Build transient key for email invite operation status. * * @param string $op_id Operation id. * @return string */ private function get_email_invite_op_key( $op_id ) { return 'aish_invite_op_' . md5( $op_id ); } /** * Persist email invite operation status for post-error confirmation checks. * * @param string $op_id Operation id. * @param string $status processing|sent|failed|unconfirmed. * @param string $message Optional status message. * @return void */ private function set_email_invite_op_state( $op_id, $status, $message = '' ) { if ( '' === $op_id ) { return; } if ( ! in_array( $status, array( 'processing', 'sent', 'failed', 'unconfirmed' ), true ) ) { $status = 'failed'; } set_transient( $this->get_email_invite_op_key( $op_id ), array( 'status' => $status, 'message' => sanitize_text_field( (string) $message ), 'updated_at' => time(), ), 15 * MINUTE_IN_SECONDS ); } /** * Read email invite operation status payload. * * @param string $op_id Operation id. * @return array */ private function get_email_invite_op_state( $op_id ) { if ( '' === $op_id ) { return array(); } $payload = get_transient( $this->get_email_invite_op_key( $op_id ) ); if ( ! is_array( $payload ) || empty( $payload['status'] ) ) { return array(); } return array( 'status' => sanitize_key( (string) $payload['status'] ), 'message' => isset( $payload['message'] ) ? sanitize_text_field( (string) $payload['message'] ) : '', 'updated_at' => isset( $payload['updated_at'] ) ? intval( $payload['updated_at'] ) : 0, ); } /** * AJAX: Query email invite operation status by operation id. */ public function ajax_get_email_invite_status() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via sanitize_text_field before normalization. $op_id_raw = isset( $_POST['op_id'] ) ? sanitize_text_field( wp_unslash( $_POST['op_id'] ) ) : ''; $op_id = $this->normalize_email_invite_op_id( $op_id_raw ); if ( '' === $op_id ) { wp_send_json_error( __( 'Missing operation ID.', 'ai-semantic-handshake' ) ); } $state = $this->get_email_invite_op_state( $op_id ); if ( empty( $state ) ) { wp_send_json_success( array( 'status' => 'unknown', ) ); } wp_send_json_success( $state ); } /** * AJAX: Send email invitation to a site without the plugin. */ public function ajax_send_email_invite() { check_ajax_referer( 'aish_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized', 'ai-semantic-handshake' ) ); } $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; $site_url = isset( $_POST['site_url'] ) ? esc_url_raw( wp_unslash( $_POST['site_url'] ) ) : ''; $email_body = isset( $_POST['email_body'] ) ? sanitize_textarea_field( wp_unslash( $_POST['email_body'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via sanitize_text_field before normalization. $op_id_raw = isset( $_POST['op_id'] ) ? sanitize_text_field( wp_unslash( $_POST['op_id'] ) ) : ''; $op_id = $this->normalize_email_invite_op_id( $op_id_raw ); $this->set_email_invite_op_state( $op_id, 'processing' ); if ( empty( $email ) || ! is_email( $email ) ) { $msg = __( 'Please provide a valid email address.', 'ai-semantic-handshake' ); $this->set_email_invite_op_state( $op_id, 'failed', $msg ); wp_send_json_error( $msg ); } try { $sent = AISH_REST::send_email_invite( $email, $site_url, $email_body ); } catch ( Throwable $e ) { $msg = __( 'Failed to send email. Check your WordPress mail configuration.', 'ai-semantic-handshake' ); $this->set_email_invite_op_state( $op_id, 'failed', $msg ); AISH_Logger::log( 'error', sprintf( 'Email invite exception for %1$s: %2$s', $email, $e->getMessage() ) ); wp_send_json_error( $msg ); } if ( $sent ) { $this->set_email_invite_op_state( $op_id, 'sent', __( 'Invitation email sent!', 'ai-semantic-handshake' ) ); wp_send_json_success( array( 'status' => 'sent', 'op_id' => $op_id, ) ); } else { $invite_error = AISH_REST::get_last_email_invite_error(); $invite_detail = AISH_REST::get_last_email_invite_error_message(); if ( 'cooldown' === $invite_error ) { $msg = __( 'Invitation already sent recently. Please wait about 1 minute before sending again.', 'ai-semantic-handshake' ); $this->set_email_invite_op_state( $op_id, 'failed', $msg ); wp_send_json_error( $msg ); } if ( 'unconfirmed' === $invite_error ) { $msg = __( 'Send action completed, but confirmation is unavailable in your current mail setup. Please ask the recipient to check inbox/spam before retrying.', 'ai-semantic-handshake' ); $this->set_email_invite_op_state( $op_id, 'unconfirmed', $msg ); wp_send_json_success( array( 'status' => 'unconfirmed', 'message' => $msg, 'op_id' => $op_id, ) ); } if ( 'mail_failed_hook' === $invite_error && '' !== $invite_detail ) { $msg = sprintf( /* translators: %s: mail transport error detail */ __( 'Failed to send email: %s', 'ai-semantic-handshake' ), $invite_detail ); } else { $msg = __( 'Failed to send email. Check your WordPress mail configuration.', 'ai-semantic-handshake' ); } $this->set_email_invite_op_state( $op_id, 'failed', $msg ); wp_send_json_error( $msg ); } } }