3, 'per_number_day' => 10, 'per_rule_hour' => 30, ); /** * Statuses that do not count towards a limit (nothing left the site). */ const NOT_COUNTED = array( 'blocked_stop', 'rate_limited' ); /** * The admin screens warn this long after the last stopped SMS. */ const NOTICE_FOR = DAY_IN_SECONDS; /** * Transient prefix: a rule whose stopped SMS were logged within the last hour. */ const LOGGED_PREFIX = 'smsportal_limit_logged_'; /** * Why an SMS to a customer must not go, or null when it may. * * @param string $recipient E.164 number. * @param int $rule_id Rule that sends it (0 = no per-rule cap). * @return string|null Reason shown in the Sent tab. */ public static function check( string $recipient, int $rule_id ): ?string { if ( ! self::country_allowed( $recipient ) ) { return __( 'Not sent: the number is from a country that is not allowed (Settings > Allowed recipient countries).', '85pl-android-sms-api' ); } $limits = self::rate_limits(); if ( self::count_since( 'recipient', $recipient, HOUR_IN_SECONDS ) >= $limits['per_number_hour'] ) { /* translators: %d: number of SMS. */ return sprintf( __( 'Safety limit: at most %d SMS an hour to one number.', '85pl-android-sms-api' ), $limits['per_number_hour'] ); } if ( self::count_since( 'recipient', $recipient, DAY_IN_SECONDS ) >= $limits['per_number_day'] ) { /* translators: %d: number of SMS. */ return sprintf( __( 'Safety limit: at most %d SMS in 24 hours to one number.', '85pl-android-sms-api' ), $limits['per_number_day'] ); } if ( $rule_id > 0 && self::count_since( 'rule_id', (string) $rule_id, HOUR_IN_SECONDS ) >= $limits['per_rule_hour'] ) { /* translators: %d: number of SMS. */ return sprintf( __( 'Safety limit: at most %d SMS an hour from one rule.', '85pl-android-sms-api' ), $limits['per_rule_hour'] ); } return null; } /** * Limits in force. * * @return array{per_number_hour:int,per_number_day:int,per_rule_hour:int} */ public static function rate_limits(): array { /** * Filters the safety limits for SMS to customers. * * @param array{per_number_hour:int,per_number_day:int,per_rule_hour:int} $limits SMS per number per hour, * per number per 24 hours, per rule per hour. */ $filtered = apply_filters( 'smsportal_rate_limits', self::DEFAULT_LIMITS ); $limits = array(); foreach ( self::DEFAULT_LIMITS as $key => $default ) { $limits[ $key ] = is_array( $filtered ) && isset( $filtered[ $key ] ) && is_numeric( $filtered[ $key ] ) ? max( 0, (int) $filtered[ $key ] ) : $default; } return $limits; } /** * Calling codes customers may have (digits, e.g. "48"). * * @return array */ public static function allowed_calling_codes(): array { $codes = self::codes_from_setting( (string) get_option( self::COUNTRIES_OPTION, '' ) ); if ( array() === $codes ) { $codes = array( Phone::default_country_code() ); } /** * Filters the calling codes of numbers that SMS to customers may go to. * * @param array $codes Calling codes, e.g. array( '48', '49' ). */ $filtered = apply_filters( 'smsportal_allowed_calling_codes', $codes ); $result = array(); foreach ( is_array( $filtered ) ? $filtered : array() as $code ) { $digits = is_scalar( $code ) ? (string) preg_replace( '/\D/', '', (string) $code ) : ''; if ( '' !== $digits && ! in_array( $digits, $result, true ) ) { $result[] = $digits; } } return $result; } /** * The setting cleaned up ("pl ;de, +420" → "PL, DE, 420"), or the entries that are not a country. * * @param string $raw As typed. * @return array{value:string,invalid:array} */ public static function clean_setting( string $raw ): array { $value = array(); $invalid = array(); foreach ( self::tokens( $raw ) as $token ) { $code = self::token_code( $token ); if ( null === $code ) { $invalid[] = $token; continue; } $entry = ctype_digit( ltrim( $token, '+' ) ) ? $code : strtoupper( $token ); if ( ! in_array( $entry, $value, true ) ) { $value[] = $entry; } } return array( 'value' => implode( ', ', $value ), 'invalid' => $invalid, ); } /** * Remember a stopped SMS in the connection log: once per rule per hour, without the number. * * @param int $rule_id Rule ID (0 = no rule). * @param string $reason Why it was stopped. * @return void */ public static function log_stopped( int $rule_id, string $reason ): void { if ( ! self::first_this_hour( 'limit_' . $rule_id ) ) { return; } if ( 0 === $rule_id ) { /* translators: %s: reason, e.g. "Safety limit: at most 3 SMS an hour to one number." */ ConnectionLog::add( 'rate_limited', sprintf( __( 'An SMS from an automation (e.g. Uncanny Automator) was stopped. %s Further stops in the next hour are not logged; see the Sent tab.', '85pl-android-sms-api' ), $reason ) ); return; } /* translators: 1: rule ID, 2: reason, e.g. "Safety limit: at most 3 SMS an hour to one number." */ ConnectionLog::add( 'rate_limited', sprintf( __( 'Rule #%1$d: an SMS to a customer was stopped. %2$s Further stops of this rule in the next hour are not logged; see the Sent tab.', '85pl-android-sms-api' ), $rule_id, $reason ) ); } /** * Remember that a rule dropped an SMS because the customer's number is not valid (once per rule per hour). * * @param int $rule_id Rule ID. * @return void */ public static function log_invalid_number( int $rule_id ): void { if ( ! self::first_this_hour( 'invalid_' . $rule_id ) ) { return; } /* translators: %d: rule ID. */ ConnectionLog::add( 'send_error', sprintf( __( 'Rule #%d: the customer\'s phone number is not valid, so no SMS was sent. Further cases of this rule in the next hour are not logged.', '85pl-android-sms-api' ), $rule_id ) ); } /** * Whether an SMS was stopped by a limit recently (the admin screens show a warning then). * * @return bool */ public static function recently_stopped(): bool { global $wpdb; $since = gmdate( 'Y-m-d H:i:s', time() - self::NOTICE_FOR ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, indexed by status. return null !== $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}smsportal_messages WHERE status = 'rate_limited' AND created_at >= %s LIMIT 1", $since ) ); } /** * Whether the number's calling code is allowed. * * @param string $recipient E.164 number. * @return bool */ private static function country_allowed( string $recipient ): bool { foreach ( self::allowed_calling_codes() as $code ) { // Calling codes are prefix-free, so a prefix match is exact. if ( 0 === strpos( $recipient, '+' . $code ) ) { return true; } } return false; } /** * Rows of a recipient or a rule created in the last $seconds, not counting blocked or stopped ones. * * @param string $column recipient or rule_id. * @param string $value Number or rule ID. * @param int $seconds Window. * @return int */ private static function count_since( string $column, string $value, int $seconds ): int { global $wpdb; $since = gmdate( 'Y-m-d H:i:s', time() - $seconds ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. $count = 'rule_id' === $column ? $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}smsportal_messages WHERE rule_id = %d AND created_at >= %s AND status NOT IN ( 'blocked_stop', 'rate_limited' )", (int) $value, $since ) ) : $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}smsportal_messages WHERE recipient = %s AND created_at >= %s AND status NOT IN ( 'blocked_stop', 'rate_limited' )", $value, $since ) ); // phpcs:enable return (int) $count; } /** * Calling codes of the setting's valid entries. * * @param string $raw Setting. * @return array */ private static function codes_from_setting( string $raw ): array { $codes = array(); foreach ( self::tokens( $raw ) as $token ) { $code = self::token_code( $token ); if ( null !== $code && ! in_array( $code, $codes, true ) ) { $codes[] = $code; } } return $codes; } /** * Entries of the setting (split on commas, semicolons and spaces). * * @param string $raw Setting. * @return array */ private static function tokens( string $raw ): array { $parts = preg_split( '/[\s,;]+/', trim( $raw ), -1, PREG_SPLIT_NO_EMPTY ); return is_array( $parts ) ? $parts : array(); } /** * Calling code of one entry: an ISO country code from Phone::COUNTRIES or 1-3 digits ("+" allowed). * * @param string $token Entry. * @return string|null */ private static function token_code( string $token ): ?string { $digits = ltrim( $token, '+' ); if ( '' !== $digits && ctype_digit( $digits ) && strlen( $digits ) <= 3 && '0' !== $digits[0] ) { return $digits; } return 2 === strlen( $token ) ? Phone::calling_code( $token ) : null; } /** * True the first time a key is seen in an hour. * * @param string $key Key. * @return bool */ private static function first_this_hour( string $key ): bool { $transient = self::LOGGED_PREFIX . $key; if ( false !== get_transient( $transient ) ) { return false; } set_transient( $transient, 1, HOUR_IN_SECONDS ); return true; } }