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' );