diff --git a/doc/02-handlers-formatters-processors.md b/doc/02-handlers-formatters-processors.md index d3d31e043..28511f693 100644 --- a/doc/02-handlers-formatters-processors.md +++ b/doc/02-handlers-formatters-processors.md @@ -33,6 +33,7 @@ - [_PushoverHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/PushoverHandler.php): Sends mobile notifications via the [Pushover](https://www.pushover.net/) API. - [_SlackWebhookHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SlackWebhookHandler.php): Logs records to a [Slack](https://www.slack.com/) account using Slack Webhooks. - [_SlackHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SlackHandler.php): Logs records to a [Slack](https://www.slack.com/) account using the Slack API (complex setup). +- [_TeamsWebhookHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/TeamsWebhookHandler.php): Logs records to a [MS Teams](https://www.microsoft.com/microsoft-teams) account using MS Teams Webhooks. - [_SendGridHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SendGridHandler.php): Sends emails via the SendGrid API. - [_MandrillHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/MandrillHandler.php): Sends emails via the [`Mandrill API`](https://mandrillapp.com/api/docs/) using a [`Swift_Message`](http://swiftmailer.org/) instance. - [_FleepHookHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/FleepHookHandler.php): Logs records to a [Fleep](https://fleep.io/) conversation using Webhooks. diff --git a/src/Monolog/Handler/Slack/SlackRecord.php b/src/Monolog/Handler/Slack/SlackRecord.php index 0e46c2d2f..4a1f30606 100644 --- a/src/Monolog/Handler/Slack/SlackRecord.php +++ b/src/Monolog/Handler/Slack/SlackRecord.php @@ -56,7 +56,7 @@ class SlackRecord private bool $useAttachment; /** - * Whether the the context/extra messages added to Slack as attachments are in a short style + * Whether the context/extra messages added to Slack as attachments are in a short style */ private bool $useShortAttachment; @@ -97,10 +97,6 @@ public function __construct( ->includeContextAndExtra($includeContextAndExtra) ->excludeFields($excludeFields) ->setFormatter($formatter); - - if ($this->includeContextAndExtra) { - $this->normalizerFormatter = new NormalizerFormatter(); - } } /** diff --git a/src/Monolog/Handler/SlackWebhookHandler.php b/src/Monolog/Handler/SlackWebhookHandler.php index f265d80c9..2cf1fe3c3 100644 --- a/src/Monolog/Handler/SlackWebhookHandler.php +++ b/src/Monolog/Handler/SlackWebhookHandler.php @@ -43,7 +43,7 @@ class SlackWebhookHandler extends AbstractProcessingHandler * @param string|null $username Name of a bot * @param bool $useAttachment Whether the message should be added to Slack as attachment (plain text otherwise) * @param string|null $iconEmoji The emoji name to use (or null) - * @param bool $useShortAttachment Whether the the context/extra messages added to Slack as attachments are in a short style + * @param bool $useShortAttachment Whether the context/extra messages added to Slack as attachments are in a short style * @param bool $includeContextAndExtra Whether the attachment should include context and extra data * @param string[] $excludeFields Dot separated list of fields to exclude from slack message. E.g. ['context.field1', 'extra.field2'] * diff --git a/src/Monolog/Handler/Teams/TeamsPayload.php b/src/Monolog/Handler/Teams/TeamsPayload.php new file mode 100644 index 000000000..ab26e710a --- /dev/null +++ b/src/Monolog/Handler/Teams/TeamsPayload.php @@ -0,0 +1,267 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler\Teams; + +use Monolog\Level; +use Monolog\Utils; +use Monolog\Formatter\NormalizerFormatter; +use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; + +/** + * MS Teams record utility helping to log to MS Teams webhooks. + * + * @author Sébastien Alfaiate + * @see https://learn.microsoft.com/adaptive-cards/authoring-cards/getting-started + * + * @internal + */ +class TeamsPayload +{ + public const COLOR_ATTENTION = 'attention'; + + public const COLOR_WARNING = 'warning'; + + public const COLOR_GOOD = 'good'; + + public const COLOR_DEFAULT = 'default'; + + private NormalizerFormatter $normalizerFormatter; + + /** + * @param bool $includeContextAndExtra Whether the card should include context and extra data + * @param bool $formatMessage Whether the message should be formatted + * @param string[] $excludeFields Dot separated list of fields to exclude from MS Teams message. E.g. ['context.field1', 'extra.field2'] + * @param string[] $toggleFields Dot separated list of fields to display with a toggle button in MS Teams message. E.g. ['context.field1', 'extra.field2'] + */ + public function __construct( + private bool $includeContextAndExtra = false, + private bool $formatMessage = false, + private array $excludeFields = [], + private array $toggleFields = [], + ) { + if ($this->includeContextAndExtra) { + $this->normalizerFormatter = new NormalizerFormatter(); + } + } + + /** + * Returns required data in format that MS Teams is expecting. + * + * @phpstan-return mixed[] + */ + public function getAdaptiveCardPayload(LogRecord $record, ?FormatterInterface $formatter = null): array + { + if ($formatter !== null && $this->formatMessage) { + $message = $formatter->format($record); + } else { + $message = $record->message; + } + + $recordData = $this->removeExcludedFields($record); + + $facts = $toggles = []; + + $facts[] = $this->generateFactField('Level', $recordData['level_name']); + + if ($this->includeContextAndExtra) { + foreach (['extra', 'context'] as $key) { + if (!isset($recordData[$key]) || \count($recordData[$key]) === 0) { + continue; + } + + $data = $this->generateContextAndExtraFields($recordData[$key], $key); + + $facts = array_merge($facts, $data['facts']); + $toggles = array_merge($toggles, $data['toggles']); + } + } + + return [ + 'type' => 'message', + 'attachments' => [ + [ + 'contentType' => 'application/vnd.microsoft.card.adaptive', + 'content' => [ + '$schema' => 'http://adaptivecards.io/schemas/adaptive-card.json', + 'type' => 'AdaptiveCard', + 'version' => '1.5', + 'body' => [ + // Card Header + [ + 'type' => 'Container', + 'style' => $this->getContainerStyle($record->level), + 'items' => [ + [ + 'type' => 'TextBlock', + 'text' => $message, + 'weight' => 'Bolder', + 'size' => 'Medium', + 'wrap' => true, + ], + ], + ], + // Context and Extra + [ + 'type' => 'Container', + 'spacing' => 'Medium', + 'items' => [ + [ + 'type' => 'FactSet', + 'facts' => $facts, + ], + ], + ] + ], + // Toggles + 'actions' => $toggles, + ], + ], + ], + ]; + } + + /** + * Returns MS Teams container style associated with provided level. + */ + private function getContainerStyle(Level $level): string + { + return match ($level) { + Level::Error, Level::Critical, Level::Alert, Level::Emergency => static::COLOR_ATTENTION, + Level::Warning => static::COLOR_WARNING, + Level::Info, Level::Notice => static::COLOR_GOOD, + Level::Debug => static::COLOR_DEFAULT + }; + } + + /** + * Stringifies an array of key/value pairs to be used in fact fields + * + * @param mixed[] $fields + */ + private function stringify(array $fields): string + { + /** @var array|bool|float|int|string|null> $normalized */ + $normalized = $this->normalizerFormatter->normalizeValue($fields); + + $hasSecondDimension = \count(array_filter($normalized, 'is_array')) > 0; + $hasOnlyNonNumericKeys = \count(array_filter(array_keys($normalized), 'is_numeric')) === 0; + + return $hasSecondDimension || $hasOnlyNonNumericKeys + ? Utils::jsonEncode($normalized, JSON_PRETTY_PRINT|Utils::DEFAULT_JSON_FLAGS) + : Utils::jsonEncode($normalized, Utils::DEFAULT_JSON_FLAGS); + } + + /** + * Generates fact field + * + * @param string|mixed[] $value + * + * @return array{title: string, value: string} + */ + private function generateFactField(string $title, $value): array + { + $value = \is_array($value) + ? substr($this->stringify($value), 0, 1990) + : $value; + + return [ + 'title' => ucfirst($title), + 'value' => $value, + ]; + } + + /** + * Generates fact field + * + * @param string|mixed[] $value + * + * @return array{type: string, title: string, card: array{type: string, body: array}} + */ + private function generateToggleField(string $title, $value): array + { + $value = \is_array($value) + ? substr($this->stringify($value), 0, 19990) + : $value; + + return [ + 'type' => 'Action.ShowCard', + 'title' => ucfirst($title), + 'card' => [ + 'type' => 'AdaptiveCard', + 'body' => [ + [ + 'type' => 'TextBlock', + 'text' => $value, + 'wrap' => true, + ], + ], + ], + ]; + } + + /** + * Generates a collection of fact fields from array + * + * @param mixed[] $data + * + * @return array{facts: array, toggles: array}}>} + */ + private function generateContextAndExtraFields(array $data, string $type): array + { + /** @var array|string> $normalized */ + $normalized = $this->normalizerFormatter->normalizeValue($data); + + $fields = [ + 'facts' => [], + 'toggles' => [], + ]; + + foreach ($normalized as $key => $value) { + if (in_array($type.'.'.$key, $this->toggleFields, true)) { + $fields['toggles'][] = $this->generateToggleField((string) $key, $value); + } else { + $fields['facts'][] = $this->generateFactField((string) $key, $value); + } + } + + return $fields; + } + + /** + * Get a copy of record with fields excluded according to $this->excludeFields + * + * @return mixed[] + */ + private function removeExcludedFields(LogRecord $record): array + { + $recordData = $record->toArray(); + + foreach ($this->excludeFields as $field) { + $keys = explode('.', $field); + $node = &$recordData; + $lastKey = end($keys); + foreach ($keys as $key) { + if (!isset($node[$key])) { + break; + } + if ($lastKey === $key) { + unset($node[$key]); + break; + } + $node = &$node[$key]; + } + } + + return $recordData; + } +} diff --git a/src/Monolog/Handler/TeamsWebhookHandler.php b/src/Monolog/Handler/TeamsWebhookHandler.php new file mode 100644 index 000000000..fc78d6db1 --- /dev/null +++ b/src/Monolog/Handler/TeamsWebhookHandler.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Level; +use Monolog\Utils; +use Monolog\Handler\Teams\TeamsPayload; +use Monolog\LogRecord; + +/** + * Sends notifications through MS Teams Webhooks + * + * @author Sébastien Alfaiate + * @see https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook + * @see https://support.microsoft.com/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498 + */ +class TeamsWebhookHandler extends AbstractProcessingHandler +{ + /** + * MS Teams Webhook URL. + * + * @var non-empty-string + */ + private string $webhookUrl; + + /** + * Instance of the TeamsPayload util class preparing data for MS Teams API. + */ + private TeamsPayload $teamsPayload; + + /** + * @param non-empty-string $webhookUrl MS Teams Webhook URL + * @param bool $includeContextAndExtra Whether the card should include context and extra data + * @param string[] $excludeFields Dot separated list of fields to exclude from MS Teams message. E.g. ['context.field1', 'extra.field2'] + * @param string[] $toggleFields Dot separated list of fields to display with a toggle button in MS Teams message. E.g. ['context.field1', 'extra.field2'] + * + * @throws MissingExtensionException If the curl extension is missing + */ + public function __construct( + string $webhookUrl, + bool $includeContextAndExtra = false, + bool $formatMessage = false, + $level = Level::Critical, + bool $bubble = true, + array $excludeFields = [], + array $toggleFields = [] + ) { + if (!\extension_loaded('curl')) { + throw new MissingExtensionException('The curl extension is needed to use the TeamsWebhookHandler'); + } + + parent::__construct($level, $bubble); + + $this->webhookUrl = $webhookUrl; + + $this->teamsPayload = new TeamsPayload($includeContextAndExtra, $formatMessage, $excludeFields, $toggleFields); + } + + public function getTeamsPayload(): TeamsPayload + { + return $this->teamsPayload; + } + + /** + * @inheritDoc + */ + protected function write(LogRecord $record): void + { + $postData = $this->teamsPayload->getAdaptiveCardPayload($record, $this->getFormatter()); + $postString = Utils::jsonEncode($postData); + + $ch = curl_init(); + $options = [ + CURLOPT_URL => $this->webhookUrl, + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Content-type: application/json'], + CURLOPT_POSTFIELDS => $postString, + ]; + + curl_setopt_array($ch, $options); + + Curl\Util::execute($ch); + } +} diff --git a/tests/Monolog/Handler/Teams/TeamsPayloadTest.php b/tests/Monolog/Handler/Teams/TeamsPayloadTest.php new file mode 100644 index 000000000..3cb1076cf --- /dev/null +++ b/tests/Monolog/Handler/Teams/TeamsPayloadTest.php @@ -0,0 +1,225 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Handler\Teams; + +use Monolog\Handler\Teams\TeamsPayload; +use Monolog\Level; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; + +#[CoversClass(TeamsPayload::class)] +class TeamsPayloadTest extends \Monolog\Test\MonologTestCase +{ + public static function dataGetContainerStyle() + { + return [ + [Level::Debug, TeamsPayload::COLOR_DEFAULT], + [Level::Info, TeamsPayload::COLOR_GOOD], + [Level::Notice, TeamsPayload::COLOR_GOOD], + [Level::Warning, TeamsPayload::COLOR_WARNING], + [Level::Error, TeamsPayload::COLOR_ATTENTION], + [Level::Critical, TeamsPayload::COLOR_ATTENTION], + [Level::Alert, TeamsPayload::COLOR_ATTENTION], + [Level::Emergency, TeamsPayload::COLOR_ATTENTION], + ]; + } + + #[DataProvider('dataGetContainerStyle')] + public function testGetContainerStyle(Level $logLevel, string $expectedColour) + { + $teamsPayload = new TeamsPayload(); + + $reflection = new \ReflectionClass(TeamsPayload::class); + $method = $reflection->getMethod('getContainerStyle'); + $method->setAccessible(true); + + $this->assertSame($expectedColour, $method->invoke($teamsPayload, $logLevel)); + } + + public static function dataStringify(): array + { + $multipleDimensions = [[1, 2]]; + $numericKeys = ['library' => 'monolog']; + $singleDimension = [1, 'Hello', 'Jordi']; + + return [ + [[], '[]'], + [$multipleDimensions, json_encode($multipleDimensions, JSON_PRETTY_PRINT)], + [$numericKeys, json_encode($numericKeys, JSON_PRETTY_PRINT)], + [$singleDimension, json_encode($singleDimension)], + ]; + } + + #[DataProvider('dataStringify')] + public function testStringify($fields, $expectedResult) + { + $teamsPayload = new TeamsPayload(true); + + $reflection = new \ReflectionClass(TeamsPayload::class); + $method = $reflection->getMethod('stringify'); + $method->setAccessible(true); + + $this->assertSame($expectedResult, $method->invoke($teamsPayload, $fields)); + } + + public function testTextEqualsFormatterOutput() + { + $formatter = $this->createMock('Monolog\\Formatter\\FormatterInterface'); + $formatter + ->expects($this->any()) + ->method('format') + ->willReturnCallback(function ($record) { + return $record->message . 'test'; + }); + + $formatter2 = $this->createMock('Monolog\\Formatter\\FormatterInterface'); + $formatter2 + ->expects($this->any()) + ->method('format') + ->willReturnCallback(function ($record) { + return $record->message . 'test1'; + }); + + $message = 'Test message'; + $record = new TeamsPayload(false, true, [], []); + $data = $record->getAdaptiveCardPayload($this->getRecord(Level::Warning, $message), $formatter); + + $this->assertArrayHasKey('text', $data['attachments'][0]['content']['body'][0]['items'][0]); + $this->assertSame($message . 'test', $data['attachments'][0]['content']['body'][0]['items'][0]['text']); + + $data = $record->getAdaptiveCardPayload($this->getRecord(Level::Warning, $message), $formatter2); + + $this->assertArrayHasKey('text', $data['attachments'][0]['content']['body'][0]['items'][0]); + $this->assertSame($message . 'test1', $data['attachments'][0]['content']['body'][0]['items'][0]['text']); + } + + public function testMapsLevelToContainerStyle() + { + $record = new TeamsPayload(); + $errorLoggerRecord = $this->getRecord(Level::Error); + $emergencyLoggerRecord = $this->getRecord(Level::Emergency); + $warningLoggerRecord = $this->getRecord(Level::Warning); + $infoLoggerRecord = $this->getRecord(Level::Info); + $debugLoggerRecord = $this->getRecord(Level::Debug); + + $data = $record->getAdaptiveCardPayload($errorLoggerRecord); + $this->assertSame(TeamsPayload::COLOR_ATTENTION, $data['attachments'][0]['content']['body'][0]['style']); + + $data = $record->getAdaptiveCardPayload($emergencyLoggerRecord); + $this->assertSame(TeamsPayload::COLOR_ATTENTION, $data['attachments'][0]['content']['body'][0]['style']); + + $data = $record->getAdaptiveCardPayload($warningLoggerRecord); + $this->assertSame(TeamsPayload::COLOR_WARNING, $data['attachments'][0]['content']['body'][0]['style']); + + $data = $record->getAdaptiveCardPayload($infoLoggerRecord); + $this->assertSame(TeamsPayload::COLOR_GOOD, $data['attachments'][0]['content']['body'][0]['style']); + + $data = $record->getAdaptiveCardPayload($debugLoggerRecord); + $this->assertSame(TeamsPayload::COLOR_DEFAULT, $data['attachments'][0]['content']['body'][0]['style']); + } + + public function testWithoutContextAndExtra() + { + $level = Level::Error; + $levelName = $level->getName(); + $record = new TeamsPayload(false); + $data = $record->getAdaptiveCardPayload($this->getRecord($level, 'test', ['test' => 1])); + + $factSet = $data['attachments'][0]['content']['body'][1]['items'][0]; + $this->assertArrayHasKey('type', $factSet); + $this->assertArrayHasKey('facts', $factSet); + $this->assertCount(1, $factSet['facts']); + $this->assertSame('FactSet', $factSet['type']); + $this->assertSame( + [[ + 'title' => 'Level', + 'value' => $levelName, + ]], + $factSet['facts'] + ); + } + + public function testWithContextAndExtra() + { + $level = Level::Error; + $levelName = $level->getName(); + $context = ['test' => 1]; + $extra = ['tags' => ['web']]; + $record = new TeamsPayload(true); + $loggerRecord = $this->getRecord($level, 'test', $context); + $loggerRecord['extra'] = $extra; + $data = $record->getAdaptiveCardPayload($loggerRecord); + + $expectedFields = [ + [ + 'title' => 'Level', + 'value' => $levelName, + ], + [ + 'title' => 'Tags', + 'value' => json_encode($extra['tags']), + ], + [ + 'title' => 'Test', + 'value' => $context['test'], + ], + ]; + + $factSet = $data['attachments'][0]['content']['body'][1]['items'][0]; + $this->assertArrayHasKey('type', $factSet); + $this->assertArrayHasKey('facts', $factSet); + $this->assertCount(3, $factSet['facts']); + $this->assertSame('FactSet', $factSet['type']); + $this->assertSame( + $expectedFields, + $factSet['facts'] + ); + } + + public function testContextHasException() + { + $record = $this->getRecord(Level::Critical, 'This is a critical message.', ['exception' => new \Exception()]); + $teamsPayload = new TeamsPayload(true); + $data = $teamsPayload->getAdaptiveCardPayload($record); + $this->assertIsString($data['attachments'][0]['content']['body'][1]['items'][0]['facts'][1]['value']); + } + + public function testExcludeExtraAndContextFields() + { + $record = $this->getRecord( + Level::Warning, + 'test', + context: ['info' => ['library' => 'monolog', 'author' => 'Jordi']], + extra: ['tags' => ['web', 'cli']], + ); + + $teamsPayload = new TeamsPayload(true, false, ['context.info.library', 'extra.tags.1']); + $data = $teamsPayload->getAdaptiveCardPayload($record); + $facts = $data['attachments'][0]['content']['body'][1]['items'][0]['facts']; + + $expected = [ + [ + 'title' => 'Info', + 'value' => json_encode(['author' => 'Jordi'], JSON_PRETTY_PRINT), + ], + [ + 'title' => 'Tags', + 'value' => json_encode(['web']), + ], + ]; + + foreach ($expected as $field) { + $this->assertNotFalse(array_search($field, $facts)); + break; + } + } +} diff --git a/tests/Monolog/Handler/TeamsWebhookHandlerTest.php b/tests/Monolog/Handler/TeamsWebhookHandlerTest.php new file mode 100644 index 000000000..13fc17004 --- /dev/null +++ b/tests/Monolog/Handler/TeamsWebhookHandlerTest.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Handler; + +use Monolog\Level; +use Monolog\Handler\Teams\TeamsPayload; +use Monolog\Handler\TeamsWebhookHandler; +use PHPUnit\Framework\Attributes\CoversClass; + +#[CoversClass(TeamsWebhookHandler::class)] +class TeamsWebhookHandlerTest extends \Monolog\Test\MonologTestCase +{ + const WEBHOOK_URL = 'https://monolog.webhook.office.com/webhookb2/b4c2e6a1-9f13-4db3-92ae-7a210b1d52e9@3d87f6b4-2c4a-44ea-95cd-1feaf83b7a61/IncomingWebhook/a91f4c3e8d2b46d28e7a32f9b508c4fa/ef72c8d1-3baf-4e58-b2a1-9c4f83d7e2b5/K7xQm1RdWvN9tG4Pa2LbF8ySzCwJr6HeXpR0BuViYaTqLk'; + + /** + * @covers ::__construct + * @covers ::getTeamsPayload + */ + public function testConstructorMinimal() + { + $handler = new TeamsWebhookHandler(self::WEBHOOK_URL); + $record = $this->getRecord(); + $teamsPayload = $handler->getTeamsPayload(); + $this->assertInstanceOf('Monolog\Handler\Teams\TeamsPayload', $teamsPayload); + $this->assertEquals([ + 'type' => 'message', + 'attachments' => [ + [ + 'contentType' => 'application/vnd.microsoft.card.adaptive', + 'content' => [ + '$schema' => 'http://adaptivecards.io/schemas/adaptive-card.json', + 'type' => 'AdaptiveCard', + 'version' => '1.5', + 'body' => [ + [ + 'type' => 'Container', + 'style' => TeamsPayload::COLOR_WARNING, + 'items' => [ + [ + 'type' => 'TextBlock', + 'text' => 'test', + 'weight' => 'Bolder', + 'size' => 'Medium', + 'wrap' => true, + ], + ], + ], + [ + 'type' => 'Container', + 'spacing' => 'Medium', + 'items' => [ + [ + 'type' => 'FactSet', + 'facts' => [ + [ + 'title' => 'Level', + 'value' => Level::Warning->getName(), + ], + ], + ], + ], + ] + ], + 'actions' => [], + ], + ], + ], + ], $teamsPayload->getAdaptiveCardPayload($record)); + } + + /** + * @covers ::__construct + * @covers ::getTeamsPayload + */ + public function testConstructorFull() + { + $handler = new TeamsWebhookHandler(self::WEBHOOK_URL, true, false, Level::Warning, false); + + $record = $this->getRecord(); + $teamsPayload = $handler->getTeamsPayload(); + $this->assertInstanceOf('Monolog\Handler\Teams\TeamsPayload', $teamsPayload); + $this->assertEquals([ + 'type' => 'message', + 'attachments' => [ + [ + 'contentType' => 'application/vnd.microsoft.card.adaptive', + 'content' => [ + '$schema' => 'http://adaptivecards.io/schemas/adaptive-card.json', + 'type' => 'AdaptiveCard', + 'version' => '1.5', + 'body' => [ + [ + 'type' => 'Container', + 'style' => TeamsPayload::COLOR_WARNING, + 'items' => [ + [ + 'type' => 'TextBlock', + 'text' => 'test', + 'weight' => 'Bolder', + 'size' => 'Medium', + 'wrap' => true, + ], + ], + ], + [ + 'type' => 'Container', + 'spacing' => 'Medium', + 'items' => [ + [ + 'type' => 'FactSet', + 'facts' => [ + [ + 'title' => 'Level', + 'value' => Level::Warning->getName(), + ], + ], + ], + ], + ] + ], + 'actions' => [], + ], + ], + ], + ], $teamsPayload->getAdaptiveCardPayload($record)); + } +}