deliver( $message ); if ( ! $result->ok && $result->retryable ) { Queue::push( self::JOB_HOOK, $message->toArray(), 30, 5, 'notify:' . $message->event, true ); } return $result; } /** * Hand the message to the queue. * * For everything nobody is waiting on — order confirmations, status changes, * reminders. These run after the response has been closed, so a slow SMS * provider can never add a second to checkout. A store that loses orders * because the confirmation SMS timed out has a much worse problem than one * whose SMS arrives four seconds late. * * @param Message $message Message to deliver. * @param int $delay Seconds to wait before the first attempt. * * @return bool Whether the job was accepted. */ public function queue( Message $message, int $delay = 0 ): bool { return Queue::push( self::JOB_HOOK, $message->toArray(), $delay, 10, 'notify:' . $message->event ) > 0; } /** * Run a queued send. * * Throws rather than returning on a retryable failure: the dispatcher reads an * exception as "release and try again", which is exactly the required * behaviour, and the backoff schedule then belongs to the queue rather than * being reimplemented here. * * @param array $payload Stored message payload. * * @throws \RuntimeException When the failure is worth retrying. * * @internal Hooked callback. */ public function handleJob( array $payload ): void { $message = Message::fromArray( $payload ); $decision = SendPolicy::decide( $message ); // Re-queued rather than slept on: the worker moves on to other work and the // message comes back when the quiet window opens. if ( SendPolicy::DEFER === $decision['action'] ) { $this->queue( $message, $decision['delay'] ); return; } if ( SendPolicy::SUPPRESS === $decision['action'] ) { self::reportSuppressed( $message, $decision['reason'] ); return; } $result = $this->deliver( $message ); if ( $result->ok || ! $result->retryable ) { return; } throw new \RuntimeException( esc_html( $result->error ) ); } /** * Deliver over every channel enabled for the event. * * @param Message $message Message to deliver. * * @return Result Outcome of the last channel attempted. */ private function deliver( Message $message ): Result { $channels = $this->channelsFor( $message ); if ( [] === $channels ) { return Result::permanent( 'no_channel', __( 'هیچ کانالی برای این رویداد فعال نیست.', 'rahkar-toolkit' ) ); } $last = Result::permanent( 'no_channel', '' ); $attempt = 0; foreach ( $channels as $channel ) { $drivers = $this->driversFor( $channel ); if ( [] === $drivers ) { $last = Result::permanent( 'no_driver', __( 'هیچ سرویس‌دهنده فعال و تنظیم‌شده‌ای برای این کانال نیست.', 'rahkar-toolkit' ) ); continue; } foreach ( $drivers as $driver ) { $last = $driver->send( $message ); $this->log( $channel, $driver->id(), $message, $last ); self::report( $channel, $driver, $message, $last, ++$attempt ); // Channels and gateways are alternatives, not a broadcast: once the // customer has been reached, sending the same message again is noise. if ( $last->ok ) { // A gateway that blacklisted this number drops the message quietly; // its report says so, and the number joins the opt-out list. if ( $driver instanceof \Rahkar\WP\Contracts\ReportingDriver && '' !== $last->messageId && SendPolicy::PROMOTIONAL === SendPolicy::kindOf( $message->event ) ) { BlacklistCheck::schedule( $driver->id(), $last->messageId ); } return $last; } } } return $last; } /** * The drivers to try for a channel, in order. * * SMS walks the store's gateway list, stopping after the first unless the * store asked for failover. Email has one delivery path — WordPress's own * mailer, or whatever the site has plugged into it — so it stays single. * * @param string $channel Channel name. * * @return list<\Rahkar\WP\Contracts\Driver> */ private function driversFor( string $channel ): array { if ( DriverRegistry::CHANNEL_SMS !== $channel ) { $driver = $this->driverFor( $channel ); return null === $driver ? [] : [ $driver ]; } $drivers = Gateways::enabled(); return Gateways::failsOver() ? $drivers : array_slice( $drivers, 0, 1 ); } /** * Which carriers should handle this message. * * @param Message $message Message to deliver. * * @return list */ private function channelsFor( Message $message ): array { $channels = []; $isEmail = str_contains( $message->to, '@' ); // The recipient decides more than settings do: an email address cannot be // texted, and a phone number cannot be mailed. if ( $isEmail ) { if ( Settings::get( 'notifications.email_enabled', false ) ) { $channels[] = DriverRegistry::CHANNEL_EMAIL; } } elseif ( Settings::get( 'notifications.sms_enabled', true ) ) { $channels[] = DriverRegistry::CHANNEL_SMS; } /** * Filters the channels used for one message. * * @param list $channels Channel names. * @param Message $message The message being sent. */ return (array) apply_filters( 'rahkar_wp_notification_channels', $channels, $message ); } /** * Resolve the driver configured for a channel. * * @param string $channel Channel name. */ private function driverFor( string $channel ): ?\Rahkar\WP\Contracts\Driver { $id = (string) Settings::get( 'notifications.' . $channel . '_driver', DriverRegistry::CHANNEL_SMS === $channel ? Drivers\Sms\IPPanel::ID : Drivers\Email\WpMail::ID ); return DriverRegistry::get( $channel, $id ); } /** * Report the attempt as `message.sent` or `message.failed` (EVENTS-v1.md §9). * * Metadata only. The text and the pattern variables never leave this method: * for a login code the variables are the code. * * Public because login codes are sent without the queue (OtpDispatcher) and * still belong in the store's SMS bill on the dashboard. * * @param string $channel Channel name. * @param \Rahkar\WP\Contracts\Driver $driver Driver used. * @param Message $message The message. * @param Result $result Outcome. * @param int $attempt 1 for the first gateway tried in this delivery. */ public static function report( string $channel, \Rahkar\WP\Contracts\Driver $driver, Message $message, Result $result, int $attempt ): void { if ( ! Events::listening() ) { return; } $usesPattern = '' !== $message->pattern || ( method_exists( $driver, 'patternFor' ) && '' !== (string) $driver->patternFor( $message->event ) ); $data = [ 'message_id' => $message->id, 'attempt' => $attempt, 'channel' => $channel, 'gateway' => sanitize_key( $driver->id() ), 'notification' => strtolower( (string) preg_replace( '/[^a-z0-9_.-]/i', '', $message->event ) ), 'to' => $message->to, 'pattern' => $usesPattern, // For a pattern send this is the length of the event text as rendered // here, which the pattern picker fills from the approved pattern: close // to what the provider delivers, not exact (EVENTS-v1.md §9). 'chars' => '' === $message->body ? null : mb_strlen( $message->body ), ] + self::origin( $message ); if ( $result->ok ) { Events::emit( 'message.sent', $data + [ 'provider_message_id' => '' === $result->messageId ? null : $result->messageId, 'cost' => $result->cost > 0 ? (string) $result->cost : null, 'cost_currency' => $result->cost > 0 ? 'IRR' : null, ] ); return; } Events::emit( 'message.failed', $data + [ 'error_code' => '' === $result->errorCode ? 'unknown' : $result->errorCode, 'retryable' => $result->retryable, ] ); } /** * Report a message the send rules kept back (`message.suppressed`), and log it. * * Public because a flow refuses entry before any message exists: it reports * the first message it would have sent. * * @param Message $message The message that does not go. * @param string $reason `opted_out` or `weekly_cap`. */ public static function reportSuppressed( Message $message, string $reason ): void { Logger::info( 'notify', 'Message kept back by the send rules.', $message->toLog() + [ 'reason' => $reason ] ); if ( ! Events::listening() ) { return; } Events::emit( 'message.suppressed', [ 'message_id' => $message->id, 'module' => '' === $message->module ? 'notifications' : $message->module, 'notification' => strtolower( (string) preg_replace( '/[^a-z0-9_.-]/i', '', $message->event ) ), 'channel' => str_contains( $message->to, '@' ) ? DriverRegistry::CHANNEL_EMAIL : DriverRegistry::CHANNEL_SMS, 'to' => $message->to, 'reason' => $reason, ] + ( [] === $message->context ? [] : [ 'context' => $message->context ] ) ); } /** * The optional `module` and `context` fields of a `message.*` event. * * @param Message $message The message. * * @return array */ private static function origin( Message $message ): array { $fields = []; if ( '' !== $message->module ) { $fields['module'] = $message->module; } if ( [] !== $message->context ) { $fields['context'] = $message->context; } return $fields; } /** * Record the attempt. * * The message's own log form masks the recipient and drops the variables — * for an OTP those hold the live code, and a log that stores codes is a way to * hand someone an account. * * @param string $channel Channel name. * @param string $driverId Driver id. * @param Message $message The message. * @param Result $result Outcome. */ private function log( string $channel, string $driverId, Message $message, Result $result ): void { if ( ! Settings::get( 'notifications.log_sends', true ) ) { return; } $context = array_merge( $message->toLog(), $result->toLog(), [ 'channel' => $channel, 'driver' => $driverId, ] ); if ( $result->ok ) { Logger::info( 'notify', 'Message accepted by provider.', $context ); return; } Logger::error( 'notify', 'Message rejected.', $context ); } }