*/ public static function options(): array { return array( self::CONNECTION => true, self::PROTECTION => true, self::WOOCOMMERCE => true, // Only needed when actually sending. self::TOKEN => false, ); } /* ------------------------------------------------------------------ */ /* Reading */ /* ------------------------------------------------------------------ */ /** * Connection tab values. * * @return array{sender: string} */ public static function connection(): array { $stored = get_option( self::CONNECTION, array() ); return wp_parse_args( is_array( $stored ) ? $stored : array(), self::connection_defaults() ); } /** * Protection tab values. * * @return array{recipient_hourly_limit: int, hourly_limit: int, log_retention_days: int} */ public static function protection(): array { $stored = get_option( self::PROTECTION, array() ); return wp_parse_args( is_array( $stored ) ? $stored : array(), self::protection_defaults() ); } /** * WooCommerce tab values. Templates may be empty, meaning "use the * default", so the default follows the site language until the shop owner * writes their own. * * @return array */ public static function woocommerce(): array { $stored = get_option( self::WOOCOMMERCE, array() ); $values = wp_parse_args( is_array( $stored ) ? $stored : array(), self::woocommerce_defaults() ); if ( ! is_array( $values['statuses'] ) ) { $values['statuses'] = array(); } if ( ! is_array( $values['admin_statuses'] ) ) { $values['admin_statuses'] = array(); } return $values; } /** * Connection defaults. */ public static function connection_defaults(): array { return array( 'sender' => '', ); } /** * Protection defaults: modest hourly caps for automatic SMS. */ public static function protection_defaults(): array { return array( 'recipient_hourly_limit' => 10, 'hourly_limit' => 200, 'log_retention_days' => 90, ); } /** * WooCommerce defaults. */ public static function woocommerce_defaults(): array { return array( 'customer_enabled' => true, 'statuses' => array(), 'phone_source' => 'billing_shipping', 'customer_note_enabled' => false, 'customer_note_template' => '', 'admin_enabled' => false, 'admin_phones' => '', 'admin_statuses' => array( 'processing' ), 'admin_template' => '', 'order_notes' => true, ); } /** * The sender to send with. */ public static function sender(): string { if ( self::sender_from_constant() ) { return self::clean_sender( (string) constant( self::SENDER_CONSTANT ) ); } return (string) self::connection()['sender']; } /** * Whether wp-config.php defines the sender. */ public static function sender_from_constant(): bool { return defined( self::SENDER_CONSTANT ) && is_string( constant( self::SENDER_CONSTANT ) ) && '' !== trim( (string) constant( self::SENDER_CONSTANT ) ); } /** * The plain access token, or '' when none is usable. */ public static function token(): string { if ( self::token_from_constant() ) { return self::clean_token( (string) constant( self::TOKEN_CONSTANT ) ); } $stored = get_option( self::TOKEN, '' ); if ( ! is_string( $stored ) || '' === $stored ) { return ''; } $plain = Crypto::decrypt( $stored ); return null === $plain ? '' : $plain; } /** * Where the token comes from: constant, option, unreadable (saved but the * WordPress salts changed) or missing. */ public static function token_state(): string { if ( self::token_from_constant() ) { return 'constant'; } $stored = get_option( self::TOKEN, '' ); if ( ! is_string( $stored ) || '' === $stored ) { return 'missing'; } return null === Crypto::decrypt( $stored ) ? 'unreadable' : 'option'; } /** * Whether wp-config.php defines the token. */ public static function token_from_constant(): bool { return defined( self::TOKEN_CONSTANT ) && is_string( constant( self::TOKEN_CONSTANT ) ) && '' !== trim( (string) constant( self::TOKEN_CONSTANT ) ); } /** * Last four characters of the token, for "saved, ends in ab12" hints. */ public static function token_hint(): string { $token = self::token(); return strlen( $token ) >= 8 ? substr( $token, -4 ) : ''; } /** * Token and sender are both in place. */ public static function is_configured(): bool { return '' !== self::token() && '' !== self::sender(); } /** * Settings for one order status, falling back to the defaults. * * @param string $status Status slug without "wc-". * @return array{enabled: bool, template: string} */ public static function customer_status( string $status ): array { $statuses = self::woocommerce()['statuses']; $stored = isset( $statuses[ $status ] ) && is_array( $statuses[ $status ] ) ? $statuses[ $status ] : null; $enabled = null === $stored ? in_array( $status, self::DEFAULT_CUSTOMER_STATUSES, true ) : ! empty( $stored['enabled'] ); $template = null === $stored ? '' : (string) ( $stored['template'] ?? '' ); return array( 'enabled' => $enabled, 'template' => '' === trim( $template ) ? self::default_customer_template( $status ) : $template, ); } /** * Admin alert template. */ public static function admin_template(): string { $template = (string) self::woocommerce()['admin_template']; return '' === trim( $template ) ? self::default_admin_template() : $template; } /** * Customer note template. */ public static function customer_note_template(): string { $template = (string) self::woocommerce()['customer_note_template']; return '' === trim( $template ) ? self::default_customer_note_template() : $template; } /** * Team phones, normalised (Israeli numbers only). * * @return string[] */ public static function admin_phones(): array { $phones = array(); foreach ( Phone::split_list( self::woocommerce()['admin_phones'] ) as $entry ) { $phone = Phone::normalize( $entry ); if ( '' !== $phone && Phone::is_israeli( $phone ) ) { $phones[ 'p' . $phone ] = $phone; } } return array_values( $phones ); } /* ------------------------------------------------------------------ */ /* Default copy (translated at the moment it is needed, never earlier) */ /* ------------------------------------------------------------------ */ /** * Default customer message for a status. * * @param string $status Status slug. */ public static function default_customer_template( string $status ): string { switch ( $status ) { case 'pending': return __( 'Hi {first_name}, your order #{order_number} at {site_name} is waiting for payment. You can complete it here: {payment_url}', 'vibrate-sms' ); case 'processing': return __( 'Hi {first_name}, thank you for your order #{order_number} at {site_name}! We are preparing it now. Total: {order_total}', 'vibrate-sms' ); case 'on-hold': return __( 'Hi {first_name}, your order #{order_number} at {site_name} is on hold. We will update you soon.', 'vibrate-sms' ); case 'completed': return __( 'Hi {first_name}, your order #{order_number} from {site_name} is complete. Thank you for shopping with us!', 'vibrate-sms' ); case 'cancelled': return __( 'Hi {first_name}, your order #{order_number} at {site_name} has been cancelled.', 'vibrate-sms' ); case 'refunded': return __( 'Hi {first_name}, your order #{order_number} at {site_name} has been refunded.', 'vibrate-sms' ); case 'failed': return __( 'Hi {first_name}, the payment for order #{order_number} at {site_name} did not go through. You can try again here: {payment_url}', 'vibrate-sms' ); default: return __( 'Hi {first_name}, the status of your order #{order_number} at {site_name} is now: {order_status}', 'vibrate-sms' ); } } /** * Default admin alert. */ public static function default_admin_template(): string { return __( 'New order #{order_number} at {site_name}: {order_total} from {full_name} ({billing_phone}). Status: {order_status}', 'vibrate-sms' ); } /** * Default customer note message. */ public static function default_customer_note_template(): string { return __( 'Update on your order #{order_number} from {site_name}: {customer_note}', 'vibrate-sms' ); } /* ------------------------------------------------------------------ */ /* Sanitising (register_setting callbacks) */ /* ------------------------------------------------------------------ */ /** * Sanitise the connection tab. * * Idempotent on purpose: WordPress runs the callback twice when an option * is created by update_option(). * * @param mixed $input Posted values. */ public static function sanitize_connection( $input ): array { $input = is_array( $input ) ? $input : array(); $current = self::connection(); $clean = self::connection_defaults(); $clean['sender'] = self::clean_sender( isset( $input['sender'] ) ? (string) $input['sender'] : $current['sender'] ); return $clean; } /** * Sanitise the access token. An empty field keeps the saved token, so the * secret never has to be printed back into the page. * * @param mixed $input Posted token. */ public static function sanitize_token( $input ): string { $existing = get_option( self::TOKEN, '' ); $existing = is_string( $existing ) ? $existing : ''; // options.php has already verified the nonce and capability. // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! empty( $_POST['vibrate_sms_token_remove'] ) ) { delete_transient( 'vibrate_sms_account' ); return ''; } $value = is_string( $input ) ? $input : ''; if ( Crypto::is_encrypted( $value ) ) { // Second pass of the same save, or our own write. return $value; } $token = self::clean_token( $value ); if ( '' === $token ) { return $existing; } if ( ! preg_match( '/^[\x21-\x7E]{16,512}$/', $token ) ) { self::settings_error( 'token', __( 'That does not look like a Vibrate access key. Copy it again from Vibrate (Developers > API Keys). The previous key was kept.', 'vibrate-sms' ) ); return $existing; } delete_transient( 'vibrate_sms_account' ); return Crypto::encrypt( $token ); } /** * Sanitise the protection tab. * * @param mixed $input Posted values. */ public static function sanitize_protection( $input ): array { $input = is_array( $input ) ? $input : array(); $clean = self::protection_defaults(); foreach ( array( 'recipient_hourly_limit', 'hourly_limit' ) as $key ) { $clean[ $key ] = isset( $input[ $key ] ) ? min( self::MAX_LIMIT, absint( $input[ $key ] ) ) : $clean[ $key ]; } $clean['log_retention_days'] = isset( $input['log_retention_days'] ) ? min( self::MAX_RETENTION_DAYS, absint( $input['log_retention_days'] ) ) : $clean['log_retention_days']; return $clean; } /** * Sanitise the WooCommerce tab. * * @param mixed $input Posted values. */ public static function sanitize_woocommerce( $input ): array { $input = is_array( $input ) ? $input : array(); $clean = self::woocommerce_defaults(); $clean['customer_enabled'] = ! empty( $input['customer_enabled'] ); $clean['customer_note_enabled'] = ! empty( $input['customer_note_enabled'] ); $clean['admin_enabled'] = ! empty( $input['admin_enabled'] ); $clean['order_notes'] = ! empty( $input['order_notes'] ); $source = isset( $input['phone_source'] ) ? sanitize_key( (string) $input['phone_source'] ) : ''; $clean['phone_source'] = in_array( $source, self::PHONE_SOURCES, true ) ? $source : 'billing_shipping'; $known = array_keys( self::order_statuses() ); $statuses = isset( $input['statuses'] ) && is_array( $input['statuses'] ) ? $input['statuses'] : array(); foreach ( $known as $status ) { $row = isset( $statuses[ $status ] ) && is_array( $statuses[ $status ] ) ? $statuses[ $status ] : array(); $template = self::clean_template( $row['template'] ?? '' ); // Saving the default text as-is stores nothing, so the default // keeps following the site language. if ( Text::trim( $template ) === Text::trim( self::default_customer_template( $status ) ) ) { $template = ''; } $clean['statuses'][ $status ] = array( 'enabled' => ! empty( $row['enabled'] ), 'template' => $template, ); } $admin_statuses = isset( $input['admin_statuses'] ) && is_array( $input['admin_statuses'] ) ? array_map( 'sanitize_key', $input['admin_statuses'] ) : array(); $clean['admin_statuses'] = array_values( array_intersect( $known, $admin_statuses ) ); foreach ( array( 'admin_template' => self::default_admin_template(), 'customer_note_template' => self::default_customer_note_template(), ) as $key => $default ) { $template = self::clean_template( $input[ $key ] ?? '' ); $clean[ $key ] = Text::trim( $template ) === Text::trim( $default ) ? '' : $template; } $valid = array(); $invalid = array(); foreach ( Phone::split_list( isset( $input['admin_phones'] ) ? (string) $input['admin_phones'] : '' ) as $entry ) { $phone = Phone::normalize( $entry ); if ( '' === $phone || ! Phone::is_israeli( $phone ) ) { $invalid[] = $entry; } else { // Prefixed key: a numeric string key would become an int. $valid[ 'p' . $phone ] = $phone; } } $clean['admin_phones'] = implode( ', ', array_map( array( Phone::class, 'display' ), array_values( $valid ) ) ); if ( $invalid ) { self::settings_error( 'admin_phones', sprintf( /* translators: %s: comma separated list of what the user typed. */ __( 'These team phone numbers are not valid Israeli numbers and were removed: %s', 'vibrate-sms' ), implode( ', ', array_map( 'sanitize_text_field', $invalid ) ) ) ); } if ( $clean['admin_enabled'] && ! $valid ) { self::settings_error( 'admin_phones', __( 'Admin alerts are on, but there is no valid phone number to send them to.', 'vibrate-sms' ) ); } return $clean; } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ /** * WooCommerce order statuses that can trigger an SMS, slug => label. * * @return array */ public static function order_statuses(): array { if ( ! function_exists( 'wc_get_order_statuses' ) ) { return array(); } $out = array(); foreach ( wc_get_order_statuses() as $key => $label ) { $slug = 0 === strpos( (string) $key, 'wc-' ) ? substr( (string) $key, 3 ) : (string) $key; if ( in_array( $slug, self::IGNORED_STATUSES, true ) ) { continue; } $out[ $slug ] = (string) $label; } return $out; } /** * Strip whitespace, invisible characters and a "Bearer " prefix that come * along when a key is pasted. * * @param string $token Raw token. */ public static function clean_token( string $token ): string { $token = trim( $token ); if ( 0 === stripos( $token, 'Bearer ' ) ) { $token = substr( $token, 7 ); } return (string) preg_replace( '/[^\x21-\x7E]/', '', $token ); } /** * Clean a sender name or number. * * @param string $sender Raw sender. */ public static function clean_sender( string $sender ): string { $sender = Text::trim( Text::without_markup( Text::clean( $sender ) ) ); $sender = (string) preg_replace( '/[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}\n\t]/u', '', $sender ); if ( Text::length( $sender ) > self::MAX_SENDER_LENGTH ) { $sender = (string) preg_replace( '/^(.{' . self::MAX_SENDER_LENGTH . '}).*$/us', '$1', $sender ); } return $sender; } /** * Clean a message template. * * @param mixed $template Raw template. */ public static function clean_template( $template ): string { $template = Text::without_markup( Text::clean( $template ) ); if ( Text::length( $template ) > Text::MAX_LENGTH ) { $template = (string) preg_replace( '/^(.{' . Text::MAX_LENGTH . '}).*$/us', '$1', $template ); } return $template; } /** * Queue a message for the settings page. Silently ignored outside it. * * @param string $code Field code. * @param string $message Message. */ private static function settings_error( string $code, string $message ): void { if ( function_exists( 'add_settings_error' ) ) { add_settings_error( 'vibrate_sms', 'vibrate_sms_' . $code, $message, 'error' ); } } }