$text ) { if ( RateLimiter::hit( self::BUCKET, $rule, 1, $cooldown ) ) { Queue::push( self::JOB_HOOK, [ 'rule' => $rule, 'text' => $text, ], 0, 5, '', true ); } } } /** * Deliver a queued alert. * * @param array $payload `rule` and `text`. * * @internal Queue handler. */ public static function handle( array $payload ): void { $text = (string) ( $payload['text'] ?? '' ); if ( '' !== $text ) { self::deliver( $text, (string) ( $payload['rule'] ?? '' ) ); } } /** * The enabled rules that currently hold, with the alert text for each. * * @param int $failures Current run of failed sends. * * @return array Rule => text. */ public static function conditions( int $failures ): array { $out = []; $shop = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); $tripped = self::on( 'alert_failsafe_on', true ) && Failsafe::isActive(); if ( $tripped ) { $out[ self::RULE_FAILSAFE ] = sprintf( /* translators: %s: site name */ __( '%s: پس از خطاهای پیاپی ارسال، ورود پیامکی کنار رفت و فرم ورود عادی نشان داده می‌شود.', 'rahkar-toolkit' ), $shop ); } // When the failsafe alert already covers it, a second message about the // same run of failures would only be noise. if ( ! $tripped && self::on( 'alert_fail_on', true ) ) { $fail = self::failureText( $failures, $shop ); if ( '' !== $fail ) { $out[ self::RULE_FAIL ] = $fail; } } if ( self::on( 'alert_volume_on', false ) ) { $limit = (int) Settings::get( OtpAuthModule::SLUG . '.alert_volume_hour', 100 ); $delivered = $limit > 0 ? MonitorStats::delivered( HOUR_IN_SECONDS ) : 0; if ( $limit > 0 && $delivered > $limit ) { $out[ self::RULE_VOLUME ] = sprintf( /* translators: 1: site name, 2: number of codes */ __( '%1$s: %2$d کد ورود در یک ساعت گذشته ارسال شد.', 'rahkar-toolkit' ), $shop, $delivered ); } } return $out; } /** * The "send a test alert" button. * * @return array{sent: int, errors: list} */ public static function sendTest(): array { return self::deliver( sprintf( /* translators: %s: site name */ __( '%s: پیام آزمایشی امنیت ورود.', 'rahkar-toolkit' ), wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ) ), 'test' ); } /** * Administrator numbers, canonical, without duplicates. * * @return list */ public static function recipients(): array { $raw = (string) Settings::get( OtpAuthModule::SLUG . '.alert_phones', '' ); $out = []; foreach ( preg_split( '/[\s,،]+/u', $raw ) ?: [] as $item ) { $phone = PhoneNumber::parse( trim( $item ) ); // Collected as values, not keys: PHP turns a numeric string key into an // integer, and a number is not a phone number to anything downstream. if ( null !== $phone && ! in_array( $phone->canonical(), $out, true ) ) { $out[] = $phone->canonical(); } } return $out; } /** * Settings key of a gateway's alert pattern. * * @param string $driverId Gateway id. */ public static function patternKey( string $driverId ): string { return 'alert_pattern_' . sanitize_key( $driverId ); } /** * Settings key of a gateway's alert pattern variable. * * @param string $driverId Gateway id. */ public static function variableKey( string $driverId ): string { return 'alert_var_' . sanitize_key( $driverId ); } /** * Send one alert to every administrator. * * @param string $text Alert text. * @param string $rule Rule that raised it, or `test`. * * @return array{sent: int, errors: list} */ private static function deliver( string $text, string $rule ): array { $gateways = self::orderedGateways(); $sent = 0; $errors = []; if ( [] === $gateways ) { return [ 'sent' => 0, 'errors' => [ __( 'هیچ درگاه پیامک فعال و تنظیم‌شده‌ای وجود ندارد.', 'rahkar-toolkit' ) ], ]; } foreach ( self::recipients() as $phone ) { foreach ( $gateways as $driver ) { $result = self::attempt( $driver, $phone, $text ); OtpLog::record( OtpLog::EVENT_ALERT, self::resultCode( $result ), [ 'phone' => $phone, 'gateway' => $driver->id(), 'via' => $rule, 'message' => $result->ok ? $text : $result->error, 'ip' => '', ] ); if ( $result->ok ) { ++$sent; continue 2; } $errors[] = $driver->name() . ': ' . $result->error; } } return [ 'sent' => $sent, 'errors' => array_values( array_unique( $errors ) ), ]; } /** * One gateway's attempt at one alert. * * @param SmsDriver $driver Gateway. * @param string $phone Canonical number. * @param string $text Alert text. */ private static function attempt( SmsDriver $driver, string $phone, string $text ): Result { $pattern = trim( (string) Settings::get( OtpAuthModule::SLUG . '.' . self::patternKey( $driver->id() ), '' ) ); $variable = trim( (string) Settings::get( OtpAuthModule::SLUG . '.' . self::variableKey( $driver->id() ), self::DEFAULT_VARIABLE ) ); $message = new Message( 'otp.alert', $phone, $text, '' !== $pattern ? [ '' !== $variable ? $variable : self::DEFAULT_VARIABLE => $text ] : [], '', true, $pattern ); try { return $driver->send( $message ); } catch ( \Throwable $e ) { return Result::retryable( 'exception', $e->getMessage() ); } } /** * Enabled gateways, healthy ones first: a gateway whose latest attempt failed * goes to the back of the queue. * * @return list */ private static function orderedGateways(): array { $healthy = []; $failing = []; foreach ( Gateways::enabled() as $driver ) { $last = MonitorStats::lastOutcomes( $driver->id() ); if ( $last['error_at'] > $last['ok_at'] ) { $failing[] = $driver; } else { $healthy[] = $driver; } } return array_merge( $healthy, $failing ); } /** * Text for the failure rule, or empty when it does not hold. * * @param int $failures Current run of failed sends. * @param string $shop Site name. */ private static function failureText( int $failures, string $shop ): string { $streak = (int) Settings::get( OtpAuthModule::SLUG . '.alert_fail_streak', 5 ); if ( $streak > 0 && $failures >= $streak ) { return sprintf( /* translators: 1: site name, 2: failed sends in a row */ __( '%1$s: %2$d ارسال کد ورود پشت سر هم ناموفق بود.', 'rahkar-toolkit' ), $shop, $failures ); } $rate = (int) Settings::get( OtpAuthModule::SLUG . '.alert_fail_rate', 50 ); $minutes = max( 1, (int) Settings::get( OtpAuthModule::SLUG . '.alert_fail_window', 30 ) ); $minimum = max( 1, (int) Settings::get( OtpAuthModule::SLUG . '.alert_fail_min', 10 ) ); if ( $rate <= 0 ) { return ''; } $window = MonitorStats::window( $minutes * MINUTE_IN_SECONDS ); if ( $window['total'] < $minimum || $window['failed'] * 100 < $rate * $window['total'] ) { return ''; } return sprintf( /* translators: 1: site name, 2: failure percentage, 3: minutes */ __( '%1$s: %2$d درصد ارسال‌های کد ورود در %3$d دقیقه گذشته ناموفق بود.', 'rahkar-toolkit' ), $shop, (int) round( $window['failed'] * 100 / $window['total'] ), $minutes ); } /** * Is a rule switched on? * * @param string $key Setting key. * @param bool $fallback Default. */ private static function on( string $key, bool $fallback ): bool { return (bool) Settings::get( OtpAuthModule::SLUG . '.' . $key, $fallback ); } /** * Log result code for a gateway result. * * @param Result $result Gateway result. */ public static function resultCode( Result $result ): string { if ( $result->ok ) { return OtpLog::RESULT_OK; } return $result->retryable ? OtpLog::RESULT_TEMPORARY : OtpLog::RESULT_PERMANENT; } }