From 992a7af1265fa67509a8a93dc2252d45175afaaf Mon Sep 17 00:00:00 2001 From: Alexander Reelsen Date: Thu, 11 Jan 2018 11:43:24 +0100 Subject: [PATCH] Watcher: Add support for actions in slack attachments (elastic/x-pack-elasticsearch#3355) In order to support buttons that can be clicked on within a slack message, this commits adds support for so called actions within attachments. This allows to create buttons, that are clicked and execute a GET request, so actions must be idempotent according to the official slack documentation. Official slack documentation is available at https://api.slack.com/docs/message-attachments#action_fields Original commit: elastic/x-pack-elasticsearch@29ddc90b01569623070459932b0dd9df3b67f215 --- .../notification/slack/message/Action.java | 165 ++++++++++++++++++ .../slack/message/Attachment.java | 74 ++++++-- .../slack/message/SlackMessageTests.java | 68 +++++++- .../test/integration/SlackServiceTests.java | 10 +- 4 files changed, 294 insertions(+), 23 deletions(-) create mode 100644 plugin/src/main/java/org/elasticsearch/xpack/watcher/notification/slack/message/Action.java diff --git a/plugin/src/main/java/org/elasticsearch/xpack/watcher/notification/slack/message/Action.java b/plugin/src/main/java/org/elasticsearch/xpack/watcher/notification/slack/message/Action.java new file mode 100644 index 00000000000..1976828ad71 --- /dev/null +++ b/plugin/src/main/java/org/elasticsearch/xpack/watcher/notification/slack/message/Action.java @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.watcher.notification.slack.message; + +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.xcontent.ObjectParser; +import org.elasticsearch.common.xcontent.ObjectParser.ValueType; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.xpack.watcher.common.text.TextTemplate; +import org.elasticsearch.xpack.watcher.common.text.TextTemplateEngine; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +public class Action implements MessageElement { + + static final ObjectParser ACTION_PARSER = new ObjectParser<>("action", Template::new); + static { + ACTION_PARSER.declareField(Template::setType, (p, c) -> new TextTemplate(p.text()), new ParseField("type"), ValueType.STRING); + ACTION_PARSER.declareField(Template::setUrl, (p, c) -> new TextTemplate(p.text()), new ParseField("url"), ValueType.STRING); + ACTION_PARSER.declareField(Template::setText, (p, c) -> new TextTemplate(p.text()), new ParseField("text"), ValueType.STRING); + ACTION_PARSER.declareField(Template::setStyle, (p, c) -> new TextTemplate(p.text()), new ParseField("style"), ValueType.STRING); + ACTION_PARSER.declareField(Template::setName, (p, c) -> new TextTemplate(p.text()), new ParseField("name"), ValueType.STRING); + } + + private static final ParseField URL = new ParseField("url"); + private static final ParseField TYPE = new ParseField("type"); + private static final ParseField TEXT = new ParseField("text"); + private static final ParseField STYLE = new ParseField("style"); + private static final ParseField NAME = new ParseField("name"); + + private String style; + private String name; + private String type; + private String text; + private String url; + + public Action() { + } + + public Action(String style, String name, String type, String text, String url) { + this.style = style; + this.name = name; + this.type = type; + this.text = text; + this.url = url; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Action template = (Action) o; + + return Objects.equals(style, template.style) && Objects.equals(type, template.type) && Objects.equals(url, template.url) + && Objects.equals(text, template.text) && Objects.equals(name, template.name); + } + + @Override + public int hashCode() { + return Objects.hash(style, type, url, name, text); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject() + .field(NAME.getPreferredName(), name) + .field(STYLE.getPreferredName(), style) + .field(TYPE.getPreferredName(), type) + .field(TEXT.getPreferredName(), text) + .field(URL.getPreferredName(), url) + .endObject(); + } + + static class Template implements ToXContent { + + private TextTemplate type; + private TextTemplate name; + private TextTemplate text; + private TextTemplate url; + private TextTemplate style; + + public Action render(TextTemplateEngine engine, Map model) { + String style = engine.render(this.style, model); + String type = engine.render(this.type, model); + String url = engine.render(this.url, model); + String name = engine.render(this.name, model); + String text = engine.render(this.text, model); + return new Action(style, name, type, text, url); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Template template = (Template) o; + + return Objects.equals(style, template.style) && Objects.equals(type, template.type) && Objects.equals(url, template.url) + && Objects.equals(text, template.text) && Objects.equals(name, template.name); + } + + @Override + public int hashCode() { + return Objects.hash(style, type, url, name, text); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject() + .field(NAME.getPreferredName(), name) + .field(STYLE.getPreferredName(), style) + .field(TYPE.getPreferredName(), type) + .field(TEXT.getPreferredName(), text) + .field(URL.getPreferredName(), url) + .endObject(); + } + + public TextTemplate getType() { + return type; + } + + public void setType(TextTemplate type) { + this.type = type; + } + + public TextTemplate getName() { + return name; + } + + public void setName(TextTemplate name) { + this.name = name; + } + + public TextTemplate getText() { + return text; + } + + public void setText(TextTemplate text) { + this.text = text; + } + + public TextTemplate getUrl() { + return url; + } + + public void setUrl(TextTemplate url) { + this.url = url; + } + + public TextTemplate getStyle() { + return style; + } + + public void setStyle(TextTemplate style) { + this.style = style; + } + } +} diff --git a/plugin/src/main/java/org/elasticsearch/xpack/watcher/notification/slack/message/Attachment.java b/plugin/src/main/java/org/elasticsearch/xpack/watcher/notification/slack/message/Attachment.java index 9eef42e0577..09eb568f857 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/watcher/notification/slack/message/Attachment.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/watcher/notification/slack/message/Attachment.java @@ -35,10 +35,11 @@ public class Attachment implements MessageElement { final String imageUrl; final String thumbUrl; final String[] markdownSupportedFields; + final List actions; public Attachment(String fallback, String color, String pretext, String authorName, String authorLink, String authorIcon, String title, String titleLink, String text, Field[] fields, - String imageUrl, String thumbUrl, String[] markdownSupportedFields) { + String imageUrl, String thumbUrl, String[] markdownSupportedFields, List actions) { this.fallback = fallback; this.color = color; @@ -53,6 +54,7 @@ public class Attachment implements MessageElement { this.imageUrl = imageUrl; this.thumbUrl = thumbUrl; this.markdownSupportedFields = markdownSupportedFields; + this.actions = actions; } @Override @@ -66,14 +68,14 @@ public class Attachment implements MessageElement { Objects.equals(authorLink, that.authorLink) && Objects.equals(authorIcon, that.authorIcon) && Objects.equals(title, that.title) && Objects.equals(titleLink, that.titleLink) && Objects.equals(text, that.text) && Objects.equals(imageUrl, that.imageUrl) && - Objects.equals(thumbUrl, that.thumbUrl) && + Objects.equals(thumbUrl, that.thumbUrl) && Objects.equals(actions, that.actions) && Arrays.equals(markdownSupportedFields, that.markdownSupportedFields) && Arrays.equals(fields, that.fields); } @Override public int hashCode() { return Objects.hash(fallback, color, pretext, authorName, authorLink, authorIcon, title, titleLink, text, fields, imageUrl, - thumbUrl, markdownSupportedFields); + thumbUrl, markdownSupportedFields, actions); } /** @@ -131,6 +133,13 @@ public class Attachment implements MessageElement { } builder.endArray(); } + if (actions != null && actions.isEmpty() == false) { + builder.startArray("actions"); + for (Action action : actions) { + action.toXContent(builder, params); + } + builder.endArray(); + } return builder.endObject(); } @@ -150,12 +159,12 @@ public class Attachment implements MessageElement { final TextTemplate imageUrl; final TextTemplate thumbUrl; final TextTemplate[] markdownSupportedFields; - + final List actions; Template(TextTemplate fallback, TextTemplate color, TextTemplate pretext, TextTemplate authorName, TextTemplate authorLink, TextTemplate authorIcon, TextTemplate title, TextTemplate titleLink, TextTemplate text, Field.Template[] fields, TextTemplate imageUrl, TextTemplate thumbUrl, - TextTemplate[] markdownSupportedFields) { + TextTemplate[] markdownSupportedFields, List actions) { this.fallback = fallback; this.color = color; @@ -170,6 +179,7 @@ public class Attachment implements MessageElement { this.imageUrl = imageUrl; this.thumbUrl = thumbUrl; this.markdownSupportedFields = markdownSupportedFields; + this.actions = actions; } public Attachment render(TextTemplateEngine engine, Map model, SlackMessageDefaults.AttachmentDefaults defaults) { @@ -198,8 +208,15 @@ public class Attachment implements MessageElement { markdownFields[i] = engine.render(this.markdownSupportedFields[i], model); } } + List actions = new ArrayList<>(); + if (this.actions != null && this.actions.isEmpty() == false) { + for (Action.Template action : this.actions) { + actions.add(action.render(engine, model)); + } + } + return new Attachment(fallback, color, pretext, authorName, authorLink, authorIcon, title, titleLink, text, fields, imageUrl, - thumbUrl, markdownFields); + thumbUrl, markdownFields, actions); } @Override @@ -209,20 +226,20 @@ public class Attachment implements MessageElement { Template template = (Template) o; - return Objects.equals(fallback, template.fallback) && Objects.equals(color, template.color) - && Objects.equals(pretext, template.pretext) && Objects.equals(authorName, template.authorName) - && Objects.equals(authorLink, template.authorLink) && Objects.equals(authorIcon, template.authorIcon) - && Objects.equals(title, template.title) && Objects.equals(titleLink, template.titleLink) - && Objects.equals(text, template.text) && Objects.equals(imageUrl, template.imageUrl) - && Objects.equals(thumbUrl, template.thumbUrl) - && Arrays.equals(fields, template.fields) - && Arrays.equals(markdownSupportedFields, template.markdownSupportedFields); + return Objects.equals(fallback, template.fallback) && Objects.equals(color, template.color) && + Objects.equals(pretext, template.pretext) && Objects.equals(authorName, template.authorName) && + Objects.equals(authorLink, template.authorLink) && Objects.equals(authorIcon, template.authorIcon) && + Objects.equals(title, template.title) && Objects.equals(titleLink, template.titleLink) && + Objects.equals(text, template.text) && Objects.equals(imageUrl, template.imageUrl) && + Objects.equals(thumbUrl, template.thumbUrl) && Objects.equals(actions, template.actions) && + Arrays.equals(fields, template.fields) && + Arrays.equals(markdownSupportedFields, template.markdownSupportedFields); } @Override public int hashCode() { return Objects.hash(fallback, color, pretext, authorName, authorLink, authorIcon, title, titleLink, text, fields, imageUrl, - thumbUrl, markdownSupportedFields); + thumbUrl, markdownSupportedFields, actions); } @Override @@ -275,6 +292,13 @@ public class Attachment implements MessageElement { } builder.endArray(); } + if (actions != null && actions.isEmpty() == false) { + builder.startArray(XField.ACTIONS.getPreferredName()); + for (Action.Template action : actions) { + action.toXContent(builder, params); + } + builder.endArray(); + } return builder.endObject(); } @@ -294,6 +318,7 @@ public class Attachment implements MessageElement { TextTemplate imageUrl = null; TextTemplate thumbUrl = null; TextTemplate[] markdownFields = null; + List actions = new ArrayList<>(); XContentParser.Token token = null; String currentFieldName = null; @@ -411,12 +436,16 @@ public class Attachment implements MessageElement { markdownFields = list.toArray(new TextTemplate[list.size()]); } else { try { - markdownFields = new TextTemplate[]{ new TextTemplate(parser.text())}; + markdownFields = new TextTemplate[]{new TextTemplate(parser.text())}; } catch (ElasticsearchParseException pe) { throw new ElasticsearchParseException("could not parse message attachment. failed to parse [{}] field", pe, XField.MARKDOWN_IN); } } + } else if (XField.ACTIONS.match(currentFieldName)) { + if (token == XContentParser.Token.START_OBJECT) { + actions.add(Action.ACTION_PARSER.parse(parser, null)); + } } else { throw new ElasticsearchParseException("could not parse message attachment field. unexpected field [{}]", currentFieldName); @@ -439,7 +468,7 @@ public class Attachment implements MessageElement { } } return new Template(fallback, color, pretext, authorName, authorLink, authorIcon, title, titleLink, text, fields, imageUrl, - thumbUrl, markdownFields); + thumbUrl, markdownFields, actions); } @@ -462,6 +491,7 @@ public class Attachment implements MessageElement { private TextTemplate imageUrl; private TextTemplate thumbUrl; private List markdownFields = new ArrayList<>(); + private List actions = new ArrayList<>(); private Builder() { } @@ -579,12 +609,17 @@ public class Attachment implements MessageElement { return this; } + public Builder addAction(Action.Template action) { + this.actions.add(action); + return this; + } + public Template build() { Field.Template[] fields = this.fields.isEmpty() ? null : this.fields.toArray(new Field.Template[this.fields.size()]); TextTemplate[] markdownFields = this.markdownFields.isEmpty() ? null : this.markdownFields.toArray(new TextTemplate[this.markdownFields.size()]); - return new Template(fallback, color, pretext, authorName, authorLink, authorIcon, title, titleLink, text, fields, - imageUrl, thumbUrl, markdownFields); + return new Template(fallback, color, pretext, authorName, authorLink, authorIcon, title, titleLink, text, fields, imageUrl, + thumbUrl, markdownFields, actions); } } } @@ -603,5 +638,6 @@ public class Attachment implements MessageElement { ParseField THUMB_URL = new ParseField("thumb_url"); ParseField MARKDOWN_IN = new ParseField("mrkdwn_in"); + ParseField ACTIONS = new ParseField("actions"); } } diff --git a/plugin/src/test/java/org/elasticsearch/xpack/watcher/notification/slack/message/SlackMessageTests.java b/plugin/src/test/java/org/elasticsearch/xpack/watcher/notification/slack/message/SlackMessageTests.java index ba335f2ab2c..5fda793b404 100644 --- a/plugin/src/test/java/org/elasticsearch/xpack/watcher/notification/slack/message/SlackMessageTests.java +++ b/plugin/src/test/java/org/elasticsearch/xpack/watcher/notification/slack/message/SlackMessageTests.java @@ -61,8 +61,12 @@ public class SlackMessageTests extends ESTestCase { String imageUrl = randomBoolean() ? null : randomAlphaOfLength(10); String thumbUrl = randomBoolean() ? null : randomAlphaOfLength(10); String[] markdownFields = randomBoolean() ? null : new String[]{"pretext"}; + List actions = new ArrayList<>(); + if (randomBoolean()) { + actions.add(new Action("primary", "action_name", "button", "action_text", "https://elastic.co")); + } attachments[i] = new Attachment(fallback, color, pretext, authorName, authorLink, authorIcon, title, titleLink, - attachmentText, fields, imageUrl, thumbUrl, markdownFields); + attachmentText, fields, imageUrl, thumbUrl, markdownFields, actions); } } @@ -102,6 +106,13 @@ public class SlackMessageTests extends ESTestCase { } builder.endArray(); } + if (attachment.actions.isEmpty() == false) { + builder.startArray("actions"); + for (Action action : attachment.actions) { + action.toXContent(builder, ToXContent.EMPTY_PARAMS); + } + builder.endArray(); + } builder.endObject(); } builder.endArray(); @@ -159,6 +170,7 @@ public class SlackMessageTests extends ESTestCase { String imageUrl = null; String thumbUrl = null; String[] markdownSupportedFields = null; + List actions = new ArrayList<>(); while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { if (token == XContentParser.Token.FIELD_NAME) { currentFieldName = parser.currentName(); @@ -201,6 +213,36 @@ public class SlackMessageTests extends ESTestCase { fieldList.add(new Field(fieldTitle, fieldValue, isShort)); } fields = fieldList.toArray(new Field[fieldList.size()]); + } else if ("actions".equals(currentFieldName)) { + MockTextTemplateEngine engine = new MockTextTemplateEngine(); + while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) { + Action.Template action = new Action.Template(); + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token.isValue()) { + switch (currentFieldName) { + case "url": + action.setUrl(new TextTemplate(parser.text())); + break; + case "name": + action.setName(new TextTemplate(parser.text())); + break; + case "style": + action.setStyle(new TextTemplate(parser.text())); + break; + case "text": + action.setText(new TextTemplate(parser.text())); + break; + case "type": + action.setType(new TextTemplate(parser.text())); + break; + } + } + + } + actions.add(action.render(engine, Collections.emptyMap())); + } } else if ("image_url".equals(currentFieldName)) { imageUrl = parser.text(); } else if ("thumb_url".equals(currentFieldName)) { @@ -214,7 +256,7 @@ public class SlackMessageTests extends ESTestCase { } } list.add(new Attachment(fallback, color, pretext, authorName, authorLink, authorIcon, title, titleLink, - attachmentText, fields, imageUrl, thumbUrl, markdownSupportedFields)); + attachmentText, fields, imageUrl, thumbUrl, markdownSupportedFields, actions)); } attachments = list.toArray(new Attachment[list.size()]); } @@ -345,9 +387,29 @@ public class SlackMessageTests extends ESTestCase { jsonBuilder.endArray(); markdownSupportedFields = new TextTemplate[] { new TextTemplate("pretext") }; } + List actions = new ArrayList<>(); + if (randomBoolean()) { + jsonBuilder.startArray("actions"); + jsonBuilder.startObject(); + jsonBuilder.field("type", "button"); + jsonBuilder.field("text", "My text"); + jsonBuilder.field("url", "https://elastic.co"); + String style = randomFrom("primary", "danger"); + jsonBuilder.field("style", style); + jsonBuilder.field("name", "somebuttonparty"); + jsonBuilder.endObject(); + jsonBuilder.endArray(); + Action.Template action = new Action.Template(); + action.setName(new TextTemplate("somebuttonparty")); + action.setStyle(new TextTemplate(style)); + action.setText(new TextTemplate("My text")); + action.setType(new TextTemplate("button")); + action.setUrl(new TextTemplate("https://elastic.co")); + actions.add(action); + } jsonBuilder.endObject(); attachments[i] = new Attachment.Template(fallback, color, pretext, authorName, authorLink, authorIcon, title, - titleLink, attachmentText, fields, imageUrl, thumbUrl, markdownSupportedFields); + titleLink, attachmentText, fields, imageUrl, thumbUrl, markdownSupportedFields, actions); } jsonBuilder.endArray(); } diff --git a/plugin/src/test/java/org/elasticsearch/xpack/watcher/test/integration/SlackServiceTests.java b/plugin/src/test/java/org/elasticsearch/xpack/watcher/test/integration/SlackServiceTests.java index 8f88767e76e..01b34fdd4a6 100644 --- a/plugin/src/test/java/org/elasticsearch/xpack/watcher/test/integration/SlackServiceTests.java +++ b/plugin/src/test/java/org/elasticsearch/xpack/watcher/test/integration/SlackServiceTests.java @@ -17,6 +17,7 @@ import org.elasticsearch.xpack.watcher.condition.InternalAlwaysCondition; import org.elasticsearch.xpack.watcher.notification.slack.SentMessages; import org.elasticsearch.xpack.watcher.notification.slack.SlackAccount; import org.elasticsearch.xpack.watcher.notification.slack.SlackService; +import org.elasticsearch.xpack.watcher.notification.slack.message.Action; import org.elasticsearch.xpack.watcher.notification.slack.message.Attachment; import org.elasticsearch.xpack.watcher.notification.slack.message.SlackMessage; import org.elasticsearch.xpack.watcher.support.xcontent.XContentSource; @@ -24,6 +25,8 @@ import org.elasticsearch.xpack.watcher.test.AbstractWatcherIntegrationTestCase; import org.elasticsearch.xpack.watcher.transport.actions.put.PutWatchResponse; import org.joda.time.DateTime; +import java.util.Collections; +import java.util.List; import java.util.Locale; import static org.elasticsearch.common.xcontent.ToXContent.EMPTY_PARAMS; @@ -54,9 +57,14 @@ public class SlackServiceTests extends AbstractWatcherIntegrationTestCase { public void testSendMessage() throws Exception { SlackService service = getInstanceFromMaster(SlackService.class); + + // String style, String name, String type, String text, String url + Action action = new Action(randomFrom("primary", "danger"), "action name", "button", "Click here to visit Elastic Homepage", + "https://elastic.co"); + List actions = randomBoolean() ? null : Collections.singletonList(action); Attachment[] attachments = new Attachment[] { new Attachment("fallback", randomFrom("good", "warning", "danger"), "pretext `code` *bold*", "author_name", null, null, - "title あいうえお", null, "_text `code` *bold*", null, null, null, new String[] { "text", "pretext" }) + "title あいうえお", null, "_text `code` *bold*", null, null, null, new String[] { "text", "pretext" }, actions) }; SlackMessage message = new SlackMessage( "SlackServiceTests",