$args Its arguments. * @param int $timestamp When (Unix time). * @return void */ public static function schedule_hook( string $hook, array $args, int $timestamp ): void { self::spawn_at_shutdown(); // Action Scheduler answers 0 when called before it is ready; WP-Cron takes over then. if ( self::use_action_scheduler() && as_schedule_single_action( $timestamp, $hook, $args, self::GROUP ) ) { return; } wp_schedule_single_event( $timestamp, $hook, $args ); } /** * Start WP-Cron once at the end of this request. WordPress starts it only at the start of a page load, * so on a site with few visitors a queued SMS would otherwise wait for the next visit. * * @return void */ private static function spawn_at_shutdown(): void { if ( false === has_action( 'shutdown', array( self::class, 'spawn_cron' ) ) ) { add_action( 'shutdown', array( self::class, 'spawn_cron' ) ); } } /** * Shutdown: start WP-Cron (a non-blocking request to wp-cron.php). Not when WP-Cron is switched off * (DISABLE_WP_CRON: a real cron job runs it), runs inline (ALTERNATE_WP_CRON redirects the browser) or * this already is a cron run; filter smsportal_spawn_cron decides otherwise. Action Scheduler runs from WP-Cron too; its own event is due only once a * minute, so an event of ours that runs its queue is made due now. * * @return void */ public static function spawn_cron(): void { remove_action( 'shutdown', array( self::class, 'spawn_cron' ) ); $disabled = ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) || ( defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON ); /** * Filters whether a request that queued an SMS starts WP-Cron when it ends. * * @param bool $spawn Default: true unless DISABLE_WP_CRON or ALTERNATE_WP_CRON is set. */ if ( ! apply_filters( 'smsportal_spawn_cron', ! $disabled ) || wp_doing_cron() ) { return; } if ( self::use_action_scheduler() && false === wp_next_scheduled( self::KICK_HOOK ) ) { wp_schedule_single_event( time(), self::KICK_HOOK ); } spawn_cron(); } /** * Job: run Action Scheduler's queue now (what its own WP-Cron event does once a minute). * * @return void */ public static function run_action_scheduler(): void { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Action Scheduler's own hook. do_action( 'action_scheduler_run_queue', 'smsportal' ); } /** * Pick up rows stuck in sending, or queued with no job waiting. Sending them again is safe: * the request_id stays the same and smsportal answers a repeat with the first result. * * @return int Rows scheduled again. */ public static function recover_stuck(): int { $before = gmdate( 'Y-m-d H:i:s', time() - self::STUCK_AFTER ); $count = 0; foreach ( Messages::stale_ids( 'sending', $before, self::SWEEP_LIMIT ) as $id ) { if ( Messages::release( $id, $before ) && ! self::is_scheduled( $id ) ) { self::schedule( $id ); ++$count; } } foreach ( Messages::stale_ids( 'queued', $before, self::SWEEP_LIMIT ) as $id ) { if ( ! self::is_scheduled( $id ) ) { self::schedule( $id ); ++$count; } } return $count; } /** * Whether a send job for the row waits in Action Scheduler or WP-Cron. * * @param int $message_id Row ID. * @return bool */ public static function is_scheduled( int $message_id ): bool { return self::is_hook_scheduled( self::HOOK, array( $message_id ) ); } /** * Whether a job of the plugin waits in Action Scheduler or WP-Cron. * * @param string $hook Action. * @param array $args Its arguments. * @return bool */ public static function is_hook_scheduled( string $hook, array $args ): bool { if ( false !== wp_next_scheduled( $hook, $args ) ) { return true; } return function_exists( 'as_has_scheduled_action' ) && as_has_scheduled_action( $hook, $args, self::GROUP ); } /** * Job: send one message. * * @param int|string $message_id Row ID. * @return void */ public static function run( $message_id ): void { $id = absint( $message_id ); $row = Messages::get( $id ); if ( null === $row || 'queued' !== $row['status'] ) { return; } $expires_at = Messages::expires_at( $row ); if ( null !== $expires_at && $expires_at <= time() ) { Messages::update( $id, array( 'status' => 'expired', 'error' => __( 'The message expired before it could be sent.', '85pl-android-sms-api' ), ) ); return; } if ( Blocklist::is_blocked( $row['recipient'] ) ) { Messages::update( $id, array( 'status' => 'blocked_stop' ) ); return; } if ( Connection::home_changed() ) { // A copy of the site (e.g. staging) must not send from the owner's account. Messages::update( $id, array( 'status' => 'failed', 'error' => __( 'The site address changed, so the connection is paused.', '85pl-android-sms-api' ), ) ); return; } if ( ! Messages::claim( $id ) ) { return; } $result = Api::send( $row['recipient'], $row['message'], $row['request_id'], $expires_at ); if ( is_wp_error( $result ) ) { self::handle_error( $id, (int) $row['attempts'] + 1, $result ); return; } Messages::update( $id, array( 'status' => 'waiting_phone', 'sms_batch_id' => $result['sms_batch_id'], 'error' => null, ) ); } /** * Retry, or record the final status. * * @param int $id Row ID. * @param int $attempts Attempts made so far, including this one. * @param WP_Error $error Error from the API. * @return void */ private static function handle_error( int $id, int $attempts, WP_Error $error ): void { $code = $error->get_error_code(); if ( in_array( $code, self::RETRY_CODES, true ) && $attempts <= count( self::RETRY_DELAYS ) ) { Messages::update( $id, array( 'status' => 'queued', 'error' => $error->get_error_message(), ) ); self::schedule( $id, self::RETRY_DELAYS[ $attempts - 1 ] ); return; } if ( 'smsportal_limit_reached' === $code ) { $status = 'limit'; $message = $error->get_error_message(); } elseif ( 'smsportal_not_connected' === $code ) { $status = 'failed'; $message = __( 'Not connected to smsportal.app.', '85pl-android-sms-api' ); } else { $status = 'failed'; $message = $error->get_error_message(); } Messages::update( $id, array( 'status' => $status, 'error' => $message, ) ); } /** * Whether to use Action Scheduler. * * @return bool */ private static function use_action_scheduler(): bool { $available = function_exists( 'as_schedule_single_action' ); /** * Filters whether queued SMS use Action Scheduler (when available) instead of WP-Cron. * * @param bool $use Default: true when Action Scheduler is loaded. */ return $available && (bool) apply_filters( 'smsportal_use_action_scheduler', $available ); } }