From ae71717c3589c02dd804e25e83e8754030877ae0 Mon Sep 17 00:00:00 2001 From: Vichheann Saing Date: Sat, 28 May 2016 22:36:30 +0200 Subject: [PATCH] Use Slack webhook or Slack API token --- README.md | 4 +- .../SlackNotificationService.java | 190 ++++++++++++++---- .../seyren/core/util/config/SeyrenConfig.java | 7 + .../SlackNotificationServiceTest.java | 88 +++++++- 4 files changed, 235 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 6766af78..8076e89a 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,11 @@ To generate a "Service API Key", see [PagerDuty Support: Adding Services](https: ##### [Slack](https://www.slack.com) -The target for a Slack subscription will be the channel name (including the `#`, for example `#channel`). You can optionally suffix the channel name with `!` and that will cause the alerts to include a `@channel` mention (for example `#channel!`). +You can specify either `SLACK_TOKEN` (which will be evaluated first for compatibility reason) or `SLACK_WEBHOOK_URL` (which should be the preferred method). +If you set `SLACK_TOKEN`, the target for a Slack subscription will be the channel name (including the `#`, for example `#channel`). You can optionally suffix the channel name with `!` and that will cause the alerts to include a `@channel` mention (for example `#channel!`). If you set `SLACK_WEBHOOK_URL`, you don't need to suffix the channel, you can simply use `#channel` or `@channel`. * `SLACK_TOKEN` - The Slack api auth token. Default: `` +* `SLACK_WEBHOOK_URL` - The Slack webhook URL. Default: `` * `SLACK_USERNAME` - The username that messages will be sent to slack. Default: `Seyren` * `SLACK_ICON_URL` - The user icon URL. Default: `` * `SLACK_EMOJIS` - Mapping between state and emojis unicode. Default: `` diff --git a/seyren-core/src/main/java/com/seyren/core/service/notification/SlackNotificationService.java b/seyren-core/src/main/java/com/seyren/core/service/notification/SlackNotificationService.java index 68e61627..337ae9ac 100644 --- a/seyren-core/src/main/java/com/seyren/core/service/notification/SlackNotificationService.java +++ b/seyren-core/src/main/java/com/seyren/core/service/notification/SlackNotificationService.java @@ -13,11 +13,13 @@ */ package com.seyren.core.service.notification; -import static com.google.common.collect.Iterables.*; +import static com.google.common.collect.Iterables.transform; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import javax.inject.Inject; import javax.inject.Named; @@ -28,12 +30,16 @@ import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.utils.HttpClientUtils; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.BasicResponseHandler; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.message.BasicNameValuePair; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Function; import com.google.common.base.Joiner; import com.google.common.base.Splitter; @@ -64,37 +70,144 @@ protected SlackNotificationService(SeyrenConfig seyrenConfig, String baseUrl) { this.baseUrl = baseUrl; } + @Override + public boolean canHandle(SubscriptionType subscriptionType) { + return subscriptionType == SubscriptionType.SLACK; + } + @Override public void sendNotification(Check check, Subscription subscription, List alerts) throws NotificationFailedException { - String token = seyrenConfig.getSlackToken(); - String channel = subscription.getTarget(); - String username = seyrenConfig.getSlackUsername(); - String iconUrl = seyrenConfig.getSlackIconUrl(); + if ( !seyrenConfig.getSlackToken().isEmpty() ) { + LOGGER.info("Will use API token"); + notifyUsingApiToken(check, subscription, alerts); + } + else if ( !seyrenConfig.getSlackWebhook().isEmpty() ) { + LOGGER.info("Will use Webhook"); + notifyUsingWebhook(check, subscription, alerts); + } + else + LOGGER.warn("Slack token and Slack Webhook are empty. Do nothing."); + } + + // + // API Test Token. You should really switch to Webhook. + // Just copied previous code. + // + + private void notifyUsingApiToken(Check check, Subscription subscription, List alerts) { + String token = seyrenConfig.getSlackToken(); + String channel = subscription.getTarget(); + String username = seyrenConfig.getSlackUsername(); + String iconUrl = seyrenConfig.getSlackIconUrl(); + + List emojis = extractEmojis(); + + String url = String.format("%s/api/chat.postMessage", baseUrl); + HttpClient client = HttpClientBuilder.create().useSystemProperties().build(); + HttpPost post = new HttpPost(url); + post.addHeader("accept", "application/json"); + + List parameters = new ArrayList(); + parameters.add(new BasicNameValuePair("token", token)); + parameters.add(new BasicNameValuePair("channel", StringUtils.removeEnd(channel, "!"))); + parameters.add(new BasicNameValuePair("text", formatContent(emojis, check, subscription, alerts))); + parameters.add(new BasicNameValuePair("username", username)); + parameters.add(new BasicNameValuePair("icon_url", iconUrl)); + + try { + post.setEntity(new UrlEncodedFormEntity(parameters)); + if (LOGGER.isDebugEnabled()) { + LOGGER.info("> parameters: {}", parameters); + } + HttpResponse response = client.execute(post); + if (LOGGER.isDebugEnabled()) { + LOGGER.info("> parameters: {}", parameters); + LOGGER.debug("Status: {}, Body: {}", response.getStatusLine(), new BasicResponseHandler().handleResponse(response)); + } + } catch (Exception e) { + LOGGER.warn("Error posting to Slack", e); + } finally { + post.releaseConnection(); + HttpClientUtils.closeQuietly(client); + } + } + + private String formatContent(List emojis, Check check, Subscription subscription, List alerts) { + String url = formatCheckUrl(check); + String alertsString = formatAlert(alerts); + + String channel = subscription.getTarget().contains("!") ? "" : ""; - List emojis = Lists.newArrayList( - Splitter.on(',').omitEmptyStrings().trimResults().split(seyrenConfig.getSlackEmojis()) + String description = formatDescription(check); + + final String state = check.getState().toString(); + + return String.format("%s*%s* %s [%s]%s\n```\n%s\n```\n#%s %s", + Iterables.get(emojis, check.getState().ordinal(), ""), + state, + check.getName(), + url, + description, + alertsString, + state.toLowerCase(Locale.getDefault()), + channel ); + } + + private List extractEmojis() { + List emojis = Lists.newArrayList( + Splitter.on(',').omitEmptyStrings().trimResults().split(seyrenConfig.getSlackEmojis()) + ); + return emojis; + } + + private String formatCheckUrl(Check check) { + String url = String.format("%s/#/checks/%s", seyrenConfig.getBaseUrl(), check.getId()); + return url; + } + + private String formatAlert(List alerts) { + String alertsString = Joiner.on("\n").join(transform(alerts, new Function() { + @Override + public String apply(Alert input) { + return String.format("%s = %s (%s to %s)", input.getTarget(), input.getValue().toString(), input.getFromType(), input.getToType()); + } + })); + return alertsString; + } + + private String formatDescription(Check check) { + String description; + if (StringUtils.isNotBlank(check.getDescription())) { + description = String.format("\n> %s", check.getDescription()); + } else { + description = ""; + } + return description; + } + + // + // Webhook + // + + private void notifyUsingWebhook(Check check, Subscription subscription, List alerts) throws NotificationFailedException { + String webhookUrl = seyrenConfig.getSlackWebhook(); + + List emojis = extractEmojis(); - String url = String.format("%s/api/chat.postMessage", baseUrl); HttpClient client = HttpClientBuilder.create().useSystemProperties().build(); - HttpPost post = new HttpPost(url); + HttpPost post = new HttpPost(webhookUrl); post.addHeader("accept", "application/json"); - List parameters = new ArrayList(); - parameters.add(new BasicNameValuePair("token", token)); - parameters.add(new BasicNameValuePair("channel", StringUtils.removeEnd(channel, "!"))); - parameters.add(new BasicNameValuePair("text", formatContent(emojis, check, subscription, alerts))); - parameters.add(new BasicNameValuePair("username", username)); - parameters.add(new BasicNameValuePair("icon_url", iconUrl)); - try { - post.setEntity(new UrlEncodedFormEntity(parameters)); + String message = generateMessage(emojis, check, subscription, alerts); + post.setEntity(new StringEntity(message, ContentType.APPLICATION_JSON)); if (LOGGER.isDebugEnabled()) { - LOGGER.info("> parameters: {}", parameters); + LOGGER.info("> message: {}", message); } HttpResponse response = client.execute(post); if (LOGGER.isDebugEnabled()) { - LOGGER.info("> parameters: {}", parameters); + LOGGER.info("> message: {}", message); LOGGER.debug("Status: {}, Body: {}", response.getStatusLine(), new BasicResponseHandler().handleResponse(response)); } } catch (Exception e) { @@ -103,43 +216,34 @@ public void sendNotification(Check check, Subscription subscription, List post.releaseConnection(); HttpClientUtils.closeQuietly(client); } - } - @Override - public boolean canHandle(SubscriptionType subscriptionType) { - return subscriptionType == SubscriptionType.SLACK; + private String generateMessage(List emojis, Check check, Subscription subscription, List alerts) throws JsonProcessingException { + Map payload = new HashMap(); + payload.put("channel", subscription.getTarget()); + payload.put("username", seyrenConfig.getSlackUsername()); + payload.put("text", formatText(emojis, check, subscription, alerts)); + payload.put("icon_url", seyrenConfig.getSlackIconUrl()); + + String message = new ObjectMapper().writeValueAsString(payload); + return message; } - private String formatContent(List emojis, Check check, Subscription subscription, List alerts) { - String url = String.format("%s/#/checks/%s", seyrenConfig.getBaseUrl(), check.getId()); - String alertsString = Joiner.on("\n").join(transform(alerts, new Function() { - @Override - public String apply(Alert input) { - return String.format("%s = %s (%s to %s)", input.getTarget(), input.getValue().toString(), input.getFromType(), input.getToType()); - } - })); + private String formatText(List emojis, Check check, Subscription subscription, List alerts) { + String url = formatCheckUrl(check); + String alertsString = formatAlert(alerts); - String channel = subscription.getTarget().contains("!") ? "" : ""; - - String description; - if (StringUtils.isNotBlank(check.getDescription())) { - description = String.format("\n> %s", check.getDescription()); - } else { - description = ""; - } + String description = formatDescription(check); final String state = check.getState().toString(); - return String.format("%s*%s* %s [%s]%s\n```\n%s\n```\n#%s %s", + return String.format("%s *%s* %s (<%s|Open>)%s\n```\n%s\n```", Iterables.get(emojis, check.getState().ordinal(), ""), state, check.getName(), url, description, - alertsString, - state.toLowerCase(Locale.getDefault()), - channel + alertsString ); } } diff --git a/seyren-core/src/main/java/com/seyren/core/util/config/SeyrenConfig.java b/seyren-core/src/main/java/com/seyren/core/util/config/SeyrenConfig.java index 9c221c1c..8c44d79b 100644 --- a/seyren-core/src/main/java/com/seyren/core/util/config/SeyrenConfig.java +++ b/seyren-core/src/main/java/com/seyren/core/util/config/SeyrenConfig.java @@ -71,6 +71,7 @@ public class SeyrenConfig { private final String ircCatHost; private final String ircCatPort; private final String slackToken; + private final String slackWebhook; private final String slackUsername; private final String slackIconUrl; private final String slackEmojis; @@ -146,6 +147,7 @@ public SeyrenConfig() { // Slack this.slackToken = configOrDefault("SLACK_TOKEN", ""); + this.slackWebhook = configOrDefault("SLACK_WEBHOOK_URL", ""); this.slackUsername = configOrDefault("SLACK_USERNAME", "Seyren"); this.slackIconUrl = configOrDefault("SLACK_ICON_URL", ""); this.slackEmojis = configOrDefault("SLACK_EMOJIS", ""); @@ -396,6 +398,11 @@ public String getSlackToken() { return slackToken; } + @JsonIgnore + public String getSlackWebhook() { + return slackWebhook; + } + @JsonIgnore public String getSlackUsername() { return slackUsername; diff --git a/seyren-core/src/test/java/com/seyren/core/service/notification/SlackNotificationServiceTest.java b/seyren-core/src/test/java/com/seyren/core/service/notification/SlackNotificationServiceTest.java index 5353712f..47b2c1c9 100644 --- a/seyren-core/src/test/java/com/seyren/core/service/notification/SlackNotificationServiceTest.java +++ b/seyren-core/src/test/java/com/seyren/core/service/notification/SlackNotificationServiceTest.java @@ -17,18 +17,25 @@ import static com.github.restdriver.clientdriver.RestClientDriver.onRequestTo; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.isEmptyString; import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; import java.net.URLDecoder; import java.net.URLEncoder; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.apache.commons.lang.StringUtils; import org.joda.time.DateTime; @@ -37,6 +44,10 @@ import org.junit.Rule; import org.junit.Test; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.restdriver.clientdriver.ClientDriverRequest; import com.github.restdriver.clientdriver.ClientDriverRule; import com.github.restdriver.clientdriver.capture.StringBodyCapture; @@ -48,7 +59,10 @@ import com.seyren.core.util.config.SeyrenConfig; public class SlackNotificationServiceTest { - private static final String USERNAME = "Seyren"; + private static final String SLACK_USERNAME = "Seyren"; + private static final String SLACK_TOKEN = "A_TOKEN"; + private static final String SLACK_WEBHOOK_URI_TO_POST = "/services/SOMETHING/ANOTHERTHING/FINALTHING"; + private static final String CONTENT_ENCODING = "ISO-8859-1"; private NotificationService notificationService; @@ -63,8 +77,7 @@ public void before() { when(mockSeyrenConfig.getBaseUrl()).thenReturn(clientDriver.getBaseUrl() + "/slack"); when(mockSeyrenConfig.getSlackEmojis()).thenReturn(""); when(mockSeyrenConfig.getSlackIconUrl()).thenReturn(""); - when(mockSeyrenConfig.getSlackToken()).thenReturn(""); - when(mockSeyrenConfig.getSlackUsername()).thenReturn(USERNAME); + when(mockSeyrenConfig.getSlackUsername()).thenReturn(SLACK_USERNAME); notificationService = new SlackNotificationService(mockSeyrenConfig, clientDriver.getBaseUrl()); } @@ -85,8 +98,10 @@ public void notificationServiceCanOnlyHandleSlackSubscription() { } @Test - public void basicSlackTest() { + public void useSlackApiTokenTest() { // Given + when(mockSeyrenConfig.getSlackToken()).thenReturn(SLACK_TOKEN); + Check check = givenCheck(); Subscription subscription = givenSlackSubscriptionWithTarget("target"); Alert alert = givenAlert(); @@ -107,7 +122,7 @@ public void basicSlackTest() { // Then String content = bodyCapture.getContent(); - System.out.println(decode(content)); + //System.out.println(decode(content)); assertContent(content, check, subscription); assertThat(content, containsString("&channel=" + subscription.getTarget())); @@ -115,7 +130,8 @@ public void basicSlackTest() { verify(mockSeyrenConfig).getSlackEmojis(); verify(mockSeyrenConfig).getSlackIconUrl(); - verify(mockSeyrenConfig).getSlackToken(); + verify(mockSeyrenConfig, atLeast(2)).getSlackToken(); + verify(mockSeyrenConfig, times(0)).getSlackWebhook(); verify(mockSeyrenConfig).getSlackUsername(); verify(mockSeyrenConfig).getBaseUrl(); } @@ -123,6 +139,8 @@ public void basicSlackTest() { @Test public void mentionChannelWhenTargetContainsExclamationTest() { //Given + when(mockSeyrenConfig.getSlackToken()).thenReturn(SLACK_TOKEN); + Check check = givenCheck(); Subscription subscription = givenSlackSubscriptionWithTarget("target!"); Alert alert = givenAlert(); @@ -143,7 +161,7 @@ public void mentionChannelWhenTargetContainsExclamationTest() { // Then String content = bodyCapture.getContent(); - System.out.println(decode(content)); + //System.out.println(decode(content)); assertContent(content, check, subscription); assertThat(content, containsString("&channel=" + StringUtils.removeEnd(subscription.getTarget(), "!"))); @@ -151,7 +169,57 @@ public void mentionChannelWhenTargetContainsExclamationTest() { verify(mockSeyrenConfig).getSlackEmojis(); verify(mockSeyrenConfig).getSlackIconUrl(); - verify(mockSeyrenConfig).getSlackToken(); + verify(mockSeyrenConfig, atLeast(2)).getSlackToken(); + verify(mockSeyrenConfig, times(0)).getSlackWebhook(); + verify(mockSeyrenConfig).getSlackUsername(); + verify(mockSeyrenConfig).getBaseUrl(); + } + + @Test + public void useSlackWebHookTest() throws JsonParseException, JsonMappingException, IOException { + // Given + when(mockSeyrenConfig.getSlackToken()).thenReturn(""); + when(mockSeyrenConfig.getSlackWebhook()).thenReturn(clientDriver.getBaseUrl() + SLACK_WEBHOOK_URI_TO_POST); + + Check check = givenCheck(); + + Subscription subscription = givenSlackSubscriptionWithTarget("target"); + + Alert alert = givenAlert(); + List alerts = Arrays.asList(alert); + + StringBodyCapture bodyCapture = new StringBodyCapture(); + + clientDriver.addExpectation( + onRequestTo(SLACK_WEBHOOK_URI_TO_POST) + .withMethod(ClientDriverRequest.Method.POST) + .capturingBodyIn(bodyCapture) + .withHeader("accept", "application/json"), + giveEmptyResponse()); + + // When + notificationService.sendNotification(check, subscription, alerts); + + // Then + String content = bodyCapture.getContent(); + assertThat(content, is(notNullValue())); + + Map map = new HashMap(); + ObjectMapper mapper = new ObjectMapper(); + TypeReference> typeRef = new TypeReference>() {}; + map = mapper.readValue(content, typeRef); + + assertThat(map.get("channel"), is(subscription.getTarget())); + assertThat(map.get("text"), containsString("*" + check.getState().name() + "* ")); + assertThat(map.get("text"), containsString("/#/checks/" + check.getId())); + assertThat(map.get("text"), containsString(check.getName())); + assertThat(map.get("username"), is(SLACK_USERNAME)); + assertThat(map.get("icon_url"), isEmptyString()); + + verify(mockSeyrenConfig, atLeast(2)).getSlackWebhook(); + verify(mockSeyrenConfig, times(1)).getSlackToken(); + verify(mockSeyrenConfig).getSlackEmojis(); + verify(mockSeyrenConfig).getSlackIconUrl(); verify(mockSeyrenConfig).getSlackUsername(); verify(mockSeyrenConfig).getBaseUrl(); } @@ -183,10 +251,10 @@ Alert givenAlert() { } private void assertContent(String content, Check check, Subscription subscription) { - assertThat(content, containsString("token=")); + assertThat(content, containsString("token=" + SLACK_TOKEN)); assertThat(content, containsString(encode("*" + check.getState().name() + "* " + check.getName()))); assertThat(content, containsString(encode("/#/checks/" + check.getId()))); - assertThat(content, containsString("&username=" + USERNAME)); + assertThat(content, containsString("&username=" + SLACK_USERNAME)); assertThat(content, containsString("&icon_url=")); }