=' ) ) { return; } self::cleanup_stale_events(); update_option( 'sixrsh_plugin_version', $current, false ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cron::maybe_upgrade_cleanup exception: ' . $e->getMessage() ); } } /** * Strip ALL stale sixrsh_sync_cart entries from the WP-Cron option. * * Versions < 1.5.2 scheduled an immediately-due, un-deduplicated single * event per cart change; affected sites accumulated thousands of stale * entries (2.5 MB `cron` row, autoload=on) that saturated CPU as stacked * cron runners rewrote the whole serialized blob every tick. * * Bounded and idempotent by design: ONE direct DB read (bypassing the * possibly-stale in-process alloptions cache), ONE array pass, ONE write — * never a per-event wp_unschedule_event() loop, which would rewrite the * blob N times. Object caches (Redis/Memcached) are defeated with * wp_cache_delete('cron','options') before and after the write. * * @return void */ public static function cleanup_stale_events(): void { global $wpdb; try { // Drop any cached copy so both our read and update_option's internal // read see the real row, not a stale object-cache entry. wp_cache_delete( 'cron', 'options' ); // phpcs:disable WordPress.DB.DirectDatabaseQuery $raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'cron' ) ); // phpcs:enable if ( ! $raw ) { return; } $cron = maybe_unserialize( $raw ); if ( ! is_array( $cron ) ) { return; } // Single pass: remove every sixrsh_sync_cart entry regardless of args, // and drop timestamp buckets left empty. The top-level 'version' key // (a scalar) is skipped by the is_array() check. $removed = 0; foreach ( $cron as $timestamp => $hooks ) { if ( ! is_array( $hooks ) || ! isset( $hooks['sixrsh_sync_cart'] ) ) { continue; } $removed += is_array( $hooks['sixrsh_sync_cart'] ) ? count( $hooks['sixrsh_sync_cart'] ) : 1; unset( $cron[ $timestamp ]['sixrsh_sync_cart'] ); if ( empty( $cron[ $timestamp ] ) ) { unset( $cron[ $timestamp ] ); } } if ( 0 === $removed ) { return; } // Single UPDATE of the pruned array. update_option( 'cron', $cron ); wp_cache_delete( 'cron', 'options' ); if ( $removed > 500 ) { SixRsh_Logger::log( 'cron_cleanup', '', '', 'info', sprintf( 'Cleaned %d stale sixrsh_sync_cart cron events.', $removed ) ); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cron::cleanup_stale_events exception: ' . $e->getMessage() ); } } /** * Deactivation: clear any pending plugin-owned cron events. * * @return void */ public static function deactivate(): void { try { wp_clear_scheduled_hook( 'sixrsh_send_order_notification' ); wp_clear_scheduled_hook( 'sixrsh_send_customer_notification' ); wp_clear_scheduled_hook( 'sixrsh_sync_cart' ); wp_clear_scheduled_hook( 'sixrsh_cron_cleanup' ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cron::deactivate exception: ' . $e->getMessage() ); } } /** * Send an order notification for a single WC order + event key. * * @param int $order_id WooCommerce order ID. * @param string $event_key Event identifier (e.g. order_placed). * * @return void */ public static function send_order_notification( int $order_id, string $event_key ): void { try { // Resolve refunds to their parent before reading billing data — see // SixRsh_Order_Resolver. A bare wc_get_order() here fatals on any // refund id, and the merge-tag resolver below reads billing too. $order = SixRsh_Order_Resolver::billable_order( $order_id ); if ( ! $order ) { error_log( "[6rsh WhatsApp] send_order_notification: no billable order behind {$order_id}." ); return; } // Check if this event is enabled (per-option format). if ( ! get_option( 'sixrsh_event_' . $event_key . '_enabled', false ) ) { return; } // Consent check. $settings = get_option( 'sixrsh_settings', array() ); if ( ! empty( $settings['consent_required'] ) ) { $consent = $order->get_meta( '_sixrsh_consent', true ); if ( '1' !== $consent ) { return; } } // Get customer phone. $raw_phone = $order->get_billing_phone(); $country_code = SixRsh_Phone::country_for( (string) $order->get_billing_country() ); $phone = SixRsh_Phone::normalize( (string) $raw_phone, $country_code ); if ( ! $phone ) { SixRsh_Logger::log( $event_key, (string) $raw_phone, '', 'failed', 'Invalid or empty phone number.' ); $order->add_order_note( sprintf( /* translators: 1: event key */ __( '[6rsh] Could not send %s notification: invalid phone number.', '6rsh-messaging-notifications' ), $event_key ) ); return; } // Get template (per-option format, fall back to legacy mapping). $tmpl_raw = get_option( 'sixrsh_event_' . $event_key . '_template', '' ); $event_map = $tmpl_raw ? json_decode( $tmpl_raw, true ) : null; if ( ! $event_map ) { $legacy = get_option( 'sixrsh_template_mapping', array() ); $event_map = $legacy[ $event_key ] ?? null; } $template_id = $event_map['template_id'] ?? null; $template_name = $event_map['template_name'] ?? ''; if ( ! $template_id ) { SixRsh_Logger::log( $event_key, $phone, '', 'failed', 'No template mapped for this event.' ); return; } // Build variables from saved per-event mapping, falling back to smart defaults. $variables = self::build_variables_from_mapping( $event_key, $template_name, $template_id, $order ); // Build API payload. Include source + event metadata so 6rsh labels // the notification correctly (woocommerce, not generic api) and can log // the precise event (order id + status). The idempotency key dedupes // re-fires / webhook retries for the same order + event server-side. // Send the numeric 6rsh template id when we have it: a name alone is // ambiguous when the same template exists in English and Arabic. $template_ref = $template_id ? (string) $template_id : $template_name; $payload = array( 'template' => $template_ref, 'source' => 'woocommerce', 'metadata' => array( 'order_id' => $order_id, 'order_number' => (string) $order->get_order_number(), 'event' => $event_key, 'order_status' => $order->get_status(), ), 'recipients' => array( array( 'phone' => $phone, 'first_name' => (string) $order->get_billing_first_name(), 'last_name' => (string) $order->get_billing_last_name(), 'variables' => $variables, ), ), ); // Idempotency dedups instant double-fires (the status_changed hook + // the dedicated status_{x} hook) and quick retries, but is bucketed to // ~5 minutes so a GENUINE later re-entry into the same status notifies // again (a 24h key would silently suppress legitimate re-processing). $idem_key = 'woo_' . $order_id . '_' . $event_key . '_' . (int) floor( time() / 300 ); $api = SixRsh_API::instance(); $result = $api ? $api->send_notification( $payload, $idem_key ) : array( 'success' => false, 'error' => 'No API key configured.' ); if ( $result['success'] ) { SixRsh_Logger::log( $event_key, $phone, $template_name, 'success' ); $order->add_order_note( sprintf( /* translators: 1: event key 2: phone */ __( '[6rsh] WhatsApp notification sent for %1$s to %2$s.', '6rsh-messaging-notifications' ), $event_key, $phone ) ); } else { $error = $result['error'] ?? 'Unknown error'; SixRsh_Logger::log( $event_key, $phone, $template_name, 'failed', $error ); // COD confirmation couldn't go out → release the hold and fall // back to the store's normal "Order Placed" message. if ( SixRsh_COD::EVENT === $event_key ) { SixRsh_COD::release_hold( $order ); if ( get_option( 'sixrsh_event_order_placed_enabled', false ) ) { self::send_order_notification( $order_id, 'order_placed' ); } } $order->add_order_note( sprintf( /* translators: 1: event key 2: error */ __( '[6rsh] WhatsApp notification failed for %1$s: %2$s', '6rsh-messaging-notifications' ), $event_key, $error ) ); } } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cron::send_order_notification exception: ' . $e->getMessage() ); } } /** * Send a new_customer notification for a newly registered WC customer. * * @param int $customer_id WP user ID. * * @return void */ public static function send_customer_notification( int $customer_id ): void { try { $event_key = 'new_customer'; if ( ! get_option( 'sixrsh_event_new_customer_enabled', false ) ) { return; } $user = get_user_by( 'id', $customer_id ); if ( ! $user ) { return; } $raw_phone = (string) get_user_meta( $customer_id, 'billing_phone', true ); $country_code = SixRsh_Phone::country_for( (string) get_user_meta( $customer_id, 'billing_country', true ) ); $phone = SixRsh_Phone::normalize( $raw_phone, $country_code ); if ( ! $phone ) { SixRsh_Logger::log( $event_key, $raw_phone, '', 'failed', 'Invalid or empty phone number.' ); return; } $tmpl_raw = get_option( 'sixrsh_event_new_customer_template', '' ); $event_map = $tmpl_raw ? json_decode( $tmpl_raw, true ) : null; if ( ! $event_map ) { $legacy = get_option( 'sixrsh_template_mapping', array() ); $event_map = $legacy[ $event_key ] ?? null; } $template_id = $event_map['template_id'] ?? null; $template_name = $event_map['template_name'] ?? ''; if ( ! $template_id ) { SixRsh_Logger::log( $event_key, $phone, '', 'failed', 'No template mapped for this event.' ); return; } $variables = self::build_customer_variables_from_mapping( $event_key, $template_name, $template_id, $user ); // Send the numeric 6rsh template id when we have it: a name alone is // ambiguous when the same template exists in English and Arabic. $template_ref = $template_id ? (string) $template_id : $template_name; $payload = array( 'template' => $template_ref, 'source' => 'woocommerce', 'recipients' => array( array( 'phone' => $phone, 'first_name' => (string) $user->first_name, 'last_name' => (string) $user->last_name, 'variables' => $variables, ), ), ); $api = SixRsh_API::instance(); $result = $api ? $api->send_notification( $payload ) : array( 'success' => false, 'error' => 'No API key configured.' ); $status = $result['success'] ? 'success' : ( $result['error'] ?? 'Unknown error' ); SixRsh_Logger::log( $event_key, $phone, $template_name, $result['success'] ? 'success' : 'failed', $result['success'] ? '' : $status ); } catch ( \Throwable $e ) { error_log( '[6rsh WhatsApp] Cron::send_customer_notification exception: ' . $e->getMessage() ); } } /** * No-op guard for legacy sixrsh_sync_cart events (since 1.5.3). * * Nothing has scheduled this hook since 1.5.2 (live capture goes through * the /6rsh/v1/capture-cart REST endpoint), but sites upgrading from older * versions may still carry stale queued events. Keeping the hook bound to * this guard means any event that fires during cleanup does nothing — * intentionally no logging, no work. Remove hook + guard in 1.5.4+. * * @param mixed $cart_token Ignored legacy cart token. * * @return void */ public static function sync_cart( $cart_token = '' ): void { // Intentional no-op. } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * Smart-default field for each {{N}} position (1-indexed). * * @return array */ private static function default_field_map(): array { return array( 1 => 'first_name', 2 => 'order_number', 3 => 'order_total', 4 => 'store_name', 5 => 'order_status', 6 => 'store_url', ); } /** * Build the keyed variables array for an order notification. * * Uses the saved per-event variable mapping when set, falling back to * position-based smart defaults trimmed to the template's variable count. * * @param string $event_key Event slug. * @param string $template_name Template name. * @param string|int $template_id Template DB id. * @param \WC_Order $order WooCommerce order. * * @return array */ /** * Public wrapper so other classes (follow-ups) can reuse variable mapping. * * @param string $event_key Event slug. * @param string $template_name Template name. * @param string|int $template_id Template id. * @param \WC_Order $order Order. * * @return array */ public static function build_order_variables( string $event_key, string $template_name, $template_id, \WC_Order $order ): array { return self::build_variables_from_mapping( $event_key, $template_name, $template_id, $order ); } private static function build_variables_from_mapping( string $event_key, string $template_name, $template_id, \WC_Order $order ): array { $mapping = self::load_var_mapping( $event_key, $template_name, $template_id ); $vars = array(); foreach ( $mapping as $item ) { $vars[ $item['key'] ] = self::resolve_field( $item['field'], $order, $item['value'] ?? '' ); } return $vars; } /** * Build the keyed variables array for a customer notification. * * @param string $event_key Event slug. * @param string $template_name Template name. * @param string|int $template_id Template DB id. * @param \WP_User $user WordPress user. * * @return array */ private static function build_customer_variables_from_mapping( string $event_key, string $template_name, $template_id, \WP_User $user ): array { $mapping = self::load_var_mapping( $event_key, $template_name, $template_id ); $vars = array(); foreach ( $mapping as $item ) { $vars[ $item['key'] ] = self::resolve_customer_field( $item['field'], $user, $item['value'] ?? '' ); } return $vars; } /** * Load the variable mapping for an event. If none is saved, builds smart * defaults based on the template's variable count from the cache. * * @param string $event_key Event slug. * @param string $template_name Template name. * @param string|int $template_id Template DB id. * * @return array */ private static function load_var_mapping( string $event_key, string $template_name, $template_id ): array { $raw = get_option( 'sixrsh_event_' . $event_key . '_variables', '' ); $mapping = $raw ? json_decode( $raw, true ) : array(); if ( ! empty( $mapping ) && is_array( $mapping ) ) { return $mapping; } // Fallback: smart defaults trimmed to the template's actual variable count. $var_count = self::get_cached_var_count( $template_name, $template_id ); $field_map = self::default_field_map(); $mapping = array(); for ( $i = 1; $i <= $var_count; $i++ ) { $mapping[] = array( 'key' => (string) $i, 'field' => $field_map[ $i ] ?? 'store_name' ); } return $mapping; } /** * Resolve a field token against a WooCommerce order. * * @param string $field Token key. * @param \WC_Order $order Order object. * @param string $custom Custom text value (used when $field === 'custom'). * * @return string */ private static function resolve_field( string $field, \WC_Order $order, string $custom = '' ): string { switch ( $field ) { case 'first_name': $v = trim( (string) $order->get_billing_first_name() ); return $v ?: __( 'Valued Customer', '6rsh-messaging-notifications' ); case 'last_name': return (string) $order->get_billing_last_name(); case 'full_name': $v = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ); return $v ?: __( 'Valued Customer', '6rsh-messaging-notifications' ); case 'order_number': return '#' . $order->get_order_number(); case 'order_total': // wc_price() returns the currency symbol as an HTML entity // ($, د.إ) and a   — WhatsApp shows those // literally, so decode to plain text. $total = wp_strip_all_tags( wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ) ); $total = html_entity_decode( $total, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); return trim( str_replace( "\xC2\xA0", ' ', $total ) ); case 'order_status': return wc_get_order_status_name( $order->get_status() ); case 'order_date': $dt = $order->get_date_created(); return $dt ? $dt->date_i18n( get_option( 'date_format' ) ) : ''; case 'store_name': return get_bloginfo( 'name' ); case 'store_url': return get_site_url(); case 'product_names': return implode( ', ', array_map( fn( $i ) => $i->get_name(), $order->get_items() ) ); case 'payment_method': return (string) $order->get_payment_method_title(); case 'billing_phone': return (string) $order->get_billing_phone(); case 'billing_city': return (string) $order->get_billing_city(); case 'billing_country': return (string) $order->get_billing_country(); case 'custom': return $custom; default: return ''; } } /** * Resolve a field token for a new-customer event (no order available). * * @param string $field Token key. * @param \WP_User $user WordPress user. * @param string $custom Custom text value. * * @return string */ private static function resolve_customer_field( string $field, \WP_User $user, string $custom = '' ): string { switch ( $field ) { case 'first_name': return $user->first_name ?: $user->display_name ?: __( 'Valued Customer', '6rsh-messaging-notifications' ); case 'last_name': return (string) $user->last_name; case 'full_name': $v = trim( $user->first_name . ' ' . $user->last_name ); return $v ?: ( $user->display_name ?: __( 'Valued Customer', '6rsh-messaging-notifications' ) ); case 'store_name': return get_bloginfo( 'name' ); case 'store_url': return get_site_url(); case 'billing_phone': return (string) get_user_meta( $user->ID, 'billing_phone', true ); case 'billing_city': return (string) get_user_meta( $user->ID, 'billing_city', true ); case 'billing_country': return (string) get_user_meta( $user->ID, 'billing_country', true ); case 'custom': return $custom; default: return ''; } } /** * Get a template's variable count from the cached available-templates list. * * @param string $name Template name. * @param string|int $id Template DB id. * * @return int */ private static function get_cached_var_count( string $name, $id ): int { $templates = get_option( 'sixrsh_available_templates', array() ); foreach ( $templates as $t ) { if ( ( $name && ( $t['name'] ?? '' ) === $name ) || ( $id && (string) ( $t['id'] ?? '' ) === (string) $id ) ) { return (int) ( $t['variables_count'] ?? 0 ); } } return 0; } }