settings = $settings; $this->scheduler = $scheduler; $this->logger = $logger; } /** * Register the background send handler. */ public function register_hooks(): void { add_action( self::SEND_HOOK, array( $this, 'handle_send' ), 10, 3 ); } /** * Queue a message for every active channel. * * @param array $message Message (see Channels\Channel). * @return int Number of channels queued. */ public function notify( array $message ): int { $message = $this->complete( $message ); $channels = array_keys( $this->settings->active_channels() ); foreach ( $channels as $channel ) { $this->scheduler->enqueue_async( self::SEND_HOOK, array( $channel, $message, 1 ) ); } return count( $channels ); } /** * Background job: send one message to one channel, retrying on failure. * * @param string $channel Channel ID. * @param array $message Message. * @param int $attempt Attempt number (1-based). */ public function handle_send( $channel, $message, $attempt = 1 ): void { $channel = (string) $channel; $message = $this->complete( is_array( $message ) ? $message : array() ); $attempt = max( 1, (int) $attempt ); // The channel may have been switched off since the job was queued. $active = $this->settings->active_channels(); if ( ! isset( $active[ $channel ] ) ) { return; } $result = $this->deliver( $channel, $message, $active[ $channel ] ); $this->log( $channel, (string) ( $message['event'] ?? '' ), $result, $attempt ); if ( is_wp_error( $result ) && $attempt <= count( self::RETRY_DELAYS ) && $this->retryable( $result ) ) { $this->scheduler->schedule_single( time() + self::RETRY_DELAYS[ $attempt - 1 ], self::SEND_HOOK, array( $channel, $message, $attempt + 1 ) ); } } /** * Send a test message right away (not queued) so the result can be shown. * * Uses the saved config, so the settings must be saved first. * * @param string $channel Channel ID. * @return true|WP_Error */ public function send_test( string $channel ) { if ( ! in_array( $channel, NotificationSettings::CHANNELS, true ) ) { return new WP_Error( 'profit_engine_notification_channel', __( 'Unknown channel.', 'profit-engine' ), array( 'status' => 400 ) ); } $config = $this->settings->get()['channels'][ $channel ]; if ( ! $this->settings->is_configured( $channel, $config ) ) { return new WP_Error( 'profit_engine_notification_unconfigured', __( 'Fill in and save this channel first.', 'profit-engine' ), array( 'status' => 400 ) ); } $message = $this->complete( array( 'event' => 'test', 'title' => __( 'ProfitEngine test notification', 'profit-engine' ), 'intro' => __( 'Stock alerts will arrive here. This is how they look:', 'profit-engine' ), 'items' => array( array( 'title' => __( 'Example product', 'profit-engine' ), 'subtitle' => 'SKU-123', 'detail' => __( 'Stock 3 · 1.1 sold/day · about 3 days left · suggested order 20', 'profit-engine' ), ), ), ) ); $result = $this->deliver( $channel, $message, $config ); $this->log( $channel, 'test', $result, 1 ); return $result; } /** * Recent delivery attempts, newest first. * * @return array> */ public function log_entries(): array { $log = get_option( self::LOG_OPTION, array() ); return is_array( $log ) ? $log : array(); } /** * Send through the channel's class. * * @param string $channel Channel ID. * @param array $message Message. * @param array $config Channel config. * @return true|WP_Error */ private function deliver( string $channel, array $message, array $config ) { $sender = $this->channel( $channel ); if ( ! $sender ) { return new WP_Error( 'profit_engine_notification_channel', __( 'Unknown channel.', 'profit-engine' ) ); } /** * Filter a message just before it is sent to one channel. * * @param array $message Message. * @param string $channel Channel ID. */ $message = (array) apply_filters( 'profit_engine_notification_message', $message, $channel ); return $sender->send( $message, $config ); } /** * Channel class for an ID. * * @param string $channel Channel ID. */ private function channel( string $channel ): ?Channel { switch ( $channel ) { case 'email': return new EmailChannel(); case 'slack': return new SlackChannel(); case 'discord': return new DiscordChannel(); case 'telegram': return new TelegramChannel(); case 'teams': return new TeamsChannel(); case 'webhook': return new WebhookChannel(); } return null; } /** * Fill in the fields every message carries. * * @param array $message Partial message. * @return array */ private function complete( array $message ): array { return array_merge( array( 'event' => '', 'title' => '', 'intro' => '', 'items' => array(), 'more' => 0, 'url' => admin_url( 'admin.php?page=profit-engine-reorder' ), 'link_label' => __( 'Open ProfitEngine', 'profit-engine' ), 'site' => wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ), ), $message ); } /** * Whether a failure is worth retrying: network errors, rate limits and * server errors are; a rejected request (bad URL, revoked webhook) is not. * * @param WP_Error $error Error. */ private function retryable( WP_Error $error ): bool { $data = $error->get_error_data(); $code = is_array( $data ) ? (int) ( $data['code'] ?? 0 ) : 0; return 0 === $code || 429 === $code || $code >= 500; } /** * Mask a channel's secrets (webhook URLs, tokens) in an error message before * it is stored in the delivery log, shown in the admin or written to the log. * * @param string $channel Channel ID. * @param string $message Error message. */ private function scrub( string $channel, string $message ): string { $config = $this->settings->get()['channels'][ $channel ] ?? array(); foreach ( array( 'webhook_url', 'url', 'bot_token', 'secret' ) as $field ) { $value = (string) ( $config[ $field ] ?? '' ); if ( strlen( $value ) >= 8 ) { $message = str_replace( $value, NotificationSettings::MASK, $message ); } } return $message; } /** * Record an attempt in the delivery log (and the WooCommerce log on failure). * * @param string $channel Channel ID. * @param string $event Alert type. * @param true|WP_Error $result Result. * @param int $attempt Attempt number. */ private function log( string $channel, string $event, $result, int $attempt ): void { $ok = true === $result; $error = $ok ? '' : $this->scrub( $channel, $result->get_error_message() ); $log = $this->log_entries(); array_unshift( $log, array( 'time' => time(), 'channel' => $channel, 'event' => $event, 'ok' => $ok, 'attempt' => $attempt, 'error' => mb_substr( $error, 0, 300 ), ) ); update_option( self::LOG_OPTION, array_slice( $log, 0, self::LOG_SIZE ), false ); if ( ! $ok ) { $this->logger->warning( sprintf( 'Notification to %s failed (attempt %d): %s', $channel, $attempt, $error ) ); } } }