From e618dbffbd7f023445a0b1c1fdd1919da995adec Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Wed, 13 Nov 2024 13:38:26 +0800 Subject: [PATCH] feat: integrate slack channel to notifications --- src/notifications/composer.json | 1 + src/notifications/src/ChannelManager.php | 11 +- .../SlackNotificationRouterChannel.php | 52 ++ .../src/Channels/SlackWebApiChannel.php | 97 +++ .../src/Channels/SlackWebhookChannel.php | 111 +++ .../src/Contracts/Slack/BlockContract.php | 11 + .../src/Contracts/Slack/ElementContract.php | 11 + .../src/Contracts/Slack/ObjectContract.php | 11 + .../src/Messages/SlackAttachment.php | 283 +++++++ .../src/Messages/SlackAttachmentField.php | 65 ++ .../src/Messages/SlackMessage.php | 218 +++++ .../Slack/BlockKit/Blocks/ActionsBlock.php | 80 ++ .../Slack/BlockKit/Blocks/ContextBlock.php | 91 ++ .../Slack/BlockKit/Blocks/DividerBlock.php | 48 ++ .../src/Slack/BlockKit/Blocks/HeaderBlock.php | 70 ++ .../src/Slack/BlockKit/Blocks/ImageBlock.php | 115 +++ .../Slack/BlockKit/Blocks/SectionBlock.php | 118 +++ .../BlockKit/Composites/ConfirmObject.php | 126 +++ .../Composites/PlainTextOnlyTextObject.php | 62 ++ .../Slack/BlockKit/Composites/TextObject.php | 66 ++ .../Slack/BlockKit/Elements/ButtonElement.php | 204 +++++ .../Slack/BlockKit/Elements/ImageElement.php | 56 ++ src/notifications/src/Slack/EventMetadata.php | 38 + src/notifications/src/Slack/SlackMessage.php | 336 ++++++++ src/notifications/src/Slack/SlackRoute.php | 39 + .../Slack/Blocks/ActionsBlockTest.php | 122 +++ .../Slack/Blocks/ContextBlockTest.php | 128 +++ .../Slack/Blocks/DividerBlockTest.php | 47 ++ .../Slack/Blocks/HeaderBlockTest.php | 77 ++ .../Slack/Blocks/ImageBlockTest.php | 113 +++ .../Slack/Blocks/SectionBlockTest.php | 173 ++++ .../Slack/Composites/ConfirmObjectTest.php | 286 +++++++ .../PlainTextOnlyTextObjectTest.php | 56 ++ .../Slack/Composites/TextObjectTest.php | 101 +++ .../Slack/Elements/ButtonElementTest.php | 271 ++++++ tests/Notifications/SlackMessageTest.php | 787 ++++++++++++++++++ 36 files changed, 4480 insertions(+), 1 deletion(-) create mode 100644 src/notifications/src/Channels/SlackNotificationRouterChannel.php create mode 100644 src/notifications/src/Channels/SlackWebApiChannel.php create mode 100644 src/notifications/src/Channels/SlackWebhookChannel.php create mode 100644 src/notifications/src/Contracts/Slack/BlockContract.php create mode 100644 src/notifications/src/Contracts/Slack/ElementContract.php create mode 100644 src/notifications/src/Contracts/Slack/ObjectContract.php create mode 100644 src/notifications/src/Messages/SlackAttachment.php create mode 100644 src/notifications/src/Messages/SlackAttachmentField.php create mode 100644 src/notifications/src/Messages/SlackMessage.php create mode 100644 src/notifications/src/Slack/BlockKit/Blocks/ActionsBlock.php create mode 100644 src/notifications/src/Slack/BlockKit/Blocks/ContextBlock.php create mode 100644 src/notifications/src/Slack/BlockKit/Blocks/DividerBlock.php create mode 100644 src/notifications/src/Slack/BlockKit/Blocks/HeaderBlock.php create mode 100644 src/notifications/src/Slack/BlockKit/Blocks/ImageBlock.php create mode 100644 src/notifications/src/Slack/BlockKit/Blocks/SectionBlock.php create mode 100644 src/notifications/src/Slack/BlockKit/Composites/ConfirmObject.php create mode 100644 src/notifications/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php create mode 100644 src/notifications/src/Slack/BlockKit/Composites/TextObject.php create mode 100644 src/notifications/src/Slack/BlockKit/Elements/ButtonElement.php create mode 100644 src/notifications/src/Slack/BlockKit/Elements/ImageElement.php create mode 100644 src/notifications/src/Slack/EventMetadata.php create mode 100644 src/notifications/src/Slack/SlackMessage.php create mode 100644 src/notifications/src/Slack/SlackRoute.php create mode 100644 tests/Notifications/Slack/Blocks/ActionsBlockTest.php create mode 100644 tests/Notifications/Slack/Blocks/ContextBlockTest.php create mode 100644 tests/Notifications/Slack/Blocks/DividerBlockTest.php create mode 100644 tests/Notifications/Slack/Blocks/HeaderBlockTest.php create mode 100644 tests/Notifications/Slack/Blocks/ImageBlockTest.php create mode 100644 tests/Notifications/Slack/Blocks/SectionBlockTest.php create mode 100644 tests/Notifications/Slack/Composites/ConfirmObjectTest.php create mode 100644 tests/Notifications/Slack/Composites/PlainTextOnlyTextObjectTest.php create mode 100644 tests/Notifications/Slack/Composites/TextObjectTest.php create mode 100644 tests/Notifications/Slack/Elements/ButtonElementTest.php create mode 100644 tests/Notifications/SlackMessageTest.php diff --git a/src/notifications/composer.json b/src/notifications/composer.json index a4119c1..6fb986d 100644 --- a/src/notifications/composer.json +++ b/src/notifications/composer.json @@ -33,6 +33,7 @@ "hyperf/contract": "~3.1.0", "hyperf/collection": "~3.1.0", "hyperf/context": "~3.1.0", + "hyperf/contract": "~3.1.0", "hyperf/database": "~3.1.0", "hyperf/di": "~3.1.0", "swooletw/hyperf-support": "~3.1.0", diff --git a/src/notifications/src/ChannelManager.php b/src/notifications/src/ChannelManager.php index 6cbe9b1..a3eb7e9 100644 --- a/src/notifications/src/ChannelManager.php +++ b/src/notifications/src/ChannelManager.php @@ -11,6 +11,7 @@ use InvalidArgumentException; use Psr\EventDispatcher\EventDispatcherInterface; use SwooleTW\Hyperf\Notifications\Channels\DatabaseChannel; +use SwooleTW\Hyperf\Notifications\Channels\SlackNotificationRouterChannel; use SwooleTW\Hyperf\Notifications\Contracts\Dispatcher as DispatcherContract; use SwooleTW\Hyperf\Notifications\Contracts\Factory as FactoryContract; use SwooleTW\Hyperf\ObjectPool\Traits\HasPoolProxy; @@ -38,7 +39,7 @@ class ChannelManager extends Manager implements DispatcherContract, FactoryContr /** * The array of drivers which will be wrapped as pool proxies. */ - protected array $poolables = []; + protected array $poolables = ['slack']; /** * The array of pool config for drivers. @@ -89,6 +90,14 @@ protected function createMailDriver(): MailChannel return $this->container->get(MailChannel::class); } + /** + * Create an instance of the slack driver. + */ + protected function createSlackDriver(): SlackNotificationRouterChannel + { + return $this->container->get(SlackNotificationRouterChannel::class); + } + /** * Create a new driver instance. * diff --git a/src/notifications/src/Channels/SlackNotificationRouterChannel.php b/src/notifications/src/Channels/SlackNotificationRouterChannel.php new file mode 100644 index 0000000..b7bb887 --- /dev/null +++ b/src/notifications/src/Channels/SlackNotificationRouterChannel.php @@ -0,0 +1,52 @@ +routeNotificationFor('slack', $notification); + + if ($route === false) { + return null; + } + + return $this->determineChannel($route)->send($notifiable, $notification); + } + + /** + * Determine which channel the Slack notification should be routed to. + */ + protected function determineChannel(mixed $route): SlackWebApiChannel|SlackWebhookChannel + { + if ($route instanceof UriInterface) { + return $this->container->get(SlackWebhookChannel::class); + } + + if (is_string($route) && Str::startsWith($route, ['http://', 'https://'])) { + return $this->container->get(SlackWebhookChannel::class); + } + + return $this->container->get(SlackWebApiChannel::class); + } +} diff --git a/src/notifications/src/Channels/SlackWebApiChannel.php b/src/notifications/src/Channels/SlackWebApiChannel.php new file mode 100644 index 0000000..7c4d15e --- /dev/null +++ b/src/notifications/src/Channels/SlackWebApiChannel.php @@ -0,0 +1,97 @@ +toSlack($notifiable); + + $route = $this->determineRoute($notifiable, $notification); + + $payload = $this->buildJsonPayload($message, $route); + + if (! $payload['channel']) { + throw new LogicException('Slack notification channel is not set.'); + } + + if (! $route->token) { + throw new LogicException('Slack API authentication token is not set.'); + } + + $response = $this->client->post(static::SLACK_API_URL, [ + 'json' => $payload, + 'headers' => [ + 'Authorization' => "Bearer {$route->token}", + ], + ]); + + $result = json_decode($content = $response->getBody()->getContents(), true); + if ($response->getStatusCode() === 200 && ($result['ok'] ?? false) === false) { + throw new RuntimeException('Slack API call failed with error [' . ($result['error'] ?? $content) . '].'); + } + + return $response; + } + + /** + * Build the JSON payload for the Slack chat.postMessage API. + */ + protected function buildJsonPayload(SlackMessage $message, SlackRoute $route): array + { + $payload = $message->toArray(); + + return array_merge($payload, [ + 'channel' => $route->channel ?? $payload['channel'] ?? $this->config->get('services.slack.notifications.channel'), + ]); + } + + /** + * Determine the API Token and Channel that the notification should be posted to. + */ + protected function determineRoute(mixed $notifiable, Notification $notification): SlackRoute + { + $route = $notifiable->routeNotificationFor('slack', $notification); + + // When the route is a string, we will assume it is a channel name and will use the default API token for the application... + if (is_string($route)) { + return SlackRoute::make($route, $this->config->get('services.slack.notifications.bot_user_oauth_token')); + } + + return SlackRoute::make( + $route->channel ?? null, + $route->token ?? $this->config->get('services.slack.notifications.bot_user_oauth_token'), + ); + } +} diff --git a/src/notifications/src/Channels/SlackWebhookChannel.php b/src/notifications/src/Channels/SlackWebhookChannel.php new file mode 100644 index 0000000..bad984f --- /dev/null +++ b/src/notifications/src/Channels/SlackWebhookChannel.php @@ -0,0 +1,111 @@ +routeNotificationFor('slack', $notification)) { + return null; + } + + return $this->client->post($url, $this->buildJsonPayload( + $notification->toSlack($notifiable) // @phpstan-ignore-line + )); + } + + /** + * Build up a JSON payload for the Slack webhook. + */ + public function buildJsonPayload(SlackMessage $message): array + { + $optionalFields = array_filter([ + 'channel' => data_get($message, 'channel'), + 'icon_emoji' => data_get($message, 'icon'), + 'icon_url' => data_get($message, 'image'), + 'link_names' => data_get($message, 'linkNames'), + 'unfurl_links' => data_get($message, 'unfurlLinks'), + 'unfurl_media' => data_get($message, 'unfurlMedia'), + 'username' => data_get($message, 'username'), + ]); + + return array_merge([ + 'json' => array_merge([ + 'text' => $message->content, + 'attachments' => $this->attachments($message), + ], $optionalFields), + ], $message->http); + } + + /** + * Format the message's attachments. + */ + protected function attachments(SlackMessage $message): array + { + return Collection::make($message->attachments)->map(function ($attachment) use ($message) { + return array_filter([ + 'actions' => $attachment->actions, + 'author_icon' => $attachment->authorIcon, + 'author_link' => $attachment->authorLink, + 'author_name' => $attachment->authorName, + 'callback_id' => $attachment->callbackId, + 'color' => $attachment->color ?: $message->color(), + 'fallback' => $attachment->fallback, + 'fields' => $this->fields($attachment), + 'footer' => $attachment->footer, + 'footer_icon' => $attachment->footerIcon, + 'image_url' => $attachment->imageUrl, + 'mrkdwn_in' => $attachment->markdown, + 'pretext' => $attachment->pretext, + 'text' => $attachment->content, + 'thumb_url' => $attachment->thumbUrl, + 'title' => $attachment->title, + 'title_link' => $attachment->url, + 'ts' => $attachment->timestamp, + ]); + })->all(); + } + + /** + * Format the attachment's fields. + */ + protected function fields(SlackAttachment $attachment): array + { + return Collection::make($attachment->fields)->map(function ($value, $key) { + if ($value instanceof SlackAttachmentField) { + return $value->toArray(); + } + + return ['title' => $key, 'value' => $value, 'short' => true]; + })->values()->all(); + } +} diff --git a/src/notifications/src/Contracts/Slack/BlockContract.php b/src/notifications/src/Contracts/Slack/BlockContract.php new file mode 100644 index 0000000..c70d0e1 --- /dev/null +++ b/src/notifications/src/Contracts/Slack/BlockContract.php @@ -0,0 +1,11 @@ +title = $title; + $this->url = $url; + + return $this; + } + + /** + * Set the pretext of the attachment. + */ + public function pretext(string $pretext): static + { + $this->pretext = $pretext; + + return $this; + } + + /** + * Set the content (text) of the attachment. + */ + public function content(string $content): static + { + $this->content = $content; + + return $this; + } + + /** + * A plain-text summary of the attachment. + */ + public function fallback(string $fallback): static + { + $this->fallback = $fallback; + + return $this; + } + + /** + * Set the color of the attachment. + */ + public function color(string $color): static + { + $this->color = $color; + + return $this; + } + + /** + * Add a field to the attachment. + */ + public function field(Closure|string $title, string $content = ''): static + { + if (is_callable($title)) { + $callback = $title; + + $callback($attachmentField = new SlackAttachmentField()); + + $this->fields[] = $attachmentField; + + return $this; + } + + $this->fields[$title] = $content; + + return $this; + } + + /** + * Set the fields of the attachment. + */ + public function fields(array $fields): static + { + $this->fields = $fields; + + return $this; + } + + /** + * Set the fields containing markdown. + */ + public function markdown(array $fields): static + { + $this->markdown = $fields; + + return $this; + } + + /** + * Set the image URL. + */ + public function image(string $url): static + { + $this->imageUrl = $url; + + return $this; + } + + /** + * Set the URL to the attachment thumbnail. + */ + public function thumb(string $url): static + { + $this->thumbUrl = $url; + + return $this; + } + + /** + * Add an action (button) under the attachment. + */ + public function action(string $title, string $url, string $style = ''): static + { + $this->actions[] = [ + 'type' => 'button', + 'text' => $title, + 'url' => $url, + 'style' => $style, + ]; + + return $this; + } + + /** + * Set the author of the attachment. + */ + public function author(string $name, ?string $link = null, ?string $icon = null): static + { + $this->authorName = $name; + $this->authorLink = $link; + $this->authorIcon = $icon; + + return $this; + } + + /** + * Set the footer content. + */ + public function footer(string $footer): static + { + $this->footer = $footer; + + return $this; + } + + /** + * Set the footer icon. + */ + public function footerIcon(string $icon): static + { + $this->footerIcon = $icon; + + return $this; + } + + /** + * Set the timestamp a DateTimeInterface, DateInterval, or the number of seconds that should be added to the current time. + */ + public function timestamp(DateInterval|DateTimeInterface|int $timestamp): static + { + $this->timestamp = $this->availableAt($timestamp); + + return $this; + } + + /** + * Set the callback ID. + */ + public function callbackId(string $callbackId): static + { + $this->callbackId = $callbackId; + + return $this; + } +} diff --git a/src/notifications/src/Messages/SlackAttachmentField.php b/src/notifications/src/Messages/SlackAttachmentField.php new file mode 100644 index 0000000..9c43985 --- /dev/null +++ b/src/notifications/src/Messages/SlackAttachmentField.php @@ -0,0 +1,65 @@ +title = $title; + + return $this; + } + + /** + * Set the content of the field. + */ + public function content(string $content): static + { + $this->content = $content; + + return $this; + } + + /** + * Indicates that the content should not be displayed side-by-side with other fields. + */ + public function long(): static + { + $this->short = false; + + return $this; + } + + /** + * Get the array representation of the attachment field. + */ + public function toArray(): array + { + return [ + 'title' => $this->title, + 'value' => $this->content, + 'short' => $this->short, + ]; + } +} diff --git a/src/notifications/src/Messages/SlackMessage.php b/src/notifications/src/Messages/SlackMessage.php new file mode 100644 index 0000000..5712633 --- /dev/null +++ b/src/notifications/src/Messages/SlackMessage.php @@ -0,0 +1,218 @@ +level = 'info'; + + return $this; + } + + /** + * Indicate that the notification gives information about a successful operation. + */ + public function success(): static + { + $this->level = 'success'; + + return $this; + } + + /** + * Indicate that the notification gives information about a warning. + */ + public function warning(): static + { + $this->level = 'warning'; + + return $this; + } + + /** + * Indicate that the notification gives information about an error. + */ + public function error(): static + { + $this->level = 'error'; + + return $this; + } + + /** + * Set a custom username and optional emoji icon for the Slack message. + */ + public function from(string $username, ?string $icon = null): static + { + $this->username = $username; + + if (! is_null($icon)) { + $this->icon = $icon; + } + + return $this; + } + + /** + * Set a custom image icon the message should use. + */ + public function image(string $image): static + { + $this->image = $image; + + return $this; + } + + /** + * Set the Slack channel the message should be sent to. + */ + public function to(string $channel): static + { + $this->channel = $channel; + + return $this; + } + + /** + * Set the content of the Slack message. + */ + public function content(string $content): static + { + $this->content = $content; + + return $this; + } + + /** + * Define an attachment for the message. + */ + public function attachment(Closure $callback): static + { + $this->attachments[] = $attachment = new SlackAttachment(); + + $callback($attachment); + + return $this; + } + + /** + * Get the color for the message. + */ + public function color(): ?string + { + switch ($this->level) { + case 'success': + return 'good'; + case 'error': + return 'danger'; + case 'warning': + return 'warning'; + } + + return null; + } + + /** + * Find and link channel names and usernames. + */ + public function linkNames(): static + { + $this->linkNames = true; + + return $this; + } + + /** + * Unfurl links to rich display. + */ + public function unfurlLinks(bool $unfurlLinks): static + { + $this->unfurlLinks = $unfurlLinks; + + return $this; + } + + /** + * Unfurl media to rich display. + */ + public function unfurlMedia(bool $unfurlMedia): static + { + $this->unfurlMedia = $unfurlMedia; + + return $this; + } + + /** + * Set additional request options for the Guzzle HTTP client. + */ + public function http(array $options): static + { + $this->http = $options; + + return $this; + } +} diff --git a/src/notifications/src/Slack/BlockKit/Blocks/ActionsBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/ActionsBlock.php new file mode 100644 index 0000000..a09b1be --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Blocks/ActionsBlock.php @@ -0,0 +1,80 @@ +blockId = $id; + + return $this; + } + + /** + * Add a button element to the block. + */ + public function button(string $text): ButtonElement + { + return tap(new ButtonElement($text), function (ButtonElement $button) { + $this->elements[] = $button; + }); + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + if ($this->blockId && strlen($this->blockId) > 255) { + throw new InvalidArgumentException('Maximum length for the block_id field is 255 characters.'); + } + + if (empty($this->elements)) { + throw new LogicException('There must be at least one element in each actions block.'); + } + + if (count($this->elements) > 25) { + throw new LogicException('There is a maximum of 25 elements in each actions block.'); + } + + $optionalFields = array_filter([ + 'block_id' => $this->blockId, + ]); + + return array_merge([ + 'type' => 'actions', + 'elements' => array_map(fn (Arrayable $element) => $element->toArray(), $this->elements), + ], $optionalFields); + } +} diff --git a/src/notifications/src/Slack/BlockKit/Blocks/ContextBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/ContextBlock.php new file mode 100644 index 0000000..09def55 --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Blocks/ContextBlock.php @@ -0,0 +1,91 @@ +blockId = $id; + + return $this; + } + + /** + * Add an image element to the block. + */ + public function image(string $imageUrl, ?string $altText = null): ImageElement + { + return tap(new ImageElement($imageUrl, $altText), function (ImageElement $element) { + $this->elements[] = $element; + }); + } + + /** + * Add a text element to the block. + */ + public function text(string $text): TextObject + { + return tap(new TextObject($text), function (TextObject $element) { + $this->elements[] = $element; + }); + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + if ($this->blockId && strlen($this->blockId) > 255) { + throw new InvalidArgumentException('Maximum length for the block_id field is 255 characters.'); + } + + if (empty($this->elements)) { + throw new LogicException('There must be at least one element in each context block.'); + } + + if (count($this->elements) > 10) { + throw new LogicException('There is a maximum of 10 elements in each context block.'); + } + + $optionalFields = array_filter([ + 'block_id' => $this->blockId, + ]); + + return array_merge([ + 'type' => 'context', + 'elements' => array_map(fn (Arrayable $element) => $element->toArray(), $this->elements), + ], $optionalFields); + } +} diff --git a/src/notifications/src/Slack/BlockKit/Blocks/DividerBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/DividerBlock.php new file mode 100644 index 0000000..1d34bda --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Blocks/DividerBlock.php @@ -0,0 +1,48 @@ +blockId = $id; + + return $this; + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + if ($this->blockId && strlen($this->blockId) > 255) { + throw new InvalidArgumentException('Maximum length for the block_id field is 255 characters.'); + } + + $optionalFields = array_filter([ + 'block_id' => $this->blockId, + ]); + + return array_merge([ + 'type' => 'divider', + ], $optionalFields); + } +} diff --git a/src/notifications/src/Slack/BlockKit/Blocks/HeaderBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/HeaderBlock.php new file mode 100644 index 0000000..d624059 --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Blocks/HeaderBlock.php @@ -0,0 +1,70 @@ +text = $object = new PlainTextOnlyTextObject($text, 150); + + if ($callback) { + $callback($object); + } + } + + /** + * Set the block identifier. + */ + public function id(string $id): static + { + $this->blockId = $id; + + return $this; + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + if ($this->blockId && strlen($this->blockId) > 255) { + throw new InvalidArgumentException('Maximum length for the block_id field is 255 characters.'); + } + + $optionalFields = array_filter([ + 'block_id' => $this->blockId, + ]); + + return array_merge([ + 'type' => 'header', + 'text' => $this->text->toArray(), + ], $optionalFields); + } +} diff --git a/src/notifications/src/Slack/BlockKit/Blocks/ImageBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/ImageBlock.php new file mode 100644 index 0000000..aefb88a --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Blocks/ImageBlock.php @@ -0,0 +1,115 @@ + 3000) { + throw new InvalidArgumentException('Maximum length for the url field is 3000 characters.'); + } + + $this->url = $url; + $this->altText = $altText; + } + + /** + * Set the block identifier. + */ + public function id(string $id): static + { + $this->blockId = $id; + + return $this; + } + + /** + * Set the alt text for the image. + */ + public function alt(string $altText): static + { + if (strlen($altText) > 2000) { + throw new InvalidArgumentException('Maximum length for the alt text field is 2000 characters.'); + } + + $this->altText = $altText; + + return $this; + } + + /** + * Set the title for the image. + */ + public function title(string $title): PlainTextOnlyTextObject + { + $this->title = $object = new PlainTextOnlyTextObject($title, 2000); + + return $object; + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + if ($this->blockId && strlen($this->blockId) > 255) { + throw new InvalidArgumentException('Maximum length for the block_id field is 255 characters.'); + } + + if (is_null($this->altText)) { + throw new LogicException('Alt text is required for an image block.'); + } + + $optionalFields = array_filter([ + 'block_id' => $this->blockId, + 'title' => $this->title?->toArray(), + ]); + + return array_merge([ + 'type' => 'image', + 'image_url' => $this->url, + 'alt_text' => $this->altText, + ], $optionalFields); + } +} diff --git a/src/notifications/src/Slack/BlockKit/Blocks/SectionBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/SectionBlock.php new file mode 100644 index 0000000..8bed5c8 --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Blocks/SectionBlock.php @@ -0,0 +1,118 @@ +blockId = $id; + + return $this; + } + + /** + * Set the text for the block. + */ + public function text(string $text): TextObject + { + $this->text = $object = new TextObject($text, 3000); + + return $object; + } + + /** + * Add a field to the block. + */ + public function field(string $text): TextObject + { + $this->fields[] = $field = new TextObject($text, 2000, 1); + + return $field; + } + + /** + * Set the accessory for the block. + */ + public function accessory(ElementContract $element): static + { + $this->accessory = $element; + + return $this; + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + if ($this->blockId && strlen($this->blockId) > 255) { + throw new InvalidArgumentException('Maximum length for the block_id field is 255 characters.'); + } + + if (is_null($this->text) && empty($this->fields)) { + throw new LogicException('A section requires at least one block, or the text to be set.'); + } + + if (count($this->fields) > 10) { + throw new LogicException('There is a maximum of 10 fields in each section block.'); + } + + $optionalFields = array_filter([ + 'text' => $this->text?->toArray(), + 'block_id' => $this->blockId, + 'accessory' => $this->accessory?->toArray(), + 'fields' => array_map(fn (Arrayable $element) => $element->toArray(), $this->fields), + ]); + + return array_merge([ + 'type' => 'section', + ], $optionalFields); + } +} diff --git a/src/notifications/src/Slack/BlockKit/Composites/ConfirmObject.php b/src/notifications/src/Slack/BlockKit/Composites/ConfirmObject.php new file mode 100644 index 0000000..c1a1dc1 --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Composites/ConfirmObject.php @@ -0,0 +1,126 @@ +title('Are you sure?'); + $this->text($text); + $this->confirm('Yes'); + $this->deny('No'); + } + + /** + * Set the title of the confirm object. + */ + public function title(string $title): PlainTextOnlyTextObject + { + $this->title = $object = new PlainTextOnlyTextObject($title, 100); + + return $object; + } + + /** + * Set the text of the confirm object. + */ + public function text(string $text): TextObject + { + $this->text = $object = new TextObject($text, 300); + + return $object; + } + + /** + * Set the confirm button label of the confirm object. + */ + public function confirm(string $label): PlainTextOnlyTextObject + { + $this->confirm = $object = new PlainTextOnlyTextObject($label, 30); + + return $object; + } + + /** + * Set the deny button label of the confirm object. + */ + public function deny(string $label): PlainTextOnlyTextObject + { + $this->deny = $object = new PlainTextOnlyTextObject($label, 30); + + return $object; + } + + /** + * Marks the confirm dialog as dangerous. + */ + public function danger(): static + { + $this->style = 'danger'; + + return $this; + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + $optionalFields = array_filter([ + 'style' => $this->style, + ]); + + return array_merge([ + 'title' => $this->title->toArray(), + 'text' => $this->text->toArray(), + 'confirm' => $this->confirm->toArray(), + 'deny' => $this->deny->toArray(), + ], $optionalFields); + } +} diff --git a/src/notifications/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php b/src/notifications/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php new file mode 100644 index 0000000..2044ee2 --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php @@ -0,0 +1,62 @@ + $maxLength) { + $text = substr($text, 0, $maxLength - 3) . '...'; + } + + $this->text = $text; + } + + /** + * Indicate that emojis should be escaped into the colon emoji format. + */ + public function emoji(): static + { + $this->emoji = true; + + return $this; + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + $optionalFields = array_filter([ + 'emoji' => $this->emoji, + ]); + + return array_merge([ + 'type' => 'plain_text', + 'text' => $this->text, + ], $optionalFields); + } +} diff --git a/src/notifications/src/Slack/BlockKit/Composites/TextObject.php b/src/notifications/src/Slack/BlockKit/Composites/TextObject.php new file mode 100644 index 0000000..781ff94 --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Composites/TextObject.php @@ -0,0 +1,66 @@ +type = 'mrkdwn'; + + return $this; + } + + /** + * Indicate that URLs, conversation names and certain mentions should not be auto-linked. + * + * Only applicable for mrkdwn text objects. + */ + public function verbatim(): static + { + $this->verbatim = true; + + return $this; + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + $optionalFields = array_filter([ + 'verbatim' => $this->verbatim, + ]); + + $built = array_merge(parent::toArray(), $optionalFields, [ + 'type' => $this->type, + ]); + + if ($this->type === 'mrkdwn') { + return Arr::except($built, ['emoji']); + } + + return Arr::except($built, ['verbatim']); + } +} diff --git a/src/notifications/src/Slack/BlockKit/Elements/ButtonElement.php b/src/notifications/src/Slack/BlockKit/Elements/ButtonElement.php new file mode 100644 index 0000000..9e82d8f --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Elements/ButtonElement.php @@ -0,0 +1,204 @@ +text = new PlainTextOnlyTextObject($text, 75); + + $this->id('button_' . Str::lower(Str::slug(substr($text, 0, 248)))); + + if ($callback) { + $callback($this->text); + } + } + + /** + * Set the URL for the button. + */ + public function url(string $url): static + { + if (strlen($url) > 3000) { + throw new InvalidArgumentException('Maximum length for the url field is 3000 characters.'); + } + + $this->url = $url; + + return $this; + } + + /** + * Set the action ID for the button. + */ + public function id(string $id): static + { + if (strlen($id) > 255) { + throw new InvalidArgumentException('Maximum length for the action_id field is 255 characters.'); + } + + $this->actionId = $id; + + return $this; + } + + /** + * Set the value for the button. + */ + public function value(string $value): static + { + if (strlen($value) > 2000) { + throw new InvalidArgumentException('Maximum length for the value field is 2000 characters.'); + } + + $this->value = $value; + + return $this; + } + + /** + * Set the style for the button to primary. + */ + public function primary(): static + { + $this->style = 'primary'; + + return $this; + } + + /** + * Set the style for the button to danger. + */ + public function danger(): static + { + $this->style = 'danger'; + + return $this; + } + + /** + * Set the confirm object for the button. + */ + public function confirm(string $text, ?Closure $callback = null): ConfirmObject + { + $this->confirm = $confirm = new ConfirmObject($text); + + if ($callback) { + $callback($confirm); + } + + return $confirm; + } + + /** + * Set the accessibility label for the button. + */ + public function accessibilityLabel(string $label): static + { + if (strlen($label) > 75) { + throw new InvalidArgumentException('Maximum length for the accessibility label is 75 characters.'); + } + + $this->accessibilityLabel = $label; + + return $this; + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + $optionalFields = array_filter([ + 'url' => $this->url, + 'value' => $this->value, + 'style' => $this->style, + 'confirm' => $this->confirm?->toArray(), + 'accessibility_label' => $this->accessibilityLabel, + ]); + + return array_merge([ + 'type' => 'button', + 'text' => $this->text->toArray(), + 'action_id' => $this->actionId, + ], $optionalFields); + } +} diff --git a/src/notifications/src/Slack/BlockKit/Elements/ImageElement.php b/src/notifications/src/Slack/BlockKit/Elements/ImageElement.php new file mode 100644 index 0000000..dab5913 --- /dev/null +++ b/src/notifications/src/Slack/BlockKit/Elements/ImageElement.php @@ -0,0 +1,56 @@ +url = $url; + $this->altText = $altText; + } + + /** + * Set the alt text for the image. + */ + public function alt(string $altText): static + { + $this->altText = $altText; + + return $this; + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + if (is_null($this->altText)) { + throw new LogicException('Alt text is required for an image element.'); + } + + return [ + 'type' => 'image', + 'image_url' => $this->url, + 'alt_text' => $this->altText, + ]; + } +} diff --git a/src/notifications/src/Slack/EventMetadata.php b/src/notifications/src/Slack/EventMetadata.php new file mode 100644 index 0000000..62aef71 --- /dev/null +++ b/src/notifications/src/Slack/EventMetadata.php @@ -0,0 +1,38 @@ + $this->type, + 'event_payload' => $this->payload, + ]; + } +} diff --git a/src/notifications/src/Slack/SlackMessage.php b/src/notifications/src/Slack/SlackMessage.php new file mode 100644 index 0000000..91cd17b --- /dev/null +++ b/src/notifications/src/Slack/SlackMessage.php @@ -0,0 +1,336 @@ +|BlockContract> + */ + protected array $blocks = []; + + /** + * The user emoji icon for the message. + */ + protected ?string $icon = null; + + /** + * The user image icon for the message. + */ + protected ?string $image = null; + + /** + * The JSON metadata for the message. + */ + protected ?EventMetadata $metaData = null; + + /** + * Indicates if you want the message to parse markdown or not. + */ + protected ?bool $mrkdwn = null; + + /** + * Indicates if you want a preview of links inlined in the message. + */ + protected ?bool $unfurlLinks = null; + + /** + * Indicates if you want a preview of links to media inlined in the message. + */ + protected ?bool $unfurlMedia = null; + + /** + * The username to send the message as. + */ + protected ?string $username = null; + + /** + * Unique, per-channel, timestamp for each message. If provided, send message as a thread reply to this message. + */ + protected ?string $threadTs = null; + + /** + * If sending message as reply to thread, whether to 'broadcast' a reference to the thread reply to the parent conversation. + */ + protected ?bool $broadcastReply = null; + + /** + * Set the Slack channel the message should be sent to. + */ + public function to(string $channel): static + { + $this->channel = $channel; + + return $this; + } + + /** + * Set the fallback and notification text of the Slack message. + */ + public function text(string $text): static + { + $this->text = $text; + + return $this; + } + + /** + * Add a new Actions block to the message. + */ + public function actionsBlock(Closure $callback): static + { + $this->blocks[] = $block = new ActionsBlock(); + + $callback($block); + + return $this; + } + + /** + * Add a new Context block to the message. + */ + public function contextBlock(Closure $callback): static + { + $this->blocks[] = $block = new ContextBlock(); + + $callback($block); + + return $this; + } + + /** + * Add a new Divider block to the message. + */ + public function dividerBlock(): static + { + $this->blocks[] = new DividerBlock(); + + return $this; + } + + /** + * Add a new Header block to the message. + */ + public function headerBlock(string $text, ?Closure $callback = null): static + { + $this->blocks[] = new HeaderBlock($text, $callback); + + return $this; + } + + /** + * Add a new Image block to the message. + */ + public function imageBlock(string $url, null|Closure|string $altText = null, ?Closure $callback = null): static + { + if ($altText instanceof Closure) { + $callback = $altText; + $altText = null; + } + + $this->blocks[] = $image = new ImageBlock($url, $altText); + + if ($callback) { + $callback($image); + } + + return $this; + } + + /** + * Add a new Section block to the message. + */ + public function sectionBlock(Closure $callback): static + { + $this->blocks[] = $block = new SectionBlock(); + + $callback($block); + + return $this; + } + + /** + * Set a custom image icon the message should use. + */ + public function emoji(string $emoji): static + { + $this->image = null; + $this->icon = $emoji; + + return $this; + } + + /** + * Set a custom image icon the message should use. + */ + public function image(string $image): static + { + $this->icon = null; + $this->image = $image; + + return $this; + } + + /** + * Set the metadata the message should include. + */ + public function metadata(string $eventType, array $payload = []): static + { + $this->metaData = new EventMetadata($eventType, $payload); + + return $this; + } + + /** + * Disable Slack's markup parsing. + */ + public function disableMarkdownParsing(): static + { + $this->mrkdwn = false; + + return $this; + } + + /** + * Unfurl links for rich display. + */ + public function unfurlLinks(bool $unfurlLinks = true): static + { + $this->unfurlLinks = $unfurlLinks; + + return $this; + } + + /** + * Unfurl media for rich display. + */ + public function unfurlMedia(bool $unfurlMedia = true): static + { + $this->unfurlMedia = $unfurlMedia; + + return $this; + } + + /** + * Set the user name for the Slack bot. + */ + public function username(string $username): static + { + $this->username = $username; + + return $this; + } + + /** + * Set the thread timestamp (message ID) to send as reply to thread. + */ + public function threadTimestamp(?string $threadTimestamp): static + { + $this->threadTs = $threadTimestamp; + + return $this; + } + + /** + * Only applicable if threadTimestamp is set. Broadcasts a reference to the threaded reply to the parent conversation. + */ + public function broadcastReply(?bool $broadcastReply = true): static + { + $this->broadcastReply = $broadcastReply; + + return $this; + } + + /** + * Specify a raw Block Kit Builder JSON payload for the message. + * + * @throws JsonException + * @throws LogicException + */ + public function usingBlockKitTemplate(string $template): static + { + $blocks = json_decode($template, true, flags: JSON_THROW_ON_ERROR); + + if (! array_key_exists('blocks', $blocks)) { + throw new LogicException('The blocks array key is missing.'); + } + + array_push($this->blocks, ...$blocks['blocks']); + + return $this; + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + if (empty($this->blocks) && $this->text === null) { + throw new LogicException('Slack messages must contain at least a text message or block.'); + } + + if (count($this->blocks) > 50) { + throw new LogicException('Slack messages can only contain up to 50 blocks.'); + } + + $optionalFields = array_filter([ + 'text' => $this->text, + 'blocks' => ! empty($this->blocks) ? array_map(fn ($block) => $block instanceof BlockContract ? $block->toArray() : $block, $this->blocks) : null, + 'icon_emoji' => $this->icon, + 'icon_url' => $this->image, + 'metadata' => $this->metaData?->toArray(), + 'mrkdwn' => $this->mrkdwn, + 'thread_ts' => $this->threadTs, + 'reply_broadcast' => $this->broadcastReply, + 'unfurl_links' => $this->unfurlLinks, + 'unfurl_media' => $this->unfurlMedia, + 'username' => $this->username, + ], fn ($value) => $value !== null); + + return array_merge([ + 'channel' => $this->channel, + ], $optionalFields); + } + + /** + * Dump the payload as a URL to the Slack Block Kit Builder. + */ + public function dd(bool $raw = false) + { + if ($raw) { + dd($this->toArray()); + } + + dd('https://app.slack.com/block-kit-builder#' . rawurlencode(json_encode(Arr::except($this->toArray(), ['username', 'text', 'channel'])))); + } +} diff --git a/src/notifications/src/Slack/SlackRoute.php b/src/notifications/src/Slack/SlackRoute.php new file mode 100644 index 0000000..eee2a02 --- /dev/null +++ b/src/notifications/src/Slack/SlackRoute.php @@ -0,0 +1,39 @@ +channel = $channel; + $this->token = $token; + } + + /** + * Fluently create a new Slack route instance. + */ + public static function make(?string $channel = null, ?string $token = null): static + { + return new static($channel, $token); + } +} diff --git a/tests/Notifications/Slack/Blocks/ActionsBlockTest.php b/tests/Notifications/Slack/Blocks/ActionsBlockTest.php new file mode 100644 index 0000000..c372689 --- /dev/null +++ b/tests/Notifications/Slack/Blocks/ActionsBlockTest.php @@ -0,0 +1,122 @@ +button('Example Button'); + + $this->assertSame([ + 'type' => 'actions', + 'elements' => [ + [ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Example Button', + ], + 'action_id' => 'button_example-button', + ], + ], + ], $block->toArray()); + } + + public function testRequiresAtLeastOneElement(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('There must be at least one element in each actions block.'); + + $block = new ActionsBlock(); + $block->toArray(); + } + + public function testDoesNotAllowMoreTwentyFiveElements(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('There is a maximum of 25 elements in each actions block.'); + + $block = new ActionsBlock(); + for ($i = 0; $i < 26; ++$i) { + $block->button('Button'); + } + + $block->toArray(); + } + + public function testCanManuallySpecifyBlockIdField(): void + { + $block = new ActionsBlock(); + $block->button('Example Button'); + $block->id('actions1'); + + $this->assertSame([ + 'type' => 'actions', + 'elements' => [ + [ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Example Button', + ], + 'action_id' => 'button_example-button', + ], + ], + 'block_id' => 'actions1', + ], $block->toArray()); + } + + public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Maximum length for the block_id field is 255 characters.'); + + $block = new ActionsBlock(); + $block->button('Button'); + $block->id(str_repeat('a', 256)); + + $block->toArray(); + } + + public function testCanAddButtons(): void + { + $block = new ActionsBlock(); + $block->button('Example Button'); + $block->button('Scary Button')->danger(); + + $this->assertSame([ + 'type' => 'actions', + 'elements' => [ + [ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Example Button', + ], + 'action_id' => 'button_example-button', + ], + [ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Scary Button', + ], + 'action_id' => 'button_scary-button', + 'style' => 'danger', + ], + ], + ], $block->toArray()); + } +} diff --git a/tests/Notifications/Slack/Blocks/ContextBlockTest.php b/tests/Notifications/Slack/Blocks/ContextBlockTest.php new file mode 100644 index 0000000..795612d --- /dev/null +++ b/tests/Notifications/Slack/Blocks/ContextBlockTest.php @@ -0,0 +1,128 @@ +text('Location: 123 Main Street, New York, NY 10010'); + + $this->assertSame([ + 'type' => 'context', + 'elements' => [ + [ + 'type' => 'plain_text', + 'text' => 'Location: 123 Main Street, New York, NY 10010', + ], + ], + ], $block->toArray()); + } + + public function testRequiresAtLeastOneElement(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('There must be at least one element in each context block.'); + + $block = new ContextBlock(); + $block->toArray(); + } + + public function testNotAllowMoreThanTenElements(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('There is a maximum of 10 elements in each context block.'); + + $block = new ContextBlock(); + for ($i = 0; $i < 11; ++$i) { + $block->text('Location: 123 Main Street, New York, NY 10010'); + } + + $block->toArray(); + } + + public function testCanManuallySpecifyBlockIdField(): void + { + $block = new ContextBlock(); + $block->text('Location: 123 Main Street, New York, NY 10010'); + $block->id('actions1'); + + $this->assertSame([ + 'type' => 'context', + 'elements' => [ + [ + 'type' => 'plain_text', + 'text' => 'Location: 123 Main Street, New York, NY 10010', + ], + ], + 'block_id' => 'actions1', + ], $block->toArray()); + } + + public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Maximum length for the block_id field is 255 characters.'); + + $block = new ContextBlock(); + $block->text('Location: 123 Main Street, New York, NY 10010'); + $block->id(str_repeat('a', 256)); + + $block->toArray(); + } + + public function testCanAddImageBlocks(): void + { + $block = new ContextBlock(); + $block->image('https://image.freepik.com/free-photo/red-drawing-pin_1156-445.jpg')->alt('images'); + $block->image('http://placekitten.com/500/500', 'An incredibly cute kitten.'); + + $this->assertSame([ + 'type' => 'context', + 'elements' => [ + [ + 'type' => 'image', + 'image_url' => 'https://image.freepik.com/free-photo/red-drawing-pin_1156-445.jpg', + 'alt_text' => 'images', + ], + [ + 'type' => 'image', + 'image_url' => 'http://placekitten.com/500/500', + 'alt_text' => 'An incredibly cute kitten.', + ], + ], + ], $block->toArray()); + } + + public function testCanAddTextBlocks(): void + { + $block = new ContextBlock(); + $block->text('Location: 123 Main Street, New York, NY 10010'); + $block->text('Description: **Bring your dog!**')->markdown(); + + $this->assertSame([ + 'type' => 'context', + 'elements' => [ + [ + 'type' => 'plain_text', + 'text' => 'Location: 123 Main Street, New York, NY 10010', + ], + [ + 'type' => 'mrkdwn', + 'text' => 'Description: **Bring your dog!**', + ], + ], + ], $block->toArray()); + } +} diff --git a/tests/Notifications/Slack/Blocks/DividerBlockTest.php b/tests/Notifications/Slack/Blocks/DividerBlockTest.php new file mode 100644 index 0000000..14be322 --- /dev/null +++ b/tests/Notifications/Slack/Blocks/DividerBlockTest.php @@ -0,0 +1,47 @@ +assertSame([ + 'type' => 'divider', + ], $block->toArray()); + } + + public function testCanManuallySpecifyBlockIdField(): void + { + $block = new DividerBlock(); + $block->id('divider1'); + + $this->assertSame([ + 'type' => 'divider', + 'block_id' => 'divider1', + ], $block->toArray()); + } + + public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Maximum length for the block_id field is 255 characters.'); + + $block = new DividerBlock(); + $block->id(str_repeat('a', 256)); + + $block->toArray(); + } +} diff --git a/tests/Notifications/Slack/Blocks/HeaderBlockTest.php b/tests/Notifications/Slack/Blocks/HeaderBlockTest.php new file mode 100644 index 0000000..66ef9e6 --- /dev/null +++ b/tests/Notifications/Slack/Blocks/HeaderBlockTest.php @@ -0,0 +1,77 @@ +assertSame([ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Budget Performance', + ], + ], $block->toArray()); + } + + public function testBlockIdCantExceedOneFiveZeroCharacters(): void + { + $blockA = new HeaderBlock(str_repeat('a', 151)); + $blockB = new HeaderBlock(str_repeat('b', 150)); + + $this->assertSame([ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => str_repeat('a', 147) . '...', + ], + ], $blockA->toArray()); + + $this->assertSame([ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => str_repeat('b', 150), + ], + ], $blockB->toArray()); + } + + public function testCanManuallySpecifyBlockIdField(): void + { + $block = new HeaderBlock('Budget Performance'); + $block->id('header1'); + + $this->assertSame([ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Budget Performance', + ], + 'block_id' => 'header1', + ], $block->toArray()); + } + + public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Maximum length for the block_id field is 255 characters.'); + + $block = new HeaderBlock('Budget Performance'); + $block->id(str_repeat('a', 256)); + + $block->toArray(); + } +} diff --git a/tests/Notifications/Slack/Blocks/ImageBlockTest.php b/tests/Notifications/Slack/Blocks/ImageBlockTest.php new file mode 100644 index 0000000..f5e3b6f --- /dev/null +++ b/tests/Notifications/Slack/Blocks/ImageBlockTest.php @@ -0,0 +1,113 @@ +assertSame([ + 'type' => 'image', + 'image_url' => 'http://placekitten.com/500/500', + 'alt_text' => 'An incredibly cute kitten.', + ], $block->toArray()); + } + + public function testUrlCantExceedThreeThousandCharacters(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Maximum length for the url field is 3000 characters.'); + + new ImageBlock(str_repeat('a', 3001)); + } + + public function testAltTextIsRequired(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Alt text is required for an image block.'); + + $block = new ImageBlock('http://placekitten.com/500/500'); + + $block->toArray(); + } + + public function testAltTextCantExceedTwoThousandCharacters(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Maximum length for the alt text field is 2000 characters.'); + + $block = new ImageBlock('http://placekitten.com/500/500'); + $block->alt(str_repeat('a', 2001)); + + $block->toArray(); + } + + public function testCanHaveTitle(): void + { + $block = new ImageBlock('http://placekitten.com/500/500', 'An incredibly cute kitten.'); + $block->title('This one is a cutesy kitten in a box.'); + + $this->assertSame([ + 'type' => 'image', + 'image_url' => 'http://placekitten.com/500/500', + 'alt_text' => 'An incredibly cute kitten.', + 'title' => [ + 'type' => 'plain_text', + 'text' => 'This one is a cutesy kitten in a box.', + ], + ], $block->toArray()); + } + + public function testTitleCantExceedTwoThousandCharacters(): void + { + $block = new ImageBlock('http://placekitten.com/500/500', 'An incredibly cute kitten.'); + $block->title(str_repeat('a', 2001)); + + $this->assertSame([ + 'type' => 'image', + 'image_url' => 'http://placekitten.com/500/500', + 'alt_text' => 'An incredibly cute kitten.', + 'title' => [ + 'type' => 'plain_text', + 'text' => str_repeat('a', 1997) . '...', + ], + ], $block->toArray()); + } + + public function testCanManuallySpecifyBlockIdField(): void + { + $block = new ImageBlock('http://placekitten.com/500/500'); + $block->alt('An incredibly cute kitten.'); + $block->id('actions1'); + + $this->assertSame([ + 'type' => 'image', + 'image_url' => 'http://placekitten.com/500/500', + 'alt_text' => 'An incredibly cute kitten.', + 'block_id' => 'actions1', + ], $block->toArray()); + } + + public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Maximum length for the block_id field is 255 characters.'); + + $block = new ImageBlock('http://placekitten.com/500/500'); + $block->id(str_repeat('a', 256)); + + $block->toArray(); + } +} diff --git a/tests/Notifications/Slack/Blocks/SectionBlockTest.php b/tests/Notifications/Slack/Blocks/SectionBlockTest.php new file mode 100644 index 0000000..451ae22 --- /dev/null +++ b/tests/Notifications/Slack/Blocks/SectionBlockTest.php @@ -0,0 +1,173 @@ +text('Location: 123 Main Street, New York, NY 10010'); + + $this->assertSame([ + 'type' => 'section', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Location: 123 Main Street, New York, NY 10010', + ], + ], $block->toArray()); + } + + public function testExceptionWithoutTextAndField(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('A section requires at least one block, or the text to be set.'); + + $block = new SectionBlock(); + + $block->toArray(); + } + + public function testTextHasAtLeastOneCharacter(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Text must be at least 1 character(s) long.'); + + $block = new SectionBlock(); + $block->text(''); + + $block->toArray(); + } + + public function testTextCantExceedThreeThousandCharacters(): void + { + $block = new SectionBlock(); + $block->text(str_repeat('a', 3001)); + + $this->assertSame([ + 'type' => 'section', + 'text' => [ + 'type' => 'plain_text', + 'text' => str_repeat('a', 2997) . '...', + ], + ], $block->toArray()); + } + + public function testTextCanBeCustomized(): void + { + $block = new SectionBlock(); + $block->text('Location: 123 Main Street, New York, NY 10010')->markdown(); + + $this->assertSame([ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => 'Location: 123 Main Street, New York, NY 10010', + ], + ], $block->toArray()); + } + + public function testNotAllowMoreThanTenFields(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('There is a maximum of 10 fields in each section block.'); + + $block = new SectionBlock(); + for ($i = 0; $i < 11; ++$i) { + $block->field('Location: 123 Main Street, New York, NY 10010'); + } + + $block->toArray(); + } + + public function testFieldCantExceedTwoThousandCharacters(): void + { + $block = new SectionBlock(); + $block->field(str_repeat('a', 2001)); + + $this->assertSame([ + 'type' => 'section', + 'fields' => [ + [ + 'type' => 'plain_text', + 'text' => str_repeat('a', 1997) . '...', + ], + ], + ], $block->toArray()); + } + + public function testFieldCanBeCustomized(): void + { + $block = new SectionBlock(); + $block->field('Location: 123 Main Street, New York, NY 10010')->markdown(); + + $this->assertSame([ + 'type' => 'section', + 'fields' => [ + [ + 'type' => 'mrkdwn', + 'text' => 'Location: 123 Main Street, New York, NY 10010', + ], + ], + ], $block->toArray()); + } + + public function testCanManuallySpecifyBlockIdField(): void + { + $block = new SectionBlock(); + $block->text('Location: 123 Main Street, New York, NY 10010'); + $block->id('section1'); + + $this->assertSame([ + 'type' => 'section', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Location: 123 Main Street, New York, NY 10010', + ], + 'block_id' => 'section1', + ], $block->toArray()); + } + + public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Maximum length for the block_id field is 255 characters.'); + + $block = new SectionBlock(); + $block->text('Location: 123 Main Street, New York, NY 10010'); + $block->id(str_repeat('a', 256)); + + $block->toArray(); + } + + public function testCanSpecifyAccesoryElement(): void + { + $block = new SectionBlock(); + $block->text('Location: 123 Main Street, New York, NY 10010'); + $block->accessory(new ImageElement('https://example.com/image.png', 'Image')); + + $this->assertSame([ + 'type' => 'section', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Location: 123 Main Street, New York, NY 10010', + ], + 'accessory' => [ + 'type' => 'image', + 'image_url' => 'https://example.com/image.png', + 'alt_text' => 'Image', + ], + ], $block->toArray()); + } +} diff --git a/tests/Notifications/Slack/Composites/ConfirmObjectTest.php b/tests/Notifications/Slack/Composites/ConfirmObjectTest.php new file mode 100644 index 0000000..9ac2351 --- /dev/null +++ b/tests/Notifications/Slack/Composites/ConfirmObjectTest.php @@ -0,0 +1,286 @@ +assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Please confirm this action.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Yes', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'No', + ], + ], $object->toArray()); + } + + public function testTitleIsCustomizable(): void + { + $object = new ConfirmObject(); + $object->title('This is a custom title.'); + + $this->assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'This is a custom title.', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Please confirm this action.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Yes', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'No', + ], + ], $object->toArray()); + } + + public function testTitleTruncatedOverOneHundredCharacters(): void + { + $object = new ConfirmObject(); + $object->title(str_repeat('a', 101)); + + $this->assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => str_repeat('a', 97) . '...', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Please confirm this action.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Yes', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'No', + ], + ], $object->toArray()); + } + + public function testTextIsCustomizable(): void + { + $object = new ConfirmObject(); + $object->text('This is some custom text.'); + + $this->assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'This is some custom text.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Yes', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'No', + ], + ], $object->toArray()); + } + + public function testTextTruncatedOverThreeHundredCharacters(): void + { + $objectA = new ConfirmObject(str_repeat('a', 301)); + + $this->assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => str_repeat('a', 297) . '...', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Yes', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'No', + ], + ], $objectA->toArray()); + + $objectB = new ConfirmObject(); + $objectB->text(str_repeat('b', 301)); + + $this->assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => str_repeat('b', 297) . '...', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Yes', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'No', + ], + ], $objectB->toArray()); + } + + public function testConfirmIsCustomizable(): void + { + $object = new ConfirmObject(); + $object->confirm('Custom confirmation button.'); + + $this->assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Please confirm this action.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Custom confirmation button.', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'No', + ], + ], $object->toArray()); + } + + public function testConfirmTruncatedOverThirtyCharacters(): void + { + $object = new ConfirmObject(); + $object->confirm(str_repeat('a', 31)); + + $this->assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Please confirm this action.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => str_repeat('a', 27) . '...', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'No', + ], + ], $object->toArray()); + } + + public function testColorSchemeWithDanger(): void + { + $object = new ConfirmObject(); + $object->danger(); + + $this->assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Please confirm this action.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Yes', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'No', + ], + 'style' => 'danger', + ], $object->toArray()); + } + + public function testDenyIsCustomizable(): void + { + $object = new ConfirmObject(); + $object->deny('Custom deny button.'); + + $this->assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Please confirm this action.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Yes', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'Custom deny button.', + ], + ], $object->toArray()); + } + + public function testDenyTruncatedOverThirtyCharacters(): void + { + $object = new ConfirmObject(); + $object->deny(str_repeat('a', 31)); + + $this->assertSame([ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Please confirm this action.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Yes', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => str_repeat('a', 27) . '...', + ], + ], $object->toArray()); + } +} diff --git a/tests/Notifications/Slack/Composites/PlainTextOnlyTextObjectTest.php b/tests/Notifications/Slack/Composites/PlainTextOnlyTextObjectTest.php new file mode 100644 index 0000000..a4fb25f --- /dev/null +++ b/tests/Notifications/Slack/Composites/PlainTextOnlyTextObjectTest.php @@ -0,0 +1,56 @@ +assertSame([ + 'type' => 'plain_text', + 'text' => 'A message *with some bold text* and _some italicized text_.', + ], $object->toArray()); + } + + public function testTextHasAtLeastOneCharacter(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Text must be at least 1 character(s) long.'); + + new PlainTextOnlyTextObject(''); + } + + public function testTextTruncatedOverThreeThousandCharacters(): void + { + $object = new PlainTextOnlyTextObject(str_repeat('a', 3001)); + + $this->assertSame([ + 'type' => 'plain_text', + 'text' => str_repeat('a', 2997) . '...', + ], $object->toArray()); + } + + public function testEscapeEmojiColonFormat(): void + { + $object = new PlainTextOnlyTextObject('Spooky time! 👻'); + $object->emoji(); + + $this->assertSame([ + 'type' => 'plain_text', + 'text' => 'Spooky time! 👻', + 'emoji' => true, + ], $object->toArray()); + } +} diff --git a/tests/Notifications/Slack/Composites/TextObjectTest.php b/tests/Notifications/Slack/Composites/TextObjectTest.php new file mode 100644 index 0000000..21da2b5 --- /dev/null +++ b/tests/Notifications/Slack/Composites/TextObjectTest.php @@ -0,0 +1,101 @@ +assertSame([ + 'type' => 'plain_text', + 'text' => 'A message *with some bold text* and _some italicized text_.', + ], $object->toArray()); + } + + public function testMarkdownTextField(): void + { + $object = new TextObject('A message *with some bold text* and _some italicized text_.'); + $object->markdown(); + + $this->assertSame([ + 'type' => 'mrkdwn', + 'text' => 'A message *with some bold text* and _some italicized text_.', + ], $object->toArray()); + } + + public function testTextHasAtLeastOneCharacter(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Text must be at least 1 character(s) long.'); + + new TextObject(''); + } + + public function testTextTruncatedOverThreeThousandCharacters(): void + { + $object = new TextObject(str_repeat('a', 3001)); + + $this->assertSame([ + 'type' => 'plain_text', + 'text' => str_repeat('a', 2997) . '...', + ], $object->toArray()); + } + + public function testEscapeEmojiColonFormat(): void + { + $object = new TextObject('Spooky time! 👻'); + $object->emoji(); + + $this->assertSame([ + 'type' => 'plain_text', + 'text' => 'Spooky time! 👻', + 'emoji' => true, + ], $object->toArray()); + } + + public function testEscapeEmojiColonFormatWhenMarkdown(): void + { + $object = new TextObject('Spooky time! 👻'); + $object->markdown()->emoji(); + + $this->assertSame([ + 'type' => 'mrkdwn', + 'text' => 'Spooky time! 👻', + ], $object->toArray()); + } + + public function testSkipClickableAnchors(): void + { + $object = new TextObject('A message *with some bold text* and _some italicized text_.'); + $object->markdown()->verbatim(); + + $this->assertSame([ + 'type' => 'mrkdwn', + 'text' => 'A message *with some bold text* and _some italicized text_.', + 'verbatim' => true, + ], $object->toArray()); + } + + public function testSkipClickableAnchorsWhenPlaintext(): void + { + $object = new TextObject('A message *with some bold text* and _some italicized text_.'); + $object->verbatim(); + + $this->assertSame([ + 'type' => 'plain_text', + 'text' => 'A message *with some bold text* and _some italicized text_.', + ], $object->toArray()); + } +} diff --git a/tests/Notifications/Slack/Elements/ButtonElementTest.php b/tests/Notifications/Slack/Elements/ButtonElementTest.php new file mode 100644 index 0000000..c448a08 --- /dev/null +++ b/tests/Notifications/Slack/Elements/ButtonElementTest.php @@ -0,0 +1,271 @@ +assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Click Me', + ], + 'action_id' => 'button_click-me', + ], $element->toArray()); + } + + public function testTextLengthIsSeventyFiveCharacters(): void + { + $element = new ButtonElement(str_repeat('a', 250)); + + $this->assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => str_repeat('a', 72) . '...', + ], + 'action_id' => 'button_' . str_repeat('a', 248), + ], $element->toArray()); + } + + public function testTextCanBeCustomized(): void + { + $element = new ButtonElement('Click Me', function (PlainTextOnlyTextObject $textObject) { + $textObject->emoji(); + }); + + $this->assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Click Me', + 'emoji' => true, + ], + 'action_id' => 'button_click-me', + ], $element->toArray()); + } + + public function testActionIdCanBeCustomized(): void + { + $element = new ButtonElement('Click Me'); + $element->id('custom_action_id'); + + $this->assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Click Me', + ], + 'action_id' => 'custom_action_id', + ], $element->toArray()); + } + + public function testActionIdCantExceedTwoFiveFiveCharacters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Maximum length for the action_id field is 255 characters.'); + + $element = new ButtonElement('Click Me'); + $element->id(str_repeat('a', 256)); + + $element->toArray(); + } + + public function testCanHaveUrl(): void + { + $element = new ButtonElement('Click Me'); + $element->url('https://laravel.com'); + + $this->assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Click Me', + ], + 'action_id' => 'button_click-me', + 'url' => 'https://laravel.com', + ], $element->toArray()); + } + + public function testUrlCantExceedThreeThousandCharacters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Maximum length for the url field is 3000 characters.'); + + $element = new ButtonElement('Click Me'); + $element->url(str_repeat('a', 3001)); + + $element->toArray(); + } + + public function testCanHaveValue(): void + { + $element = new ButtonElement('Click Me'); + $element->value('click_me_123'); + + $this->assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Click Me', + ], + 'action_id' => 'button_click-me', + 'value' => 'click_me_123', + ], $element->toArray()); + } + + public function testValueCantExceedTwoThousandCharacters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Maximum length for the value field is 2000 characters.'); + + $element = new ButtonElement('Click Me'); + $element->value(str_repeat('a', 2001)); + + $element->toArray(); + } + + public function testCanHavePrimaryStyle(): void + { + $element = new ButtonElement('Click Me'); + $element->primary(); + + $this->assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Click Me', + ], + 'action_id' => 'button_click-me', + 'style' => 'primary', + ], $element->toArray()); + } + + public function testCanHaveDangerStyle(): void + { + $element = new ButtonElement('Click Me'); + $element->danger(); + + $this->assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Click Me', + ], + 'action_id' => 'button_click-me', + 'style' => 'danger', + ], $element->toArray()); + } + + public function testCanHaveConfirmableDialog(): void + { + $element = new ButtonElement('Click Me'); + $element->confirm('This will do some thing.')->deny('Yikes!'); + + $this->assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Click Me', + ], + 'action_id' => 'button_click-me', + 'confirm' => [ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'This will do some thing.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Yes', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'Yikes!', + ], + ], + ], $element->toArray()); + } + + public function testConfirmationWithMultipleOptions(): void + { + $element = new ButtonElement('Click Me'); + $element->confirm('This will do some thing.', function (ConfirmObject $dialog) { + $dialog->deny('Yikes!'); + $dialog->confirm('Woohoo!'); + }); + + $this->assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Click Me', + ], + 'action_id' => 'button_click-me', + 'confirm' => [ + 'title' => [ + 'type' => 'plain_text', + 'text' => 'Are you sure?', + ], + 'text' => [ + 'type' => 'plain_text', + 'text' => 'This will do some thing.', + ], + 'confirm' => [ + 'type' => 'plain_text', + 'text' => 'Woohoo!', + ], + 'deny' => [ + 'type' => 'plain_text', + 'text' => 'Yikes!', + ], + ], + ], $element->toArray()); + } + + public function testCanHaveAccessibilityLabel(): void + { + $element = new ButtonElement('Click Me'); + $element->accessibilityLabel('Click Me Button'); + + $this->assertSame([ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Click Me', + ], + 'action_id' => 'button_click-me', + 'accessibility_label' => 'Click Me Button', + ], $element->toArray()); + } + + public function testAccessibilityLabelCantExceedSeventyFiveCharacters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Maximum length for the accessibility label is 75 characters.'); + + $element = new ButtonElement('Click Me'); + $element->accessibilityLabel(str_repeat('a', 76)); + + $element->toArray(); + } +} diff --git a/tests/Notifications/SlackMessageTest.php b/tests/Notifications/SlackMessageTest.php new file mode 100644 index 0000000..4bf5129 --- /dev/null +++ b/tests/Notifications/SlackMessageTest.php @@ -0,0 +1,787 @@ +slackChannel = $this->getSlackChannel(); + } + + public function tearDown(): void + { + $this->slackChannel = null; + $this->client = null; + $this->config = null; + + Mockery::close(); + } + + public function testExceptionWhenNoTextOrBlock(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Slack messages must contain at least a text message or block.'); + + $this->sendNotification(function (SlackMessage $message) { + $message->to('foo'); + }); + } + + public function testExceptionWhenTooManyBlocks(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Slack messages can only contain up to 50 blocks.'); + + $this->sendNotification(function (SlackMessage $message) { + for ($i = 0; $i < 51; ++$i) { + $message->dividerBlock(); + } + }); + } + + public function testSendBasicMessage(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'This is a simple Web API text message. See https://api.slack.com/reference/messaging/payload for more information.', + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('This is a simple Web API text message. See https://api.slack.com/reference/messaging/payload for more information.'); + }); + } + + public function testExceptionWithInvalidToken(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Slack API call failed with error [invalid_auth].'); + + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'This is a simple Web API text message. See https://api.slack.com/reference/messaging/payload for more information.', + ], ['ok' => false, 'error' => 'invalid_auth']); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('This is a simple Web API text message. See https://api.slack.com/reference/messaging/payload for more information.'); + }); + } + + public function testSetDefaultChannelForMessage(): void + { + $this->assertNotificationSent([ + 'channel' => '#general', + 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->to('#general'); + }, null); + } + + public function testEmojiAsIconForMessage(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', + 'icon_emoji' => ':ghost:', + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->image('emoji-overrides-image-url-automatically-according-to-spec')->emoji(':ghost:'); + }); + } + + public function testImageAsIconForMessage(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', + 'icon_url' => 'http://lorempixel.com/48/48', + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->emoji('auto-clearing-as-to-prefer-image-since-its-called-after')->image('http://lorempixel.com/48/48'); + }); + } + + public function testCanIncludeMetadata(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', + 'metadata' => [ + 'event_type' => 'task_created', + 'event_payload' => ['id' => '11223', 'title' => 'Redesign Homepage'], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->metadata('task_created', ['id' => '11223', 'title' => 'Redesign Homepage']); + }); + } + + public function testDisableSlackMarkdownParsing(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', + 'mrkdwn' => false, + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->disableMarkdownParsing(); + }); + } + + public function testUnfurlLink(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', + 'unfurl_links' => true, + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->unfurlLinks(); + }); + } + + public function testUnfurlMedia(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', + 'unfurl_media' => true, + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->unfurlMedia(); + }); + } + + public function it_can_reply_as_thread(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', + 'thread_ts' => '123456.7890', + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->threadTimestamp('123456.7890'); + }); + } + + public function testSendThreadedReplyAsBroadcastReference(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', + 'reply_broadcast' => true, + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->broadcastReply(true); + }); + } + + public function testSetBotUserName(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', + 'username' => 'larabot', + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->username('larabot'); + }); + } + + public function testContainsBothBlocksAndFallbackTextInNotifications(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'text' => 'This is now a fallback text used in notifications. See https://api.slack.com/methods/chat.postMessage for more information.', + 'blocks' => [ + [ + 'type' => 'divider', + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->text('This is now a fallback text used in notifications. See https://api.slack.com/methods/chat.postMessage for more information.'); + $message->dividerBlock(); + }); + } + + public function testContainActionBlocks(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'actions', + 'elements' => [ + [ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Cancel', + ], + 'action_id' => 'button_1', + 'value' => 'cancel', + ], + ], + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->actionsBlock(function (ActionsBlock $actions) { + $actions->button('Cancel')->value('cancel')->id('button_1'); + }); + }); + } + + public function testContainContextBlocks(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'context', + 'elements' => [ + [ + 'type' => 'image', + 'image_url' => 'https://image.freepik.com/free-photo/red-drawing-pin_1156-445.jpg', + 'alt_text' => 'images', + ], + ], + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->contextBlock(function (ContextBlock $context) { + $context->image('https://image.freepik.com/free-photo/red-drawing-pin_1156-445.jpg')->alt('images'); + }); + }); + } + + public function testContainDividerBlocks(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'divider', + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->dividerBlock(); + }); + } + + public function testContainHeaderBlocks(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Budget Performance', + ], + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->headerBlock('Budget Performance'); + }); + } + + public function testContainImageBlocks(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'image', + 'image_url' => 'http://placekitten.com/500/500', + 'alt_text' => 'An incredibly cute kitten.', + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->imageBlock('http://placekitten.com/500/500', function (ImageBlock $imageBlock) { + $imageBlock->alt('An incredibly cute kitten.'); + }); + }); + } + + public function testContainSectionBlocks(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => 'A message *with some bold text* and _some italicized text_.', + ], + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->sectionBlock(function (SectionBlock $sectionBlock) { + $sectionBlock->text('A message *with some bold text* and _some italicized text_.')->markdown(); + }); + }); + } + + public function testAddBlocksConditionally(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => 'I *will* be included.', + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => 'But I *will* be included!', + ], + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->when(true, function (SlackMessage $message) { + $message->sectionBlock(function (SectionBlock $sectionBlock) { + $sectionBlock->text('I *will* be included.')->markdown(); + }); + })->when(false, function (SlackMessage $message) { + $message->sectionBlock(function (SectionBlock $sectionBlock) { + $sectionBlock->text("I *won't* be included.")->markdown(); + }); + })->when(false, function (SlackMessage $message) { + $message->sectionBlock(function (SectionBlock $sectionBlock) { + $sectionBlock->text("I'm *not* included either...")->markdown(); + }); + }, function (SlackMessage $message) { + $message->sectionBlock(function (SectionBlock $sectionBlock) { + $sectionBlock->text('But I *will* be included!')->markdown(); + }); + }); + }); + } + + public function testBlocksInTheOrder(): void + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Budget Performance', + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => 'A message *with some bold text* and _some italicized text_.', + ], + ], + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'Market Performance', + ], + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->headerBlock('Budget Performance'); + $message->sectionBlock(function (SectionBlock $sectionBlock) { + $sectionBlock->text('A message *with some bold text* and _some italicized text_.')->markdown(); + }); + $message->headerBlock('Market Performance'); + }); + } + + public function testCopiedBlockKitTemplate() + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'This is a header block', + 'emoji' => true, + ], + ], + [ + 'type' => 'context', + 'elements' => [ + [ + 'type' => 'image', + 'image_url' => 'https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg', + 'alt_text' => 'cute cat', + ], + [ + 'type' => 'mrkdwn', + 'text' => '*Cat* has approved this message.', + ], + ], + ], + [ + 'type' => 'image', + 'image_url' => 'https://assets3.thrillist.com/v1/image/1682388/size/tl-horizontal_main.jpg', + 'alt_text' => 'delicious tacos', + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->usingBlockKitTemplate(<<<'JSON' + { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "This is a header block", + "emoji": true + } + }, + { + "type": "context", + "elements": [ + { + "type": "image", + "image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg", + "alt_text": "cute cat" + }, + { + "type": "mrkdwn", + "text": "*Cat* has approved this message." + } + ] + }, + { + "type": "image", + "image_url": "https://assets3.thrillist.com/v1/image/1682388/size/tl-horizontal_main.jpg", + "alt_text": "delicious tacos" + } + ] + } + JSON); + }); + } + + public function testCombinedBlockKitTemplateAndBlockContractInOrder() + { + $this->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'This is a header block', + 'emoji' => true, + ], + ], + [ + 'type' => 'divider', + ], + [ + 'type' => 'image', + 'image_url' => 'https://assets3.thrillist.com/v1/image/1682388/size/tl-horizontal_main.jpg', + 'alt_text' => 'delicious tacos', + ], + ], + ]); + + $this->sendNotification(function (SlackMessage $message) { + $message->usingBlockKitTemplate(<<<'JSON' + { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "This is a header block", + "emoji": true + } + } + ] + } + JSON); + + $message->dividerBlock(); + + $message->usingBlockKitTemplate(<<<'JSON' + { + "blocks": [ + { + "type": "image", + "image_url": "https://assets3.thrillist.com/v1/image/1682388/size/tl-horizontal_main.jpg", + "alt_text": "delicious tacos" + } + ] + } + JSON); + }); + } + + public function testRouteNotificationForStringChannel(): void + { + $this->config->set('services.slack.notifications.bot_user_oauth_token', 'config-set-token'); + + $this->assertNotificationSent([ + 'channel' => 'example-channel', + 'text' => 'Content', + ], [], 'config-set-token'); + + $this->slackChannel->send( + new SlackChannelTestNotifiable('example-channel'), + new SlackChannelTestNotification(function (SlackMessage $message) { + $message->text('Content')->to('ignored-channel'); + }) + ); + } + + public function testRouteNotificationForSlackRoute(): void + { + $this->config->set('services.slack.notifications.bot_user_oauth_token', 'config-set-token'); + + $this->assertNotificationSent([ + 'channel' => 'route-set-channel', + 'text' => 'Content', + ], [], 'config-set-token'); + + $this->slackChannel->send( + new SlackChannelTestNotifiable(SlackRoute::make('route-set-channel')), + new SlackChannelTestNotification(function (SlackMessage $message) { + $message->text('Content'); + }) + ); + } + + public function testRouteNotificationForSlackRouteWithToken(): void + { + $this->config->set('services.slack.notifications.bot_user_oauth_token', 'config-set-token'); + + $this->assertNotificationSent([ + 'channel' => 'route-set-channel', + 'text' => 'Content', + ], [], 'route-set-token'); + + $this->slackChannel->send( + new SlackChannelTestNotifiable(SlackRoute::make('route-set-channel', 'route-set-token')), + new SlackChannelTestNotification(function (SlackMessage $message) { + $message->text('Content'); + }) + ); + } + + public function testRouteNotificationForEmptySlackRoute(): void + { + $this->config->set('services.slack.notifications.bot_user_oauth_token', 'config-set-token'); + $this->config->set('services.slack.notifications.channel', 'config-set-channel'); + + $this->assertNotificationSent([ + 'channel' => 'config-set-channel', + 'text' => 'Content', + ], [], 'config-set-token'); + + $this->slackChannel->send( + new SlackChannelTestNotifiable(), + new SlackChannelTestNotification(function (SlackMessage $message) { + $message->text('Content'); + }) + ); + } + + public function testPrefersNotificationChannel(): void + { + $this->config->set('services.slack.notifications.bot_user_oauth_token', 'config-set-token'); + $this->config->set('services.slack.notifications.channel', 'config-set-channel'); + + $this->assertNotificationSent([ + 'channel' => 'notification-channel', + 'text' => 'Content', + ], [], 'config-set-token'); + + $this->slackChannel->send( + new SlackChannelTestNotifiable(), + new SlackChannelTestNotification(function (SlackMessage $message) { + $message->text('Content')->to('notification-channel'); + }) + ); + } + + public function testExceptionWithoutChannel(): void + { + $this->config->set('services.slack.notifications.bot_user_oauth_token', 'config-set-token'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Slack notification channel is not set.'); + + $this->slackChannel->send( + new SlackChannelTestNotifiable(), + new SlackChannelTestNotification(function (SlackMessage $message) { + $message->text('Content'); + }) + ); + } + + public function testExceptionWithoutToken(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Slack API authentication token is not set.'); + + $this->slackChannel->send( + new SlackChannelTestNotifiable(SlackRoute::make('laravel-channel')), + new SlackChannelTestNotification(function (SlackMessage $message) { + $message->text('Content'); + }) + ); + } + + protected function getSlackChannel(): SlackWebApiChannel + { + return new SlackWebApiChannel( + $this->client = Mockery::mock(HttpClient::class), + $this->config = new Config([]) + ); + } + + protected function sendNotification(Closure $callback, ?string $routeChannel = '#ghost-talk'): self + { + $this->slackChannel->send( + new SlackChannelTestNotifiable(new SlackRoute($routeChannel, 'fake-token')), + new SlackChannelTestNotification($callback) + ); + + return $this; + } + + protected function assertNotificationSent(array $payload, array $response = [], string $token = 'fake-token'): void + { + $this->client->shouldReceive('post') + ->once() + ->with( + 'https://slack.com/api/chat.postMessage', + [ + 'json' => $payload, + 'headers' => [ + 'Authorization' => "Bearer {$token}", + ], + ] + )->andReturn(new Response( + 200, + [], + json_encode($response ?: ['ok' => true]) + )); + } +} + +class SlackChannelTestNotifiable +{ + use Notifiable; + + protected $route; + + public function __construct($route = null) + { + $this->route = $route; + } + + public function routeNotificationForSlack() + { + return $this->route; + } +} + +class SlackChannelTestNotification extends Notification +{ + private Closure $callback; + + public function __construct(?Closure $callback = null) + { + $this->callback = $callback ?? function () { + }; + } + + public function toSlack($notifiable) + { + return tap(new SlackMessage(), $this->callback); + } +}