api_key = $api_key; } /** * Return a shared instance built from the stored option. * * @return self|null Null when no key is configured. */ public static function instance(): ?self { $key = get_option( 'sixrsh_api_key', '' ); if ( ! $key ) { return null; } return new self( (string) $key ); } // ------------------------------------------------------------------------- // Endpoints // ------------------------------------------------------------------------- /** * GET /api/v1/ping — verify connection and get account info. * * @return array{success: bool, data: mixed, error: string} */ public function ping(): array { return $this->get( 'ping' ); } /** * GET /api/v1/templates — fetch available templates. * * @return array{success: bool, data: mixed, error: string} */ public function get_templates(): array { return $this->get( 'templates' ); } /** * GET /api/v1/plugin/entitlements — plan feature flags + limits. * * Used for the LIVE check on gated actions: pass a tight timeout (3s) so a * slow endpoint can never hold up a click. This is the freshness guarantee * behind the ≤5s upgrade-unlock requirement. * * @param int $timeout Request timeout in seconds. * * @return array{success: bool, data: mixed, error: string} */ public function get_entitlements( int $timeout = 3 ): array { return $this->get( 'plugin/entitlements', $timeout ); } /** * POST /api/v1/plugin/callback — register the store's plan.changed callback * URL. Returns the HMAC signing secret (store it once). * * @param string $callback_url Public URL the platform posts plan.changed to. * * @return array{success: bool, data: mixed, error: string} */ public function register_callback( string $callback_url ): array { return $this->post( 'plugin/callback', array( 'callback_url' => $callback_url ) ); } /** * POST /api/v1/web-chat/provision — fetch (creating on first call) this * store's Web Chat widget key and register the storefront's domain. * * This is what makes the Web Chat setup one click instead of a copy-paste of * 32 characters: the store is already connected, so it asks over that link. * Registering the domain in the same call matters as much as the key — the * server's Origin allowlist fails closed, so a key without its domain is a * widget that renders nothing and reports nothing. * * @param string $domain Storefront host or URL. Server stores host only. * * @return array{success: bool, data: mixed, error: string} */ public function provision_web_chat( string $domain = '' ): array { return $this->post( 'web-chat/provision', array( 'domain' => $domain ) ); } /** * POST /api/v1/messages/send — send a plain text message. * * @param string $phone Recipient phone in E.164 format. * @param string $text Message body. * * @return array{success: bool, data: mixed, error: string} */ public function send_message( string $phone, string $text ): array { return $this->post( 'messages/send', array( 'phone' => $phone, 'message' => $text ) ); } /** * POST /api/v1/notifications/send — dispatch a notification. * * @param array $payload Notification payload. * @param string $idempotency_key Optional unique key so re-fires / retries never double-send. * * @return array{success: bool, data: mixed, error: string} */ public function send_notification( array $payload, string $idempotency_key = '' ): array { $headers = '' !== $idempotency_key ? array( 'Idempotency-Key' => $idempotency_key ) : array(); return $this->post( 'notifications/send', $payload, $headers ); } /** * POST /api/v1/marketing/followup-send — delayed marketing follow-up. * * @param array $payload Follow-up payload. * @param string $idempotency_key Unique key so retries never double-send. * * @return array{success: bool, data: mixed, error: string} */ public function send_followup( array $payload, string $idempotency_key ): array { return $this->post( 'marketing/followup-send', $payload, array( 'Idempotency-Key' => $idempotency_key ) ); } /** * POST /api/v1/orders/upsert — sync live order status for AI lookups. * * @param array $payload Order snapshot. * * @return array{success: bool, data: mixed, error: string} */ public function upsert_order( array $payload ): array { return $this->post( 'orders/upsert', $payload ); } /** * POST /api/v1/orders/upsert — FIRE-AND-FORGET order sync. * * The order upsert is idempotent on (user_id, order_number) server-side and we * never use its response, so it runs non-blocking: the worker dispatches the * request and is freed immediately instead of waiting up to TIMEOUT seconds. * This is what keeps the live shutdown push and the hourly reconcile sweep from * pinning workers / CPU. Returns true when the request was handed off (never a * delivery guarantee — the reconcile sweep is the backstop). * * @param array $payload Order snapshot. * * @return bool True when the request was dispatched without a transport error. */ public function upsert_order_async( array $payload ): bool { return $this->post_async( 'orders/upsert', $payload ); } /** * POST /api/v1/carts/upsert — create or update a tracked cart. * * @param array $payload Cart data. * * @return array{success: bool, data: mixed, error: string} */ public function upsert_cart( array $payload ): array { return $this->post( 'carts/upsert', $payload ); } /** * POST /api/v1/carts/close — mark a cart as closed/converted. * * @param array $payload Must include cart_token. * * @return array{success: bool, data: mixed, error: string} */ public function close_cart( array $payload ): array { return $this->post( 'carts/close', $payload ); } /** * POST /api/v1/products/upsert — push up to 100 catalogue rows. * * @param array $rows Product rows (see SixRsh_Product_Sync::row()). * * @return array{success: bool, data: mixed, error: string} */ public function upsert_products( array $rows ): array { return $this->post( 'products/upsert', array( 'products' => array_values( $rows ) ) ); } /** * POST /api/v1/products/delete — remove products (and their variations). * * @param string[] $ids Product ids. * * @return array{success: bool, data: mixed, error: string} */ public function delete_products( array $ids ): array { return $this->post( 'products/delete', array( 'external_ids' => array_values( $ids ) ) ); } /** * GET /api/v1/carts/stats — retrieve cart recovery statistics. * * @return array{success: bool, data: mixed, error: string} */ public function get_cart_stats(): array { return $this->get( 'carts/stats' ); } // ------------------------------------------------------------------------- // HTTP helpers // ------------------------------------------------------------------------- /** * Build the common request headers. * * @return array */ private function headers(): array { return array( 'Authorization' => 'Bearer ' . $this->api_key, 'Content-Type' => 'application/json', 'Accept' => 'application/json', // Lets 6rsh show which plugin version a connected store runs. 'X-6rsh-Plugin-Version' => SIXRSH_VERSION, ); } /** * Perform a GET request. * * @param string $endpoint Relative endpoint path. * * @return array{success: bool, data: mixed, error: string} */ private function get( string $endpoint, ?int $timeout = null ): array { try { $url = SIXRSH_API_BASE . '/' . ltrim( $endpoint, '/' ); $response = wp_remote_get( $url, array( 'timeout' => $timeout ?? self::TIMEOUT, 'headers' => $this->headers(), 'sslverify' => true, ) ); return $this->parse_response( $response ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] API GET exception: ' . $e->getMessage() ); return array( 'success' => false, 'data' => null, 'error' => $e->getMessage() ); } } /** * Perform a POST request. * * @param string $endpoint Relative endpoint path. * @param array $body Request body (will be JSON-encoded). * * @return array{success: bool, data: mixed, error: string} */ private function post( string $endpoint, array $body = array(), array $extra_headers = array() ): array { try { $url = SIXRSH_API_BASE . '/' . ltrim( $endpoint, '/' ); $response = wp_remote_post( $url, array( 'timeout' => self::TIMEOUT, 'headers' => array_merge( $this->headers(), $extra_headers ), 'body' => wp_json_encode( $body ), 'sslverify' => true, ) ); return $this->parse_response( $response ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] API POST exception: ' . $e->getMessage() ); return array( 'success' => false, 'data' => null, 'error' => $e->getMessage() ); } } /** * Perform a fire-and-forget POST. The request is dispatched non-blocking * (blocking=false) with a very short timeout, so the worker is freed at once * and never waits on the round-trip. Use ONLY for idempotent calls whose * response we do not need. * * @param string $endpoint Relative endpoint path. * @param array $body Request body (JSON-encoded). * @param array $extra_headers Optional extra headers. * * @return bool True when dispatched without a transport error. */ private function post_async( string $endpoint, array $body = array(), array $extra_headers = array() ): bool { try { $url = SIXRSH_API_BASE . '/' . ltrim( $endpoint, '/' ); $response = wp_remote_post( $url, array( 'timeout' => self::ASYNC_TIMEOUT, 'blocking' => false, 'headers' => array_merge( $this->headers(), $extra_headers ), 'body' => wp_json_encode( $body ), 'sslverify' => true, ) ); return ! is_wp_error( $response ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] API POST(async) exception: ' . $e->getMessage() ); return false; } } /** * Parse a wp_remote_* response into a normalized array. * * @param array|\WP_Error $response Response from wp_remote_get/post. * * @return array{success: bool, data: mixed, error: string} */ private function parse_response( $response ): array { if ( is_wp_error( $response ) ) { $raw_msg = $response->get_error_message(); // Replace low-level cURL errors with a user-friendly message. $message = ( strpos( $raw_msg, 'cURL error' ) !== false || strpos( $raw_msg, 'Could not resolve' ) !== false ) ? __( 'Cannot reach 6rsh.com. Please check your server\'s internet connection or firewall settings.', '6rsh-messaging-notifications' ) : $raw_msg; return array( 'success' => false, 'data' => null, 'error' => $message, 'error_code' => 'NETWORK_ERROR' ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $decoded = json_decode( $body, true ); if ( $code >= 400 ) { $message = ''; $error_code = ''; if ( is_array( $decoded ) ) { $raw = $decoded['message'] ?? $decoded['error'] ?? null; if ( is_array( $raw ) ) { // Structured error object: {code, message, details} $message = $raw['message'] ?? wp_json_encode( $raw ); $error_code = $raw['code'] ?? ''; } elseif ( $raw !== null ) { $message = (string) $raw; } elseif ( ! empty( $decoded['results'] ) ) { // Per-recipient failure: extract first result's error message. $first = reset( $decoded['results'] ); $err = $first['error'] ?? null; $message = is_array( $err ) ? ( $err['message'] ?? wp_json_encode( $err ) ) : (string) ( $err ?? 'Unknown error' ); } else { $message = wp_remote_retrieve_response_message( $response ); } } else { $message = wp_remote_retrieve_response_message( $response ); } return array( 'success' => false, 'data' => $decoded, 'error' => $message, 'error_code' => $error_code, ); } $api_success = is_array( $decoded ) && isset( $decoded['success'] ) ? (bool) $decoded['success'] : true; if ( ! $api_success ) { $raw = $decoded['message'] ?? $decoded['error'] ?? 'Unknown error'; $err = is_array( $raw ) ? ( $raw['message'] ?? wp_json_encode( $raw ) ) : (string) $raw; $error_code = is_array( $raw ) ? ( $raw['code'] ?? '' ) : ''; } else { $err = ''; $error_code = ''; } return array( 'success' => $api_success, 'data' => $decoded, 'error' => $err, 'error_code' => $error_code, ); } }