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@29ddc90b01
This commit is contained in:
Alexander Reelsen 2018-01-11 11:43:24 +01:00 committed by GitHub
parent 3fc17ab918
commit 992a7af126
4 changed files with 294 additions and 23 deletions

View File

@ -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<Template, Void> 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<String, Object> 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;
}
}
}

View File

@ -35,10 +35,11 @@ public class Attachment implements MessageElement {
final String imageUrl;
final String thumbUrl;
final String[] markdownSupportedFields;
final List<Action> 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<Action> 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<Action.Template> 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<Action.Template> 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<String, Object> model, SlackMessageDefaults.AttachmentDefaults defaults) {
@ -198,8 +208,15 @@ public class Attachment implements MessageElement {
markdownFields[i] = engine.render(this.markdownSupportedFields[i], model);
}
}
List<Action> 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<Action.Template> 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<TextTemplate> markdownFields = new ArrayList<>();
private List<Action.Template> 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");
}
}

View File

@ -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<Action> 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<Action> 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<Action.Template> 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();
}

View File

@ -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<Action> 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",