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 '
';
+
+ 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 '';
+ }
+
+ /**
+ * 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';