submissions = $submissions; } /** Action handler. @return null|WP_Error */ public function handle(Submission $s, array $settings) { $request = self::build($s, $settings); if ($request instanceof WP_Error) { Log::error(self::ID, $request->get_error_message(), ['form' => $s->form_id]); return $request; } $result = self::send($request); if (!($result instanceof WP_Error)) { $this->mark($s->id, 'delivered', 1, ''); return null; } Log::error(self::ID, $result->get_error_message(), ['form' => $s->form_id, 'submission' => $s->id, 'attempt' => 1]); if (!empty($settings['webhook_required'])) { $this->mark($s->id, 'failed', 1, $result->get_error_message()); return $result; } $this->enqueue($request, $s, $result->get_error_message()); return null; } /** * The request to send: url, method, format, headers, body (array), timeout. * * @return array{url: string, method: string, format: string, headers: array, body: array, timeout: int}|WP_Error */ public static function build(Submission $s, array $settings) { $url = isset($settings['webhook_url']) && is_string($settings['webhook_url']) ? trim(Shortcodes::render(trim($settings['webhook_url']), $s, 'url')) : ''; if (!preg_match('#^https?://[^\s/?\#]+#i', $url) || esc_url_raw($url) === '') { return new WP_Error('zadex_webhook_url', 'Webhook: missing or invalid URL.'); } $method = strtoupper((string) ($settings['webhook_method'] ?? 'POST')) === 'PUT' ? 'PUT' : 'POST'; $format = ($settings['webhook_format'] ?? 'json') === 'form' ? 'form' : 'json'; $timeout = isset($settings['webhook_timeout']) && is_numeric($settings['webhook_timeout']) ? max(1, min(30, (int) $settings['webhook_timeout'])) : 10; $headers = []; foreach (is_array($settings['webhook_headers'] ?? null) ? $settings['webhook_headers'] : [] as $row) { $name = is_array($row) && isset($row['name']) && is_scalar($row['name']) ? trim((string) $row['name']) : ''; if ($name === '' || !preg_match('/^[A-Za-z0-9\-]{1,64}$/', $name) || in_array(strtolower($name), ['host', 'content-length'], true)) { continue; } $headers[$name] = Shortcodes::render(is_scalar($row['value'] ?? null) ? (string) $row['value'] : '', $s, 'header'); } $request = [ 'url' => esc_url_raw($url), 'method' => $method, 'format' => $format, 'headers' => $headers, 'body' => self::payload($s, $settings), 'timeout' => $timeout, ]; $filtered = apply_filters('zadex/forms/webhook_request', $request, $s); return is_array($filtered) && isset($filtered['url'], $filtered['body']) ? array_merge($request, $filtered) : $request; } /** @return array */ public static function payload(Submission $s, array $settings): array { $map = is_array($settings['webhook_map'] ?? null) ? $settings['webhook_map'] : []; $body = []; foreach ($map as $row) { $key = is_array($row) && isset($row['key']) && is_scalar($row['key']) ? trim((string) $row['key']) : ''; if ($key === '') { continue; } $value = is_scalar($row['value'] ?? null) ? (string) $row['value'] : ''; if (preg_match('/^\[field:([a-z0-9_\-]+)\]$/i', trim($value), $m)) { $id = strtolower($m[1]); $body[$key] = $s->type($id) === 'password' ? '' : ($s->values[$id] ?? ''); } else { $body[$key] = Shortcodes::render($value, $s, 'plain'); } } if (!$body) { $fields = []; foreach (Fields::inputs($s->fields) as $id => $field) { if ($field['type'] !== 'password') { $fields[$id] = $s->values[$id] ?? ''; } } $body = [ 'form' => [ 'id' => $s->form_id, 'name' => $s->form_name, 'post_id' => $s->post_id, 'page_url' => (string) ($s->meta['page'] ?? ''), ], 'submission_id' => $s->id, 'fields' => $fields, ]; } if (!empty($settings['webhook_meta'])) { $body['meta'] = [ 'ip' => (string) ($s->meta['ip'] ?? ''), 'user_agent' => (string) ($s->meta['user_agent'] ?? ''), 'referrer' => (string) ($s->meta['referrer'] ?? ''), 'page' => (string) ($s->meta['page'] ?? ''), 'user_id' => (int) ($s->meta['user_id'] ?? 0), 'submitted_at' => gmdate('c'), ]; } return $body; } /** @return array{code: int, body: string, json: mixed}|WP_Error */ public static function send(array $request) { $headers = (array) ($request['headers'] ?? []); if (($request['format'] ?? 'json') === 'form') { $body = http_build_query((array) $request['body']); $headers += ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8']; } else { $body = (string) wp_json_encode($request['body'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $headers += ['Content-Type' => 'application/json; charset=UTF-8']; } return Http::request((string) $request['method'], (string) $request['url'], [ 'headers' => $headers, 'body' => $body, 'timeout' => (int) ($request['timeout'] ?? 10), 'reject_unsafe_urls' => (bool) apply_filters('zadex/forms/webhook_reject_unsafe', true, $request), ]); } /** Seconds to wait after attempt `$attempt` failed: 60, 120, 240, 480 … */ public static function delay(int $attempt): int { return self::BASE_DELAY * (2 ** max(0, $attempt - 1)); } /** @return array> */ public static function queue(): array { $queue = get_option(self::QUEUE_OPTION, []); return is_array($queue) ? $queue : []; } /** @param array> $queue */ private static function save_queue(array $queue): void { if ($queue) { update_option(self::QUEUE_OPTION, $queue, false); } else { delete_option(self::QUEUE_OPTION); } } /** Queues a failed first attempt and schedules the retry. Returns the job id. */ public function enqueue(array $request, Submission $s, string $error): string { $id = bin2hex(random_bytes(8)); $queue = self::queue(); $queue[$id] = [ 'request' => $request, 'attempts' => 1, 'error' => $error, 'submission' => ['id' => $s->id, 'form_id' => $s->form_id, 'form_name' => $s->form_name, 'post_id' => $s->post_id], 'next' => time() + self::delay(1), ]; self::save_queue($queue); wp_schedule_single_event($queue[$id]['next'], self::CRON_HOOK, [$id]); $this->mark($s->id, 'retrying', 1, $error); return $id; } /** WP-Cron callback: one more attempt for a queued job. */ public function retry(string $id): void { $queue = self::queue(); if (!isset($queue[$id]) || !is_array($queue[$id]['request'] ?? null)) { return; } $job = $queue[$id]; $attempt = (int) ($job['attempts'] ?? 1) + 1; $sub = (array) ($job['submission'] ?? []); $submission_id = (int) ($sub['id'] ?? 0); $result = self::send($job['request']); if (!($result instanceof WP_Error)) { unset($queue[$id]); self::save_queue($queue); $this->mark($submission_id, 'delivered', $attempt, ''); return; } $message = $result->get_error_message(); Log::error(self::ID, $message, ['form' => (string) ($sub['form_id'] ?? ''), 'submission' => $submission_id, 'attempt' => $attempt]); if ($attempt >= self::MAX_ATTEMPTS) { unset($queue[$id]); self::save_queue($queue); $this->mark($submission_id, 'failed', $attempt, $message); $submission = new Submission((string) ($sub['form_id'] ?? ''), (string) ($sub['form_name'] ?? ''), (int) ($sub['post_id'] ?? 0), [], [], [], [], $submission_id); do_action('zadex/forms/action_failed', self::ID, new WP_Error('zadex_webhook_failed', sprintf('Webhook failed after %d attempts: %s', $attempt, $message)), $submission); return; } $queue[$id]['attempts'] = $attempt; $queue[$id]['error'] = $message; $queue[$id]['next'] = time() + self::delay($attempt); self::save_queue($queue); wp_schedule_single_event($queue[$id]['next'], self::CRON_HOOK, [$id]); $this->mark($submission_id, 'retrying', $attempt, $message); } /** Stores the delivery status in the submission meta (`integrations.webhook`). */ private function mark(int $submission_id, string $status, int $attempts, string $error): void { if ($submission_id <= 0) { return; } $repo = $this->submissions ?? new Submissions(); $repo->set_integration_status($submission_id, self::ID, array_filter([ 'status' => $status, 'attempts' => $attempts, 'error' => $error, 'time' => gmdate('c'), ], static fn($v): bool => $v !== '')); } }