PluginProbe
AI Chatbot for WooCommerce & Live Chat – onWebChat / 3.7.0
AI Chatbot for WooCommerce & Live Chat – onWebChat v3.7.0
3.10.0 3.9.2 3.9.3 3.9.1 3.9.0 3.8.4 3.8.2 3.8.1 3.8.0 3.7.2 3.7.1 3.7.0 3.6.0 3.5.5 trunk 1.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.2 1.0.3 1.0.4 1.0.5 All 49 releases
onwebchat / includes / woocommerce-orders.php

woocommerce-orders.php in AI Chatbot for WooCommerce & Live Chat – onWebChat 3.7.0, at includes/woocommerce-orders.php

436 lines 18.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Order Lookup Module (live order status for the onWebChat AI chatbot)
4 *
5 * This is the OPPOSITE direction from woocommerce-sync.php:
6 * - woocommerce-sync.php : this plugin pushes catalog data TO onWebChat (plugin -> server).
7 * - woocommerce-orders.php: onWebChat asks THIS store for a single order, live, during a chat
8 * (server -> plugin), when the AI decides it needs "where is my order?" data.
9 *
10 * Why a separate, real-time path (and not product sync / embeddings):
11 * Order status is per-customer, changes constantly and is sensitive. Putting it in the AI's training
12 * data risks leaking Customer A's order to Customer B. So it must be a scoped, verified, on-demand
13 * lookup instead.
14 *
15 * Security:
16 * - Exposes a REST endpoint: POST /wp-json/onwebchat/v1/order-lookup
17 * - Authenticated with HMAC-SHA256 over (timestamp.nonce.body) using the SAME shared secret that the
18 * product sync already established (onwebchat_wc_sync_secret). The onWebChat server holds the same
19 * secret and signs every request.
20 * - IDENTITY VERIFICATION IS DONE HERE: order data is only returned when the supplied email OR billing
21 * last name matches the order. A wrong/guessed identifier returns verified=false with no details,
22 * so the AI can never reveal one customer's order to another.
23 */
24
25 if (!defined('ABSPATH')) {
26 exit; // Exit if accessed directly
27 }
28
29 class OnWebChat_WooCommerce_Orders {
30
31 // Reject requests whose timestamp is more than this many seconds off (replay/clock-skew guard).
32 private $max_timestamp_diff = 300; // 5 minutes
33
34 private $use_testing_mode;
35
36 public function __construct() {
37 $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false;
38
39 // The endpoint is always registered so the onWebChat server can reach it; the handler still
40 // honours the on/off option below.
41 add_action('rest_api_init', array($this, 'register_routes'));
42
43 // Admin: toggle the feature on/off (saves the option + pushes config to onWebChat).
44 add_action('wp_ajax_onwebchat_wc_save_order_lookup', array($this, 'ajax_save_order_lookup'));
45 }
46
47 /**
48 * Register the REST route the onWebChat server calls to fetch a single order.
49 */
50 public function register_routes() {
51 register_rest_route('onwebchat/v1', '/order-lookup', array(
52 'methods' => 'POST',
53 'callback' => array($this, 'handle_order_lookup'),
54 // Auth is enforced inside the callback via HMAC; this just lets the request through.
55 'permission_callback' => '__return_true',
56 ));
57 }
58
59 /**
60 * Verify the HMAC signature of an incoming server -> plugin request.
61 * Scheme: signature = HMAC_SHA256(secret, timestamp . "." . nonce . "." . rawBody)
62 *
63 * @param WP_REST_Request $request
64 * @return bool
65 */
66 private function verify_request($request) {
67 $secret = get_option('onwebchat_wc_sync_secret');
68 if (empty($secret)) {
69 return false;
70 }
71
72 $timestamp = $request->get_header('x-owc-timestamp');
73 $nonce = $request->get_header('x-owc-nonce');
74 $signature = $request->get_header('x-owc-signature');
75
76 if (empty($timestamp) || empty($nonce) || empty($signature)) {
77 return false;
78 }
79
80 // Freshness window (prevents replaying an old captured request).
81 if (abs(time() - intval($timestamp)) > $this->max_timestamp_diff) {
82 return false;
83 }
84
85 // Replay protection: a nonce may be used only once within the freshness window.
86 $nonce_key = 'owc_ord_nonce_' . md5($nonce);
87 if (get_transient($nonce_key)) {
88 return false;
89 }
90
91 $body = $request->get_body();
92 $message = $timestamp . '.' . $nonce . '.' . $body;
93 $expected = hash_hmac('sha256', $message, $secret);
94
95 if (!hash_equals($expected, (string) $signature)) {
96 return false;
97 }
98
99 set_transient($nonce_key, 1, $this->max_timestamp_diff);
100 return true;
101 }
102
103 /**
104 * Handle a live order lookup. Returns order status only when the caller proves the customer's
105 * identity (email or billing last name matching the order).
106 *
107 * @param WP_REST_Request $request
108 * @return WP_REST_Response
109 */
110 public function handle_order_lookup($request) {
111 // Feature must be enabled by the merchant.
112 if (!get_option('onwebchat_wc_order_lookup_enabled', false)) {
113 return new WP_REST_Response(array('error' => 'disabled'), 403);
114 }
115
116 // WooCommerce must be available.
117 if (!function_exists('wc_get_order')) {
118 return new WP_REST_Response(array('error' => 'woocommerce_inactive'), 503);
119 }
120
121 // Authenticate.
122 if (!$this->verify_request($request)) {
123 return new WP_REST_Response(array('error' => 'unauthorized'), 401);
124 }
125
126 $params = $request->get_json_params();
127 if (!is_array($params)) {
128 $params = array();
129 }
130
131 $order_number = isset($params['order_number']) ? sanitize_text_field($params['order_number']) : '';
132 $email = isset($params['email']) ? sanitize_email($params['email']) : '';
133 // last_name is accepted but is NOT a standalone key; the email is always required.
134 $last_name = isset($params['last_name']) ? sanitize_text_field($params['last_name']) : '';
135
136 // Both the order number AND the email are required for verification.
137 if (empty($order_number) || empty($email)) {
138 return new WP_REST_Response(array('found' => false, 'verified' => false, 'error' => 'need_more_info'), 200);
139 }
140
141 // Anti-abuse: store-wide failed-attempt throttle. Catches scripted enumeration across many
142 // different order numbers, which the per-order throttle below cannot see.
143 $site_fail_key = 'owc_ord_fail_global';
144 $site_fail = (int) get_transient($site_fail_key);
145 if ($site_fail >= 20) {
146 return new WP_REST_Response(array('found' => false, 'verified' => false, 'throttled' => true), 200);
147 }
148
149 $order = $this->find_order($order_number);
150 if (!$order) {
151 // Unknown order number: count it toward the store-wide throttle (order-number scanning) and
152 // do not reveal whether it exists.
153 set_transient($site_fail_key, $site_fail + 1, 10 * MINUTE_IN_SECONDS);
154 return new WP_REST_Response(array('found' => false, 'verified' => false), 200);
155 }
156
157 // Anti-brute-force: also cap failed verification attempts per order number. Resets after the window.
158 $fail_key = 'owc_ord_fail_' . md5((string) $order->get_id());
159 $fail_count = (int) get_transient($fail_key);
160 if ($fail_count >= 8) {
161 return new WP_REST_Response(array('found' => true, 'verified' => false, 'throttled' => true), 200);
162 }
163
164 // Identity verification: the order's billing email must match (case-insensitive). The email is
165 // the required key; a name alone can never unlock an order.
166 $verified = false;
167 $order_email = strtolower(trim((string) $order->get_billing_email()));
168 if ($order_email !== '' && $order_email === strtolower(trim($email))) {
169 $verified = true;
170 }
171
172 if (!$verified) {
173 // Order exists but the email did not match: count the miss (per-order AND store-wide) and
174 // return nothing sensitive.
175 set_transient($fail_key, $fail_count + 1, 15 * MINUTE_IN_SECONDS);
176 set_transient($site_fail_key, $site_fail + 1, 10 * MINUTE_IN_SECONDS);
177 return new WP_REST_Response(array('found' => true, 'verified' => false), 200);
178 }
179
180 // Successful match: clear the per-order failed-attempt counter.
181 delete_transient($fail_key);
182
183 // Build the (verified) response.
184 $items = array();
185 foreach ($order->get_items() as $item) {
186 $items[] = array(
187 'name' => $item->get_name(),
188 'quantity' => $item->get_quantity(),
189 );
190 if (count($items) >= 20) {
191 break;
192 }
193 }
194
195 $status = $order->get_status(); // e.g. 'processing', 'completed'
196 $data = array(
197 'found' => true,
198 'verified' => true,
199 'order_number' => (string) $order->get_order_number(),
200 'status' => $status,
201 'status_label' => function_exists('wc_get_order_status_name') ? wc_get_order_status_name($status) : $status,
202 'date_created' => $order->get_date_created() ? wc_format_datetime($order->get_date_created()) : '',
203 'total' => $order->get_total(),
204 'currency' => $order->get_currency(),
205 'payment_method' => $order->get_payment_method_title(),
206 'customer_note' => $order->get_customer_note(),
207 'items' => $items,
208 'tracking' => $this->get_tracking($order),
209 );
210
211 return new WP_REST_Response($data, 200);
212 }
213
214 /**
215 * Resolve an order number (which may be a custom/sequential number, possibly with a leading #)
216 * to a WC_Order. Best-effort support for the common order-number plugins.
217 *
218 * @param string $order_number
219 * @return WC_Order|false
220 */
221 private function find_order($order_number) {
222 $order_number = trim($order_number);
223 $clean = ltrim($order_number, '#');
224
225 // Sequential Order Numbers Pro / Free.
226 if (function_exists('wc_seq_order_number_pro')) {
227 $id = wc_seq_order_number_pro()->find_order_by_order_number($order_number);
228 if ($id) {
229 $order = wc_get_order($id);
230 if ($order) {
231 return $order;
232 }
233 }
234 }
235
236 // Let any other plugin map a display order number to an order ID.
237 $filtered_id = apply_filters('onwebchat_resolve_order_number', 0, $order_number);
238 if ($filtered_id) {
239 $order = wc_get_order(intval($filtered_id));
240 if ($order) {
241 return $order;
242 }
243 }
244
245 // Fall back to treating it as a numeric order ID.
246 if (is_numeric($clean)) {
247 $order = wc_get_order(intval($clean));
248 if ($order) {
249 return $order;
250 }
251 }
252
253 return false;
254 }
255
256 /**
257 * Best-effort tracking extraction from the popular shipment-tracking plugins.
258 *
259 * @param WC_Order $order
260 * @return array
261 */
262 private function get_tracking($order) {
263 $tracking = array();
264
265 // WooCommerce Shipment Tracking (official) and compatible plugins.
266 $items = $order->get_meta('_wc_shipment_tracking_items');
267 if (is_array($items)) {
268 foreach ($items as $it) {
269 if (!is_array($it)) {
270 continue;
271 }
272 $carrier = '';
273 if (!empty($it['tracking_provider'])) {
274 $carrier = $it['tracking_provider'];
275 } elseif (!empty($it['custom_tracking_provider'])) {
276 $carrier = $it['custom_tracking_provider'];
277 }
278 $tracking[] = array(
279 'carrier' => $carrier,
280 'number' => isset($it['tracking_number']) ? $it['tracking_number'] : '',
281 'url' => isset($it['custom_tracking_link']) ? $it['custom_tracking_link'] : '',
282 );
283 }
284 }
285
286 // AfterShip.
287 $aftership_num = $order->get_meta('_aftership_tracking_number');
288 if (!empty($aftership_num)) {
289 $tracking[] = array(
290 'carrier' => (string) $order->get_meta('_aftership_tracking_provider_name'),
291 'number' => (string) $aftership_num,
292 'url' => '',
293 );
294 }
295
296 return $tracking;
297 }
298
299 /**
300 * AJAX: merchant toggles "AI order status lookup" in the WooCommerce tab.
301 * Saves the option and pushes the config (enabled flag + REST url) to onWebChat.
302 */
303 public function ajax_save_order_lookup() {
304 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
305
306 if (!current_user_can('manage_options')) {
307 wp_send_json_error('Insufficient permissions');
308 }
309
310 $enabled = isset($_POST['order_lookup_enabled']) && $_POST['order_lookup_enabled'] === '1';
311
312 update_option('onwebchat_wc_order_lookup_enabled', $enabled);
313
314 $pushed = $this->push_order_lookup_config($enabled);
315
316 if (!$pushed['success']) {
317 if ($this->use_testing_mode) {
318 // Local testing: there may be no onWebChat server reachable on 127.0.0.1:81. Keep the
319 // local flag so the REST endpoint can be exercised directly, and just warn that the
320 // server was not notified (it would normally store the lookup URL).
321 wp_send_json_success(array(
322 'enabled' => $enabled,
323 'warning' => $pushed['error'],
324 'message' => ($enabled ? 'AI order status lookup enabled locally' : 'AI order status lookup disabled locally')
325 . ' (testing mode: onWebChat server not notified: ' . $pushed['error'] . ')',
326 ));
327 }
328
329 // Production: roll back the local flag if the server could not be told, so the UI reflects reality.
330 update_option('onwebchat_wc_order_lookup_enabled', false);
331 wp_send_json_error($pushed['error']);
332 }
333
334 wp_send_json_success(array(
335 'enabled' => $enabled,
336 'message' => $enabled ? 'AI order status lookup enabled' : 'AI order status lookup disabled',
337 ));
338 }
339
340 /**
341 * Push the order-lookup configuration to onWebChat (signed with the shared secret).
342 *
343 * @param bool $enabled
344 * @return array ['success' => bool, 'error' => string]
345 */
346 public function push_order_lookup_config($enabled) {
347 $chatId = get_option('onwebchat_plugin_option');
348 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
349
350 if (empty($chatId)) {
351 return array('success' => false, 'error' => 'No Chat ID configured. Please connect onWebChat first.');
352 }
353
354 $secret = get_option('onwebchat_wc_sync_secret');
355 if (empty($secret)) {
356 return array('success' => false, 'error' => 'WooCommerce is not connected. Please connect WooCommerce sync first.');
357 }
358
359 $chatIdKey = explode('/', $chatId)[0];
360
361 $endpoint = $this->use_testing_mode
362 ? 'http://127.0.0.1:81/api/integrations/woocommerce/order-lookup/config'
363 : 'https://www.onwebchat.com/api/integrations/woocommerce/order-lookup/config';
364
365 $payload = array(
366 'site_id' => $chatIdKey,
367 // Full REST url so the server reaches us correctly regardless of permalink settings.
368 'order_url' => rest_url('onwebchat/v1/order-lookup'),
369 'enabled' => $enabled ? 1 : 0,
370 );
371
372 // Sign with the existing plugin -> server scheme: HMAC(secret, siteKey.timestamp.nonce.body).
373 $timestamp = time();
374 $nonce = base64_encode(random_bytes(16));
375 $body_json = wp_json_encode($payload);
376 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
377 $signature = hash_hmac('sha256', $message, $secret);
378
379 $request_args = array(
380 'method' => 'POST',
381 'timeout' => 15,
382 'headers' => array(
383 'Content-Type' => 'application/json',
384 'X-OWC-SiteId' => $chatIdKey,
385 'X-OWC-Timestamp' => $timestamp,
386 'X-OWC-Nonce' => $nonce,
387 'X-OWC-Signature' => $signature,
388 ),
389 'body' => $body_json,
390 );
391
392 if ($this->use_testing_mode) {
393 $request_args['sslverify'] = false;
394 }
395
396 $response = wp_remote_post($endpoint, $request_args);
397
398 if (is_wp_error($response)) {
399 error_log('onWebChat Order Lookup - config push error: ' . $response->get_error_message());
400 return array('success' => false, 'error' => 'Could not reach onWebChat: ' . $response->get_error_message());
401 }
402
403 $code = wp_remote_retrieve_response_code($response);
404 if ($code >= 200 && $code < 300) {
405 // Clear any previous auth error on success (same convention as product sync).
406 delete_transient('onwebchat_wc_auth_error');
407 return array('success' => true, 'error' => '');
408 }
409
410 // Authentication failed: the shared secret is stale / out of sync with onWebChat. Self-heal the
411 // same way product sync does: drop the bad secret and raise the standard "reconnect" notice. The
412 // merchant reconnects ONCE and that single secret then works for BOTH product sync and order
413 // lookup, so order lookup never needs its own separate authorization.
414 if ($code === 401) {
415 if (!$this->use_testing_mode) {
416 delete_option('onwebchat_wc_sync_secret');
417 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);
418 error_log('onWebChat Order Lookup - config push auth failed (401): cleared secret, reconnect required.');
419 return array('success' => false, 'error' => 'Authentication expired. Please reconnect WooCommerce (one click), then enable order lookup again.', 'needs_reconnect' => true);
420 }
421 // Testing mode keeps the manually-managed secret; just report the mismatch.
422 error_log('onWebChat Order Lookup - config push auth failed (401) in testing mode: secret kept.');
423 return array('success' => false, 'error' => 'Invalid signature (testing mode: ensure the localhost ai_settings.woocommerce_secret matches the plugin secret).');
424 }
425
426 $body = json_decode(wp_remote_retrieve_body($response), true);
427 $err = (is_array($body) && isset($body['error'])) ? $body['error'] : ('HTTP ' . $code);
428 error_log('onWebChat Order Lookup - config push failed: ' . $err);
429 return array('success' => false, 'error' => $err);
430 }
431 }
432
433 // Initialize the order-lookup module.
434 global $onwebchat_wc_orders;
435 $onwebchat_wc_orders = new OnWebChat_WooCommerce_Orders();
436