0, 'success' => 0, 'warning' => 1, 'error' => 2, 'critical' => 3 ); public static function init() { add_action( 'apwpm_event_logged', array( __CLASS__, 'on_event' ), 20, 2 ); } public static function on_event( $log_id, $event ) { $s = (array) get_option( 'apwpm_settings', array() ); if ( empty( $s['enable_webhooks'] ) ) { return; } if ( ! self::passes_min_severity( $event['severity'] ?? 'info', $s['webhook_min_sev'] ?? 'warning' ) ) { return; } $payload = self::build_payload( $log_id, $event ); if ( ! empty( $s['webhook_slack'] ) ) { self::dispatch( $s['webhook_slack'], self::slack_body( $payload ) ); } if ( ! empty( $s['webhook_discord'] ) ) { self::dispatch( $s['webhook_discord'], self::discord_body( $payload ) ); } if ( ! empty( $s['webhook_teams'] ) ) { self::dispatch( $s['webhook_teams'], self::teams_body( $payload ) ); } if ( ! empty( $s['webhook_generic'] ) ) { self::dispatch_signed( $s['webhook_generic'], $payload, $s['webhook_secret'] ?? '' ); } } protected static function passes_min_severity( $event_sev, $min_sev ) { $order = self::SEVERITY_ORDER; $a = isset( $order[ $event_sev ] ) ? $order[ $event_sev ] : 0; $b = isset( $order[ $min_sev ] ) ? $order[ $min_sev ] : 1; return $a >= $b; } protected static function build_payload( $log_id, $event ) { $site = get_bloginfo( 'name' ); $url = APWPM_Admin::timeline_url( (int) $log_id ); return array( 'id' => (int) $log_id, 'site' => $site, 'site_url' => home_url(), 'time' => $event['created_at'] ?? current_time( 'mysql' ), 'user_login' => $event['user_login'] ?? '', 'action_type' => $event['action_type'] ?? '', 'object_type' => $event['object_type'] ?? '', 'object_title' => $event['object_title'] ?? '', 'description' => $event['description'] ?? '', 'severity' => $event['severity'] ?? 'info', 'ip_address' => $event['ip_address'] ?? '', 'link' => $url, ); } protected static function severity_emoji( $sev ) { switch ( $sev ) { case 'critical': return ':rotating_light:'; case 'error': return ':x:'; case 'warning': return ':warning:'; case 'success': return ':white_check_mark:'; default: return ':information_source:'; } } protected static function slack_body( $p ) { $text = sprintf( '%s *[%s]* `%s` on _%s_ by *%s* — %s', self::severity_emoji( $p['severity'] ), strtoupper( $p['severity'] ), $p['action_type'], $p['site'], $p['user_login'] ?: 'system', $p['description'] ); return wp_json_encode( array( 'text' => $text, 'attachments' => array( array( 'fallback' => $p['description'], 'fields' => array( array( 'title' => 'Object', 'value' => $p['object_type'] . ' ' . $p['object_title'], 'short' => true ), array( 'title' => 'IP', 'value' => $p['ip_address'] ?: '—', 'short' => true ), ), 'actions' => array( array( 'type' => 'button', 'text' => 'Open in timeline', 'url' => $p['link'] ), ), ), ), ) ); } protected static function discord_body( $p ) { $color_map = array( 'critical' => 15158332, 'error' => 15105570, 'warning' => 15844367, 'success' => 3066993, 'info' => 3447003 ); return wp_json_encode( array( 'username' => 'Activity Timeline', 'embeds' => array( array( 'title' => strtoupper( $p['severity'] ) . ' · ' . $p['action_type'], 'description' => $p['description'], 'url' => $p['link'], 'color' => $color_map[ $p['severity'] ] ?? 3447003, 'fields' => array( array( 'name' => 'Site', 'value' => $p['site'], 'inline' => true ), array( 'name' => 'User', 'value' => $p['user_login'] ?: 'system', 'inline' => true ), array( 'name' => 'Object', 'value' => $p['object_type'] . ' ' . $p['object_title'], 'inline' => false ), ), 'timestamp' => mysql2date( 'c', $p['time'] ), ), ), ) ); } protected static function teams_body( $p ) { $color_map = array( 'critical' => 'D32F2F', 'error' => 'E53935', 'warning' => 'FB8C00', 'success' => '43A047', 'info' => '1E88E5' ); return wp_json_encode( array( '@type' => 'MessageCard', '@context' => 'http://schema.org/extensions', 'themeColor' => $color_map[ $p['severity'] ] ?? '1E88E5', 'summary' => $p['action_type'], 'title' => strtoupper( $p['severity'] ) . ' · ' . $p['action_type'], 'sections' => array( array( 'activityTitle' => $p['site'], 'activitySubtitle' => $p['time'], 'facts' => array( array( 'name' => 'User', 'value' => $p['user_login'] ?: 'system' ), array( 'name' => 'Object', 'value' => $p['object_type'] . ' ' . $p['object_title'] ), array( 'name' => 'IP', 'value' => $p['ip_address'] ?: '—' ), ), 'text' => $p['description'], ), ), 'potentialAction' => array( array( '@type' => 'OpenUri', 'name' => 'Open timeline', 'targets' => array( array( 'os' => 'default', 'uri' => $p['link'] ), ), ), ), ) ); } protected static function dispatch( $url, $json_body ) { wp_remote_post( esc_url_raw( $url ), array( 'timeout' => 4, 'blocking' => false, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => $json_body, ) ); } protected static function dispatch_signed( $url, array $payload, $secret ) { $body = wp_json_encode( $payload ); $sig = $secret ? hash_hmac( 'sha256', $body, $secret ) : ''; wp_remote_post( esc_url_raw( $url ), array( 'timeout' => 4, 'blocking' => false, 'headers' => array( 'Content-Type' => 'application/json', 'X-APWPM-Signature' => $sig, 'X-APWPM-Event' => $payload['action_type'], ), 'body' => $body, ) ); } /** * Synchronous test send used by the settings page "Test webhook" button. */ public static function send_test( $channel = 'slack' ) { $s = (array) get_option( 'apwpm_settings', array() ); $p = array( 'id' => 0, 'site' => get_bloginfo( 'name' ), 'site_url' => home_url(), 'time' => current_time( 'mysql' ), 'user_login' => wp_get_current_user()->user_login ?? 'admin', 'action_type' => 'webhook_test', 'object_type' => 'system', 'object_title' => 'Activity Timeline', 'description' => 'Test message from ActivityPilot for WP.', 'severity' => 'info', 'ip_address' => '', 'link' => APWPM_Admin::timeline_url(), ); switch ( $channel ) { case 'discord': return ! empty( $s['webhook_discord'] ) ? wp_remote_post( esc_url_raw( $s['webhook_discord'] ), array( 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => self::discord_body( $p ) ) ) : new WP_Error( 'no_url', 'Discord URL is empty' ); case 'teams': return ! empty( $s['webhook_teams'] ) ? wp_remote_post( esc_url_raw( $s['webhook_teams'] ), array( 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => self::teams_body( $p ) ) ) : new WP_Error( 'no_url', 'Teams URL is empty' ); case 'generic': if ( empty( $s['webhook_generic'] ) ) { return new WP_Error( 'no_url', 'Generic URL is empty' ); } $body = wp_json_encode( $p ); $sig = ! empty( $s['webhook_secret'] ) ? hash_hmac( 'sha256', $body, $s['webhook_secret'] ) : ''; return wp_remote_post( esc_url_raw( $s['webhook_generic'] ), array( 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json', 'X-APWPM-Signature' => $sig ), 'body' => $body, ) ); case 'slack': default: return ! empty( $s['webhook_slack'] ) ? wp_remote_post( esc_url_raw( $s['webhook_slack'] ), array( 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => self::slack_body( $p ) ) ) : new WP_Error( 'no_url', 'Slack URL is empty' ); } } }