Files
tonys-sportspress-enhancements/includes/sp-webhooks.php

1417 lines
45 KiB
PHP

<?php
/**
* Configurable SportsPress webhooks.
*
* @package Tonys_Sportspress_Enhancements
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) {
/**
* Manage Tony's Settings webhooks and event-triggered delivery.
*/
class Tony_Sportspress_Webhooks {
/**
* Webhook settings option key.
*/
const OPTION_KEY = 'tse_sportspress_webhooks_settings';
/**
* Webhook settings group.
*/
const OPTION_GROUP = 'tse_sportspress_webhooks';
/**
* Tony's Settings tab slug.
*/
const TAB_WEBHOOKS = 'webhooks';
/**
* Meta key used to suppress duplicate result notifications.
*/
const RESULTS_SIGNATURE_META_KEY = '_tse_sp_webhook_results_signature';
/**
* Meta key used to suppress duplicate schedule notifications.
*/
const SCHEDULE_SIGNATURE_META_KEY = '_tse_sp_webhook_schedule_signature';
/**
* Singleton instance.
*
* @var Tony_Sportspress_Webhooks|null
*/
private static $instance = null;
/**
* Whether hooks were already registered.
*
* @var bool
*/
private $booted = false;
/**
* Get singleton instance.
*
* @return Tony_Sportspress_Webhooks
*/
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register the feature hooks.
*
* @return void
*/
public function boot() {
if ( $this->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 '<form method="post" action="options.php">';
settings_fields( self::OPTION_GROUP );
wp_referer_field();
echo '<h2>' . esc_html__( 'SportsPress Webhooks', 'tonys-sportspress-enhancements' ) . '</h2>';
echo '<p>' . 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' ) . '</p>';
echo '<p class="description">' . 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' ) . '</p>';
echo '<div style="margin:18px 0 12px;padding:16px 18px;border:1px solid #dcdcde;background:#fff;max-width:1100px;">';
echo '<strong>' . esc_html__( 'Message template variables', 'tonys-sportspress-enhancements' ) . '</strong>';
echo '<p style="margin:8px 0 0;">';
echo esc_html__( 'Use Jinja-style placeholders such as', 'tonys-sportspress-enhancements' ) . ' ';
echo '<code>{{ event.title }}</code>, <code>{{ event.permalink }}</code>, <code>{{ trigger.key }}</code>, <code>{{ changes.previous.local_display }}</code>, <code>{{ changes.current.local_display }}</code>, <code>{{ results.summary }}</code>, ';
echo esc_html__( 'or serialize values safely for JSON with', 'tonys-sportspress-enhancements' ) . ' <code>{{ event|tojson }}</code> ' . esc_html__( 'and', 'tonys-sportspress-enhancements' ) . ' <code>{{ event.title|tojson }}</code>. ';
echo esc_html__( 'The rendered template becomes the outgoing message text.', 'tonys-sportspress-enhancements' );
echo '</p>';
echo '</div>';
echo '<div id="tse-webhook-rows" style="display:grid;gap:16px;max-width:1100px;">';
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 '</div>';
echo '<p style="margin-top:16px;"><button type="button" class="button" id="tse-webhook-add">' . esc_html__( 'Add Webhook', 'tonys-sportspress-enhancements' ) . '</button></p>';
submit_button( __( 'Save Webhooks', 'tonys-sportspress-enhancements' ) );
echo '</form>';
echo '<script type="text/html" id="tmpl-tse-webhook-row">';
$this->render_webhook_row( $this->default_webhook(), '__index__' );
echo '</script>';
$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 '<section class="tse-webhook-row" style="padding:18px 20px;border:1px solid #dcdcde;background:#fff;">';
echo '<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;">';
echo '<h3 data-tse-webhook-label style="margin:0;">' . esc_html__( 'Webhook', 'tonys-sportspress-enhancements' ) . '</h3>';
echo '<button type="button" class="button-link-delete" data-tse-remove-webhook="1">' . esc_html__( 'Remove', 'tonys-sportspress-enhancements' ) . '</button>';
echo '</div>';
echo '<input type="hidden" name="' . esc_attr( $base . '[id]' ) . '" value="' . esc_attr( $webhook['id'] ) . '" />';
echo '<p>';
echo '<label style="display:inline-flex;align-items:center;gap:6px;">';
echo '<input type="checkbox" name="' . esc_attr( $base . '[enabled]' ) . '" value="1" ' . checked( '1' === $webhook['enabled'], true, false ) . ' />';
echo esc_html__( 'Enabled', 'tonys-sportspress-enhancements' );
echo '</label>';
echo '</p>';
echo '<table class="form-table" role="presentation" style="margin-top:0;"><tbody>';
echo '<tr>';
echo '<th scope="row"><label>' . esc_html__( 'Name', 'tonys-sportspress-enhancements' ) . '</label></th>';
echo '<td><input type="text" class="regular-text" name="' . esc_attr( $base . '[name]' ) . '" value="' . esc_attr( $webhook['name'] ) . '" placeholder="' . esc_attr__( 'Game Time Updates', 'tonys-sportspress-enhancements' ) . '" /></td>';
echo '</tr>';
echo '<tr>';
echo '<th scope="row"><label>' . esc_html__( 'Triggers', 'tonys-sportspress-enhancements' ) . '</label></th>';
echo '<td>';
foreach ( $this->trigger_labels() as $trigger => $label ) {
$input_id = sanitize_html_class( 'tse-webhook-' . $index . '-' . $trigger );
echo '<label for="' . esc_attr( $input_id ) . '" style="display:inline-flex;align-items:center;gap:6px;margin:0 18px 8px 0;">';
echo '<input id="' . esc_attr( $input_id ) . '" type="checkbox" name="' . esc_attr( $base . '[triggers][]' ) . '" value="' . esc_attr( $trigger ) . '" ' . checked( in_array( $trigger, $webhook['triggers'], true ), true, false ) . ' />';
echo esc_html( $label );
echo '</label>';
}
echo '<p class="description" style="margin:0;">' . esc_html__( 'Create separate webhook rows if each trigger should send to a different destination or use a different body template.', 'tonys-sportspress-enhancements' ) . '</p>';
echo '</td>';
echo '</tr>';
echo '<tr>';
echo '<th scope="row"><label>' . esc_html__( 'Destination Type', 'tonys-sportspress-enhancements' ) . '</label></th>';
echo '<td>';
echo '<select name="' . esc_attr( $base . '[provider]' ) . '">';
foreach ( $this->provider_labels() as $provider_key => $provider_label ) {
echo '<option value="' . esc_attr( $provider_key ) . '" ' . selected( $webhook['provider'], $provider_key, false ) . '>' . esc_html( $provider_label ) . '</option>';
}
echo '</select>';
echo '<p class="description" style="margin:6px 0 0;">' . esc_html__( 'Choose how this message is delivered.', 'tonys-sportspress-enhancements' ) . '</p>';
echo '</td>';
echo '</tr>';
echo '<tr>';
echo '<th scope="row"><label>' . esc_html__( 'Destination', 'tonys-sportspress-enhancements' ) . '</label></th>';
echo '<td>';
echo '<input type="text" class="large-text code" name="' . esc_attr( $base . '[url]' ) . '" value="' . esc_attr( $webhook['url'] ) . '" placeholder="https://chat.googleapis.com/v1/spaces/... or GroupMe bot_id" />';
echo '<p class="description" style="margin:6px 0 0;">' . esc_html__( 'Google Chat: paste the full incoming webhook URL. GroupMe Bot: enter the bot_id. Generic JSON: enter the destination URL.', 'tonys-sportspress-enhancements' ) . '</p>';
echo '</td>';
echo '</tr>';
echo '<tr>';
echo '<th scope="row"><label>' . esc_html__( 'Message Template', 'tonys-sportspress-enhancements' ) . '</label></th>';
echo '<td>';
echo '<textarea class="large-text code" rows="11" name="' . esc_attr( $base . '[template]' ) . '">' . esc_textarea( $webhook['template'] ) . '</textarea>';
echo '<p class="description" style="margin:6px 0 0;">' . esc_html__( 'Example message:', 'tonys-sportspress-enhancements' ) . ' <code>{{ trigger.label }} - {{ event.title }} - {{ results.summary }}</code></p>';
echo '<p style="margin:10px 0 0;">';
echo '<button type="button" class="button" data-tse-send-test="1">' . esc_html__( 'Send Test', 'tonys-sportspress-enhancements' ) . '</button> ';
echo '<span class="description" data-tse-test-status="1"></span>';
echo '</p>';
echo '</td>';
echo '</tr>';
echo '</tbody></table>';
echo '</section>';
}
/**
* 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' );
?>
<script>
(function(){
var container = document.getElementById('tse-webhook-rows');
var addButton = document.getElementById('tse-webhook-add');
var template = document.getElementById('tmpl-tse-webhook-row');
var ajaxUrl = <?php echo wp_json_encode( $ajax_url ); ?>;
var nonce = <?php echo wp_json_encode( $nonce ); ?>;
if (!container || !addButton || !template) {
return;
}
function syncLabels() {
Array.prototype.slice.call(container.querySelectorAll('.tse-webhook-row')).forEach(function(row, index) {
var label = row.querySelector('[data-tse-webhook-label]');
if (label) {
label.textContent = 'Webhook ' + (index + 1);
}
});
}
addButton.addEventListener('click', function(event) {
event.preventDefault();
var index = container.querySelectorAll('.tse-webhook-row').length;
var wrapper = document.createElement('div');
wrapper.innerHTML = template.innerHTML.replace(/__index__/g, String(index));
if (wrapper.firstElementChild) {
container.appendChild(wrapper.firstElementChild);
syncLabels();
}
});
container.addEventListener('click', function(event) {
var button = event.target.closest('[data-tse-remove-webhook]');
if (!button) {
button = event.target.closest('[data-tse-send-test]');
if (!button) {
return;
}
event.preventDefault();
var testRow = button.closest('.tse-webhook-row');
var status = testRow ? testRow.querySelector('[data-tse-test-status]') : null;
var formData = new FormData();
formData.append('action', 'tse_sp_webhook_test');
formData.append('nonce', nonce);
if (testRow) {
Array.prototype.slice.call(testRow.querySelectorAll('input, textarea, select')).forEach(function(field) {
if (!field.name || field.disabled) {
return;
}
if ((field.type === 'checkbox' || field.type === 'radio') && !field.checked) {
return;
}
formData.append(field.name, field.value);
});
}
if (status) {
status.textContent = 'Sending test...';
}
fetch(ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body: formData
})
.then(function(response) {
return response.json();
})
.then(function(data) {
if (!status) {
return;
}
if (data && data.success && data.data && data.data.message) {
status.textContent = data.data.message;
return;
}
status.textContent = (data && data.data && data.data.message) ? data.data.message : 'Test failed.';
})
.catch(function() {
if (status) {
status.textContent = 'Test failed.';
}
});
return;
}
event.preventDefault();
var row = button.closest('.tse-webhook-row');
if (row) {
row.remove();
syncLabels();
}
});
syncLabels();
})();
</script>
<?php
}
/**
* Get the configured webhooks option with defaults.
*
* @return array
*/
private function get_settings() {
return wp_parse_args( get_option( self::OPTION_KEY, array() ), $this->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' );