0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0, 'by_trigger' => array(), ); if ('live' !== get_option('aieo_push_engine_status', 'paused')) { return array_merge($stats, array('skipped' => 'engine_paused')); } if (class_exists('AIEO_DMM_Web_Push') && !\AIEO_DMM_Web_Push::is_engine_live()) { return array_merge($stats, array('skipped' => 'engine_paused')); } if (!class_exists('AIEO_Push_Trigger_Registry') || !class_exists('AIEO_Push_Sender')) { return array_merge($stats, array('skipped' => 'classes_missing')); } $registry = AIEO_Push_Trigger_Registry::instance(); $sender = AIEO_Push_Sender::instance(); $send_budget = self::MAX_PER_TICK; foreach ($registry->all() as $trigger_id => $trigger) { $by = array('candidates' => 0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0); $candidates = $trigger->produce_candidates(self::PER_TRIGGER_LIMIT); $by['candidates'] = count($candidates); foreach ($candidates as $c) { if ($send_budget <= 0) { break 2; } $stats['processed']++; $result = $this->process_candidate($trigger, $c, $sender); if ($result === 'sent') { $stats['sent']++; $by['sent']++; $send_budget--; } elseif ($result === 'failed') { $stats['failed']++; $by['failed']++; } else { $stats['suppressed']++; $by['suppressed']++; } } $stats['by_trigger'][$trigger_id] = $by; } return $stats; } private function process_candidate(AIEO_Push_Trigger_Base $trigger, array $c, AIEO_Push_Sender $sender) { global $wpdb; $tbl_dispatch = $wpdb->prefix . 'aieo_push_dispatch'; $tbl_subs = $wpdb->prefix . 'aieo_push_subscriptions'; $tbl_caps = $wpdb->prefix . 'aieo_push_caps'; $tbl_tpl = $wpdb->prefix . 'aieo_push_templates'; $sub_id = (int) ($c['subscription_id'] ?? 0); $user_id = (int) ($c['user_id'] ?? 0); $cap_key = ($user_id > 0) ? $user_id : ( self::ANON_CAP_OFFSET + $sub_id ); $target_pid = (int) ($c['target_product_id'] ?? 0); $payload_data = is_array($c['payload_data'] ?? null) ? $c['payload_data'] : array(); $target_url = (string) ($c['target_url'] ?? ''); $now = current_time('mysql', true); if ($trigger->respects_quiet_hours() && $this->is_quiet_now()) { $this->log_suppressed($trigger->get_id(), $sub_id, $user_id, $target_pid, $target_url, $payload_data, 'quiet_hours'); return 'suppressed'; } $today = wp_date('Y-m-d'); $sent_today = (int) $wpdb->get_var($wpdb->prepare( "SELECT sent_count FROM {$tbl_caps} WHERE user_id = %d AND window_kind = 'day' AND window_start = %s", $cap_key, $today )); if ($sent_today >= $trigger->cap_per_day()) { $this->log_suppressed($trigger->get_id(), $sub_id, $user_id, $target_pid, $target_url, $payload_data, 'cap_day'); return 'suppressed'; } $week_start = wp_date('Y-m-d', strtotime('monday this week')); $sent_this_week = (int) $wpdb->get_var($wpdb->prepare( "SELECT sent_count FROM {$tbl_caps} WHERE user_id = %d AND window_kind = 'week' AND window_start = %s", $cap_key, $week_start )); if ($sent_this_week >= $trigger->cap_per_week()) { $this->log_suppressed($trigger->get_id(), $sub_id, $user_id, $target_pid, $target_url, $payload_data, 'cap_week'); return 'suppressed'; } $dedup_days = max(1, (int) $trigger->dedup_days()); if ($user_id > 0) { $ident_sql = 'user_id = %d'; $ident_arg = $user_id; } else { $ident_sql = 'subscription_id = %d'; $ident_arg = $sub_id; } $dedup_hit = (int) $wpdb->get_var($wpdb->prepare( "SELECT dispatch_id FROM {$tbl_dispatch} WHERE channel = 'push' AND trigger_id = %s AND target_product_id = %d AND {$ident_sql} AND state IN ('sent','clicked','dismissed') AND sent_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY) LIMIT 1", $trigger->get_id(), $target_pid, $ident_arg, $dedup_days )); if ($dedup_hit > 0) { $this->log_suppressed($trigger->get_id(), $sub_id, $user_id, $target_pid, $target_url, $payload_data, 'dedup_recent'); return 'suppressed'; } $segment = $this->resolve_user_segment($user_id); $template = $this->pick_template($trigger->get_id(), $segment); if ($template === null) { $this->log_suppressed($trigger->get_id(), $sub_id, $user_id, $target_pid, $target_url, $payload_data, 'no_template'); return 'suppressed'; } $sub = $wpdb->get_row($wpdb->prepare( "SELECT subscription_id, endpoint, p256dh, auth_secret FROM {$tbl_subs} WHERE subscription_id = %d AND status = 'active' LIMIT 1", $sub_id ), ARRAY_A); if (!$sub || empty($sub['endpoint'])) { $this->log_suppressed($trigger->get_id(), $sub_id, $user_id, $target_pid, $target_url, $payload_data, 'not_subscribed'); return 'suppressed'; } $title = $this->render_template((string) $template['title_template'], $payload_data); $body = $this->render_template((string) $template['body_template'], $payload_data); $icon = (string) ($template['icon_url'] ?? ''); $wpdb->insert( $tbl_dispatch, array( 'subscription_id' => $sub_id, 'user_id' => $user_id, 'trigger_id' => $trigger->get_id(), 'template_id' => (int) ($template['template_id'] ?? 0), 'target_product_id' => $target_pid, 'target_url' => $target_url, 'payload_json' => wp_json_encode($payload_data), 'state' => 'queued', 'queued_at' => $now, 'created_at' => $now, ), array('%d','%d','%s','%d','%d','%s','%s','%s','%s','%s') ); $dispatch_id = (int) $wpdb->insert_id; $tracked_url = $this->append_attribution_params($target_url, array( 'aieo_src' => 'push', 'aieo_dispatch_id' => (string) $dispatch_id, 'utm_source' => 'aieo_push', 'utm_medium' => 'push', 'utm_campaign' => $trigger->get_id(), )); $payload = wp_json_encode(array( 'title' => $title, 'body' => $body, 'icon' => $icon ?: '', 'tag' => $trigger->get_id() . '-' . $target_pid, 'url' => $tracked_url, 'dispatchId' => $dispatch_id, )); $resp = $sender->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_dispatch, array( 'state' => 'sent', 'sent_at' => $now, 'provider_status' => (int) $resp['status'], ), array('dispatch_id' => $dispatch_id), array('%s','%s','%d'), array('%d') ); $this->bump_caps($cap_key, $today, $week_start); return 'sent'; } if (in_array((int) $resp['status'], array(404, 410), true)) { $wpdb->update( $tbl_subs, array('status' => 'revoked', 'bounced_at' => $now), array('subscription_id' => $sub_id), array('%s','%s'), array('%d') ); } $wpdb->update( $tbl_dispatch, array( 'state' => 'failed', 'provider_status' => (int) $resp['status'], 'provider_error' => substr((string) $resp['error'], 0, 200), ), array('dispatch_id' => $dispatch_id), array('%s','%d','%s'), array('%d') ); return 'failed'; } private function log_suppressed($trigger_id, $sub_id, $user_id, $pid, $url, $data, $reason) { global $wpdb; $tbl = $wpdb->prefix . 'aieo_push_dispatch'; $now = current_time('mysql', true); $wpdb->insert( $tbl, array( 'subscription_id' => (int) $sub_id, 'user_id' => (int) $user_id, 'trigger_id' => (string) $trigger_id, 'target_product_id' => (int) $pid, 'target_url' => (string) $url, 'payload_json' => wp_json_encode($data), 'state' => 'suppressed', 'suppress_reason' => (string) $reason, 'queued_at' => $now, 'created_at' => $now, ), array('%d','%d','%s','%d','%s','%s','%s','%s','%s','%s') ); } private function bump_caps($user_id, $today, $week_start) { global $wpdb; $tbl = $wpdb->prefix . 'aieo_push_caps'; $now = current_time('mysql', true); $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, $user_id, 'day', $today, $now)); $wpdb->query($wpdb->prepare($sql, $user_id, 'week', $week_start, $now)); } private function pick_template($trigger_id, $segment) { global $wpdb; $tbl = $wpdb->prefix . 'aieo_push_templates'; $rows = $wpdb->get_results($wpdb->prepare( "SELECT template_id, segment_id, variant_label, weight, title_template, body_template, icon_url, cta_label FROM {$tbl} WHERE trigger_id = %s AND is_active = 1 AND channel = 'push' AND segment_id IN (%s, '*')", $trigger_id, $segment ?: '*' ), ARRAY_A); if (empty($rows)) { return null; } $specific = array_filter($rows, function ($r) use ($segment) { return $segment !== '' && $r['segment_id'] === $segment; }); $pool = !empty($specific) ? $specific : $rows; $total = 0; foreach ($pool as $r) { $total += max(1, (int) $r['weight']); } if ($total <= 0) { return $pool[0] ?? null; } $pick = wp_rand(1, $total); $acc = 0; foreach ($pool as $r) { $acc += max(1, (int) $r['weight']); if ($pick <= $acc) { return $r; } } return $pool[0] ?? null; } private function resolve_user_segment($user_id) { if ($user_id <= 0) { return ''; } global $wpdb; $tbl = $wpdb->prefix . 'aieo_customer_intelligence'; $row = $wpdb->get_row($wpdb->prepare( "SELECT segments_json FROM {$tbl} WHERE wp_user_id = %d LIMIT 1", $user_id ), ARRAY_A); if (!$row || empty($row['segments_json'])) { return ''; } $segs = json_decode((string) $row['segments_json'], true); if (!is_array($segs)) { return ''; } if (class_exists('AIEO_Audience_Rules')) { $aliases = AIEO_Audience_Rules::SEGMENT_SLUG_ALIASES; foreach ($segs as $i => $s) { if (is_string($s) && isset($aliases[$s])) { $segs[$i] = $aliases[$s]; } } } foreach (array('vip', 'loyalist', 'overdue', 'brand_devoted', 'discount_hunter', 'replenisher', 'newbie') as $pri) { if (in_array($pri, $segs, true)) { return $pri; } } return ''; } private function append_attribution_params($url, array $params) { if ($url === '' || strpos($url, 'http') !== 0) { return $url; } $clean = array(); foreach ($params as $k => $v) { $v = (string) $v; if ($v !== '') { $clean[$k] = $v; } } if (empty($clean)) { return $url; } return add_query_arg($clean, $url); } private function render_template($template, array $data) { if ($template === '' || empty($data)) { return $template; } return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/', function ($m) use ($data) { $key = $m[1]; return isset($data[$key]) ? (string) $data[$key] : $m[0]; }, $template); } private function is_quiet_now() { $start = self::QUIET_START * 60; $end = self::QUIET_END * 60; if (class_exists('AIEO_DMM_Settings')) { $start = self::hhmm_to_minutes((string) AIEO_DMM_Settings::get('contact_quiet_start', '22:00'), $start); $end = self::hhmm_to_minutes((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 false; } if ($start < $end) { return $m >= $start && $m < $end; } return $m >= $start || $m < $end; } private static function hhmm_to_minutes($hhmm, $fallback) { if (preg_match('/^(\d{1,2}):(\d{2})$/', trim((string) $hhmm), $p) && (int) $p[1] <= 23 && (int) $p[2] <= 59) { return (int) $p[1] * 60 + (int) $p[2]; } return (int) $fallback; } }