diff --git a/includes/sp-webhooks.php b/includes/sp-webhooks.php new file mode 100644 index 0000000..9658ebd --- /dev/null +++ b/includes/sp-webhooks.php @@ -0,0 +1,1416 @@ +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' ); diff --git a/tests/test-sp-webhooks.php b/tests/test-sp-webhooks.php new file mode 100644 index 0000000..f5cbf9d --- /dev/null +++ b/tests/test-sp-webhooks.php @@ -0,0 +1,81 @@ + array( + 'key' => 'event_results_updated', + ), + 'event' => array( + 'id' => 55, + 'teams' => array( + array( + 'name' => 'Blue Team', + ), + ), + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertStringContainsString( 'Trigger=event_results_updated', $rendered ); + $this->assertStringContainsString( 'Team=Blue Team', $rendered ); + $this->assertStringContainsString( '"id":55', $rendered ); + } + + /** + * Sanitization should keep only complete provider-specific webhook rows. + */ + public function test_sanitize_settings_keeps_only_valid_webhooks() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $sanitized = $webhooks->sanitize_settings( + array( + 'webhooks' => array( + array( + 'name' => 'Results', + 'enabled' => '1', + 'provider' => 'google_chat', + 'url' => 'https://chat.googleapis.com/v1/spaces/AAA/messages?key=test&token=test', + 'triggers' => array( 'event_results_updated' ), + 'template' => '{"summary":"{{ results.summary }}"}', + ), + array( + 'name' => 'Invalid', + 'enabled' => '1', + 'provider' => 'groupme_bot', + 'url' => 'invalid bot id', + 'triggers' => array( 'event_datetime_changed' ), + 'template' => 'ignored', + ), + array( + 'name' => 'Missing trigger', + 'enabled' => '1', + 'provider' => 'generic_json', + 'url' => 'https://example.com/missing-trigger', + 'template' => 'ignored', + ), + ), + ) + ); + + $this->assertCount( 1, $sanitized['webhooks'] ); + $this->assertSame( 'Results', $sanitized['webhooks'][0]['name'] ); + $this->assertSame( 'google_chat', $sanitized['webhooks'][0]['provider'] ); + $this->assertSame( 'https://chat.googleapis.com/v1/spaces/AAA/messages?key=test&token=test', $sanitized['webhooks'][0]['url'] ); + $this->assertSame( array( 'event_results_updated' ), $sanitized['webhooks'][0]['triggers'] ); + } +} diff --git a/tonys-sportspress-enhancements.php b/tonys-sportspress-enhancements.php index 49d6af2..fa1278b 100644 --- a/tonys-sportspress-enhancements.php +++ b/tonys-sportspress-enhancements.php @@ -46,6 +46,7 @@ require_once plugin_dir_path(__FILE__) . 'includes/sp-event-quick-edit-officials require_once plugin_dir_path(__FILE__) . 'includes/sp-event-team-ordering.php'; require_once plugin_dir_path(__FILE__) . 'includes/sp-printable-calendars.php'; require_once plugin_dir_path(__FILE__) . 'includes/sp-url-builder.php'; +require_once plugin_dir_path(__FILE__) . 'includes/sp-webhooks.php'; require_once plugin_dir_path(__FILE__) . 'includes/sp-schedule-exporter.php'; require_once plugin_dir_path(__FILE__) . 'includes/sp-venue-meta.php';