|null */ private static ?array $testPhones = null; /** * Kinds by event id, built once per request. * * @var array|null */ private static ?array $kinds = null; /** * What to do with a message right now. * * @param Message $message The message about to be delivered. * @param int|null $now Timestamp to judge; null for now. * * @return array{action: string, delay: int, reason: string} */ public static function decide( Message $message, ?int $now = null ): array { $kind = self::kindOf( $message->event ); if ( self::IMMEDIATE === $kind ) { return self::verdict( self::SEND ); } $wait = self::quietSeconds( $now ?? time() ); if ( $wait > 0 ) { return self::verdict( self::DEFER, $wait ); } // Checked at send time as well as at flow entry: someone can opt out // between the first reminder and the second. if ( self::PROMOTIONAL === $kind && Optouts::has( $message->to ) ) { return self::verdict( self::SUPPRESS, 0, self::REASON_OPTED_OUT ); } return self::verdict( self::SEND ); } /** * Seconds until the quiet window opens, zero when sending is allowed. * * Flows ask this before they start anything, so a reminder is never queued * at night to go out in the morning after the customer has already bought. * * @param int $now Timestamp to judge. */ public static function quietSeconds( int $now ): int { if ( ! Settings::get( 'notifications.quiet_enabled', true ) ) { return 0; } return QuietHours::secondsUntilOpen( $now, (string) Settings::get( 'notifications.quiet_from', '22:00' ), (string) Settings::get( 'notifications.quiet_to', '08:00' ) ); } /** * Is this moment inside the quiet window? * * @param int $now Timestamp to judge. */ public static function isQuiet( int $now ): bool { return self::quietSeconds( $now ) > 0; } /** * Flow entries allowed per number in seven days; zero switches the cap off. */ public static function weeklyCap(): int { return max( 0, min( 10, (int) Settings::get( 'notifications.weekly_cap', 2 ) ) ); } /** * Days a sale order keeps a customer out of new promotional flows; zero is off. */ public static function recentDays(): int { return max( 0, min( self::RECENT_MAX, (int) Settings::get( 'notifications.recent_days', self::RECENT_DEFAULT ) ) ); } /** * Control-group share in percent, clamped to the allowed range. */ public static function holdoutPercent(): int { $percent = (int) Settings::get( 'notifications.holdout_percent', self::HOLDOUT_DEFAULT ); return max( self::HOLDOUT_MIN, min( self::HOLDOUT_MAX, $percent ) ); } /** * Is this one of the shop's test numbers? * * Listed under Notifications → send rules, so the shop can try a flow on its * own phone without landing in the control group or skewing the dashboard. * * @param string $raw Phone number in any form. */ public static function isTestPhone( string $raw ): bool { if ( null === self::$testPhones ) { self::$testPhones = []; // One per line or comma-separated; spaces inside a number are allowed. foreach ( preg_split( '/[\r\n,،]+/u', (string) Settings::get( 'notifications.test_phones', '' ) ) ?: [] as $entry ) { $phone = PhoneNumber::parse( (string) preg_replace( '/\s+/u', '', $entry ) ); if ( null !== $phone ) { self::$testPhones[ $phone->canonical() ] = true; } } } $phone = PhoneNumber::parse( $raw ); return null !== $phone && isset( self::$testPhones[ $phone->canonical() ] ); } /** * The rules in force, as the dashboard heartbeat reports them. * * @return array{quiet_from: string, quiet_to: string, weekly_cap: int, holdout_percent: int} */ public static function summary(): array { $enabled = (bool) Settings::get( 'notifications.quiet_enabled', true ); $from = (string) Settings::get( 'notifications.quiet_from', '22:00' ); $to = (string) Settings::get( 'notifications.quiet_to', '08:00' ); $valid = static fn( string $time ): bool => 1 === preg_match( '/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $time ); // No window is reported as two equal ends, which QuietHours reads the same way. if ( ! $enabled || ! $valid( $from ) || ! $valid( $to ) ) { $from = '00:00'; $to = '00:00'; } return [ 'quiet_from' => $from, 'quiet_to' => $to, 'weekly_cap' => self::weeklyCap(), 'holdout_percent' => self::holdoutPercent(), ]; } /** * The kind an event declared, `immediate` for any event that did not. * * @param string $event Event id, e.g. `cart.reminder1.customer`. */ public static function kindOf( string $event ): string { if ( null === self::$kinds ) { self::$kinds = []; /** This filter is the same registry the events screen reads. */ $definitions = (array) apply_filters( 'rahkar_wp_notification_events', OrderEvents::all() ); foreach ( $definitions as $definition ) { if ( $definition instanceof EventDefinition ) { self::$kinds[ $definition->id ] = $definition->kind; } } } $kind = self::$kinds[ $event ] ?? self::IMMEDIATE; return in_array( $kind, [ self::SERVICE, self::PROMOTIONAL ], true ) ? $kind : self::IMMEDIATE; } /** * Forget the kinds read so far. For tests and long-running workers. */ public static function flush(): void { self::$kinds = null; self::$testPhones = null; } /** * A verdict in the shape decide() returns. * * @param string $action One of the action constants. * @param int $delay Seconds to wait, for DEFER. * @param string $reason Why, for SUPPRESS. * * @return array{action: string, delay: int, reason: string} */ private static function verdict( string $action, int $delay = 0, string $reason = '' ): array { return [ 'action' => $action, 'delay' => $delay, 'reason' => $reason, ]; } }