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
',
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(
'',
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(
'%s ',
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
',
$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(
'%2$s
',
esc_attr( $partner['url'] ),
esc_html__( 'Cancel Request', 'ai-semantic-handshake' )
);
} elseif ( in_array( $status, array( 'active', 'broken' ), true ) ) {
$actions_html = sprintf(
'%2$s
',
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
',
$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(
'
%s
',
__( '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(
'',
__( '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 .= '
' . __( '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' ) . '
';
$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(
'
%s
%s
%s
%s
%s
%s
%s
%s
%s
',
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 .= '
';
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(
'%s ',
esc_attr( $value ),
selected( $status_filter, $value, false ),
esc_html( $this->get_history_status_label( $value ) )
);
}
$html = '';
$html .= '
';
$html .= '' . esc_html__( 'Status Filter', 'ai-semantic-handshake' ) . ' ';
$html .= '' . $options_html . ' ';
$html .= '
';
$html .= '
';
$html .= '' . esc_html__( 'Retry Selected', 'ai-semantic-handshake' ) . ' ';
$html .= '' . esc_html__( 'Reset Selected', 'ai-semantic-handshake' ) . ' ';
$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 .= '' . esc_html__( 'Direction', 'ai-semantic-handshake' ) . ' ';
$html .= '' . esc_html__( 'Site URL', 'ai-semantic-handshake' ) . ' ';
$html .= '' . esc_html__( 'Site Name', 'ai-semantic-handshake' ) . ' ';
$html .= '' . esc_html__( 'Relation', 'ai-semantic-handshake' ) . ' ';
$html .= '' . esc_html__( 'Note', 'ai-semantic-handshake' ) . ' ';
$html .= '' . esc_html__( 'Status', 'ai-semantic-handshake' ) . ' ';
$html .= '' . esc_html__( 'Sync', 'ai-semantic-handshake' ) . ' ';
$html .= '' . esc_html__( 'Snapshot Score', 'ai-semantic-handshake' ) . ' ';
$html .= '' . esc_html__( 'Time', 'ai-semantic-handshake' ) . ' ';
$html .= '' . esc_html__( 'Actions', 'ai-semantic-handshake' ) . ' ';
$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(
'
%s
%s
%s
%s
%s
%s
%s
%s
%s
%s
%s
',
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 .= '
';
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(
'%2$s %3$s
',
$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(
'',
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 );
}
}
}