crypto = $crypto; } /** * Whether a URL looks like a Slack incoming webhook. * * @param string $url URL. * @return bool */ public static function valid_url( $url ) { return (bool) preg_match( '#^https://hooks\.slack\.com/services/[A-Za-z0-9]+/[A-Za-z0-9]+/[A-Za-z0-9]+$#D', (string) $url ); } /** * Whether a webhook is saved. * * @return bool */ public function configured() { return '' !== $this->url(); } /** * Saves the webhook URL. * * @param string $url URL. * @return bool False when the URL isn't a Slack webhook. */ public function save( $url ) { $url = trim( (string) $url ); if ( ! self::valid_url( $url ) ) { return false; } update_option( self::OPTION, $this->crypto->encrypt( $url ), false ); return true; } /** * Removes the webhook URL. * * @return void */ public function forget() { delete_option( self::OPTION ); } /** * The last part of the saved webhook, for "Saved (…abc12)". * * @return string */ public function hint() { $url = $this->url(); return '' === $url ? '' : '…' . substr( $url, -5 ); } /** * Sends an alert. * * @param array $alert Presenter::present() output. * @param string $note Optional note from the person sharing it. * @param string $sender Who shared it. * @return true|\WP_Error */ public function send_alert( array $alert, $note = '', $sender = '' ) { return $this->post( self::message( $alert, $note, $sender ) ); } /** * Sends a test message. * * @param string $sender Who asked. * @return true|\WP_Error */ public function send_test( $sender = '' ) { $text = '' !== $sender /* translators: %s: person's name. */ ? sprintf( __( 'TurtleReport is connected to this channel. %s sent this test from the Client Monitor settings.', 'turtlereport' ), $sender ) : __( 'TurtleReport is connected to this channel.', 'turtlereport' ); return $this->post( array( 'text' => $text, 'blocks' => array( array( 'type' => 'section', 'text' => array( 'type' => 'mrkdwn', 'text' => ':white_check_mark: ' . self::esc( $text ), ), ), ), ) ); } /** * The Slack message for an alert: a plain-text fallback and Block Kit blocks. * * @param array $alert Presented alert. * @param string $note Note. * @param string $sender Who shared it. * @return array */ public static function message( array $alert, $note = '', $sender = '' ) { $high = 'high' === $alert['severity']; $title = $alert['siteName'] . ': ' . ( '' !== (string) $alert['kind'] ? $alert['kind'] : $alert['title'] ); $where = self::where( $alert ); $blocks = array( array( 'type' => 'header', 'text' => array( 'type' => 'plain_text', 'text' => self::cut( $title, 150 ), 'emoji' => true, ), ), array( 'type' => 'section', 'text' => array( 'type' => 'mrkdwn', 'text' => ( $high ? ':red_circle:' : ':large_orange_circle:' ) . ' *' . self::esc( $high ? __( 'High priority', 'turtlereport' ) : __( 'Worth a look', 'turtlereport' ) ) . '* · *' . self::esc( self::cut( (string) $alert['headline'], 300 ) ) . '*' . ( '' !== $where ? "\n" . $where : '' ), ), ), ); $note = trim( (string) $note ); if ( '' !== $note ) { $who = '' !== $sender ? $sender : __( 'Note', 'turtlereport' ); $blocks[] = array( 'type' => 'section', 'text' => array( 'type' => 'mrkdwn', 'text' => '> *' . self::esc( $who ) . ':* ' . self::esc( self::cut( $note, 1000 ) ), ), ); } if ( ! empty( $alert['metrics'] ) ) { $fields = array(); foreach ( array_slice( $alert['metrics'], 0, 4 ) as $m ) { $fields[] = array( 'type' => 'mrkdwn', 'text' => '*' . self::esc( $m['label'] ) . "*\n" . self::esc( $m['value'] . ( '' !== (string) $m['change'] ? ' (' . $m['change'] . ')' : '' ) ), ); } $blocks[] = array( 'type' => 'section', 'fields' => $fields, ); } if ( ! empty( $alert['insights'] ) ) { $blocks[] = array( 'type' => 'section', 'text' => array( 'type' => 'mrkdwn', 'text' => '*' . self::esc( __( 'What the data suggests', 'turtlereport' ) ) . "*\n" . self::esc( self::cut( implode( ' ', $alert['insights'] ), 2800 ) ), ), ); } if ( ! empty( $alert['checks'] ) ) { $lines = array(); foreach ( array_values( $alert['checks'] ) as $i => $check ) { $lines[] = ( $i + 1 ) . '. ' . self::esc( $check ); } $blocks[] = array( 'type' => 'section', 'text' => array( 'type' => 'mrkdwn', 'text' => '*' . self::esc( __( 'Recommended next checks', 'turtlereport' ) ) . "*\n" . self::cut( implode( "\n", $lines ), 2800 ), ), ); } $blocks[] = array( 'type' => 'actions', 'elements' => array( array( 'type' => 'button', 'text' => array( 'type' => 'plain_text', 'text' => __( 'Open the report', 'turtlereport' ), ), 'url' => (string) $alert['reportUrl'], ), ), ); $context = array_filter( array( isset( $alert['confidence']['text'] ) ? (string) $alert['confidence']['text'] : '', (string) $alert['basis'], /* translators: %s: person's name. */ '' !== $sender ? sprintf( __( 'Shared by %s from TurtleReport', 'turtlereport' ), $sender ) : '', ), static function ( $part ) { return '' !== $part; } ); $blocks[] = array( 'type' => 'context', 'elements' => array( array( 'type' => 'mrkdwn', 'text' => self::esc( self::cut( implode( ' · ', $context ), 2900 ) ), ), ), ); return array( 'text' => $alert['siteName'] . ': ' . $alert['title'] . ' — ' . $alert['summary'], 'blocks' => $blocks, ); } /** * The same alert as plain text, for "Copy for Slack" (or email, or a ticket). * * @param array $alert Presented alert. * @return string */ public static function plain( array $alert ) { $lines = array(); $lines[] = $alert['siteName'] . ' · ' . $alert['kind'] . ' (' . ( 'high' === $alert['severity'] ? __( 'High priority', 'turtlereport' ) : __( 'Worth a look', 'turtlereport' ) ) . ')'; $lines[] = $alert['headline']; if ( '' !== (string) $alert['url'] ) { $lines[] = $alert['url']; } if ( ! empty( $alert['metrics'] ) ) { $lines[] = ''; foreach ( $alert['metrics'] as $m ) { $lines[] = '• ' . $m['label'] . ': ' . $m['value'] . ( '' !== (string) $m['change'] ? ' (' . $m['change'] . ')' : '' ); } } if ( ! empty( $alert['insights'] ) ) { $lines[] = ''; $lines[] = __( 'What the data suggests', 'turtlereport' ) . ':'; $lines[] = implode( ' ', $alert['insights'] ); } if ( ! empty( $alert['checks'] ) ) { $lines[] = ''; $lines[] = __( 'Recommended next checks', 'turtlereport' ) . ':'; foreach ( array_values( $alert['checks'] ) as $i => $check ) { $lines[] = ( $i + 1 ) . '. ' . $check; } } $lines[] = ''; if ( ! empty( $alert['confidence']['text'] ) ) { $lines[] = $alert['confidence']['text']; } $lines[] = $alert['basis']; return implode( "\n", $lines ); } /** * The affected address as a Slack link, or ''. * * @param array $alert Presented alert. * @return string */ private static function where( array $alert ) { $url = (string) $alert['url']; if ( '' === $url || ! preg_match( '#^https?://#i', $url ) ) { return '' !== (string) $alert['path'] ? self::esc( $alert['path'] ) : ''; } $url = str_replace( array( '<', '>', '|', ' ' ), array( '%3C', '%3E', '%7C', '%20' ), $url ); return '<' . $url . '|' . self::esc( '' !== (string) $alert['path'] && '/' === substr( (string) $alert['path'], 0, 1 ) ? $alert['path'] : $url ) . '>'; } /** * Posts a payload to the webhook. * * @param array $payload Payload. * @return true|\WP_Error */ private function post( array $payload ) { $url = $this->url(); if ( '' === $url ) { return new \WP_Error( 'turtlereport_slack_missing', __( 'Add a Slack webhook in Settings → Client Monitor first.', 'turtlereport' ) ); } $response = wp_remote_post( $url, array( 'timeout' => 10, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ), 'body' => wp_json_encode( $payload ), ) ); if ( is_wp_error( $response ) ) { /* translators: %s: network error. */ return new \WP_Error( 'turtlereport_slack_network', sprintf( __( 'Couldn\'t reach Slack: %s', 'turtlereport' ), $response->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = trim( (string) wp_remote_retrieve_body( $response ) ); if ( 200 === $code ) { return true; } switch ( $body ) { case 'no_service': case 'invalid_token': case 'no_team': $message = __( 'Slack doesn\'t recognise this webhook any more. It may have been removed: create a new one and save it in Settings → Client Monitor.', 'turtlereport' ); break; case 'channel_is_archived': $message = __( 'The Slack channel for this webhook is archived. Create a webhook for another channel.', 'turtlereport' ); break; case 'action_prohibited': $message = __( 'A Slack admin has blocked posting to this channel.', 'turtlereport' ); break; case 'invalid_blocks': case 'invalid_payload': $message = __( 'Slack refused the message format. Please report this to the plugin author.', 'turtlereport' ); break; default: /* translators: 1: HTTP status, 2: Slack's answer. */ $message = sprintf( __( 'Slack answered %1$d: %2$s', 'turtlereport' ), $code, self::cut( sanitize_text_field( $body ), 120 ) ); } return new \WP_Error( 'turtlereport_slack_failed', $message ); } /** * The decrypted webhook URL, or ''. * * @return string */ private function url() { $stored = get_option( self::OPTION, '' ); if ( ! is_string( $stored ) || '' === $stored ) { return ''; } $url = (string) $this->crypto->decrypt( $stored ); return self::valid_url( $url ) ? $url : ''; } /** * Escapes text for Slack mrkdwn (&, < and >). * * @param string $text Text. * @return string */ private static function esc( $text ) { return str_replace( array( '&', '<', '>' ), array( '&', '<', '>' ), (string) $text ); } /** * Shortens text to a length limit Slack enforces. * * @param string $text Text. * @param int $max Characters. * @return string */ private static function cut( $text, $max ) { $text = (string) $text; return Text::length( $text ) <= $max ? $text : Text::substr( $text, 0, $max - 1 ) . '…'; } }