! empty( $web_chat['enabled'] ), 'public_key' => isset( $web_chat['public_key'] ) ? (string) $web_chat['public_key'] : '', // Privacy: when a merchant turns identity passing off we send an // anonymous session and nothing else. Defaults to ON only because the // merchant must explicitly enable Web Chat in the first place. 'pass_identity' => ! isset( $web_chat['pass_identity'] ) || ! empty( $web_chat['pass_identity'] ), 'pass_cart' => ! isset( $web_chat['pass_cart'] ) || ! empty( $web_chat['pass_cart'] ), 'position' => isset( $web_chat['position'] ) && in_array( $web_chat['position'], array( 'left', 'right' ), true ) ? $web_chat['position'] : 'auto', 'host' => isset( $web_chat['host'] ) && $web_chat['host'] ? untrailingslashit( (string) $web_chat['host'] ) : 'https://www.6rsh.com', ); } /** * Inject the loader and the bridge script. * * Never throws, never blocks render, and never emits a notice: a store's * checkout must not care whether 6rsh is reachable. * * @return void */ public static function render(): void { try { $settings = self::settings(); if ( ! $settings['enabled'] || '' === $settings['public_key'] ) { return; } // Never render on the checkout or pay pages: a chat bubble over a // payment form costs conversions and is exactly where a script error // would be most expensive. if ( function_exists( 'is_checkout' ) && is_checkout() ) { return; } // Version-stamped so a widget update actually reaches shoppers. // The loader sits behind the host's CDN/Varnish, which rewrites the // Cache-Control on this file; without a changing query string a // browser (and the edge) would keep serving the copy it cached the // first time a visitor loaded the store. $loader = add_query_arg( 'v', SIXRSH_VERSION, $settings['host'] . '/webchat/v1/6rsh.js' ); $context = array( 'restUrl' => esc_url_raw( rest_url( '6rsh/v1/webchat-identity' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'identify' => $settings['pass_identity'] && is_user_logged_in(), 'cart' => $settings['pass_cart'] ? self::cart_context() : null, 'product' => self::product_context(), ); $position = 'auto' === $settings['position'] ? '' : $settings['position']; wp_print_inline_script_tag( '(function(w,d,s,k,p){w.SixrshWidget=w.SixrshWidget||{};w.SixrshWidget.key=k;if(p){w.SixrshWidget.position=p;}' . 'var f=d.getElementsByTagName(s)[0],j=d.createElement(s);j.async=1;' . 'j.src=' . wp_json_encode( $loader ) . ';f.parentNode.insertBefore(j,f);})' . '(window,document,"script",' . wp_json_encode( $settings['public_key'] ) . ',' . wp_json_encode( $position ) . ');' . 'window.SixrshWooContext=' . wp_json_encode( $context ) . ';' ); wp_enqueue_script( 'sixrsh-webchat-bridge', plugins_url( 'public/webchat-bridge.js', SIXRSH_FILE ), array(), SIXRSH_VERSION, true ); } catch ( \Throwable $e ) { // Swallowed ON PURPOSE at the render boundary only: a broken chat // widget must never take down a merchant's storefront. It is still // recorded so the failure is not invisible. if ( class_exists( 'SixRsh_Logger' ) ) { SixRsh_Logger::log( 'web_chat_render_failed', $e->getMessage() ); } } } /** * Register the identity-signing route. * * @return void */ public static function register_routes(): void { register_rest_route( '6rsh/v1', '/webchat-identity', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'sign_identity' ), // Anyone may call it; the callback itself returns nothing useful // unless WordPress says the caller is logged in. 'permission_callback' => '__return_true', 'args' => array( 'visitor_token' => array( 'required' => true, 'type' => 'string', ), ), ) ); register_rest_route( '6rsh/v1', '/webchat-cart', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'rest_cart' ), 'permission_callback' => '__return_true', ) ); } /** * Current cart, for the bridge's live cart-change pushes. * * Returns the CALLER'S OWN cart — WooCommerce resolves it from their session * cookie, so there is no way to ask for somebody else's basket. * * @return WP_REST_Response */ public static function rest_cart() { $settings = self::settings(); if ( ! $settings['enabled'] || ! $settings['pass_cart'] ) { return new WP_REST_Response( array( 'cart' => null ), 200 ); } if ( function_exists( 'wc_load_cart' ) && ( ! function_exists( 'WC' ) || ! WC()->cart ) ) { // REST requests do not initialise the cart; hydrate it from the // customer's session the same way class-cart-tracker does. wc_load_cart(); } return new WP_REST_Response( array( 'cart' => self::cart_context() ), 200 ); } /** * Sign an identity assertion for the CURRENTLY LOGGED-IN customer. * * The only identity this will ever sign is the one WordPress's own auth * cookie proves. A logged-out caller, or a merchant who disabled identity * passing, gets nothing — not an error the page has to handle, just no * assertion, and the visitor stays anonymous. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public static function sign_identity( $request ) { $settings = self::settings(); if ( ! $settings['enabled'] || ! $settings['pass_identity'] || ! is_user_logged_in() ) { return new WP_REST_Response( array( 'assertion' => null ), 200 ); } $token = (string) $request->get_param( 'visitor_token' ); // The token is a 40-char handle minted by 6rsh; anything else is noise. if ( 40 !== strlen( $token ) || ! ctype_alnum( $token ) ) { return new WP_REST_Response( array( 'assertion' => null ), 200 ); } $api_key = (string) get_option( 'sixrsh_api_key', '' ); if ( '' === $api_key ) { return new WP_REST_Response( array( 'assertion' => null ), 200 ); } $user = wp_get_current_user(); $email = strtolower( trim( (string) $user->user_email ) ); $phone = self::customer_phone( $user->ID ); $expires = time() + self::ASSERTION_TTL; $nonce = wp_generate_password( 24, false, false ); $payload = implode( '|', array( $token, $email, $phone, $expires, $nonce ) ); // The shared secret is the SHA-256 of the API key: derivable by both // sides, transmitted by neither. The raw key never leaves this server. $signature = hash_hmac( 'sha256', $payload, hash( 'sha256', $api_key ) ); return new WP_REST_Response( array( 'assertion' => array( 'key_prefix' => substr( $api_key, 0, 12 ) . '...' . substr( $api_key, -4 ), 'email' => $email, 'phone' => $phone, 'expires_at' => $expires, 'nonce' => $nonce, 'signature' => $signature, ), 'name' => $user->display_name, ), 200 ); } /** * Best-effort billing phone for a customer, normalised the way 6rsh stores it. * * @param int $user_id User id. * @return string */ private static function customer_phone( int $user_id ): string { $raw = (string) get_user_meta( $user_id, 'billing_phone', true ); if ( '' === $raw ) { return ''; } $plus = 0 === strpos( trim( $raw ), '+' ); $digits = preg_replace( '/\D+/', '', $raw ); if ( '' === $digits ) { return ''; } return $plus ? '+' . $digits : $digits; } /** * Current cart contents, value and currency. * * @return array|null */ public static function cart_context(): ?array { try { if ( ! function_exists( 'WC' ) || ! WC()->cart ) { return null; } $items = array(); foreach ( WC()->cart->get_cart() as $item ) { $product = isset( $item['data'] ) ? $item['data'] : null; if ( ! $product ) { continue; } $items[] = array( 'id' => (int) $product->get_id(), 'name' => (string) $product->get_name(), 'quantity' => (int) $item['quantity'], ); // A cart summary is context for an agent, not an inventory feed. if ( count( $items ) >= 20 ) { break; } } return array( 'items' => $items, 'count' => (int) WC()->cart->get_cart_contents_count(), 'value' => (float) WC()->cart->get_cart_contents_total(), 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '', ); } catch ( \Throwable $e ) { return null; } } /** * Product / category context for the current page, so the AI can answer * "is this available in M?" without the customer naming the product. * * @return array|null */ private static function product_context(): ?array { try { if ( function_exists( 'is_product' ) && is_product() ) { $product = function_exists( 'wc_get_product' ) ? wc_get_product( get_the_ID() ) : null; if ( ! $product ) { return null; } $terms = get_the_terms( $product->get_id(), 'product_cat' ); return array( 'type' => 'product', 'id' => (int) $product->get_id(), 'name' => (string) $product->get_name(), 'price' => (string) $product->get_price(), 'in_stock' => (bool) $product->is_in_stock(), 'category' => ( is_array( $terms ) && $terms ) ? (string) $terms[0]->name : '', ); } if ( function_exists( 'is_product_category' ) && is_product_category() ) { $term = get_queried_object(); return array( 'type' => 'category', 'id' => isset( $term->term_id ) ? (int) $term->term_id : 0, 'name' => isset( $term->name ) ? (string) $term->name : '', ); } return null; } catch ( \Throwable $e ) { return null; } } }