server). * - woocommerce-orders.php: onWebChat asks THIS store for a single order, live, during a chat * (server -> plugin), when the AI decides it needs "where is my order?" data. * * Why a separate, real-time path (and not product sync / embeddings): * Order status is per-customer, changes constantly and is sensitive. Putting it in the AI's training * data risks leaking Customer A's order to Customer B. So it must be a scoped, verified, on-demand * lookup instead. * * Security: * - Exposes a REST endpoint: POST /wp-json/onwebchat/v1/order-lookup * - Authenticated with HMAC-SHA256 over (timestamp.nonce.body) using the SAME shared secret that the * product sync already established (onwebchat_wc_sync_secret). The onWebChat server holds the same * secret and signs every request. * - IDENTITY VERIFICATION IS DONE HERE: order data is only returned when the supplied email OR billing * last name matches the order. A wrong/guessed identifier returns verified=false with no details, * so the AI can never reveal one customer's order to another. */ if (!defined('ABSPATH')) { exit; // Exit if accessed directly } class OnWebChat_WooCommerce_Orders { // Reject requests whose timestamp is more than this many seconds off (replay/clock-skew guard). private $max_timestamp_diff = 300; // 5 minutes private $use_testing_mode; public function __construct() { $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false; // The endpoint is always registered so the onWebChat server can reach it; the handler still // honours the on/off option below. add_action('rest_api_init', array($this, 'register_routes')); // Admin: toggle the feature on/off (saves the option + pushes config to onWebChat). add_action('wp_ajax_onwebchat_wc_save_order_lookup', array($this, 'ajax_save_order_lookup')); } /** * Register the REST route the onWebChat server calls to fetch a single order. */ public function register_routes() { register_rest_route('onwebchat/v1', '/order-lookup', array( 'methods' => 'POST', 'callback' => array($this, 'handle_order_lookup'), // Auth is enforced inside the callback via HMAC; this just lets the request through. 'permission_callback' => '__return_true', )); } /** * Verify the HMAC signature of an incoming server -> plugin request. * Scheme: signature = HMAC_SHA256(secret, timestamp . "." . nonce . "." . rawBody) * * @param WP_REST_Request $request * @return bool */ private function verify_request($request) { $secret = get_option('onwebchat_wc_sync_secret'); if (empty($secret)) { return false; } $timestamp = $request->get_header('x-owc-timestamp'); $nonce = $request->get_header('x-owc-nonce'); $signature = $request->get_header('x-owc-signature'); if (empty($timestamp) || empty($nonce) || empty($signature)) { return false; } // Freshness window (prevents replaying an old captured request). if (abs(time() - intval($timestamp)) > $this->max_timestamp_diff) { return false; } // Replay protection: a nonce may be used only once within the freshness window. $nonce_key = 'owc_ord_nonce_' . md5($nonce); if (get_transient($nonce_key)) { return false; } $body = $request->get_body(); $message = $timestamp . '.' . $nonce . '.' . $body; $expected = hash_hmac('sha256', $message, $secret); if (!hash_equals($expected, (string) $signature)) { return false; } set_transient($nonce_key, 1, $this->max_timestamp_diff); return true; } /** * Handle a live order lookup. Returns order status only when the caller proves the customer's * identity (email or billing last name matching the order). * * @param WP_REST_Request $request * @return WP_REST_Response */ public function handle_order_lookup($request) { // Feature must be enabled by the merchant. if (!get_option('onwebchat_wc_order_lookup_enabled', false)) { return new WP_REST_Response(array('error' => 'disabled'), 403); } // WooCommerce must be available. if (!function_exists('wc_get_order')) { return new WP_REST_Response(array('error' => 'woocommerce_inactive'), 503); } // Authenticate. if (!$this->verify_request($request)) { return new WP_REST_Response(array('error' => 'unauthorized'), 401); } $params = $request->get_json_params(); if (!is_array($params)) { $params = array(); } $order_number = isset($params['order_number']) ? sanitize_text_field($params['order_number']) : ''; $email = isset($params['email']) ? sanitize_email($params['email']) : ''; // last_name is accepted but is NOT a standalone key; the email is always required. $last_name = isset($params['last_name']) ? sanitize_text_field($params['last_name']) : ''; $action = isset($params['action']) ? sanitize_text_field($params['action']) : ''; // "My recent orders" for a signed-in customer: email-only, no order number. Safe because the // onWebChat server only ever sends a VERIFIED email here (it recomputed the identity hash this // plugin emitted for the logged-in customer), and the request itself is HMAC-authenticated. if ($action === 'recent_orders') { return $this->handle_recent_orders($email); } // Both the order number AND the email are required for verification. if (empty($order_number) || empty($email)) { return new WP_REST_Response(array('found' => false, 'verified' => false, 'error' => 'need_more_info'), 200); } // Anti-abuse: store-wide failed-attempt throttle. Catches scripted enumeration across many // different order numbers, which the per-order throttle below cannot see. $site_fail_key = 'owc_ord_fail_global'; $site_fail = (int) get_transient($site_fail_key); if ($site_fail >= 20) { return new WP_REST_Response(array('found' => false, 'verified' => false, 'throttled' => true), 200); } $order = $this->find_order($order_number); if (!$order) { // Unknown order number: count it toward the store-wide throttle (order-number scanning) and // do not reveal whether it exists. set_transient($site_fail_key, $site_fail + 1, 10 * MINUTE_IN_SECONDS); return new WP_REST_Response(array('found' => false, 'verified' => false), 200); } // Anti-brute-force: also cap failed verification attempts per order number. Resets after the window. $fail_key = 'owc_ord_fail_' . md5((string) $order->get_id()); $fail_count = (int) get_transient($fail_key); if ($fail_count >= 8) { return new WP_REST_Response(array('found' => true, 'verified' => false, 'throttled' => true), 200); } // Identity verification: the order's billing email must match (case-insensitive). The email is // the required key; a name alone can never unlock an order. $verified = false; $order_email = strtolower(trim((string) $order->get_billing_email())); if ($order_email !== '' && $order_email === strtolower(trim($email))) { $verified = true; } if (!$verified) { // Order exists but the email did not match: count the miss (per-order AND store-wide) and // return nothing sensitive. set_transient($fail_key, $fail_count + 1, 15 * MINUTE_IN_SECONDS); set_transient($site_fail_key, $site_fail + 1, 10 * MINUTE_IN_SECONDS); return new WP_REST_Response(array('found' => true, 'verified' => false), 200); } // Successful match: clear the per-order failed-attempt counter. delete_transient($fail_key); return new WP_REST_Response($this->build_order_response($order), 200); } /** * List the customer's most recent orders by email (action=recent_orders). Called (already * HMAC-authenticated) only with server-verified emails, so no per-order fail counting applies; * the response is always scoped to exactly that email. * * @param string $email * @return WP_REST_Response */ private function handle_recent_orders($email) { if (empty($email)) { return new WP_REST_Response(array('found' => false, 'verified' => false, 'error' => 'need_more_info'), 200); } $wc_orders = wc_get_orders(array( 'billing_email' => $email, 'limit' => 5, 'orderby' => 'date', 'order' => 'DESC', 'type' => 'shop_order', // exclude refund objects )); $orders = array(); foreach ($wc_orders as $order) { // Never expose unfinished checkout drafts. if ($order->get_status() === 'checkout-draft') { continue; } $orders[] = $this->build_order_response($order); } if (empty($orders)) { return new WP_REST_Response(array('found' => false, 'verified' => true, 'orders' => array()), 200); } return new WP_REST_Response(array('found' => true, 'verified' => true, 'orders' => $orders), 200); } /** * Build the verified per-order payload in the shape the onWebChat server normalizer expects. * Used by both the single-order lookup and the recent-orders listing. * * @param WC_Order $order * @return array */ private function build_order_response($order) { $items = array(); foreach ($order->get_items() as $item) { $items[] = array( 'name' => $item->get_name(), 'quantity' => $item->get_quantity(), ); if (count($items) >= 20) { break; } } $status = $order->get_status(); // e.g. 'processing', 'completed' return array( 'found' => true, 'verified' => true, 'order_number' => (string) $order->get_order_number(), 'status' => $status, 'status_label' => function_exists('wc_get_order_status_name') ? wc_get_order_status_name($status) : $status, 'date_created' => $order->get_date_created() ? wc_format_datetime($order->get_date_created()) : '', 'total' => $order->get_total(), 'currency' => $order->get_currency(), 'payment_method' => $order->get_payment_method_title(), 'shipping_method' => $order->get_shipping_method(), 'customer_note' => $order->get_customer_note(), 'items' => $items, 'tracking' => $this->get_tracking($order), 'history' => $this->get_history($order), ); } /** * Customer-facing order notes as a status timeline: lets the AI explain how the order has * progressed, and merchants often paste carrier + tracking info into these notes. ONLY notes of * type "customer" are exposed (internal/private notes may hold merchant-only info). Oldest first, * capped at the 6 most recent. * * @param WC_Order $order * @return array */ private function get_history($order) { $history = array(); if (!function_exists('wc_get_order_notes')) { return $history; } $notes = wc_get_order_notes(array( 'order_id' => $order->get_id(), 'type' => 'customer', 'limit' => 6, )); foreach (array_reverse($notes) as $note) { $history[] = array( 'date' => isset($note->date_created) && $note->date_created ? wc_format_datetime($note->date_created) : '', 'status' => '', 'comment' => isset($note->content) ? (string) $note->content : '', ); } return $history; } /** * Resolve an order number (which may be a custom/sequential number, possibly with a leading #) * to a WC_Order. Best-effort support for the common order-number plugins. * * @param string $order_number * @return WC_Order|false */ private function find_order($order_number) { $order_number = trim($order_number); $clean = ltrim($order_number, '#'); // Sequential Order Numbers Pro / Free. if (function_exists('wc_seq_order_number_pro')) { $id = wc_seq_order_number_pro()->find_order_by_order_number($order_number); if ($id) { $order = wc_get_order($id); if ($order) { return $order; } } } // Let any other plugin map a display order number to an order ID. $filtered_id = apply_filters('onwebchat_resolve_order_number', 0, $order_number); if ($filtered_id) { $order = wc_get_order(intval($filtered_id)); if ($order) { return $order; } } // Fall back to treating it as a numeric order ID. if (is_numeric($clean)) { $order = wc_get_order(intval($clean)); if ($order) { return $order; } } return false; } /** * Best-effort tracking extraction from the popular shipment-tracking plugins. * * @param WC_Order $order * @return array */ private function get_tracking($order) { $tracking = array(); // WooCommerce Shipment Tracking (official) and compatible plugins. $items = $order->get_meta('_wc_shipment_tracking_items'); if (is_array($items)) { foreach ($items as $it) { if (!is_array($it)) { continue; } $carrier = ''; if (!empty($it['tracking_provider'])) { $carrier = $it['tracking_provider']; } elseif (!empty($it['custom_tracking_provider'])) { $carrier = $it['custom_tracking_provider']; } $tracking[] = array( 'carrier' => $carrier, 'number' => isset($it['tracking_number']) ? $it['tracking_number'] : '', 'url' => isset($it['custom_tracking_link']) ? $it['custom_tracking_link'] : '', ); } } // AfterShip. $aftership_num = $order->get_meta('_aftership_tracking_number'); if (!empty($aftership_num)) { $tracking[] = array( 'carrier' => (string) $order->get_meta('_aftership_tracking_provider_name'), 'number' => (string) $aftership_num, 'url' => '', ); } return $tracking; } /** * AJAX: merchant toggles "AI order status lookup" in the WooCommerce tab. * Saves the option and pushes the config (enabled flag + REST url) to onWebChat. */ public function ajax_save_order_lookup() { check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Insufficient permissions'); } $enabled = isset($_POST['order_lookup_enabled']) && $_POST['order_lookup_enabled'] === '1'; update_option('onwebchat_wc_order_lookup_enabled', $enabled); $pushed = $this->push_order_lookup_config($enabled); if (!$pushed['success']) { if ($this->use_testing_mode) { // Local testing: there may be no onWebChat server reachable on 127.0.0.1:81. Keep the // local flag so the REST endpoint can be exercised directly, and just warn that the // server was not notified (it would normally store the lookup URL). wp_send_json_success(array( 'enabled' => $enabled, 'warning' => $pushed['error'], 'message' => ($enabled ? 'AI order status lookup enabled locally' : 'AI order status lookup disabled locally') . ' (testing mode: onWebChat server not notified: ' . $pushed['error'] . ')', )); } // Production: roll back the local flag if the server could not be told, so the UI reflects reality. update_option('onwebchat_wc_order_lookup_enabled', false); wp_send_json_error($pushed['error']); } wp_send_json_success(array( 'enabled' => $enabled, 'message' => $enabled ? 'AI order status lookup enabled' : 'AI order status lookup disabled', )); } /** * Push the order-lookup configuration to onWebChat (signed with the shared secret). * * @param bool $enabled * @return array ['success' => bool, 'error' => string] */ public function push_order_lookup_config($enabled) { $chatId = get_option('onwebchat_plugin_option'); $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; if (empty($chatId)) { return array('success' => false, 'error' => 'No Chat ID configured. Please connect onWebChat first.'); } $secret = get_option('onwebchat_wc_sync_secret'); if (empty($secret)) { return array('success' => false, 'error' => 'WooCommerce is not connected. Please connect WooCommerce sync first.'); } $chatIdKey = explode('/', $chatId)[0]; $endpoint = $this->use_testing_mode ? 'http://127.0.0.1:81/api/integrations/woocommerce/order-lookup/config' : 'https://www.onwebchat.com/api/integrations/woocommerce/order-lookup/config'; $payload = array( 'site_id' => $chatIdKey, // Full REST url so the server reaches us correctly regardless of permalink settings. 'order_url' => rest_url('onwebchat/v1/order-lookup'), 'enabled' => $enabled ? 1 : 0, ); // Sign with the existing plugin -> server scheme: HMAC(secret, siteKey.timestamp.nonce.body). $timestamp = time(); $nonce = base64_encode(random_bytes(16)); $body_json = wp_json_encode($payload); $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json; $signature = hash_hmac('sha256', $message, $secret); $request_args = array( 'method' => 'POST', 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json', 'X-OWC-SiteId' => $chatIdKey, 'X-OWC-Timestamp' => $timestamp, 'X-OWC-Nonce' => $nonce, 'X-OWC-Signature' => $signature, ), 'body' => $body_json, ); if ($this->use_testing_mode) { $request_args['sslverify'] = false; } $response = wp_remote_post($endpoint, $request_args); if (is_wp_error($response)) { error_log('onWebChat Order Lookup - config push error: ' . $response->get_error_message()); return array('success' => false, 'error' => 'Could not reach onWebChat: ' . $response->get_error_message()); } $code = wp_remote_retrieve_response_code($response); if ($code >= 200 && $code < 300) { // Clear any previous auth error on success (same convention as product sync). delete_transient('onwebchat_wc_auth_error'); return array('success' => true, 'error' => ''); } // Authentication failed: the shared secret is stale / out of sync with onWebChat. Self-heal the // same way product sync does: drop the bad secret and raise the standard "reconnect" notice. The // merchant reconnects ONCE and that single secret then works for BOTH product sync and order // lookup, so order lookup never needs its own separate authorization. if ($code === 401) { if (!$this->use_testing_mode) { delete_option('onwebchat_wc_sync_secret'); set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600); error_log('onWebChat Order Lookup - config push auth failed (401): cleared secret, reconnect required.'); return array('success' => false, 'error' => 'Authentication expired. Please reconnect WooCommerce (one click), then enable order lookup again.', 'needs_reconnect' => true); } // Testing mode keeps the manually-managed secret; just report the mismatch. error_log('onWebChat Order Lookup - config push auth failed (401) in testing mode: secret kept.'); return array('success' => false, 'error' => 'Invalid signature (testing mode: ensure the localhost ai_settings.woocommerce_secret matches the plugin secret).'); } $body = json_decode(wp_remote_retrieve_body($response), true); $err = (is_array($body) && isset($body['error'])) ? $body['error'] : ('HTTP ' . $code); error_log('onWebChat Order Lookup - config push failed: ' . $err); return array('success' => false, 'error' => $err); } } // Initialize the order-lookup module. global $onwebchat_wc_orders; $onwebchat_wc_orders = new OnWebChat_WooCommerce_Orders();