From 4dd369294e6db5ecb4c90959b793c7188063e45c Mon Sep 17 00:00:00 2001 From: jiannelli Date: Sun, 6 Oct 2024 17:40:08 -0300 Subject: [PATCH] Fix Discord Transport when Fields to Embed Left Empty (#16439) * Add PHPUnit test for LibreNMS Discord transport * Style * Adding comments for methods and others. * Refactor title, color, embedFields and footer to its own methods. Renaming and comments. * testDiscordDelivery includes img and html tags. * testDiscordDelivery tests INI options * renamed getter methods for clarity. Refactor to includeINIFields() method. * refactor attribute $discord_message; * Discord.php: Bugfix "Error: Invalid Field" when discord-embed-fields is left empty. Discord.php: Removed DEFAULT_EMBEDS (defaults are not working with text fields). DiscordTest.php: transport config tests improved. * Transport.md: Documented options, images and ebed fields. Better examples. * typos * styleCI and PHPStan * StyleCI * StyleCI --- LibreNMS/Alert/Transport/Discord.php | 133 ++++++++--- doc/Alerting/Transports.md | 15 +- tests/Unit/Alert/Transports/DiscordTest.php | 237 ++++++++++++++++++++ 3 files changed, 345 insertions(+), 40 deletions(-) create mode 100644 tests/Unit/Alert/Transports/DiscordTest.php diff --git a/LibreNMS/Alert/Transport/Discord.php b/LibreNMS/Alert/Transport/Discord.php index 6d02bbc8d3..5a562cddcf 100644 --- a/LibreNMS/Alert/Transport/Discord.php +++ b/LibreNMS/Alert/Transport/Discord.php @@ -1,4 +1,5 @@ 'Rule Name', ]; + private array $discord_message = []; + + /** + * Composes a Discord JSON message and delivers it using HTTP POST + * https://discord.com/developers/docs/resources/message#create-message + * + * @param array $alert_data + * @return bool + */ public function deliverAlert(array $alert_data): bool { - $added_fields = $this->parseUserOptions($this->config['options']); - - $discord_title = '#' . $alert_data['uid'] . ' ' . $alert_data['title']; - $discord_msg = $alert_data['msg']; - $color = hexdec(preg_replace('/[^\dA-Fa-f]/', '', self::getColorForState($alert_data['state']))); - - // Special handling for the elapsed text in the footer if the elapsed is not set. - $footer_text = $alert_data['elapsed'] ? 'alert took ' . $alert_data['elapsed'] : ''; - - $data = [ + $this->discord_message = [ 'embeds' => [ [ - 'title' => $discord_title, - 'color' => $color, - 'description' => $discord_msg, - 'fields' => $this->createDiscordFields($alert_data), + 'title' => $this->getTitle($alert_data), + 'color' => $this->getColorOfAlertState($alert_data), + 'description' => $this->getDescription($alert_data), + 'fields' => $this->getEmbedFields($alert_data), 'footer' => [ - 'text' => $footer_text, + 'text' => $this->getFooter($alert_data), ], ], ], ]; - if (! empty($added_fields)) { - $data = array_merge($data, $added_fields); - } - $data = $this->embedGraphs($data); + $this->includeINIFields(); + $this->embedGraphs(); + $this->stripHTMLTagsFromDescription(); - // remove all remaining HTML tags - $data['embeds'][0]['description'] = strip_tags($data['embeds'][0]['description']); - - $res = Http::client()->post($this->config['url'], $data); + $res = Http::client()->post($this->config['url'], $this->discord_message); if ($res->successful()) { return true; } - throw new AlertTransportDeliveryException($alert_data, $res->status(), $res->body(), $discord_msg, $data); + throw new AlertTransportDeliveryException($alert_data, $res->status(), $res->body(), $alert_data['msg'], $this->discord_message); } - private function embedGraphs(array $data): array + private function getTitle(array $alert_data): string { + return '#' . $alert_data['uid'] . ' ' . $alert_data['title']; + } + + private function stripHTMLTagsFromDescription(): array + { + $this->discord_message['embeds'][0]['description'] = strip_tags($this->discord_message['embeds'][0]['description']); + + return $this->discord_message; + } + + private function getColorOfAlertState(array $alert_data): int + { + $hexColor = self::getColorForState($alert_data['state']); + $sanitized = preg_replace('/[^\dA-Fa-f]/', '', $hexColor); + + return hexdec($sanitized); + } + + private function getDescription(array $alert_data): string + { + return $alert_data['msg']; + } + + private function getFooter(array $alert_data): string + { + return $alert_data['elapsed'] ? 'alert took ' . $alert_data['elapsed'] : ''; + } + + private function includeINIFields(): array + { + $ini_fileds = $this->parseUserOptions($this->config['options']); + + if (! empty($ini_fileds)) { + $this->discord_message = array_merge($this->discord_message, $ini_fileds); + } + + return $this->discord_message; + } + + /** + * Convert an html tag to a json Discord message Embed Image Structure + * https://discord.com/developers/docs/resources/message#embed-object-embed-image-structure + * + * @return array + */ + private function embedGraphs(): array + { + $regex = '##'; $count = 1; - $data['embeds'][0]['description'] = preg_replace_callback('##', function ($match) use (&$data, &$count) { - $data['embeds'][] = [ + + $this->discord_message['embeds'][0]['description'] = preg_replace_callback($regex, function ($match) use (&$count) { + $this->discord_message['embeds'][] = [ 'image' => [ 'url' => $match[1], ], ]; return '[Image ' . ($count++) . ']'; - }, $data['embeds'][0]['description']); + }, $this->discord_message['embeds'][0]['description']); - return $data; + return $this->discord_message; } - public function createDiscordFields(array $alert_data): array + /** + * Converts comma-separated values into an array of name-value pairs. + * https://discord.com/developers/docs/resources/message#embed-object-embed-field-structure + * + * * @param array $alert_data Array containing the values. + * @return array An array of name-value pairs. + * + * @example + * Example with 'hostname,sysDescr' as fields: + * $result will be: + * [ + * ['name' => 'Hostname', 'value' => 'server1'], + * ['name' => 'SysDescr', 'value' => 'Linux server description'], + * ] + */ + public function getEmbedFields(array $alert_data): array { $result = []; - $fields = explode(',', $this->config['discord-embed-fields'] ?? self::DEFAULT_EMBEDS); + if (empty($this->config['discord-embed-fields'])) { + return $result; + } + + $fields = explode(',', $this->config['discord-embed-fields']); foreach ($fields as $field) { + $field = trim($field); + $result[] = [ 'name' => $this->embedFieldTranslations[$field] ?? ucfirst($field), 'value' => $alert_data[$field] ?? 'Error: Invalid Field', @@ -133,9 +199,8 @@ class Discord extends Transport [ 'title' => 'Fields to embed in the alert', 'name' => 'discord-embed-fields', - 'descr' => 'Comma seperated list of fields from the alert to attach to the Discord message', + 'descr' => 'Comma seperated list from the alert to embed i.e. hostname,name,timestamp,severity', 'type' => 'text', - 'default' => self::DEFAULT_EMBEDS, ], ], 'validation' => [ diff --git a/doc/Alerting/Transports.md b/doc/Alerting/Transports.md index 951aa0662b..de9a9be9cb 100644 --- a/doc/Alerting/Transports.md +++ b/doc/Alerting/Transports.md @@ -269,20 +269,23 @@ Here an example using 3 numbers, any amount of numbers is supported: ## Discord The Discord transport will POST the alert message to your Discord -Incoming WebHook. Simple html tags are stripped from the message. +Incoming WebHook. The only required value is Discord URL, without this no call to Discord will be made. -The only required value is for url, without this no call to Discord -will be made. The Options field supports the JSON/Form Params listed -in the Discord Docs below. +Graphs can be included in the template using: ``````. The rest of the html tags are stripped from the message. + + + The Options field supports JSON/Form Params listed +in the +[Discord Docs](https://discordapp.com/developers/docs/resources/webhook#execute-webhook). Fields to embed is a comma separated list from the [Alert Data](https://github.com/librenms/librenms/blob/master/LibreNMS/Alert/AlertData.php)). -[Discord Docs](https://discordapp.com/developers/docs/resources/webhook#execute-webhook) **Example:** | Config | Example | | ------ | ------- | | Discord URL | | -| Options | username=myname | +| Options | username=myname
content=Some content
tts=false | +| Fields to embed | hostname,name,timestamp,severity | ## Elasticsearch diff --git a/tests/Unit/Alert/Transports/DiscordTest.php b/tests/Unit/Alert/Transports/DiscordTest.php new file mode 100644 index 0000000000..4ea89ec0ae --- /dev/null +++ b/tests/Unit/Alert/Transports/DiscordTest.php @@ -0,0 +1,237 @@ +. + * + * @link https://www.librenms.org + * + * @copyright 2022 Juan Diego Iannelli + * @author Juan Diego Iannelli + */ + +namespace LibreNMS\Tests\Unit\Alert\Transports; + +use App\Models\AlertTransport; +use App\Models\Device; +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Http; +use LibreNMS\Alert\AlertData; +use LibreNMS\Alert\Transport; +use LibreNMS\Tests\TestCase; + +use function PHPUnit\Framework\assertEquals; + +class DiscordTest extends TestCase +{ + public function testDiscordNoConfigDelivery(): void + { + Http::fake(); + + $transport = new Transport\Discord(new AlertTransport([ + 'transport_config' => [ + 'url' => '', + 'options' => '', + 'discord-embed-fields' => '', + ], + ])); + + /** @var Device $mock_device */ + $mock_device = Device::factory()->make(['hostname' => 'my-hostname.com']); + + $transport->deliverAlert(AlertData::testData($mock_device)); + + Http::assertSent(function (Request $request) { + assertEquals('', $request->url()); + assertEquals('POST', $request->method()); + assertEquals('application/json', $request->header('Content-Type')[0]); + assertEquals( + [ + 'embeds' => [ + [ + 'title' => '#000 Testing transport from LibreNMS', + 'color' => 16711680, + 'description' => 'This is a test alert', + 'fields' => [], + 'footer' => [ + 'text' => 'alert took 11s', + ], + ], + ], + ], + $request->data() + ); + + return true; + }); + } + + public function testBadOptionsDelivery(): void + { + Http::fake(); + + $transport = new Transport\Discord(new AlertTransport([ + 'transport_config' => [ + 'url' => 'https://discord.com/api/webhooks/number/id', + 'options' => 'multi-line options not in INIFormat' . PHP_EOL . 'are ignored', + 'discord-embed-fields' => '', + ], + ])); + + /** @var Device $mock_device */ + $mock_device = Device::factory()->make(['hostname' => 'my-hostname.com']); + + $transport->deliverAlert(AlertData::testData($mock_device)); + + Http::assertSent(function (Request $request) { + assertEquals('https://discord.com/api/webhooks/number/id', $request->url()); + assertEquals('POST', $request->method()); + assertEquals('application/json', $request->header('Content-Type')[0]); + assertEquals( + [ + 'embeds' => [ + [ + 'title' => '#000 Testing transport from LibreNMS', + 'color' => 16711680, + 'description' => 'This is a test alert', + 'fields' => [], + 'footer' => [ + 'text' => 'alert took 11s', + ], + ], + ], + ], + $request->data() + ); + + return true; + }); + } + + public function testBadEmbedFieldsDelivery(): void + { + Http::fake(); + + $transport = new Transport\Discord(new AlertTransport([ + 'transport_config' => [ + 'url' => 'https://discord.com/api/webhooks/number/id', + 'options' => '', + 'discord-embed-fields' => 'hostname severity', + ], + ])); + + /** @var Device $mock_device */ + $mock_device = Device::factory()->make(['hostname' => 'my-hostname.com']); + + $transport->deliverAlert(AlertData::testData($mock_device)); + + Http::assertSent(function (Request $request) { + assertEquals('https://discord.com/api/webhooks/number/id', $request->url()); + assertEquals('POST', $request->method()); + assertEquals('application/json', $request->header('Content-Type')[0]); + assertEquals( + [ + 'embeds' => [ + [ + 'title' => '#000 Testing transport from LibreNMS', + 'color' => 16711680, + 'description' => 'This is a test alert', + 'fields' => [ + [ + 'name' => 'Hostname severity', + 'value' => 'Error: Invalid Field', + ], + ], + 'footer' => [ + 'text' => 'alert took 11s', + ], + ], + ], + ], + $request->data() + ); + + return true; + }); + } + + public function testDiscordDelivery(): void + { + Http::fake(); + + $transport = new Transport\Discord(new AlertTransport([ + 'transport_config' => [ + 'url' => 'https://discord.com/api/webhooks/number/id', + 'options' => 'tts=true' . PHP_EOL . 'content=This is a text', + 'discord-embed-fields' => 'hostname,severity,wrongfield', + ], + ])); + + /** @var Device $mock_device */ + $mock_device = Device::factory()->make(['hostname' => 'my-hostname.com']); + + $alert_data = AlertData::testData($mock_device); + + $alert_data['msg'] = 'This test alert should not have image or

html tags


'; + + $transport->deliverAlert($alert_data); + + Http::assertSent(function (Request $request) { + assertEquals($request->url(), 'https://discord.com/api/webhooks/number/id'); + assertEquals($request->method(), 'POST'); + assertEquals($request->header('Content-Type')[0], 'application/json'); + assertEquals( + [ + 'tts' => 'true', + 'content' => 'This is a text', + 'embeds' => [ + [ + 'title' => '#000 Testing transport from LibreNMS', + 'color' => 16711680, + 'description' => 'This test alert should not have image [Image 1] or html tags', + 'fields' => [ + [ + 'name' => 'Hostname', + 'value' => 'my-hostname.com', + ], + [ + 'name' => 'Severity', + 'value' => 'critical', + ], + [ + 'name' => 'Wrongfield', + 'value' => 'Error: Invalid Field', + ], + ], + 'footer' => [ + 'text' => 'alert took 11s', + ], + ], + [ + 'image' => [ + 'url' => 'google.jpeg', + ], + ], + ], + ], + $request->data() + ); + + return true; + }); + } +}