diff --git a/README.md b/README.md index 6766af78..f2ace11b 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,12 @@ 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!`). +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 notifications can be sent via [Incoming Webhooks](https://api.slack.com/incoming-webhooks) (preferred) or the [Slack Web API](https://api.slack.com/web). + +* `SLACK_TOKEN` - The Slack web api auth token. Default: `` +* `SLACK_WEBHOOK_URL` - The Slack webhook URL. If specified, `SLACK_TOKEN` will be ignored. 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..1fcedc40 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,27 +13,35 @@ */ package com.seyren.core.service.notification; -import static com.google.common.collect.Iterables.*; +import static com.google.common.collect.Iterables.transform; +import java.io.UnsupportedEncodingException; 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; import org.apache.commons.lang.StringUtils; +import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; 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; @@ -65,36 +73,47 @@ protected SlackNotificationService(SeyrenConfig seyrenConfig, String baseUrl) { } @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(); - - List emojis = Lists.newArrayList( - Splitter.on(',').omitEmptyStrings().trimResults().split(seyrenConfig.getSlackEmojis()) - ); + public boolean canHandle(SubscriptionType subscriptionType) { + return subscriptionType == SubscriptionType.SLACK; + } - String url = String.format("%s/api/chat.postMessage", baseUrl); + @Override + public void sendNotification(Check check, Subscription subscription, List alerts) throws NotificationFailedException { + String token = seyrenConfig.getSlackToken(); + String webhookUrl = seyrenConfig.getSlackWebhook(); + + String url; + HttpEntity entity; + + if (!webhookUrl.isEmpty()) { + LOGGER.debug("Publishing notification using configured Webhook"); + url = webhookUrl; + try { + entity = createJsonEntity(check, subscription, alerts); + } catch (JsonProcessingException e) { + throw new NotificationFailedException("Failed to serialize message alert.", e); + } + } else if (!token.isEmpty()){ + LOGGER.debug("Publishing notification using slack web API"); + url = String.format("%s/api/chat.postMessage", baseUrl); + try { + entity = createFormEntity(check, subscription, alerts); + } catch (UnsupportedEncodingException e) { + throw new NotificationFailedException("Failed to serialize alert.", e); + } + } else { + LOGGER.warn("No SLACK_WEBHOOK_URL or SLACK_TOKEN set. Cannot notify slack."); + return; + } + 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); - } + post.setEntity(entity); 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) { @@ -103,36 +122,51 @@ public void sendNotification(Check check, Subscription subscription, List post.releaseConnection(); HttpClientUtils.closeQuietly(client); } - } - - @Override - public boolean canHandle(SubscriptionType subscriptionType) { - return subscriptionType == SubscriptionType.SLACK; + + private HttpEntity createJsonEntity(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", formatForWebhook(check, subscription, alerts)); + payload.put("icon_url", seyrenConfig.getSlackIconUrl()); + + String message = new ObjectMapper().writeValueAsString(payload); + + if (LOGGER.isDebugEnabled()) { + LOGGER.info("> message: {}", message); + } + + return new StringEntity(message, ContentType.APPLICATION_JSON); } - 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 HttpEntity createFormEntity(Check check, Subscription subscription, List alerts) throws UnsupportedEncodingException { + List parameters = new ArrayList(); + parameters.add(new BasicNameValuePair("token", seyrenConfig.getSlackToken())); + parameters.add(new BasicNameValuePair("channel", StringUtils.removeEnd(subscription.getTarget(), "!"))); + parameters.add(new BasicNameValuePair("text", formatForWebApi(check, subscription, alerts))); + parameters.add(new BasicNameValuePair("username", seyrenConfig.getSlackUsername())); + parameters.add(new BasicNameValuePair("icon_url", seyrenConfig.getSlackIconUrl())); + + if (LOGGER.isDebugEnabled()) { + LOGGER.info("> parameters: {}", parameters); + } + + return new UrlEncodedFormEntity(parameters); + } + + private String formatForWebApi(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", - Iterables.get(emojis, check.getState().ordinal(), ""), + Iterables.get(extractEmojis(), check.getState().ordinal(), ""), state, check.getName(), url, @@ -142,4 +176,54 @@ public String apply(Alert input) { channel ); } + + private String formatForWebhook(Check check, Subscription subscription, List alerts) { + String url = formatCheckUrl(check); + String alertsString = formatAlert(alerts); + + String description = formatDescription(check); + + final String state = check.getState().toString(); + + return String.format("%s *%s* %s (<%s|Open>)%s\n```\n%s\n```", + Iterables.get(extractEmojis(), check.getState().ordinal(), ""), + state, + check.getName(), + url, + description, + alertsString + ); + } + + 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; + } } 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 00190953..17282f62 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 @@ -13,25 +13,38 @@ */ package com.seyren.core.service.notification; -import static com.github.restdriver.clientdriver.RestClientDriver.*; -import static org.hamcrest.Matchers.*; -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; - +import static com.github.restdriver.clientdriver.RestClientDriver.giveEmptyResponse; +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.mock; +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.hamcrest.Matchers; +import org.apache.commons.lang.StringUtils; import org.joda.time.DateTime; import org.junit.After; import org.junit.Before; 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; @@ -43,6 +56,12 @@ import com.seyren.core.util.config.SeyrenConfig; public class SlackNotificationServiceTest { + 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; private SeyrenConfig mockSeyrenConfig; @@ -55,8 +74,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("Seyren"); + when(mockSeyrenConfig.getSlackUsername()).thenReturn(SLACK_USERNAME); notificationService = new SlackNotificationService(mockSeyrenConfig, clientDriver.getBaseUrl()); } @@ -66,7 +84,7 @@ public void after() { } @Test - public void notifcationServiceCanOnlyHandleSlackSubscription() { + public void notificationServiceCanOnlyHandleSlackSubscription() { assertThat(notificationService.canHandle(SubscriptionType.SLACK), is(true)); for (SubscriptionType type : SubscriptionType.values()) { if (type == SubscriptionType.SLACK) { @@ -77,23 +95,15 @@ public void notifcationServiceCanOnlyHandleSlackSubscription() { } @Test - public void basicSlackTest() { - BigDecimal value = new BigDecimal("1.0"); + public void useSlackApiTokenTest() { + // Given + when(mockSeyrenConfig.getSlackToken()).thenReturn(SLACK_TOKEN); + when(mockSeyrenConfig.getSlackWebhook()).thenReturn(""); + + Check check = givenCheck(); + Subscription subscription = givenSlackSubscriptionWithTarget("target"); + Alert alert = givenAlert(); - Check check = new Check() - .withId("123") - .withEnabled(true) - .withName("test-check") - .withState(AlertType.ERROR); - Subscription subscription = new Subscription() - .withEnabled(true) - .withType(SubscriptionType.SLACK) - .withTarget("target"); - Alert alert = new Alert() - .withValue(value) - .withTimestamp(new DateTime()) - .withFromType(AlertType.OK) - .withToType(AlertType.ERROR); List alerts = Arrays.asList(alert); StringBodyCapture bodyCapture = new StringBodyCapture(); @@ -105,44 +115,28 @@ public void basicSlackTest() { .withHeader("accept", "application/json"), giveEmptyResponse()); + // When notificationService.sendNotification(check, subscription, alerts); + // Then String content = bodyCapture.getContent(); - System.out.println(decode(content)); - - assertThat(content, Matchers.containsString("token=")); - assertThat(content, Matchers.containsString("&channel=target")); - assertThat(content, not(Matchers.containsString(encode("")))); - assertThat(content, Matchers.containsString(encode("*ERROR* test-check"))); - assertThat(content, Matchers.containsString(encode("/#/checks/123"))); - assertThat(content, Matchers.containsString("&username=Seyren")); - assertThat(content, Matchers.containsString("&icon_url=")); - - verify(mockSeyrenConfig).getSlackEmojis(); - verify(mockSeyrenConfig).getSlackIconUrl(); - verify(mockSeyrenConfig).getSlackToken(); - verify(mockSeyrenConfig).getSlackUsername(); - verify(mockSeyrenConfig).getBaseUrl(); + //System.out.println(decode(content)); + + assertContent(content, check, subscription); + assertThat(content, containsString("&channel=" + subscription.getTarget())); + assertThat(content, not(containsString(encode("")))); } @Test public void mentionChannelWhenTargetContainsExclamationTest() { - BigDecimal value = new BigDecimal("1.0"); + //Given + when(mockSeyrenConfig.getSlackToken()).thenReturn(SLACK_TOKEN); + when(mockSeyrenConfig.getSlackWebhook()).thenReturn(""); + + Check check = givenCheck(); + Subscription subscription = givenSlackSubscriptionWithTarget("target!"); + Alert alert = givenAlert(); - Check check = new Check() - .withId("123") - .withEnabled(true) - .withName("test-check") - .withState(AlertType.ERROR); - Subscription subscription = new Subscription() - .withEnabled(true) - .withType(SubscriptionType.SLACK) - .withTarget("target!"); - Alert alert = new Alert() - .withValue(value) - .withTimestamp(new DateTime()) - .withFromType(AlertType.OK) - .withToType(AlertType.ERROR); List alerts = Arrays.asList(alert); StringBodyCapture bodyCapture = new StringBodyCapture(); @@ -154,29 +148,97 @@ public void mentionChannelWhenTargetContainsExclamationTest() { .withHeader("accept", "application/json"), giveEmptyResponse()); + // When notificationService.sendNotification(check, subscription, alerts); + // Then String content = bodyCapture.getContent(); - System.out.println(decode(content)); - - assertThat(content, Matchers.containsString("token=")); - assertThat(content, Matchers.containsString("&channel=target")); - assertThat(content, Matchers.containsString(encode(""))); - assertThat(content, Matchers.containsString(encode("*ERROR* test-check"))); - assertThat(content, Matchers.containsString(encode("/#/checks/123"))); - assertThat(content, Matchers.containsString("&username=Seyren")); - assertThat(content, Matchers.containsString("&icon_url=")); - - verify(mockSeyrenConfig).getSlackEmojis(); - verify(mockSeyrenConfig).getSlackIconUrl(); - verify(mockSeyrenConfig).getSlackToken(); - verify(mockSeyrenConfig).getSlackUsername(); - verify(mockSeyrenConfig).getBaseUrl(); + //System.out.println(decode(content)); + + assertContent(content, check, subscription); + assertThat(content, containsString("&channel=" + StringUtils.removeEnd(subscription.getTarget(), "!"))); + assertThat(content, containsString(encode(""))); + } + + @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()); + } + + Check givenCheck() { + Check check = new Check() + .withId("123") + .withEnabled(true) + .withName("test-check") + .withState(AlertType.ERROR); + return check; + } + + Subscription givenSlackSubscriptionWithTarget(String target) { + Subscription subscription = new Subscription() + .withEnabled(true) + .withType(SubscriptionType.SLACK) + .withTarget(target); + return subscription; + } + + Alert givenAlert() { + Alert alert = new Alert() + .withValue(new BigDecimal("1.0")) + .withTimestamp(new DateTime()) + .withFromType(AlertType.OK) + .withToType(AlertType.ERROR); + return alert; + } + + private void assertContent(String content, Check check, Subscription subscription) { + 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=" + SLACK_USERNAME)); + assertThat(content, containsString("&icon_url=")); } String encode(String data) { try { - return URLEncoder.encode(data, "ISO-8859-1"); + return URLEncoder.encode(data, CONTENT_ENCODING); } catch (UnsupportedEncodingException e) { return null; } @@ -184,7 +246,7 @@ String encode(String data) { String decode(String data) { try { - return URLDecoder.decode(data, "ISO-8859-1"); + return URLDecoder.decode(data, CONTENT_ENCODING); } catch (UnsupportedEncodingException e) { return null; }