[ label, format, placeholder, help ] */ public static function presets() { return array( 'slack' => array( 'label' => 'Slack', 'format' => 'slack', 'placeholder' => 'https://hooks.slack.com/services/T000/B000/XXXX', 'help' => __( 'In Slack, create an app with Incoming Webhooks enabled, add a webhook to a channel and paste its URL here.', 'hookbird' ), ), 'discord' => array( 'label' => 'Discord', 'format' => 'discord', 'placeholder' => 'https://discord.com/api/webhooks/000/XXXX', 'help' => __( 'In Discord, open Channel settings, Integrations, Webhooks, create a webhook and copy its URL.', 'hookbird' ), ), 'telegram' => array( 'label' => 'Telegram', 'format' => 'telegram', 'placeholder' => 'https://api.telegram.org/bot/sendMessage', 'help' => __( 'Create a bot with @BotFather, then use https://api.telegram.org/bot/sendMessage as the URL and add the chat ID of your channel, group or user.', 'hookbird' ), ), 'teams' => array( 'label' => 'Microsoft Teams', 'format' => 'teams', 'placeholder' => 'https://….logic.azure.com/workflows/…', 'help' => __( 'In Teams, add the "Post to a channel when a webhook request is received" workflow to a channel and paste the workflow URL here.', 'hookbird' ), ), 'googlechat' => array( 'label' => 'Google Chat', 'format' => 'googlechat', 'placeholder' => 'https://chat.googleapis.com/v1/spaces/…/messages?key=…', 'help' => __( 'In a Google Chat space, open Apps and integrations, Webhooks, add a webhook and paste its URL.', 'hookbird' ), ), 'n8n' => array( 'label' => 'n8n', 'format' => 'json', 'placeholder' => 'https://your-n8n.example.com/webhook/…', 'help' => __( 'Add a Webhook trigger node set to POST and paste its production URL. Events arrive as JSON.', 'hookbird' ), ), 'make' => array( 'label' => 'Make', 'format' => 'json', 'placeholder' => 'https://hook.eu1.make.com/…', 'help' => __( 'Add a "Custom webhook" module as the first step of your scenario and paste its address.', 'hookbird' ), ), 'zapier' => array( 'label' => 'Zapier', 'format' => 'json', 'placeholder' => 'https://hooks.zapier.com/hooks/catch/…', 'help' => __( 'Use the "Webhooks by Zapier" trigger with "Catch Hook" and paste the URL it gives you.', 'hookbird' ), ), 'custom' => array( 'label' => __( 'Custom webhook', 'hookbird' ), 'format' => 'json', 'placeholder' => 'https://api.example.com/webhooks/wordpress', 'help' => __( 'Any URL that accepts a JSON POST request. Requests are signed so your receiver can verify them.', 'hookbird' ), ), ); } /** * Human label for a format. * * @param string $format Format. * @return string */ public static function format_label( $format ) { $labels = array( 'json' => __( 'JSON', 'hookbird' ), 'slack' => 'Slack', 'discord' => 'Discord', 'telegram' => 'Telegram', 'teams' => 'Microsoft Teams', 'googlechat' => 'Google Chat', ); return isset( $labels[ $format ] ) ? $labels[ $format ] : $format; } /** * Build the request body for an endpoint. * * @param array $endpoint Endpoint. * @param array $envelope Envelope. * @return array Body to JSON encode. */ public static function body( array $endpoint, array $envelope ) { $format = $endpoint['format']; if ( 'json' === $format ) { $body = $envelope; } else { $summary = self::summary( $envelope ); switch ( $format ) { case 'slack': $body = self::slack( $summary ); break; case 'discord': $body = self::discord( $summary ); break; case 'telegram': $body = self::telegram( $summary, isset( $endpoint['options']['chat_id'] ) ? $endpoint['options']['chat_id'] : '' ); break; case 'teams': $body = self::teams( $summary ); break; case 'googlechat': $body = self::googlechat( $summary ); break; default: $body = $envelope; } } /** * Filter the request body before it is sent. * * @param array $body Body. * @param array $envelope Event envelope. * @param array $endpoint Endpoint. */ return (array) apply_filters( 'hookbird_request_body', $body, $envelope, $endpoint ); } /** * Readable summary of an event: title, fields, link and colour. * * @param array $envelope Envelope. * @return array */ public static function summary( array $envelope ) { $event = isset( $envelope['event'] ) ? (string) $envelope['event'] : ''; $definition = Registry::get( $event ); $data = isset( $envelope['data'] ) && is_array( $envelope['data'] ) ? $envelope['data'] : array(); $fields = array(); if ( $definition && '' !== $definition['title'] ) { $title = self::render( $definition['title'], $envelope ); foreach ( (array) $definition['fields'] as $label => $path ) { $value = trim( self::stringify( Conditions::resolve( $envelope, $path ) ) ); if ( '' !== $value ) { $fields[] = array( (string) $label, $value ); } } $link = $definition['link'] ? self::stringify( Conditions::resolve( $envelope, $definition['link'] ) ) : ''; } else { $title = $definition ? $definition['label'] : $event; foreach ( $data as $key => $value ) { $value = trim( self::stringify( $value ) ); $label = trim( ucfirst( str_replace( '_', ' ', wp_strip_all_tags( (string) $key ) ) ) ); if ( '' !== $value && count( $fields ) < 10 ) { $fields[] = array( '' !== $label ? self::truncate( $label, 100 ) : __( 'Value', 'hookbird' ), $value ); } } $link = ''; } $site = isset( $envelope['site']['name'] ) ? (string) $envelope['site']['name'] : ''; $title = trim( $title ); if ( '' === $title ) { $title = $definition ? $definition['label'] : $event; } $summary = array( 'title' => ( ! empty( $envelope['test'] ) ? '[' . __( 'Test', 'hookbird' ) . '] ' : '' ) . self::truncate( $title, 240 ), 'fields' => $fields, 'link' => preg_match( '#^https?://#i', $link ) ? esc_url_raw( $link ) : '', 'color' => Registry::color( $event ), 'footer' => trim( $site . ' · ' . $event, ' ·' ), 'time' => isset( $envelope['created_at'] ) ? (string) $envelope['created_at'] : gmdate( 'c' ), ); /** * Filter the readable summary used for chat destinations. * * @param array $summary Summary with title, fields, link, color, footer and time. * @param array $envelope Event envelope. */ return (array) apply_filters( 'hookbird_message_summary', $summary, $envelope ); } /** * Replace {path} tokens in a template. * * @param string $template Template. * @param array $envelope Envelope. * @return string */ public static function render( $template, array $envelope ) { return (string) preg_replace_callback( '/\{([A-Za-z0-9_.\-]+)\}/', static function ( $token ) use ( $envelope ) { return self::stringify( Conditions::resolve( $envelope, $token[1] ) ); }, (string) $template ); } /** * Convert a value to display text. * * @param mixed $value Value. * @return string */ public static function stringify( $value ) { if ( null === $value ) { return ''; } if ( is_bool( $value ) ) { return $value ? __( 'Yes', 'hookbird' ) : __( 'No', 'hookbird' ); } if ( is_array( $value ) ) { $parts = array(); foreach ( $value as $item ) { if ( is_scalar( $item ) ) { $parts[] = (string) $item; } } return self::truncate( implode( ', ', $parts ), 500 ); } if ( is_float( $value ) ) { return rtrim( rtrim( number_format( $value, 2, '.', '' ), '0' ), '.' ); } return self::truncate( wp_strip_all_tags( (string) $value ), 1000 ); } /** * Truncate text on a character boundary. * * @param string $text Text. * @param int $max Max characters. * @return string */ public static function truncate( $text, $max ) { $text = (string) $text; return mb_strlen( $text ) > $max ? mb_substr( $text, 0, $max - 1 ) . '…' : $text; } /** * Length in UTF-16 code units, which is how Telegram counts message length. * * @param string $text Text. * @return int */ public static function utf16_length( $text ) { $text = (string) $text; return (int) ( mb_strlen( $text ) + preg_match_all( '/[\x{10000}-\x{10FFFF}]/u', $text ) ); } /** * Truncate so the escaped form stays within a limit, never cutting through an escape sequence. * * @param string $text Raw text. * @param int $max Max length of the escaped text. * @param callable $escape Escape function. * @param callable $measure Length function (defaults to mb_strlen). * @return string Escaped text. */ public static function fit( $text, $max, callable $escape, $measure = 'mb_strlen' ) { $text = (string) $text; $out = call_user_func( $escape, $text ); if ( call_user_func( $measure, $out ) <= $max ) { return $out; } $keep = max( 0, min( mb_strlen( $text ), $max ) - 1 ); while ( $keep > 0 ) { $out = call_user_func( $escape, mb_substr( $text, 0, $keep ) . '…' ); if ( call_user_func( $measure, $out ) <= $max ) { return $out; } $keep = (int) floor( $keep * 0.8 ); } return call_user_func( $escape, '…' ); } /** * Make a URL safe inside Slack and Google Chat link markup. * * @param string $url URL. * @return string */ private static function link_url( $url ) { return str_replace( array( '|', '<', '>', ' ' ), array( '%7C', '%3C', '%3E', '%20' ), (string) $url ); } /** * Escape text for Slack mrkdwn. * * @param string $text Text. * @return string */ private static function slack_escape( $text ) { return str_replace( array( '&', '<', '>' ), array( '&', '<', '>' ), (string) $text ); } /** * Slack Incoming Webhook body. * * @param array $s Summary. * @return array */ public static function slack( array $s ) { $esc = array( __CLASS__, 'slack_escape' ); $title = self::fit( $s['title'], 1000, $esc ); $header = $s['link'] ? '*<' . self::link_url( $s['link'] ) . '|' . $title . '>*' : '*' . $title . '*'; $blocks = array( array( 'type' => 'section', 'text' => array( 'type' => 'mrkdwn', 'text' => $header, ), ), ); if ( $s['fields'] ) { $fields = array(); foreach ( array_slice( $s['fields'], 0, 10 ) as $field ) { $fields[] = array( 'type' => 'mrkdwn', 'text' => '*' . self::fit( $field[0], 100, $esc ) . "*\n" . self::fit( $field[1], 1850, $esc ), ); } $blocks[] = array( 'type' => 'section', 'fields' => $fields, ); } $blocks[] = array( 'type' => 'context', 'elements' => array( array( 'type' => 'mrkdwn', 'text' => self::fit( $s['footer'], 2000, $esc ), ), ), ); return array( 'text' => self::truncate( $s['title'], 3000 ), 'blocks' => $blocks, ); } /** * Discord webhook body. Mentions are disabled so user content cannot ping @everyone. * * @param array $s Summary. * @return array */ public static function discord( array $s ) { $embed = array( 'title' => self::truncate( $s['title'], 256 ), 'color' => min( 0xFFFFFF, (int) hexdec( ltrim( (string) $s['color'], '#' ) ) ), 'footer' => array( 'text' => self::truncate( $s['footer'], 256 ) ), 'timestamp' => $s['time'], ); if ( $s['link'] && filter_var( $s['link'], FILTER_VALIDATE_URL ) ) { $embed['url'] = $s['link']; } // Discord rejects embeds whose title, fields and footer add up to more than 6000 characters. $budget = 5900 - mb_strlen( $embed['title'] ) - mb_strlen( $embed['footer']['text'] ); if ( $s['fields'] ) { $embed['fields'] = array(); foreach ( array_slice( $s['fields'], 0, 25 ) as $field ) { $name = self::truncate( $field[0], 256 ); $room = min( 1024, $budget - mb_strlen( $name ) ); $value = $field[1]; if ( $room < 20 ) { break; } $value = self::truncate( $value, $room ); $budget -= mb_strlen( $name ) + mb_strlen( $value ); $embed['fields'][] = array( 'name' => $name, 'value' => $value, 'inline' => mb_strlen( $value ) < 40, ); } } return array( 'embeds' => array( $embed ), 'allowed_mentions' => array( 'parse' => array() ), ); } /** * Telegram Bot API sendMessage body (HTML parse mode). * * @param array $s Summary. * @param string $chat_id Chat ID. * @return array */ public static function telegram( array $s, $chat_id ) { $esc = static function ( $text ) { return htmlspecialchars( (string) $text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); }; // Telegram allows 4096 characters (UTF-16 code units) of visible text; markup is built // line by line so a long value is shortened instead of cutting through a tag. $len = array( __CLASS__, 'utf16_length' ); $budget = 4000; $title = self::truncate( $s['title'], 250 ); $footer = self::truncate( $s['footer'], 200 ); $budget -= self::utf16_length( $title ) + self::utf16_length( $footer ) + 2; $head = '' . $esc( $title ) . ''; if ( $s['link'] ) { $head = '' . $head . ''; } $lines = array( $head ); foreach ( $s['fields'] as $field ) { $label = self::truncate( $field[0], 100 ); $room = $budget - self::utf16_length( $label ) - 3; if ( $room < 20 ) { break; } $value = self::fit( $field[1], $room, 'strval', $len ); $budget -= self::utf16_length( $label ) + self::utf16_length( $value ) + 3; $lines[] = '' . $esc( $label ) . ': ' . $esc( $value ); } $lines[] = '' . $esc( $footer ) . ''; return array( 'chat_id' => (string) $chat_id, 'text' => implode( "\n", $lines ), 'parse_mode' => 'HTML', 'disable_web_page_preview' => true, ); } /** * Microsoft Teams Workflows webhook body (Adaptive Card). * * @param array $s Summary. * @return array */ public static function teams( array $s ) { $body = array( array( 'type' => 'TextBlock', 'text' => $s['title'], 'weight' => 'Bolder', 'size' => 'Medium', 'wrap' => true, ), ); if ( $s['fields'] ) { // Teams workflow webhooks accept up to 28 KB per message. $facts = array(); $budget = 20000; foreach ( $s['fields'] as $field ) { $room = $budget - strlen( $field[0] ); if ( $room < 100 ) { break; } $value = self::fit( $field[1], $room, 'strval', 'strlen' ); $budget -= strlen( $field[0] ) + strlen( $value ); $facts[] = array( 'title' => (string) $field[0], 'value' => $value, ); } $body[] = array( 'type' => 'FactSet', 'facts' => $facts, ); } $body[] = array( 'type' => 'TextBlock', 'text' => $s['footer'], 'isSubtle' => true, 'size' => 'Small', 'wrap' => true, ); $card = array( '$schema' => 'http://adaptivecards.io/schemas/adaptive-card.json', 'type' => 'AdaptiveCard', 'version' => '1.4', 'body' => $body, ); if ( $s['link'] ) { $card['actions'] = array( array( 'type' => 'Action.OpenUrl', 'title' => __( 'Open', 'hookbird' ), 'url' => $s['link'], ), ); } return array( 'type' => 'message', 'attachments' => array( array( 'contentType' => 'application/vnd.microsoft.card.adaptive', 'contentUrl' => null, 'content' => $card, ), ), ); } /** * Google Chat webhook body. * * @param array $s Summary. * @return array */ public static function googlechat( array $s ) { $esc = static function ( $text ) { return str_replace( array( '<', '>' ), array( '‹', '›' ), (string) $text ); }; $title = $esc( self::truncate( $s['title'], 250 ) ); $footer = $esc( self::truncate( $s['footer'], 200 ) ); $lines = array( $s['link'] ? '*<' . self::link_url( $s['link'] ) . '|' . $title . '>*' : '*' . $title . '*' ); $budget = 4000 - mb_strlen( $lines[0] ) - mb_strlen( $footer ) - 4; foreach ( $s['fields'] as $field ) { $label = $esc( self::truncate( $field[0], 100 ) ); $room = $budget - mb_strlen( $label ) - 5; if ( $room < 20 ) { break; } $value = self::fit( $field[1], $room, $esc ); $budget -= mb_strlen( $label ) + mb_strlen( $value ) + 5; $lines[] = '*' . $label . ':* ' . $value; } $lines[] = '_' . $footer . '_'; return array( 'text' => implode( "\n", $lines ) ); } }