PluginProbe
Live Chat & AI Chatbot – onWebChat / trunk
Live Chat & AI Chatbot – onWebChat vtrunk
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 1.0.6 All 48 releases
onwebchat / includes / woocommerce-orders.php

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

524 lines 21.8 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 $action = isset($params['action']) ? sanitize_text_field($params['action']) : '';
136
137 // "My recent orders" for a signed-in customer: email-only, no order number. Safe because the
138 // onWebChat server only ever sends a VERIFIED email here (it recomputed the identity hash this
139 // plugin emitted for the logged-in customer), and the request itself is HMAC-authenticated.
140 if ($action === 'recent_orders') {
141 return $this->handle_recent_orders($email);
142 }
143
144 // Both the order number AND the email are required for verification.
145 if (empty($order_number) || empty($email)) {
146 return new WP_REST_Response(array('found' => false, 'verified' => false, 'error' => 'need_more_info'), 200);
147 }
148
149 // Anti-abuse: store-wide failed-attempt throttle. Catches scripted enumeration across many
150 // different order numbers, which the per-order throttle below cannot see.
151 $site_fail_key = 'owc_ord_fail_global';
152 $site_fail = (int) get_transient($site_fail_key);
153 if ($site_fail >= 20) {
154 return new WP_REST_Response(array('found' => false, 'verified' => false, 'throttled' => true), 200);
155 }
156
157 $order = $this->find_order($order_number);
158 if (!$order) {
159 // Unknown order number: count it toward the store-wide throttle (order-number scanning) and
160 // do not reveal whether it exists.
161 set_transient($site_fail_key, $site_fail + 1, 10 * MINUTE_IN_SECONDS);
162 return new WP_REST_Response(array('found' => false, 'verified' => false), 200);
163 }
164
165 // Anti-brute-force: also cap failed verification attempts per order number. Resets after the window.
166 $fail_key = 'owc_ord_fail_' . md5((string) $order->get_id());
167 $fail_count = (int) get_transient($fail_key);
168 if ($fail_count >= 8) {
169 return new WP_REST_Response(array('found' => true, 'verified' => false, 'throttled' => true), 200);
170 }
171
172 // Identity verification: the order's billing email must match (case-insensitive). The email is
173 // the required key; a name alone can never unlock an order.
174 $verified = false;
175 $order_email = strtolower(trim((string) $order->get_billing_email()));
176 if ($order_email !== '' && $order_email === strtolower(trim($email))) {
177 $verified = true;
178 }
179
180 if (!$verified) {
181 // Order exists but the email did not match: count the miss (per-order AND store-wide) and
182 // return nothing sensitive.
183 set_transient($fail_key, $fail_count + 1, 15 * MINUTE_IN_SECONDS);
184 set_transient($site_fail_key, $site_fail + 1, 10 * MINUTE_IN_SECONDS);
185 return new WP_REST_Response(array('found' => true, 'verified' => false), 200);
186 }
187
188 // Successful match: clear the per-order failed-attempt counter.
189 delete_transient($fail_key);
190
191 return new WP_REST_Response($this->build_order_response($order), 200);
192 }
193
194 /**
195 * List the customer's most recent orders by email (action=recent_orders). Called (already
196 * HMAC-authenticated) only with server-verified emails, so no per-order fail counting applies;
197 * the response is always scoped to exactly that email.
198 *
199 * @param string $email
200 * @return WP_REST_Response
201 */
202 private function handle_recent_orders($email) {
203 if (empty($email)) {
204 return new WP_REST_Response(array('found' => false, 'verified' => false, 'error' => 'need_more_info'), 200);
205 }
206
207 $wc_orders = wc_get_orders(array(
208 'billing_email' => $email,
209 'limit' => 5,
210 'orderby' => 'date',
211 'order' => 'DESC',
212 'type' => 'shop_order', // exclude refund objects
213 ));
214
215 $orders = array();
216 foreach ($wc_orders as $order) {
217 // Never expose unfinished checkout drafts.
218 if ($order->get_status() === 'checkout-draft') {
219 continue;
220 }
221 $orders[] = $this->build_order_response($order);
222 }
223
224 if (empty($orders)) {
225 return new WP_REST_Response(array('found' => false, 'verified' => true, 'orders' => array()), 200);
226 }
227
228 return new WP_REST_Response(array('found' => true, 'verified' => true, 'orders' => $orders), 200);
229 }
230
231 /**
232 * Build the verified per-order payload in the shape the onWebChat server normalizer expects.
233 * Used by both the single-order lookup and the recent-orders listing.
234 *
235 * @param WC_Order $order
236 * @return array
237 */
238 private function build_order_response($order) {
239 $items = array();
240 foreach ($order->get_items() as $item) {
241 $items[] = array(
242 'name' => $item->get_name(),
243 'quantity' => $item->get_quantity(),
244 );
245 if (count($items) >= 20) {
246 break;
247 }
248 }
249
250 $status = $order->get_status(); // e.g. 'processing', 'completed'
251 return array(
252 'found' => true,
253 'verified' => true,
254 'order_number' => (string) $order->get_order_number(),
255 'status' => $status,
256 'status_label' => function_exists('wc_get_order_status_name') ? wc_get_order_status_name($status) : $status,
257 'date_created' => $order->get_date_created() ? wc_format_datetime($order->get_date_created()) : '',
258 'total' => $order->get_total(),
259 'currency' => $order->get_currency(),
260 'payment_method' => $order->get_payment_method_title(),
261 'shipping_method' => $order->get_shipping_method(),
262 'customer_note' => $order->get_customer_note(),
263 'items' => $items,
264 'tracking' => $this->get_tracking($order),
265 'history' => $this->get_history($order),
266 );
267 }
268
269 /**
270 * Customer-facing order notes as a status timeline: lets the AI explain how the order has
271 * progressed, and merchants often paste carrier + tracking info into these notes. ONLY notes of
272 * type "customer" are exposed (internal/private notes may hold merchant-only info). Oldest first,
273 * capped at the 6 most recent.
274 *
275 * @param WC_Order $order
276 * @return array
277 */
278 private function get_history($order) {
279 $history = array();
280
281 if (!function_exists('wc_get_order_notes')) {
282 return $history;
283 }
284
285 $notes = wc_get_order_notes(array(
286 'order_id' => $order->get_id(),
287 'type' => 'customer',
288 'limit' => 6,
289 ));
290
291 foreach (array_reverse($notes) as $note) {
292 $history[] = array(
293 'date' => isset($note->date_created) && $note->date_created ? wc_format_datetime($note->date_created) : '',
294 'status' => '',
295 'comment' => isset($note->content) ? (string) $note->content : '',
296 );
297 }
298
299 return $history;
300 }
301
302 /**
303 * Resolve an order number (which may be a custom/sequential number, possibly with a leading #)
304 * to a WC_Order. Best-effort support for the common order-number plugins.
305 *
306 * @param string $order_number
307 * @return WC_Order|false
308 */
309 private function find_order($order_number) {
310 $order_number = trim($order_number);
311 $clean = ltrim($order_number, '#');
312
313 // Sequential Order Numbers Pro / Free.
314 if (function_exists('wc_seq_order_number_pro')) {
315 $id = wc_seq_order_number_pro()->find_order_by_order_number($order_number);
316 if ($id) {
317 $order = wc_get_order($id);
318 if ($order) {
319 return $order;
320 }
321 }
322 }
323
324 // Let any other plugin map a display order number to an order ID.
325 $filtered_id = apply_filters('onwebchat_resolve_order_number', 0, $order_number);
326 if ($filtered_id) {
327 $order = wc_get_order(intval($filtered_id));
328 if ($order) {
329 return $order;
330 }
331 }
332
333 // Fall back to treating it as a numeric order ID.
334 if (is_numeric($clean)) {
335 $order = wc_get_order(intval($clean));
336 if ($order) {
337 return $order;
338 }
339 }
340
341 return false;
342 }
343
344 /**
345 * Best-effort tracking extraction from the popular shipment-tracking plugins.
346 *
347 * @param WC_Order $order
348 * @return array
349 */
350 private function get_tracking($order) {
351 $tracking = array();
352
353 // WooCommerce Shipment Tracking (official) and compatible plugins.
354 $items = $order->get_meta('_wc_shipment_tracking_items');
355 if (is_array($items)) {
356 foreach ($items as $it) {
357 if (!is_array($it)) {
358 continue;
359 }
360 $carrier = '';
361 if (!empty($it['tracking_provider'])) {
362 $carrier = $it['tracking_provider'];
363 } elseif (!empty($it['custom_tracking_provider'])) {
364 $carrier = $it['custom_tracking_provider'];
365 }
366 $tracking[] = array(
367 'carrier' => $carrier,
368 'number' => isset($it['tracking_number']) ? $it['tracking_number'] : '',
369 'url' => isset($it['custom_tracking_link']) ? $it['custom_tracking_link'] : '',
370 );
371 }
372 }
373
374 // AfterShip.
375 $aftership_num = $order->get_meta('_aftership_tracking_number');
376 if (!empty($aftership_num)) {
377 $tracking[] = array(
378 'carrier' => (string) $order->get_meta('_aftership_tracking_provider_name'),
379 'number' => (string) $aftership_num,
380 'url' => '',
381 );
382 }
383
384 return $tracking;
385 }
386
387 /**
388 * AJAX: merchant toggles "AI order status lookup" in the WooCommerce tab.
389 * Saves the option and pushes the config (enabled flag + REST url) to onWebChat.
390 */
391 public function ajax_save_order_lookup() {
392 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
393
394 if (!current_user_can('manage_options')) {
395 wp_send_json_error('Insufficient permissions');
396 }
397
398 $enabled = isset($_POST['order_lookup_enabled']) && $_POST['order_lookup_enabled'] === '1';
399
400 update_option('onwebchat_wc_order_lookup_enabled', $enabled);
401
402 $pushed = $this->push_order_lookup_config($enabled);
403
404 if (!$pushed['success']) {
405 if ($this->use_testing_mode) {
406 // Local testing: there may be no onWebChat server reachable on 127.0.0.1:81. Keep the
407 // local flag so the REST endpoint can be exercised directly, and just warn that the
408 // server was not notified (it would normally store the lookup URL).
409 wp_send_json_success(array(
410 'enabled' => $enabled,
411 'warning' => $pushed['error'],
412 'message' => ($enabled ? 'AI order status lookup enabled locally' : 'AI order status lookup disabled locally')
413 . ' (testing mode: onWebChat server not notified: ' . $pushed['error'] . ')',
414 ));
415 }
416
417 // Production: roll back the local flag if the server could not be told, so the UI reflects reality.
418 update_option('onwebchat_wc_order_lookup_enabled', false);
419 wp_send_json_error($pushed['error']);
420 }
421
422 wp_send_json_success(array(
423 'enabled' => $enabled,
424 'message' => $enabled ? 'AI order status lookup enabled' : 'AI order status lookup disabled',
425 ));
426 }
427
428 /**
429 * Push the order-lookup configuration to onWebChat (signed with the shared secret).
430 *
431 * @param bool $enabled
432 * @return array ['success' => bool, 'error' => string]
433 */
434 public function push_order_lookup_config($enabled) {
435 $chatId = get_option('onwebchat_plugin_option');
436 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
437
438 if (empty($chatId)) {
439 return array('success' => false, 'error' => 'No Chat ID configured. Please connect onWebChat first.');
440 }
441
442 $secret = get_option('onwebchat_wc_sync_secret');
443 if (empty($secret)) {
444 return array('success' => false, 'error' => 'WooCommerce is not connected. Please connect WooCommerce sync first.');
445 }
446
447 $chatIdKey = explode('/', $chatId)[0];
448
449 $endpoint = $this->use_testing_mode
450 ? 'http://127.0.0.1:81/api/integrations/woocommerce/order-lookup/config'
451 : 'https://www.onwebchat.com/api/integrations/woocommerce/order-lookup/config';
452
453 $payload = array(
454 'site_id' => $chatIdKey,
455 // Full REST url so the server reaches us correctly regardless of permalink settings.
456 'order_url' => rest_url('onwebchat/v1/order-lookup'),
457 'enabled' => $enabled ? 1 : 0,
458 );
459
460 // Sign with the existing plugin -> server scheme: HMAC(secret, siteKey.timestamp.nonce.body).
461 $timestamp = time();
462 $nonce = base64_encode(random_bytes(16));
463 $body_json = wp_json_encode($payload);
464 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
465 $signature = hash_hmac('sha256', $message, $secret);
466
467 $request_args = array(
468 'method' => 'POST',
469 'timeout' => 15,
470 'headers' => array(
471 'Content-Type' => 'application/json',
472 'X-OWC-SiteId' => $chatIdKey,
473 'X-OWC-Timestamp' => $timestamp,
474 'X-OWC-Nonce' => $nonce,
475 'X-OWC-Signature' => $signature,
476 ),
477 'body' => $body_json,
478 );
479
480 if ($this->use_testing_mode) {
481 $request_args['sslverify'] = false;
482 }
483
484 $response = wp_remote_post($endpoint, $request_args);
485
486 if (is_wp_error($response)) {
487 error_log('onWebChat Order Lookup - config push error: ' . $response->get_error_message());
488 return array('success' => false, 'error' => 'Could not reach onWebChat: ' . $response->get_error_message());
489 }
490
491 $code = wp_remote_retrieve_response_code($response);
492 if ($code >= 200 && $code < 300) {
493 // Clear any previous auth error on success (same convention as product sync).
494 delete_transient('onwebchat_wc_auth_error');
495 return array('success' => true, 'error' => '');
496 }
497
498 // Authentication failed: the shared secret is stale / out of sync with onWebChat. Self-heal the
499 // same way product sync does: drop the bad secret and raise the standard "reconnect" notice. The
500 // merchant reconnects ONCE and that single secret then works for BOTH product sync and order
501 // lookup, so order lookup never needs its own separate authorization.
502 if ($code === 401) {
503 if (!$this->use_testing_mode) {
504 delete_option('onwebchat_wc_sync_secret');
505 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);
506 error_log('onWebChat Order Lookup - config push auth failed (401): cleared secret, reconnect required.');
507 return array('success' => false, 'error' => 'Authentication expired. Please reconnect WooCommerce (one click), then enable order lookup again.', 'needs_reconnect' => true);
508 }
509 // Testing mode keeps the manually-managed secret; just report the mismatch.
510 error_log('onWebChat Order Lookup - config push auth failed (401) in testing mode: secret kept.');
511 return array('success' => false, 'error' => 'Invalid signature (testing mode: ensure the localhost ai_settings.woocommerce_secret matches the plugin secret).');
512 }
513
514 $body = json_decode(wp_remote_retrieve_body($response), true);
515 $err = (is_array($body) && isset($body['error'])) ? $body['error'] : ('HTTP ' . $code);
516 error_log('onWebChat Order Lookup - config push failed: ' . $err);
517 return array('success' => false, 'error' => $err);
518 }
519 }
520
521 // Initialize the order-lookup module.
522 global $onwebchat_wc_orders;
523 $onwebchat_wc_orders = new OnWebChat_WooCommerce_Orders();
524