booted ) { return; } $this->booted = true; add_filter( 'tse_tonys_settings_tabs', array( $this, 'register_settings_tab' ) ); add_action( 'tse_tonys_settings_render_tab_' . self::TAB_WEBHOOKS, array( $this, 'render_settings_tab' ) ); add_action( 'post_updated', array( $this, 'handle_event_schedule_update' ), 10, 3 ); add_action( 'added_post_meta', array( $this, 'handle_event_results_meta_change' ), 10, 4 ); add_action( 'updated_post_meta', array( $this, 'handle_event_results_meta_change' ), 10, 4 ); add_action( 'deleted_post_meta', array( $this, 'handle_event_results_meta_change' ), 10, 4 ); if ( is_admin() ) { add_action( 'admin_init', array( $this, 'register_settings' ) ); add_filter( 'option_page_capability_' . self::OPTION_GROUP, array( $this, 'settings_capability' ) ); add_action( 'wp_ajax_tse_sp_webhook_test', array( $this, 'handle_test_webhook_ajax' ) ); } } /** * Register the settings option. * * @return void */ public function register_settings() { register_setting( self::OPTION_GROUP, self::OPTION_KEY, array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize_settings' ), 'default' => $this->default_settings(), ) ); } /** * Capability required to save this option group. * * @return string */ public function settings_capability() { return 'manage_sportspress'; } /** * Add the Webhooks tab to Tony's Settings. * * @param array $tabs Existing tab labels. * @return array */ public function register_settings_tab( $tabs ) { $tabs[ self::TAB_WEBHOOKS ] = __( 'Webhooks', 'tonys-sportspress-enhancements' ); return $tabs; } /** * Sanitize webhook settings. * * @param mixed $input Raw option payload. * @return array */ public function sanitize_settings( $input ) { $rows = is_array( $input ) && isset( $input['webhooks'] ) && is_array( $input['webhooks'] ) ? $input['webhooks'] : array(); $normalized = array(); $row_number = 0; foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } ++$row_number; if ( $this->is_empty_webhook_row( $row ) ) { continue; } $sanitized = $this->sanitize_webhook_row( $row, true ); if ( is_wp_error( $sanitized ) ) { add_settings_error( self::OPTION_GROUP, 'tse_sp_webhooks_invalid_' . $row_number, sprintf( __( 'Webhook %1$d was skipped: %2$s', 'tonys-sportspress-enhancements' ), $row_number, $sanitized->get_error_message() ), 'error' ); continue; } if ( '' === $sanitized['name'] ) { $sanitized['name'] = sprintf( /* translators: %d: webhook row number. */ __( 'Webhook %d', 'tonys-sportspress-enhancements' ), count( $normalized ) + 1 ); } $normalized[] = $sanitized; } return array( 'webhooks' => $normalized, ); } /** * Render the Webhooks tab. * * @return void */ public function render_settings_tab() { if ( ! current_user_can( 'manage_sportspress' ) ) { return; } $settings = $this->get_settings(); $webhooks = isset( $settings['webhooks'] ) && is_array( $settings['webhooks'] ) ? $settings['webhooks'] : array(); settings_errors( self::OPTION_GROUP ); echo '
'; settings_fields( self::OPTION_GROUP ); wp_referer_field(); echo '

' . esc_html__( 'SportsPress Webhooks', 'tonys-sportspress-enhancements' ) . '

'; echo '

' . esc_html__( 'Create multiple outbound notifications for SportsPress events. Each webhook can listen for schedule changes, result updates, or both, then send the rendered message to the selected destination type.', 'tonys-sportspress-enhancements' ) . '

'; echo '

' . esc_html__( 'Google Chat incoming webhooks send JSON with a text field. GroupMe bot delivery sends bot_id and text to the GroupMe bots endpoint. Generic JSON sends a richer payload with message and context.', 'tonys-sportspress-enhancements' ) . '

'; echo '
'; echo '' . esc_html__( 'Message template variables', 'tonys-sportspress-enhancements' ) . ''; echo '

'; echo esc_html__( 'Use Jinja-style placeholders such as', 'tonys-sportspress-enhancements' ) . ' '; echo '{{ event.title }}, {{ event.permalink }}, {{ trigger.key }}, {{ changes.previous.local_display }}, {{ changes.current.local_display }}, {{ results.summary }}, '; echo esc_html__( 'or serialize values safely for JSON with', 'tonys-sportspress-enhancements' ) . ' {{ event|tojson }} ' . esc_html__( 'and', 'tonys-sportspress-enhancements' ) . ' {{ event.title|tojson }}. '; echo esc_html__( 'The rendered template becomes the outgoing message text.', 'tonys-sportspress-enhancements' ); echo '

'; echo '
'; echo '
'; if ( empty( $webhooks ) ) { $this->render_webhook_row( $this->default_webhook(), 0 ); } else { foreach ( array_values( $webhooks ) as $index => $webhook ) { $this->render_webhook_row( $webhook, $index ); } } echo '
'; echo '

'; submit_button( __( 'Save Webhooks', 'tonys-sportspress-enhancements' ) ); echo '
'; echo ''; $this->render_settings_script(); } /** * Send notifications when an event date/time changes. * * @param int $post_id Event post ID. * @param WP_Post $post_after Updated post object. * @param WP_Post $post_before Previous post object. * @return void */ public function handle_event_schedule_update( $post_id, $post_after, $post_before ) { if ( ! $post_after instanceof WP_Post || ! $post_before instanceof WP_Post ) { return; } if ( ! $this->should_handle_event_post( $post_id, $post_after ) ) { return; } $previous = $this->event_schedule_from_post( $post_before ); $current = $this->event_schedule_from_post( $post_after ); if ( $previous['local_iso'] === $current['local_iso'] && $previous['gmt_iso'] === $current['gmt_iso'] ) { return; } $signature = md5( $current['gmt_iso'] . '|' . $current['local_iso'] ); if ( $signature === (string) get_post_meta( $post_id, self::SCHEDULE_SIGNATURE_META_KEY, true ) ) { return; } update_post_meta( $post_id, self::SCHEDULE_SIGNATURE_META_KEY, $signature ); $this->dispatch_trigger( 'event_datetime_changed', $post_id, array( 'previous' => $previous, 'current' => $current, ) ); } /** * Send notifications when results metadata changes. * * @param mixed $meta_id_or_ids Meta identifier. * @param int $post_id Event post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * @return void */ public function handle_event_results_meta_change( $meta_id_or_ids, $post_id, $meta_key, $meta_value ) { unset( $meta_id_or_ids, $meta_value ); if ( 'sp_results' !== $meta_key ) { return; } $post = get_post( $post_id ); if ( ! $this->should_handle_event_post( $post_id, $post ) ) { return; } $results = get_post_meta( $post_id, 'sp_results', true ); if ( ! $this->has_meaningful_results( $results ) ) { delete_post_meta( $post_id, self::RESULTS_SIGNATURE_META_KEY ); return; } $signature = md5( (string) wp_json_encode( $results ) ); if ( $signature === (string) get_post_meta( $post_id, self::RESULTS_SIGNATURE_META_KEY, true ) ) { return; } update_post_meta( $post_id, self::RESULTS_SIGNATURE_META_KEY, $signature ); $this->dispatch_trigger( 'event_results_updated', $post_id, array( 'current' => is_array( $results ) ? $results : array(), ) ); } /** * Render one webhook settings card. * * @param array $webhook Webhook configuration. * @param int|string $index Array index placeholder. * @return void */ private function render_webhook_row( $webhook, $index ) { $webhook = wp_parse_args( is_array( $webhook ) ? $webhook : array(), $this->default_webhook() ); $base = self::OPTION_KEY . '[webhooks][' . $index . ']'; if ( ! is_int( $index ) && ! ctype_digit( (string) $index ) ) { $webhook['id'] = ''; } echo '
'; echo '
'; echo '

' . esc_html__( 'Webhook', 'tonys-sportspress-enhancements' ) . '

'; echo ''; echo '
'; echo ''; echo '

'; echo ''; echo '

'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '
'; } /** * Render the repeatable-row admin script. * * @return void */ private function render_settings_script() { $ajax_url = admin_url( 'admin-ajax.php' ); $nonce = wp_create_nonce( 'tse_sp_webhook_test' ); ?> default_settings() ); } /** * Get the option defaults. * * @return array */ private function default_settings() { return array( 'webhooks' => array(), ); } /** * Get the default webhook row. * * @return array */ private function default_webhook() { return array( 'id' => sanitize_key( wp_generate_uuid4() ), 'enabled' => '1', 'name' => '', 'provider' => 'generic_json', 'url' => '', 'triggers' => array(), 'template' => $this->default_template(), ); } /** * Get supported delivery providers. * * @return array */ private function provider_labels() { return array( 'generic_json' => __( 'Generic JSON', 'tonys-sportspress-enhancements' ), 'google_chat' => __( 'Google Chat', 'tonys-sportspress-enhancements' ), 'groupme_bot' => __( 'GroupMe Bot', 'tonys-sportspress-enhancements' ), ); } /** * Get trigger labels. * * @return array */ private function trigger_labels() { return array( 'event_datetime_changed' => __( 'Game date/time changes', 'tonys-sportspress-enhancements' ), 'event_results_updated' => __( 'Results updated', 'tonys-sportspress-enhancements' ), ); } /** * Get the default webhook request body. * * @return string */ private function default_template() { return "{{ trigger.label }}\n{{ event.title }}\n{{ event.scheduled.local_display }}\n{{ results.summary }}\n{{ event.permalink }}"; } /** * Handle an admin AJAX test-send request. * * @return void */ public function handle_test_webhook_ajax() { if ( ! current_user_can( 'manage_sportspress' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to send test webhooks.', 'tonys-sportspress-enhancements' ), ), 403 ); } check_ajax_referer( 'tse_sp_webhook_test', 'nonce' ); $raw_settings = isset( $_POST[ self::OPTION_KEY ] ) ? wp_unslash( $_POST[ self::OPTION_KEY ] ) : array(); $rows = is_array( $raw_settings ) && isset( $raw_settings['webhooks'] ) && is_array( $raw_settings['webhooks'] ) ? array_values( $raw_settings['webhooks'] ) : array(); $row = isset( $rows[0] ) && is_array( $rows[0] ) ? $rows[0] : array(); if ( empty( $row ) ) { wp_send_json_error( array( 'message' => __( 'No webhook row was submitted.', 'tonys-sportspress-enhancements' ), ), 400 ); } $webhook = $this->sanitize_webhook_row( $row, false ); if ( is_wp_error( $webhook ) ) { wp_send_json_error( array( 'message' => $webhook->get_error_message(), ), 400 ); } $trigger = ! empty( $webhook['triggers'] ) ? $webhook['triggers'][0] : 'manual_test'; $context = $this->build_test_context( $trigger, $webhook ); $result = $this->deliver_webhook( $webhook['url'], $webhook['template'], $webhook, $context ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message(), ), 500 ); } $status_code = isset( $result['status_code'] ) ? (int) $result['status_code'] : 0; wp_send_json_success( array( 'message' => sprintf( __( 'Test sent successfully. Remote response: HTTP %d.', 'tonys-sportspress-enhancements' ), $status_code ), 'status_code' => $status_code, ) ); } /** * Sanitize a provider-specific destination. * * @param string $url Raw destination value. * @param string $provider Delivery provider. * @return string */ private function sanitize_destination_url( $url, $provider ) { $url = trim( (string) $url ); if ( '' === $url ) { return ''; } if ( 'groupme_bot' === $provider ) { return preg_match( '/^[A-Za-z0-9_-]+$/', $url ) ? $url : ''; } $validated = wp_http_validate_url( $url ); if ( ! $validated ) { return ''; } $scheme = strtolower( (string) wp_parse_url( $validated, PHP_URL_SCHEME ) ); if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) { return ''; } return esc_url_raw( $validated, array( 'http', 'https' ) ); } /** * Sanitize a stored template. * * @param mixed $template Raw template. * @return string */ private function sanitize_template( $template ) { $template = is_string( $template ) ? wp_unslash( $template ) : ''; $template = str_replace( array( "\r\n", "\r" ), "\n", $template ); $template = wp_check_invalid_utf8( $template ); $template = wp_kses_no_null( $template ); return trim( $template ); } /** * Determine whether a row is effectively empty. * * @param mixed $row Candidate row. * @return bool */ private function is_empty_webhook_row( $row ) { if ( ! is_array( $row ) ) { return true; } $name = isset( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : ''; $url_raw = isset( $row['url'] ) ? trim( wp_unslash( (string) $row['url'] ) ) : ''; $template = isset( $row['template'] ) ? $this->sanitize_template( $row['template'] ) : ''; $triggers = isset( $row['triggers'] ) && is_array( $row['triggers'] ) ? $row['triggers'] : array(); return '' === $name && '' === $url_raw && empty( $triggers ) && ( '' === $template || $this->default_template() === $template ); } /** * Sanitize a single webhook row. * * @param array $row Raw row payload. * @param bool $require_triggers Whether at least one trigger is required. * @return array|WP_Error */ private function sanitize_webhook_row( $row, $require_triggers ) { $name = isset( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : ''; $url_raw = isset( $row['url'] ) ? trim( wp_unslash( (string) $row['url'] ) ) : ''; $template = isset( $row['template'] ) ? $this->sanitize_template( $row['template'] ) : ''; $enabled = ! empty( $row['enabled'] ) ? '1' : '0'; $id = isset( $row['id'] ) ? sanitize_key( wp_unslash( (string) $row['id'] ) ) : ''; $provider = isset( $row['provider'] ) ? sanitize_key( wp_unslash( (string) $row['provider'] ) ) : 'generic_json'; $triggers = array(); if ( ! isset( $this->provider_labels()[ $provider ] ) ) { $provider = 'generic_json'; } if ( isset( $row['triggers'] ) && is_array( $row['triggers'] ) ) { $triggers = array_map( 'sanitize_key', array_map( 'wp_unslash', $row['triggers'] ) ); $triggers = array_values( array_intersect( $triggers, array_keys( $this->trigger_labels() ) ) ); } $url = $this->sanitize_destination_url( $url_raw, $provider ); if ( '' === $url ) { if ( 'groupme_bot' === $provider ) { return new WP_Error( 'invalid_url', __( 'GroupMe delivery requires a valid bot_id.', 'tonys-sportspress-enhancements' ) ); } if ( 'google_chat' === $provider ) { return new WP_Error( 'invalid_url', __( 'Google Chat delivery requires a valid incoming webhook URL.', 'tonys-sportspress-enhancements' ) ); } return new WP_Error( 'invalid_url', __( 'Generic JSON delivery requires a valid HTTP or HTTPS URL.', 'tonys-sportspress-enhancements' ) ); } if ( $require_triggers && empty( $triggers ) ) { return new WP_Error( 'missing_triggers', __( 'At least one trigger is required.', 'tonys-sportspress-enhancements' ) ); } if ( '' === $template ) { $template = $this->default_template(); } if ( '' === $id ) { $id = sanitize_key( wp_generate_uuid4() ); } return array( 'id' => $id, 'enabled' => $enabled, 'name' => $name, 'provider' => $provider, 'url' => $url, 'triggers' => $triggers, 'template' => $template, ); } /** * Determine whether a post should trigger SportsPress webhooks. * * @param int $post_id Event post ID. * @param WP_Post|bool $post Post object. * @return bool */ private function should_handle_event_post( $post_id, $post ) { if ( ! $post instanceof WP_Post ) { return false; } if ( 'sp_event' !== $post->post_type ) { return false; } if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return false; } return true; } /** * Build schedule metadata for an event post object. * * @param WP_Post $post Event post object. * @return array */ private function event_schedule_from_post( $post ) { $timezone = wp_timezone(); $utc = new DateTimeZone( 'UTC' ); $empty = array( 'local_iso' => '', 'local_display' => '', 'gmt_iso' => '', 'timestamp' => 0, ); if ( ! $post instanceof WP_Post ) { return $empty; } $local = null; $gmt = null; if ( ! empty( $post->post_date_gmt ) && '0000-00-00 00:00:00' !== $post->post_date_gmt ) { $gmt = new DateTimeImmutable( $post->post_date_gmt, $utc ); $local = $gmt->setTimezone( $timezone ); } elseif ( ! empty( $post->post_date ) && '0000-00-00 00:00:00' !== $post->post_date ) { $local = new DateTimeImmutable( $post->post_date, $timezone ); $gmt = $local->setTimezone( $utc ); } if ( ! $local instanceof DateTimeImmutable || ! $gmt instanceof DateTimeImmutable ) { return $empty; } return array( 'local_iso' => $local->format( DATE_ATOM ), 'local_display' => wp_date( 'Y-m-d g:i A T', $local->getTimestamp(), $timezone ), 'gmt_iso' => $gmt->format( DATE_ATOM ), 'timestamp' => $local->getTimestamp(), ); } /** * Build the template/render context for a webhook delivery. * * @param int $post_id Event post ID. * @param string $trigger Trigger slug. * @param array $changes Trigger-specific change data. * @param array $webhook Webhook configuration. * @return array */ private function build_context( $post_id, $trigger, $changes, $webhook ) { $post = get_post( $post_id ); $schedule = $this->event_schedule_from_post( $post ); $results = get_post_meta( $post_id, 'sp_results', true ); $teams = $this->get_event_teams( $post_id ); $venue = $this->get_event_venue( $post_id ); $event_title = $this->get_event_title( $post_id ); $results_arr = is_array( $results ) ? $results : array(); $results_sum = $this->get_results_summary( $post ); $now = new DateTimeImmutable( 'now', wp_timezone() ); $context = array( 'trigger' => array( 'key' => $trigger, 'label' => isset( $this->trigger_labels()[ $trigger ] ) ? $this->trigger_labels()[ $trigger ] : $trigger, ), 'webhook' => array( 'id' => isset( $webhook['id'] ) ? (string) $webhook['id'] : '', 'name' => isset( $webhook['name'] ) ? (string) $webhook['name'] : '', 'url' => isset( $webhook['url'] ) ? (string) $webhook['url'] : '', ), 'site' => array( 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ), 'timezone' => wp_timezone_string(), ), 'event' => array( 'id' => $post instanceof WP_Post ? (int) $post->ID : 0, 'title' => $event_title, 'raw_title' => $post instanceof WP_Post ? get_the_title( $post ) : '', 'permalink' => $post instanceof WP_Post ? get_permalink( $post ) : '', 'edit_url' => $post instanceof WP_Post ? get_edit_post_link( $post->ID, 'raw' ) : '', 'post_status' => $post instanceof WP_Post ? (string) $post->post_status : '', 'sp_status' => (string) get_post_meta( $post_id, 'sp_status', true ), 'scheduled' => $schedule, 'teams' => $teams, 'venue' => $venue, ), 'changes' => is_array( $changes ) ? $changes : array(), 'results' => array( 'summary' => $results_sum, 'data' => $results_arr, ), 'occurred_at' => array( 'local_iso' => $now->format( DATE_ATOM ), 'local_display' => wp_date( 'Y-m-d g:i A T', $now->getTimestamp(), wp_timezone() ), ), ); /** * Filter the webhook render context before dispatch. * * @param array $context Event context. * @param int $post_id Event post ID. * @param string $trigger Trigger slug. * @param array $changes Trigger-specific data. * @param array $webhook Webhook configuration. */ return apply_filters( 'tse_sp_webhook_context', $context, $post_id, $trigger, $changes, $webhook ); } /** * Build a sample context for manual test sends. * * @param string $trigger Trigger slug. * @param array $webhook Webhook configuration. * @return array */ private function build_test_context( $trigger, $webhook ) { $labels = $this->trigger_labels(); $now = new DateTimeImmutable( 'now', wp_timezone() ); $next = $now->modify( '+2 hours' ); return array( 'trigger' => array( 'key' => $trigger, 'label' => isset( $labels[ $trigger ] ) ? $labels[ $trigger ] : __( 'Manual test', 'tonys-sportspress-enhancements' ), ), 'webhook' => array( 'id' => isset( $webhook['id'] ) ? (string) $webhook['id'] : '', 'name' => isset( $webhook['name'] ) ? (string) $webhook['name'] : __( 'Test Webhook', 'tonys-sportspress-enhancements' ), 'url' => isset( $webhook['url'] ) ? (string) $webhook['url'] : '', ), 'site' => array( 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ), 'timezone' => wp_timezone_string(), ), 'event' => array( 'id' => 0, 'title' => __( 'Test Event: Away at Home', 'tonys-sportspress-enhancements' ), 'raw_title' => __( 'Test Event', 'tonys-sportspress-enhancements' ), 'permalink' => home_url( '/?tse-webhook-test=1' ), 'edit_url' => admin_url( 'edit.php?post_type=sp_event' ), 'post_status' => 'publish', 'sp_status' => 'future', 'scheduled' => array( 'local_iso' => $next->format( DATE_ATOM ), 'local_display' => wp_date( 'Y-m-d g:i A T', $next->getTimestamp(), wp_timezone() ), 'gmt_iso' => $next->setTimezone( new DateTimeZone( 'UTC' ) )->format( DATE_ATOM ), 'timestamp' => $next->getTimestamp(), ), 'teams' => array( array( 'id' => 0, 'name' => __( 'Home Team', 'tonys-sportspress-enhancements' ), 'short_name' => __( 'Home', 'tonys-sportspress-enhancements' ), 'abbreviation' => 'HOME', 'role' => 'home', ), array( 'id' => 0, 'name' => __( 'Away Team', 'tonys-sportspress-enhancements' ), 'short_name' => __( 'Away', 'tonys-sportspress-enhancements' ), 'abbreviation' => 'AWAY', 'role' => 'away', ), ), 'venue' => array( 'id' => 0, 'name' => __( 'Sample Field', 'tonys-sportspress-enhancements' ), 'slug' => 'sample-field', ), ), 'changes' => array( 'previous' => array( 'local_iso' => $now->format( DATE_ATOM ), 'local_display' => wp_date( 'Y-m-d g:i A T', $now->getTimestamp(), wp_timezone() ), 'gmt_iso' => $now->setTimezone( new DateTimeZone( 'UTC' ) )->format( DATE_ATOM ), 'timestamp' => $now->getTimestamp(), ), 'current' => array( 'local_iso' => $next->format( DATE_ATOM ), 'local_display' => wp_date( 'Y-m-d g:i A T', $next->getTimestamp(), wp_timezone() ), 'gmt_iso' => $next->setTimezone( new DateTimeZone( 'UTC' ) )->format( DATE_ATOM ), 'timestamp' => $next->getTimestamp(), ), ), 'results' => array( 'summary' => __( 'Home Team 3 Away Team 2', 'tonys-sportspress-enhancements' ), 'data' => array( 'home' => array( 'r' => 3 ), 'away' => array( 'r' => 2 ), ), ), 'occurred_at' => array( 'local_iso' => $now->format( DATE_ATOM ), 'local_display' => wp_date( 'Y-m-d g:i A T', $now->getTimestamp(), wp_timezone() ), ), ); } /** * Deliver all matching webhooks for a trigger. * * @param string $trigger Trigger slug. * @param int $post_id Event post ID. * @param array $changes Trigger-specific data. * @return void */ private function dispatch_trigger( $trigger, $post_id, $changes ) { $settings = $this->get_settings(); $webhooks = isset( $settings['webhooks'] ) && is_array( $settings['webhooks'] ) ? $settings['webhooks'] : array(); if ( empty( $webhooks ) ) { return; } foreach ( $webhooks as $webhook ) { if ( ! is_array( $webhook ) ) { continue; } if ( empty( $webhook['enabled'] ) ) { continue; } $triggers = isset( $webhook['triggers'] ) && is_array( $webhook['triggers'] ) ? $webhook['triggers'] : array(); if ( ! in_array( $trigger, $triggers, true ) ) { continue; } $url = isset( $webhook['url'] ) ? (string) $webhook['url'] : ''; if ( '' === $url ) { continue; } $context = $this->build_context( $post_id, $trigger, $changes, $webhook ); $this->deliver_webhook( $url, isset( $webhook['template'] ) ? (string) $webhook['template'] : '', $webhook, $context ); } } /** * Send the webhook notification through the configured provider. * * @param string $url Destination value. * @param string $template Message template. * @param array $webhook Webhook configuration. * @param array $context Render context. * @return array|WP_Error */ private function deliver_webhook( $url, $template, $webhook, $context ) { $message = trim( $this->render_template( (string) $template, $context ) ); if ( '' === $message ) { return new WP_Error( 'empty_message', __( 'Rendered message is empty.', 'tonys-sportspress-enhancements' ) ); } $provider = isset( $webhook['provider'] ) ? sanitize_key( (string) $webhook['provider'] ) : 'generic_json'; $title = $this->build_notification_title( $webhook, $context ); switch ( $provider ) { case 'google_chat': $request_url = $url; $payload = array( 'text' => $message, ); break; case 'groupme_bot': $request_url = 'https://api.groupme.com/v3/bots/post'; $payload = array( 'bot_id' => $url, 'text' => $message, ); break; case 'generic_json': default: $request_url = $url; $payload = $this->build_generic_payload( $message, $title, $webhook, $context ); break; } $args = array( 'timeout' => 10, 'redirection' => 2, 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8', 'User-Agent' => 'Tonys-SportsPress-Enhancements/' . TONY_SPORTSPRESS_ENHANCEMENTS_VERSION, ), 'body' => wp_json_encode( $payload ), 'data_format' => 'body', ); /** * Filter webhook request arguments before delivery. * * @param array $args Request args. * @param string $request_url Final request URL. * @param array $payload Provider-specific payload. * @param array $webhook Webhook configuration. * @param array $context Render context. */ $args = apply_filters( 'tse_sp_webhook_request_args', $args, $request_url, $payload, $webhook, $context ); $response = wp_remote_post( $request_url, $args ); if ( is_wp_error( $response ) ) { return $response; } $status_code = (int) wp_remote_retrieve_response_code( $response ); $body = (string) wp_remote_retrieve_body( $response ); if ( $status_code < 200 || $status_code >= 300 ) { return new WP_Error( 'delivery_failed', sprintf( /* translators: 1: HTTP status code, 2: response body. */ __( 'Webhook delivery failed with HTTP %1$d. %2$s', 'tonys-sportspress-enhancements' ), $status_code, '' !== trim( $body ) ? trim( $body ) : __( 'No response body.', 'tonys-sportspress-enhancements' ) ) ); } return array( 'status_code' => $status_code, 'body' => $body, ); } /** * Build a title for outbound notifications. * * @param array $webhook Webhook configuration. * @param array $context Render context. * @return string */ private function build_notification_title( $webhook, $context ) { $title_parts = array(); if ( ! empty( $webhook['name'] ) ) { $title_parts[] = (string) $webhook['name']; } if ( isset( $context['trigger']['label'] ) && '' !== (string) $context['trigger']['label'] ) { $title_parts[] = (string) $context['trigger']['label']; } if ( isset( $context['event']['title'] ) && '' !== (string) $context['event']['title'] ) { $title_parts[] = (string) $context['event']['title']; } $title = implode( ' | ', array_slice( $title_parts, 0, 3 ) ); if ( '' === $title ) { $title = (string) get_bloginfo( 'name' ); } /** * Filter the outbound notification title. * * @param string $title Notification title. * @param array $webhook Webhook configuration. * @param array $context Render context. */ return (string) apply_filters( 'tse_sp_webhook_title', $title, $webhook, $context ); } /** * Build the generic JSON payload. * * @param string $message Rendered message. * @param string $title Derived title. * @param array $webhook Webhook configuration. * @param array $context Render context. * @return array */ private function build_generic_payload( $message, $title, $webhook, $context ) { return array( 'title' => $title, 'message' => $message, 'body' => $message, 'webhook' => array( 'id' => isset( $webhook['id'] ) ? (string) $webhook['id'] : '', 'name' => isset( $webhook['name'] ) ? (string) $webhook['name'] : '', 'provider' => isset( $webhook['provider'] ) ? (string) $webhook['provider'] : 'generic_json', ), 'context' => $context, ); } /** * Render Jinja-style placeholders with a minimal dot-path syntax. * * Supports `{{ event.title }}` and `{{ event|tojson }}`. * * @param string $template Template body. * @param array $context Template context. * @return string */ public function render_template( $template, $context ) { $template = (string) $template; $context = is_array( $context ) ? $context : array(); return preg_replace_callback( '/\{\{\s*(.+?)\s*\}\}/', function ( $matches ) use ( $context ) { $expression = trim( (string) $matches[1] ); $parts = array_map( 'trim', explode( '|', $expression ) ); $path = array_shift( $parts ); $value = $this->resolve_context_path( $context, $path ); $force_json = false; foreach ( $parts as $filter ) { if ( in_array( strtolower( $filter ), array( 'tojson', 'json' ), true ) ) { $force_json = true; } } if ( $force_json ) { return (string) wp_json_encode( $value ); } if ( is_array( $value ) || is_object( $value ) ) { return (string) wp_json_encode( $value ); } if ( is_bool( $value ) ) { return $value ? 'true' : 'false'; } if ( null === $value ) { return ''; } return (string) $value; }, $template ); } /** * Resolve a dot-path from the render context. * * @param mixed $context Full context. * @param string $path Dot path. * @return mixed */ private function resolve_context_path( $context, $path ) { $path = trim( (string) $path ); if ( '' === $path ) { return ''; } $segments = array_filter( explode( '.', $path ), 'strlen' ); $current = $context; foreach ( $segments as $segment ) { if ( is_array( $current ) && array_key_exists( $segment, $current ) ) { $current = $current[ $segment ]; continue; } if ( is_object( $current ) && isset( $current->{$segment} ) ) { $current = $current->{$segment}; continue; } if ( is_array( $current ) && ctype_digit( $segment ) ) { $segment = (int) $segment; if ( array_key_exists( $segment, $current ) ) { $current = $current[ $segment ]; continue; } } return ''; } return $current; } /** * Determine whether a nested results payload actually contains values. * * @param mixed $results Results payload. * @return bool */ private function has_meaningful_results( $results ) { if ( is_array( $results ) ) { foreach ( $results as $value ) { if ( $this->has_meaningful_results( $value ) ) { return true; } } return false; } return '' !== trim( (string) $results ); } /** * Get a summary string for current results. * * @param WP_Post|false|null $post Event post. * @return string */ private function get_results_summary( $post ) { if ( ! $post instanceof WP_Post ) { return ''; } if ( function_exists( 'tse_sp_event_export_get_ical_summary' ) ) { return (string) tse_sp_event_export_get_ical_summary( $post, array( 'format' => 'matchup' ) ); } return (string) get_the_title( $post ); } /** * Get a display title for the event. * * @param int $post_id Event post ID. * @return string */ private function get_event_title( $post_id ) { if ( function_exists( 'asc_generate_sp_event_title' ) ) { return (string) asc_generate_sp_event_title( $post_id ); } return (string) get_the_title( $post_id ); } /** * Get team metadata in event order. * * @param int $post_id Event post ID. * @return array */ private function get_event_teams( $post_id ) { $team_ids = get_post_meta( $post_id, 'sp_team', false ); $teams = array(); foreach ( $team_ids as $index => $team_id ) { while ( is_array( $team_id ) ) { $team_id = array_shift( array_filter( $team_id ) ); } $team_id = absint( $team_id ); if ( $team_id <= 0 ) { continue; } $teams[] = array( 'id' => $team_id, 'name' => get_the_title( $team_id ), 'short_name' => function_exists( 'sp_team_short_name' ) ? (string) sp_team_short_name( $team_id ) : get_the_title( $team_id ), 'abbreviation' => function_exists( 'sp_team_abbreviation' ) ? (string) sp_team_abbreviation( $team_id ) : '', 'role' => 0 === $index ? 'home' : ( 1 === $index ? 'away' : 'team' ), ); } return $teams; } /** * Get basic venue metadata for an event. * * @param int $post_id Event post ID. * @return array */ private function get_event_venue( $post_id ) { $terms = get_the_terms( $post_id, 'sp_venue' ); if ( is_wp_error( $terms ) || empty( $terms ) ) { return array( 'id' => 0, 'name' => '', 'slug' => '', ); } $venue = reset( $terms ); return array( 'id' => isset( $venue->term_id ) ? (int) $venue->term_id : 0, 'name' => isset( $venue->name ) ? (string) $venue->name : '', 'slug' => isset( $venue->slug ) ? (string) $venue->slug : '', ); } } } /** * Bootstrap SportsPress webhooks after plugins load. * * @return void */ function tony_sportspress_webhooks_boot() { Tony_Sportspress_Webhooks::instance()->boot(); } add_action( 'plugins_loaded', 'tony_sportspress_webhooks_boot' );