'none', 'reason' => 'no_subject' ); } $user_id = self::user_for_subject( $subject_id ); $scope = self::scope( $args ); $sub = ( $user_id > 0 ) ? self::active_subscription( $user_id ) : null; $push_skip = ''; $test = class_exists( 'AIEO_Automation_Test_Mode' ) && AIEO_Automation_Test_Mode::opens( 'push' ) && AIEO_Automation_Test_Mode::is_tester( $user_id ); if ( $sub && ! empty( $args['push']['title'] ) ) { if ( ! $test && ! self::push_live() ) { $push_skip = 'engine_paused'; } elseif ( ! $test && self::is_quiet_now() ) { $push_skip = 'quiet_hours'; } elseif ( ! $test && ( self::over_cap( $user_id, 'day' ) || self::over_cap( $user_id, 'week' ) ) ) { $push_skip = 'capped'; } elseif ( ! $test && self::recently_sent_push( $trigger_id, $product_id, $user_id, $scope ) ) { $push_skip = 'deduped'; } else { $dispatch_id = self::send_push( $trigger_id, $subject_id, $user_id, $product_id, $sub, (array) $args['push'], (array) ( $args['email'] ?? array() ), $scope ); if ( $dispatch_id > 0 ) { if ( ! $test ) { self::bump_caps( $user_id ); } self::schedule_fallback( $dispatch_id ); return array( 'channel' => 'push', 'reason' => 'push_sent_fallback_scheduled' ); } $push_skip = 'push_failed'; } } $email_reason = self::send_email_leg( $trigger_id, $subject_id, $user_id, $product_id, (array) ( $args['email'] ?? array() ), $scope ); if ( '' !== $push_skip && in_array( $email_reason, array( 'no_email_payload', 'no_consented_email', 'engine_paused' ), true ) ) { return array( 'channel' => 'push', 'reason' => $push_skip ); } return array( 'channel' => 'email', 'reason' => $email_reason ); } public static function dispatch_guest( array $args ) { $email = strtolower( trim( (string) ( $args['guest_email'] ?? '' ) ) ); if ( '' === $email || empty( $args['push']['title'] ) ) { return array( 'channel' => 'none', 'reason' => 'no_push' ); } $trigger_id = (string) ( $args['trigger_id'] ?? '' ); $product_id = (int) ( $args['target_product_id'] ?? 0 ); $sub = self::active_subscription_by_email( $email ); $scope = self::scope( $args ); $test = class_exists( 'AIEO_Automation_Test_Mode' ) && AIEO_Automation_Test_Mode::opens( 'push' ) && AIEO_Automation_Test_Mode::is_tester( 0, $email ); if ( ! $sub || ( ! $test && ! self::push_live() ) || ( ! $test && self::is_quiet_now() ) || ( ! $test && self::recently_sent_push_sub( $trigger_id, $product_id, (int) $sub['subscription_id'], $scope ) ) ) { return array( 'channel' => 'none', 'reason' => 'no_push' ); } $dispatch_id = self::send_push( $trigger_id, 0, 0, $product_id, $sub, (array) $args['push'], array(), $scope ); if ( $dispatch_id > 0 ) { global $wpdb; $wpdb->update( $wpdb->prefix . 'aieo_push_dispatch', array( 'recipient_email' => $email ), array( 'dispatch_id' => (int) $dispatch_id ), array( '%s' ), array( '%d' ) ); return array( 'channel' => 'push', 'reason' => 'push_sent', 'dispatch_id' => (int) $dispatch_id ); } return array( 'channel' => 'none', 'reason' => 'push_failed' ); } const FALLBACK_MAX_RETRIES = 24; public static function fallback_tick( $dispatch_id, $retries = 0 ) { global $wpdb; $dispatch_id = (int) $dispatch_id; if ( $dispatch_id <= 0 ) { return; } $tbl = $wpdb->prefix . 'aieo_push_dispatch'; $row = $wpdb->get_row( $wpdb->prepare( "SELECT state, subject_id, user_id, trigger_id, target_product_id, payload_json FROM `{$tbl}` WHERE dispatch_id = %d", $dispatch_id ), ARRAY_A ); if ( ! $row || 'clicked' === (string) $row['state'] ) { return; } $payload = json_decode( (string) $row['payload_json'], true ); $email = is_array( $payload ) && isset( $payload['email'] ) ? (array) $payload['email'] : array(); if ( empty( $email ) ) { return; } $is_test = class_exists( 'AIEO_Automation_Test_Mode' ) && AIEO_Automation_Test_Mode::SUFFIX === substr( (string) $row['trigger_id'], -strlen( AIEO_Automation_Test_Mode::SUFFIX ) ); if ( $is_test ) { AIEO_Automation_Test_Mode::enter(); } try { $reason = self::send_email_leg( (string) $row['trigger_id'], (int) $row['subject_id'], (int) $row['user_id'], (int) $row['target_product_id'], $email, array( 'step' => isset( $payload['wf_step'] ) ? (int) $payload['wf_step'] : null, 'secs' => (int) ( $payload['wf_dedup'] ?? 0 ), ) ); } finally { if ( $is_test ) { AIEO_Automation_Test_Mode::leave(); } } if ( in_array( (string) $reason, array( 'quiet_hours', 'capped', 'wp_mail_failed' ), true ) && (int) $retries < self::FALLBACK_MAX_RETRIES ) { $when = time() + HOUR_IN_SECONDS; if ( function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( $when, self::HOOK_FALLBACK, array( $dispatch_id, (int) $retries + 1 ), 'aieo' ); } elseif ( ! wp_next_scheduled( self::HOOK_FALLBACK, array( $dispatch_id, (int) $retries + 1 ) ) ) { wp_schedule_single_event( $when, self::HOOK_FALLBACK, array( $dispatch_id, (int) $retries + 1 ) ); } } } private static function send_push( $trigger_id, $subject_id, $user_id, $product_id, array $sub, array $push, array $email, array $scope = array() ) { global $wpdb; if ( ! class_exists( 'AIEO_Push_Sender' ) ) { return 0; } $tbl = $wpdb->prefix . 'aieo_push_dispatch'; $now = gmdate( 'Y-m-d H:i:s' ); $url = (string) ( $push['url'] ?? home_url( '/' ) ); $wpdb->insert( $tbl, array( 'channel' => 'push', 'subscription_id' => (int) $sub['subscription_id'], 'subject_id' => (int) $subject_id, 'user_id' => (int) $user_id, 'trigger_id' => (string) $trigger_id, 'target_product_id' => (int) $product_id, 'target_url' => $url, 'payload_json' => wp_json_encode( ( null !== ( $scope['step'] ?? null ) ? array( 'wf_step' => (int) $scope['step'], 'wf_dedup' => (int) ( $scope['secs'] ?? 0 ) ) : array() ) + array( 'email' => $email ) ), 'state' => 'queued', 'queued_at' => $now, 'created_at' => $now, ), array( '%s', '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s' ) ); $dispatch_id = (int) $wpdb->insert_id; if ( $dispatch_id <= 0 ) { return 0; } $tracked = add_query_arg( array( 'aieo_src' => 'push', 'aieo_dispatch_id' => (string) $dispatch_id, 'utm_source' => 'aieo_push', 'utm_medium' => 'push', 'utm_campaign' => $trigger_id, ), $url ); $payload = wp_json_encode( array( 'title' => (string) ( $push['title'] ?? '' ), 'body' => (string) ( $push['body'] ?? '' ), 'icon' => (string) ( $push['icon'] ?? '' ), 'tag' => $trigger_id . '-' . $product_id, 'url' => $tracked, 'dispatchId' => $dispatch_id, ) ); $resp = AIEO_Push_Sender::instance()->send( array( 'endpoint' => (string) $sub['endpoint'], 'p256dh' => (string) $sub['p256dh'], 'auth_secret' => (string) $sub['auth_secret'], ), (string) $payload, 86400 ); if ( ! empty( $resp['ok'] ) ) { $wpdb->update( $tbl, array( 'state' => 'sent', 'sent_at' => $now, 'provider_status' => (int) ( $resp['status'] ?? 0 ) ), array( 'dispatch_id' => $dispatch_id ), array( '%s', '%s', '%d' ), array( '%d' ) ); return $dispatch_id; } $wpdb->update( $tbl, array( 'state' => 'failed', 'provider_status' => (int) ( $resp['status'] ?? 0 ), 'provider_error' => (string) ( $resp['error'] ?? '' ) ), array( 'dispatch_id' => $dispatch_id ), array( '%s', '%d', '%s' ), array( '%d' ) ); return 0; } private static function send_email_leg( $trigger_id, $subject_id, $user_id, $product_id, array $email, array $scope = array() ) { if ( ! class_exists( 'AIEO_Email_Channel' ) || empty( $email['subject'] ) || empty( $email['html'] ) ) { return 'no_email_payload'; } $addr = AIEO_Email_Channel::resolve_recipient( $subject_id, $user_id ); if ( '' === $addr ) { return 'no_consented_email'; } $res = AIEO_Email_Channel::dispatch( array( 'trigger_id' => (string) $trigger_id, 'user_id' => (int) $user_id, 'subject_id' => (int) $subject_id, 'email' => $addr, 'subject' => (string) $email['subject'], 'html' => (string) $email['html'], 'target_product_id' => (int) $product_id, ) + ( null !== ( $scope['step'] ?? null ) ? array( 'dedup_step' => (int) $scope['step'], 'dedup_secs' => (int) ( $scope['secs'] ?? 0 ) ) : array() ) ); return (string) ( $res['reason'] ?? 'unknown' ); } private static function is_quiet_now() { $start = 22 * 60; $end = 9 * 60; if ( class_exists( 'AIEO_DMM_Settings' ) ) { $start = self::hhmm( (string) AIEO_DMM_Settings::get( 'contact_quiet_start', '22:00' ), $start ); $end = self::hhmm( (string) AIEO_DMM_Settings::get( 'contact_quiet_end', '09:00' ), $end ); } if ( $start === $end ) { return false; } try { $now = new \DateTime( 'now', new \DateTimeZone( 'Europe/Athens' ) ); $m = (int) $now->format( 'G' ) * 60 + (int) $now->format( 'i' ); } catch ( \Throwable $e ) { return true; } return ( $start < $end ) ? ( $m >= $start && $m < $end ) : ( $m >= $start || $m < $end ); } private static function hhmm( $v, $fallback ) { if ( preg_match( '/^(\d{1,2}):(\d{2})$/', trim( (string) $v ), $mm ) ) { return ( (int) $mm[1] ) * 60 + (int) $mm[2]; } return (int) $fallback; } private static function over_cap( $user_id, $kind ) { global $wpdb; $tbl = $wpdb->prefix . 'aieo_push_caps'; if ( ! self::table_exists( $tbl ) ) { return false; } $start = ( 'week' === $kind ) ? self::week_start() : self::today(); $limit = ( 'week' === $kind ) ? (int) apply_filters( 'aieo_cascade_cap_per_week', 5 ) : (int) apply_filters( 'aieo_cascade_cap_per_day', 2 ); if ( $limit <= 0 ) { return false; } $sent = (int) $wpdb->get_var( $wpdb->prepare( "SELECT sent_count FROM `{$tbl}` WHERE user_id = %d AND window_kind = %s AND window_start = %s", (int) $user_id, $kind, $start ) ); return $sent >= $limit; } private static function bump_caps( $user_id ) { global $wpdb; $tbl = $wpdb->prefix . 'aieo_push_caps'; if ( ! self::table_exists( $tbl ) ) { return; } $now = gmdate( 'Y-m-d H:i:s' ); $sql = "INSERT INTO `{$tbl}` (user_id, window_kind, window_start, sent_count, updated_at) VALUES (%d, %s, %s, 1, %s) ON DUPLICATE KEY UPDATE sent_count = sent_count + 1, updated_at = VALUES(updated_at)"; $wpdb->query( $wpdb->prepare( $sql, (int) $user_id, 'day', self::today(), $now ) ); $wpdb->query( $wpdb->prepare( $sql, (int) $user_id, 'week', self::week_start(), $now ) ); } private static function recently_sent_push( $trigger_id, $product_id, $user_id, array $scope = array() ) { global $wpdb; if ( '' === $trigger_id || $user_id <= 0 ) { return false; } $tbl = $wpdb->prefix . 'aieo_push_dispatch'; if ( ! self::table_exists( $tbl ) ) { return false; } list( $since, $step, $params ) = self::dedup_window( $scope ); return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$tbl}` WHERE channel = 'push' AND trigger_id = %s AND target_product_id = %d AND user_id = %d AND state IN ('sent','clicked') AND sent_at >= %s{$step}", array_merge( array( $trigger_id, (int) $product_id, (int) $user_id, $since ), $params ) ) ) > 0; } private static function today() { return wp_date( 'Y-m-d' ); } private static function week_start() { $dow = (int) wp_date( 'N' ); return wp_date( 'Y-m-d', time() - ( ( $dow - 1 ) * DAY_IN_SECONDS ) ); } private static function schedule_fallback( $dispatch_id ) { $when = time() + self::fallback_hours() * HOUR_IN_SECONDS; if ( function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( $when, self::HOOK_FALLBACK, array( (int) $dispatch_id ), 'aieo' ); } else { wp_schedule_single_event( $when, self::HOOK_FALLBACK, array( (int) $dispatch_id ) ); } } private static function active_subscription( $user_id ) { global $wpdb; $tbl = $wpdb->prefix . 'aieo_push_subscriptions'; if ( ! self::table_exists( $tbl ) ) { return null; } $row = $wpdb->get_row( $wpdb->prepare( "SELECT subscription_id, endpoint, p256dh, auth_secret FROM `{$tbl}` WHERE user_id = %d AND status = 'active' AND endpoint <> '' ORDER BY subscribed_at DESC LIMIT 1", (int) $user_id ), ARRAY_A ); return ( $row && ! empty( $row['endpoint'] ) ) ? $row : null; } private static function active_subscription_by_email( $email ) { global $wpdb; $tbl = $wpdb->prefix . 'aieo_push_subscriptions'; if ( '' === $email || ! self::table_exists( $tbl ) || ! self::has_col( $tbl, 'identified_email' ) ) { return null; } $row = $wpdb->get_row( $wpdb->prepare( "SELECT subscription_id, endpoint, p256dh, auth_secret FROM `{$tbl}` WHERE status = 'active' AND endpoint <> '' AND identified_email = %s ORDER BY subscription_id DESC LIMIT 1", $email ), ARRAY_A ); return ( $row && ! empty( $row['endpoint'] ) ) ? $row : null; } private static function recently_sent_push_sub( $trigger_id, $product_id, $subscription_id, array $scope = array() ) { global $wpdb; if ( '' === $trigger_id || (int) $subscription_id <= 0 ) { return false; } $tbl = $wpdb->prefix . 'aieo_push_dispatch'; if ( ! self::table_exists( $tbl ) ) { return false; } list( $since, $step, $params ) = self::dedup_window( $scope ); return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$tbl}` WHERE channel = 'push' AND trigger_id = %s AND target_product_id = %d AND subscription_id = %d AND state IN ('sent','clicked') AND sent_at >= %s{$step}", array_merge( array( $trigger_id, (int) $product_id, (int) $subscription_id, $since ), $params ) ) ) > 0; } private static function scope( array $args ) { return class_exists( 'AIEO_Email_Channel' ) && method_exists( 'AIEO_Email_Channel', 'dedup_scope' ) ? AIEO_Email_Channel::dedup_scope( $args ) : array( 'step' => null, 'secs' => self::DEDUP_DAYS * DAY_IN_SECONDS ); } private static function dedup_window( array $scope ) { global $wpdb; $secs = (int) ( $scope['secs'] ?? 0 ); $secs = $secs > 0 ? min( $secs, self::DEDUP_DAYS * DAY_IN_SECONDS ) : self::DEDUP_DAYS * DAY_IN_SECONDS; $since = gmdate( 'Y-m-d H:i:s', time() - $secs ); if ( null === ( $scope['step'] ?? null ) || ! class_exists( 'AIEO_Email_Channel' ) ) { return array( $since, '', array() ); } return array( $since, ' AND payload_json LIKE %s', array( $wpdb->esc_like( AIEO_Email_Channel::payload_prefix( (int) $scope['step'] ) ) . '%' ) ); } private static function has_col( $tbl, $col ) { global $wpdb; return (bool) $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM `{$tbl}` LIKE %s", $col ) ); } private static function user_for_subject( $subject_id ) { global $wpdb; $t = $wpdb->prefix . 'aieo_subjects'; if ( ! self::table_exists( $t ) ) { return 0; } return (int) $wpdb->get_var( $wpdb->prepare( "SELECT wp_user_id FROM `{$t}` WHERE subject_id = %d", (int) $subject_id ) ); } private static function push_live() { if ( 'live' !== get_option( 'aieo_push_engine_status', 'paused' ) ) { return false; } if ( class_exists( 'AIEO_DMM_Web_Push' ) && method_exists( 'AIEO_DMM_Web_Push', 'is_engine_live' ) ) { return (bool) AIEO_DMM_Web_Push::is_engine_live(); } return true; } private static function table_exists( $tbl ) { global $wpdb; return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $tbl ) ) === $tbl; } }