From 7f73c60f6a2b8c2d7c4d8df770f1eb74e896314f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 19 Mar 2020 12:41:19 +0900 Subject: [PATCH] Add support for response_urls in modal --- .../java/samples/AttachmentActionSample.java | 4 +- .../src/test/java/samples/DialogSample.java | 2 +- .../java/samples/MessageShortcutSample.java | 2 +- .../samples/ViewResponseUrlEnabledSample.java | 60 +++++++ .../src/test/java/samples/ViewSample.java | 2 +- .../src/test/java/util/ResourceLoader.java | 4 +- .../resources/action_response/message1.json | 0 .../test/resources/attachments/message1.json | 0 .../src/test/resources/dialogs/dialog.json | 0 .../src/test/resources/views/view1.json | 0 .../src/test/resources/views/view2.json | 0 .../src/test/resources/views/view3.json | 0 .../src/test/resources/views/view4.json | 64 +++++++ .../context/InputBlockRespondUtility.java | 46 +++++ .../builtin/ViewSubmissionContext.java | 40 ++++- .../builtin/GlobalShortcutHandler.java | 2 - .../builtin/ViewSubmissionRequest.java | 23 ++- .../slack/api/bolt/response/Responder.java | 19 +- .../context/ViewSubmissionContextTest.java | 114 ++++++++++++ .../request/ViewSubmissionRequestTest.java | 170 ++++++++++++++++++ .../test_locally/response/ResponderTest.java | 4 + .../views/InputBlockResponseSender.java | 59 ++++++ .../views/payload/ViewSubmissionPayload.java | 16 ++ .../views/response/InputBlockResponse.java | 21 +++ .../views/InputBlockResponseSenderTest.java | 98 ++++++++++ .../{payload => }/ViewClosedPayloadTest.java | 2 +- .../ViewSubmissionPayloadTest.java | 2 +- 27 files changed, 736 insertions(+), 18 deletions(-) create mode 100644 bolt-servlet/src/test/java/samples/ViewResponseUrlEnabledSample.java rename {bolt => bolt-servlet}/src/test/resources/action_response/message1.json (100%) rename {bolt => bolt-servlet}/src/test/resources/attachments/message1.json (100%) rename {bolt => bolt-servlet}/src/test/resources/dialogs/dialog.json (100%) rename {bolt => bolt-servlet}/src/test/resources/views/view1.json (100%) rename {bolt => bolt-servlet}/src/test/resources/views/view2.json (100%) rename {bolt => bolt-servlet}/src/test/resources/views/view3.json (100%) create mode 100644 bolt-servlet/src/test/resources/views/view4.json create mode 100644 bolt/src/main/java/com/slack/api/bolt/context/InputBlockRespondUtility.java create mode 100644 bolt/src/test/java/test_locally/request/ViewSubmissionRequestTest.java create mode 100644 slack-app-backend/src/main/java/com/slack/api/app_backend/views/InputBlockResponseSender.java create mode 100644 slack-app-backend/src/main/java/com/slack/api/app_backend/views/response/InputBlockResponse.java create mode 100644 slack-app-backend/src/test/java/test_locally/app_backend/views/InputBlockResponseSenderTest.java rename slack-app-backend/src/test/java/test_locally/app_backend/views/{payload => }/ViewClosedPayloadTest.java (98%) rename slack-app-backend/src/test/java/test_locally/app_backend/views/{payload => }/ViewSubmissionPayloadTest.java (99%) diff --git a/bolt-servlet/src/test/java/samples/AttachmentActionSample.java b/bolt-servlet/src/test/java/samples/AttachmentActionSample.java index e743e1c11..89058ba74 100644 --- a/bolt-servlet/src/test/java/samples/AttachmentActionSample.java +++ b/bolt-servlet/src/test/java/samples/AttachmentActionSample.java @@ -14,14 +14,14 @@ public static void main(String[] args) throws Exception { AppConfig config = ResourceLoader.loadAppConfig(); App app = new App(config); - // https://github.com/slackapi/java-slack-sdk/blob/master/bolt/src/test/resources/attachments/message1.json + // src/test/resources/attachments/message1.json String firstMessage = ResourceLoader.load("attachments/message1.json"); app.command("/view", (req, ctx) -> { ctx.respond(JsonOps.fromJson(firstMessage, SlashCommandResponse.class)); return ctx.ack(); }); - // https://github.com/slackapi/java-slack-sdk/blob/master/bolt/src/test/resources/action_response/message1.json + // src/test/resources/action_response/message1.json String secondMessage = ResourceLoader.load("action_response/message1.json"); app.attachmentAction("wopr_game", (req, ctx) -> { ctx.logger.info("attachment action - {}", req.getPayload()); diff --git a/bolt-servlet/src/test/java/samples/DialogSample.java b/bolt-servlet/src/test/java/samples/DialogSample.java index 12c62fde0..f196b1f04 100644 --- a/bolt-servlet/src/test/java/samples/DialogSample.java +++ b/bolt-servlet/src/test/java/samples/DialogSample.java @@ -28,7 +28,7 @@ public static void main(String[] args) throws Exception { App app = new App(config); app.command("/dialog", (req, ctx) -> { - // https://github.com/slackapi/java-slack-sdk/blob/master/bolt/src/test/resources/dialogs/dialog.json + // src/test/resources/dialogs/dialog.json String dialog = ResourceLoader.load("dialogs/dialog.json"); DialogOpenResponse apiResponse = ctx.client().dialogOpen(r -> r .triggerId(req.getPayload().getTriggerId()) diff --git a/bolt-servlet/src/test/java/samples/MessageShortcutSample.java b/bolt-servlet/src/test/java/samples/MessageShortcutSample.java index 5eb5cb2e9..cd92e0287 100644 --- a/bolt-servlet/src/test/java/samples/MessageShortcutSample.java +++ b/bolt-servlet/src/test/java/samples/MessageShortcutSample.java @@ -17,7 +17,7 @@ public static void main(String[] args) throws Exception { // https://api.slack.com/apps/{your apiAppId}/interactive-messages app.messageShortcut("open-view", (req, ctx) -> { - // https://github.com/slackapi/java-slack-sdk/blob/master/bolt/src/test/resources/views/view3.json + // src/test/resources/views/view3.json String view = ResourceLoader.load("views/view3.json"); ChatGetPermalinkResponse permalinkResponse = ctx.client().chatGetPermalink(r -> r diff --git a/bolt-servlet/src/test/java/samples/ViewResponseUrlEnabledSample.java b/bolt-servlet/src/test/java/samples/ViewResponseUrlEnabledSample.java new file mode 100644 index 000000000..2ddb1f536 --- /dev/null +++ b/bolt-servlet/src/test/java/samples/ViewResponseUrlEnabledSample.java @@ -0,0 +1,60 @@ +package samples; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.response.Responder; +import com.slack.api.methods.response.views.ViewsOpenResponse; +import com.slack.api.model.view.View; +import util.ResourceLoader; +import util.TestSlackAppServer; + +import java.net.URLDecoder; + +public class ViewResponseUrlEnabledSample { + + public static void main(String[] args) throws Exception { + AppConfig config = ResourceLoader.loadAppConfig("appConfig_responseUrlEnabled.json"); + App app = new App(config); + app.use((req, resp, chain) -> { + String body = req.getRequestBodyAsString(); + String payload = body.startsWith("payload=") ? URLDecoder.decode(body.split("payload=")[1], "UTF-8") : body; + req.getContext().logger.info(payload); + return chain.next(req); + }); + + // src/test/resources + String view1 = ResourceLoader.load("views/view4.json"); + app.command("/view", (req, ctx) -> { + ViewsOpenResponse apiResponse = ctx.client().viewsOpen(r -> r + .triggerId(ctx.getTriggerId()) + .viewAsString(view1) + ); + if (apiResponse.isOk()) { + return ctx.ack(); + } else { + return ctx.ackWithJson(apiResponse); + } + }); + + app.viewSubmission("view-callback-id", (req, ctx) -> { + View view = req.getPayload().getView(); + ctx.logger.info("state - {}, private_metadata - {}", view.getState(), view.getPrivateMetadata()); + + ctx.respond("[ephemeral] Thank you for submitting the data!"); + ctx.respond(r -> r.responseType("in_channel").text("[in_channel] Thank you for submitting the data!")); + + ctx.getResponder("b1", "a").send("This message was sent via response_url given by channels_select block element (block_id: b1, action_id: a)"); + + String responseUrl = ctx.getResponseUrls().get(0).getResponseUrl(); + new Responder(ctx.getSlack(), responseUrl).sendFromModal(r -> r.text("Sent using a manually created responder")); + + return ctx.ack(); // just close the view + }); + + app.viewClosed("view-callback-id", (req, ctx) -> ctx.ack()); + + TestSlackAppServer server = new TestSlackAppServer(app); + server.start(); + } + +} diff --git a/bolt-servlet/src/test/java/samples/ViewSample.java b/bolt-servlet/src/test/java/samples/ViewSample.java index 403857a7d..0f47de1a9 100644 --- a/bolt-servlet/src/test/java/samples/ViewSample.java +++ b/bolt-servlet/src/test/java/samples/ViewSample.java @@ -31,7 +31,7 @@ public static void main(String[] args) throws Exception { AppConfig config = ResourceLoader.loadAppConfig(); App app = new App(config); - // https://github.com/slackapi/java-slack-sdk/blob/master/bolt/src/test/resources + // src/test/resources String view1 = ResourceLoader.load("views/view1.json"); app.command("/view", (req, ctx) -> { ViewsOpenResponse apiResponse = ctx.client().viewsOpen(r -> r diff --git a/bolt-servlet/src/test/java/util/ResourceLoader.java b/bolt-servlet/src/test/java/util/ResourceLoader.java index d009528b0..8cc9c0620 100644 --- a/bolt-servlet/src/test/java/util/ResourceLoader.java +++ b/bolt-servlet/src/test/java/util/ResourceLoader.java @@ -31,7 +31,9 @@ public static AppConfig loadAppConfig(String fileName) { String json = new BufferedReader(isr).lines().collect(joining()); JsonObject j = new Gson().fromJson(json, JsonElement.class).getAsJsonObject(); config.setSigningSecret(j.get("signingSecret").getAsString()); - config.setSingleTeamBotToken(j.get("singleTeamBotToken").getAsString()); + if (j.get("singleTeamBotToken") != null) { + config.setSingleTeamBotToken(j.get("singleTeamBotToken").getAsString()); + } } catch (IOException e) { log.error(e.getMessage(), e); } diff --git a/bolt/src/test/resources/action_response/message1.json b/bolt-servlet/src/test/resources/action_response/message1.json similarity index 100% rename from bolt/src/test/resources/action_response/message1.json rename to bolt-servlet/src/test/resources/action_response/message1.json diff --git a/bolt/src/test/resources/attachments/message1.json b/bolt-servlet/src/test/resources/attachments/message1.json similarity index 100% rename from bolt/src/test/resources/attachments/message1.json rename to bolt-servlet/src/test/resources/attachments/message1.json diff --git a/bolt/src/test/resources/dialogs/dialog.json b/bolt-servlet/src/test/resources/dialogs/dialog.json similarity index 100% rename from bolt/src/test/resources/dialogs/dialog.json rename to bolt-servlet/src/test/resources/dialogs/dialog.json diff --git a/bolt/src/test/resources/views/view1.json b/bolt-servlet/src/test/resources/views/view1.json similarity index 100% rename from bolt/src/test/resources/views/view1.json rename to bolt-servlet/src/test/resources/views/view1.json diff --git a/bolt/src/test/resources/views/view2.json b/bolt-servlet/src/test/resources/views/view2.json similarity index 100% rename from bolt/src/test/resources/views/view2.json rename to bolt-servlet/src/test/resources/views/view2.json diff --git a/bolt/src/test/resources/views/view3.json b/bolt-servlet/src/test/resources/views/view3.json similarity index 100% rename from bolt/src/test/resources/views/view3.json rename to bolt-servlet/src/test/resources/views/view3.json diff --git a/bolt-servlet/src/test/resources/views/view4.json b/bolt-servlet/src/test/resources/views/view4.json new file mode 100644 index 000000000..afc96ad53 --- /dev/null +++ b/bolt-servlet/src/test/resources/views/view4.json @@ -0,0 +1,64 @@ +{ + "callback_id": "view-callback-id", + "type": "modal", + "notify_on_close": true, + "title": { + "type": "plain_text", + "text": "My App", + "emoji": true + }, + "submit": { + "type": "plain_text", + "text": "Submit", + "emoji": true + }, + "close": { + "type": "plain_text", + "text": "Cancel", + "emoji": true + }, + "blocks": [ + { + "type": "input", + "block_id": "b1", + "element": { + "type": "channels_select", + "action_id": "a", + "response_url_enabled": true, + "placeholder": { + "type": "plain_text", + "text": "Select a channel", + "emoji": true + } + }, + "label": { + "type": "plain_text", + "text": "Label", + "emoji": true + } + }, + { + "type": "input", + "block_id": "b2", + "element": { + "type": "conversations_select", + "action_id": "a", + "filter": { + "include": ["public"], + "exclude_external_shared_channels": true, + "exclude_bot_users": false + }, + "placeholder": { + "type": "plain_text", + "text": "Select a channel", + "emoji": true + } + }, + "label": { + "type": "plain_text", + "text": "Label", + "emoji": true + } + } + ] +} \ No newline at end of file diff --git a/bolt/src/main/java/com/slack/api/bolt/context/InputBlockRespondUtility.java b/bolt/src/main/java/com/slack/api/bolt/context/InputBlockRespondUtility.java new file mode 100644 index 000000000..5f8e7e570 --- /dev/null +++ b/bolt/src/main/java/com/slack/api/bolt/context/InputBlockRespondUtility.java @@ -0,0 +1,46 @@ +package com.slack.api.bolt.context; + +import com.slack.api.Slack; +import com.slack.api.app_backend.views.InputBlockResponseSender; +import com.slack.api.app_backend.views.payload.ViewSubmissionPayload; +import com.slack.api.app_backend.views.response.InputBlockResponse; +import com.slack.api.bolt.util.BuilderConfigurator; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.webhook.WebhookResponse; + +import java.io.IOException; +import java.util.List; + +public interface InputBlockRespondUtility { + + Slack getSlack(); + + InputBlockResponseSender getResponder(String blockId, String actionId); + + InputBlockResponseSender getFirstResponder(); + + List getResponseUrls(); + + default WebhookResponse respond(String text) throws IOException { + return respond(InputBlockResponse.builder().text(text).build()); + } + + default WebhookResponse respond(List blocks) throws IOException { + return respond(InputBlockResponse.builder().blocks(blocks).build()); + } + + default WebhookResponse respond(InputBlockResponse response) throws IOException { + InputBlockResponseSender responder = getFirstResponder(); + if (responder == null) { + throw new IllegalStateException("This payload doesn't have response_urls. " + + "The response_urls are available only when your modal has input block elements with response_url_enabled = true."); + } + return responder.send(response); + } + + default WebhookResponse respond( + BuilderConfigurator builder) throws IOException { + return respond(builder.configure(InputBlockResponse.builder()).build()); + } + +} diff --git a/bolt/src/main/java/com/slack/api/bolt/context/builtin/ViewSubmissionContext.java b/bolt/src/main/java/com/slack/api/bolt/context/builtin/ViewSubmissionContext.java index a50d89fba..0d3198c06 100644 --- a/bolt/src/main/java/com/slack/api/bolt/context/builtin/ViewSubmissionContext.java +++ b/bolt/src/main/java/com/slack/api/bolt/context/builtin/ViewSubmissionContext.java @@ -1,20 +1,29 @@ package com.slack.api.bolt.context.builtin; +import com.slack.api.app_backend.views.InputBlockResponseSender; +import com.slack.api.app_backend.views.payload.ViewSubmissionPayload; import com.slack.api.app_backend.views.response.ViewSubmissionResponse; import com.slack.api.bolt.context.Context; +import com.slack.api.bolt.context.InputBlockRespondUtility; +import com.slack.api.bolt.response.Responder; import com.slack.api.bolt.response.Response; import com.slack.api.bolt.util.BuilderConfigurator; import com.slack.api.model.view.View; import lombok.*; +import java.util.List; import java.util.Map; @Getter @Setter @Builder +@AllArgsConstructor @ToString(callSuper = true) @EqualsAndHashCode(callSuper = false) -public class ViewSubmissionContext extends Context { +public class ViewSubmissionContext extends Context implements InputBlockRespondUtility { + + private List responseUrls; + private Responder responder; public ViewSubmissionContext() { } @@ -68,4 +77,33 @@ public Response ack(String responseAction, String view) { return ack(response); } + @Override + public InputBlockResponseSender getResponder(String blockId, String actionId) { + if (responseUrls != null) { + for (ViewSubmissionPayload.ResponseUrl url : responseUrls) { + if (url.getBlockId().equals(blockId) && url.getActionId().equals(actionId)) { + return new InputBlockResponseSender(getSlack(), url.getResponseUrl()); + } + } + } + throw new IllegalArgumentException("A response_url was not found for block_id: " + blockId + " & action_id: " + actionId); + } + + @Override + public InputBlockResponseSender getFirstResponder() { + List urls = getResponseUrls(); + if (urls == null || urls.size() == 0) { + return null; + } else { + if (urls.size() > 1) { + // NOTE: As of March 2020, response_url_enabled field can be used on a single block element in a view. + // That said, the payload can have multiple URLs here. The warning message is just for possible changes in the future. + String warnMessage = "You have " + urls.size() + " `response_url`s in the payload. " + + "Context#getFirstResponder() and #respond(...) work but always use the first URL. " + + "Consider using Context#getResponder(blockId, actionId).send(...) instead."; + getLogger().warn(warnMessage); + } + return new InputBlockResponseSender(getSlack(), urls.get(0).getResponseUrl()); + } + } } diff --git a/bolt/src/main/java/com/slack/api/bolt/handler/builtin/GlobalShortcutHandler.java b/bolt/src/main/java/com/slack/api/bolt/handler/builtin/GlobalShortcutHandler.java index 523e8f3f9..9aa0b6060 100644 --- a/bolt/src/main/java/com/slack/api/bolt/handler/builtin/GlobalShortcutHandler.java +++ b/bolt/src/main/java/com/slack/api/bolt/handler/builtin/GlobalShortcutHandler.java @@ -1,10 +1,8 @@ package com.slack.api.bolt.handler.builtin; import com.slack.api.bolt.context.builtin.GlobalShortcutContext; -import com.slack.api.bolt.context.builtin.MessageShortcutContext; import com.slack.api.bolt.handler.Handler; import com.slack.api.bolt.request.builtin.GlobalShortcutRequest; -import com.slack.api.bolt.request.builtin.MessageShortcutRequest; import com.slack.api.bolt.response.Response; @FunctionalInterface diff --git a/bolt/src/main/java/com/slack/api/bolt/request/builtin/ViewSubmissionRequest.java b/bolt/src/main/java/com/slack/api/bolt/request/builtin/ViewSubmissionRequest.java index b8a48b280..f704f9ea0 100644 --- a/bolt/src/main/java/com/slack/api/bolt/request/builtin/ViewSubmissionRequest.java +++ b/bolt/src/main/java/com/slack/api/bolt/request/builtin/ViewSubmissionRequest.java @@ -26,6 +26,7 @@ public ViewSubmissionRequest( getContext().setEnterpriseId(payload.getTeam().getEnterpriseId()); getContext().setTeamId(payload.getTeam().getId()); getContext().setRequestUserId(payload.getUser().getId()); + getContext().setResponseUrls(payload.getResponseUrls()); } private ViewSubmissionContext context = new ViewSubmissionContext(); @@ -50,12 +51,24 @@ public RequestHeaders getHeaders() { return this.headers; } - public ViewSubmissionPayload getPayload() { - return payload; - } - @Override public String getResponseUrl() { - return null; + if (payload.getResponseUrls() == null || payload.getResponseUrls().size() == 0) { + return null; + } else { + int numOfResponseUrls = payload.getResponseUrls().size(); + if (numOfResponseUrls > 1) { + // NOTE: As of March 2020, response_url_enabled field can be used on a single block element in a view. + // That said, the payload can have multiple URLs here. The warning message is just for possible changes in the future. + String warnMessage = "You have " + numOfResponseUrls + " `response_url`s in the payload. " + + "ViewSubmissionRequest#getResponseUrl() always returns the first one."; + getContext().getLogger().warn(warnMessage); + } + return payload.getResponseUrls().get(0).getResponseUrl(); + } + } + + public ViewSubmissionPayload getPayload() { + return payload; } } diff --git a/bolt/src/main/java/com/slack/api/bolt/response/Responder.java b/bolt/src/main/java/com/slack/api/bolt/response/Responder.java index badcf9e03..c5a832340 100644 --- a/bolt/src/main/java/com/slack/api/bolt/response/Responder.java +++ b/bolt/src/main/java/com/slack/api/bolt/response/Responder.java @@ -6,6 +6,8 @@ import com.slack.api.app_backend.interactive_components.response.ActionResponse; import com.slack.api.app_backend.slash_commands.SlashCommandResponseSender; import com.slack.api.app_backend.slash_commands.response.SlashCommandResponse; +import com.slack.api.app_backend.views.InputBlockResponseSender; +import com.slack.api.app_backend.views.response.InputBlockResponse; import com.slack.api.webhook.WebhookResponse; import java.io.IOException; @@ -20,6 +22,7 @@ public class Responder { private final ActionResponseSender actionResponseSender; private final SlashCommandResponseSender slashCommandResponseSender; + private final InputBlockResponseSender inputBlockResponseSender; /** * Initializes with a valid response_url @@ -32,20 +35,28 @@ public Responder(Slack slack, String responseUrl) { this.responseUrl = responseUrl; this.actionResponseSender = new ActionResponseSender(slack); this.slashCommandResponseSender = new SlashCommandResponseSender(slack); + this.inputBlockResponseSender = new InputBlockResponseSender(slack); } /** * Sends an HTTP response for a slash command invocation. */ public WebhookResponse send(SlashCommandResponse response) throws IOException { - return new SlashCommandResponseSender(slack).send(responseUrl, response); + return slashCommandResponseSender.send(responseUrl, response); } /** * Sends an HTTP response for an action. */ public WebhookResponse send(ActionResponse response) throws IOException { - return new ActionResponseSender(slack).send(responseUrl, response); + return actionResponseSender.send(responseUrl, response); + } + + /** + * Sends an HTTP response for an input block in a modal. + */ + public WebhookResponse send(InputBlockResponse response) throws IOException { + return inputBlockResponseSender.send(responseUrl, response); } public WebhookResponse sendToAction(RequestConfigurator body) throws IOException { @@ -56,4 +67,8 @@ public WebhookResponse sendToCommand(RequestConfigurator body) throws IOException { + return send(body.configure(InputBlockResponse.builder()).build()); + } + } diff --git a/bolt/src/test/java/test_locally/context/ViewSubmissionContextTest.java b/bolt/src/test/java/test_locally/context/ViewSubmissionContextTest.java index e7725b60d..1cbae5070 100644 --- a/bolt/src/test/java/test_locally/context/ViewSubmissionContextTest.java +++ b/bolt/src/test/java/test_locally/context/ViewSubmissionContextTest.java @@ -1,14 +1,24 @@ package test_locally.context; +import com.slack.api.Slack; +import com.slack.api.app_backend.views.InputBlockResponseSender; +import com.slack.api.app_backend.views.payload.ViewSubmissionPayload; import com.slack.api.bolt.context.builtin.ViewSubmissionContext; import com.slack.api.bolt.response.Response; import com.slack.api.model.view.View; +import com.slack.api.webhook.WebhookResponse; import org.junit.Test; +import util.WebhookMockServer; +import java.io.IOException; +import java.util.Arrays; import java.util.Collections; +import static com.slack.api.model.block.Blocks.asBlocks; +import static com.slack.api.model.block.Blocks.divider; import static com.slack.api.model.view.Views.view; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; public class ViewSubmissionContextTest { @@ -61,4 +71,108 @@ public void ackWithErrors() { assertEquals(200L, response.getStatusCode().longValue()); } + @Test + public void respond_no_url() throws Exception { + ViewSubmissionContext context = new ViewSubmissionContext(); + context.setSlack(Slack.getInstance()); + context.setResponseUrls(null); + try { + context.respond("Test"); + fail(); + } catch (IllegalStateException e) { + assertEquals("This payload doesn't have response_urls. " + + "The response_urls are available only when your modal has input block elements with response_url_enabled = true.", e.getMessage()); + } + } + + @Test + public void respond() throws Exception { + WebhookMockServer webhookMockServer = new WebhookMockServer(); + webhookMockServer.start(); + try { + ViewSubmissionContext context = new ViewSubmissionContext(); + context.setSlack(Slack.getInstance()); + ViewSubmissionPayload.ResponseUrl responseUrl = new ViewSubmissionPayload.ResponseUrl(); + responseUrl.setBlockId("b1"); + responseUrl.setActionId("a"); + responseUrl.setChannelId("C123"); + responseUrl.setResponseUrl(webhookMockServer.getWebhookUrl()); + context.setResponseUrls(Arrays.asList(responseUrl)); + WebhookResponse response = context.respond("Test"); + assertEquals(200L, response.getCode().longValue()); + + } finally { + webhookMockServer.stop(); + } + } + + @Test + public void respond_blocks() throws Exception { + WebhookMockServer webhookMockServer = new WebhookMockServer(); + webhookMockServer.start(); + try { + ViewSubmissionContext context = new ViewSubmissionContext(); + context.setSlack(Slack.getInstance()); + ViewSubmissionPayload.ResponseUrl responseUrl = new ViewSubmissionPayload.ResponseUrl(); + responseUrl.setBlockId("b1"); + responseUrl.setActionId("a"); + responseUrl.setChannelId("C123"); + responseUrl.setResponseUrl(webhookMockServer.getWebhookUrl()); + context.setResponseUrls(Arrays.asList(responseUrl)); + WebhookResponse response = context.respond(asBlocks(divider())); + assertEquals(200L, response.getCode().longValue()); + + } finally { + webhookMockServer.stop(); + } + } + + @Test + public void respond_lambda() throws Exception { + WebhookMockServer webhookMockServer = new WebhookMockServer(); + webhookMockServer.start(); + try { + ViewSubmissionContext context = new ViewSubmissionContext(); + context.setSlack(Slack.getInstance()); + ViewSubmissionPayload.ResponseUrl responseUrl = new ViewSubmissionPayload.ResponseUrl(); + responseUrl.setBlockId("b1"); + responseUrl.setActionId("a"); + responseUrl.setChannelId("C123"); + responseUrl.setResponseUrl(webhookMockServer.getWebhookUrl()); + context.setResponseUrls(Arrays.asList(responseUrl)); + WebhookResponse response = context.respond(r -> r.blocks(asBlocks(divider())).responseType("in_channel")); + assertEquals(200L, response.getCode().longValue()); + + } finally { + webhookMockServer.stop(); + } + } + + @Test(expected = IllegalArgumentException.class) + public void getResponder_absent() { + ViewSubmissionContext context = new ViewSubmissionContext(); + context.getResponder("blockId", "actionId"); + } + + @Test + public void getResponder() throws Exception { + WebhookMockServer webhookMockServer = new WebhookMockServer(); + webhookMockServer.start(); + try { + ViewSubmissionContext context = new ViewSubmissionContext(); + context.setSlack(Slack.getInstance()); + ViewSubmissionPayload.ResponseUrl responseUrl = new ViewSubmissionPayload.ResponseUrl(); + responseUrl.setBlockId("blockId"); + responseUrl.setActionId("actionId"); + responseUrl.setChannelId("C123"); + responseUrl.setResponseUrl(webhookMockServer.getWebhookUrl()); + context.setResponseUrls(Arrays.asList(responseUrl)); + InputBlockResponseSender responder = context.getResponder("blockId", "actionId"); + WebhookResponse response = responder.send("Hi"); + assertEquals(200L, response.getCode().longValue()); + } finally { + webhookMockServer.stop(); + } + } + } diff --git a/bolt/src/test/java/test_locally/request/ViewSubmissionRequestTest.java b/bolt/src/test/java/test_locally/request/ViewSubmissionRequestTest.java new file mode 100644 index 000000000..7a6efa4d7 --- /dev/null +++ b/bolt/src/test/java/test_locally/request/ViewSubmissionRequestTest.java @@ -0,0 +1,170 @@ +package test_locally.request; + +import com.slack.api.bolt.request.RequestHeaders; +import com.slack.api.bolt.request.builtin.ViewSubmissionRequest; +import org.junit.Test; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.Collections; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class ViewSubmissionRequestTest { + + String nullPayload = "{\n" + + " \"type\": \"view_submission\",\n" + + " \"team\": {\n" + + " \"id\": \"T123\",\n" + + " \"domain\": \"seratch\"\n" + + " },\n" + + " \"user\": {\n" + + " \"id\": \"U03E94MK0\",\n" + + " \"username\": \"seratch\",\n" + + " \"name\": \"seratch\",\n" + + " \"team_id\": \"T123\"\n" + + " },\n" + + " \"api_app_id\": \"A123\",\n" + + " \"token\": \"bpzxahAmfJQf5gdBvm05x64O\",\n" + + " \"trigger_id\": \"123.123.random\",\n" + + " \"view\": {\n" + + " \"id\": \"V123\",\n" + + " \"team_id\": \"T123\",\n" + + " \"type\": \"modal\",\n" + + " \"blocks\": [],\n" + + " \"private_metadata\": \"\",\n" + + " \"callback_id\": \"view-callback-id\",\n" + + " \"state\": {\n" + + " \"values\": {}\n" + + " },\n" + + " \"hash\": \"123.123\",\n" + + " \"title\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"My App\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"clear_on_close\": false,\n" + + " \"notify_on_close\": true,\n" + + " \"close\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Cancel\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"submit\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Submit\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"previous_view_id\": null,\n" + + " \"root_view_id\": \"V123\",\n" + + " \"app_id\": \"A123\",\n" + + " \"external_id\": \"\",\n" + + " \"app_installed_team_id\": \"T123\",\n" + + " \"bot_id\": \"B123\"\n" + + " },\n" + + " \"response_urls\": []\n" + + "}"; + + @Test + public void responseUrl_null() throws UnsupportedEncodingException { + RequestHeaders headers = new RequestHeaders(Collections.emptyMap()); + ViewSubmissionRequest req = new ViewSubmissionRequest("payload=" + URLEncoder.encode(nullPayload, "UTF-8"), nullPayload, headers); + assertNull(req.getResponseUrl()); + } + + String payload = "{\n" + + " \"type\": \"view_submission\",\n" + + " \"team\": {\n" + + " \"id\": \"T123\",\n" + + " \"domain\": \"seratch\"\n" + + " },\n" + + " \"user\": {\n" + + " \"id\": \"U03E94MK0\",\n" + + " \"username\": \"seratch\",\n" + + " \"name\": \"seratch\",\n" + + " \"team_id\": \"T123\"\n" + + " },\n" + + " \"api_app_id\": \"A123\",\n" + + " \"token\": \"bpzxahAmfJQf5gdBvm05x64O\",\n" + + " \"trigger_id\": \"123.123.random\",\n" + + " \"view\": {\n" + + " \"id\": \"V123\",\n" + + " \"team_id\": \"T123\",\n" + + " \"type\": \"modal\",\n" + + " \"blocks\": [\n" + + " {\n" + + " \"type\": \"input\",\n" + + " \"block_id\": \"Cuw\",\n" + + " \"label\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Label\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"optional\": false,\n" + + " \"element\": {\n" + + " \"type\": \"channels_select\",\n" + + " \"response_url_enabled\": true,\n" + + " \"placeholder\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Select a channel\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"action_id\": \"0HiH\"\n" + + " }\n" + + " }\n" + + " ],\n" + + " \"private_metadata\": \"\",\n" + + " \"callback_id\": \"view-callback-id\",\n" + + " \"state\": {\n" + + " \"values\": {\n" + + " \"Cuw\": {\n" + + " \"0HiH\": {\n" + + " \"type\": \"channels_select\",\n" + + " \"selected_channel\": \"C234\"\n" + + " }\n" + + " }\n" + + " }\n" + + " },\n" + + " \"hash\": \"123.123\",\n" + + " \"title\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"My App\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"clear_on_close\": false,\n" + + " \"notify_on_close\": true,\n" + + " \"close\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Cancel\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"submit\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Submit\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"previous_view_id\": null,\n" + + " \"root_view_id\": \"V123\",\n" + + " \"app_id\": \"A123\",\n" + + " \"external_id\": \"\",\n" + + " \"app_installed_team_id\": \"T123\",\n" + + " \"bot_id\": \"B123\"\n" + + " },\n" + + " \"response_urls\": [\n" + + " {\n" + + " \"block_id\": \"Cuw\",\n" + + " \"action_id\": \"0HiH\",\n" + + " \"channel_id\": \"C234\",\n" + + " \"response_url\": \"https://hooks.slack.com/app/T123/123/random\"\n" + + " }\n" + + " ]\n" + + "}"; + + @Test + public void responseUrl_single() throws UnsupportedEncodingException { + RequestHeaders headers = new RequestHeaders(Collections.emptyMap()); + ViewSubmissionRequest req = new ViewSubmissionRequest("payload=" + URLEncoder.encode(payload, "UTF-8"), payload, headers); + assertEquals("https://hooks.slack.com/app/T123/123/random", req.getResponseUrl()); + } +} diff --git a/bolt/src/test/java/test_locally/response/ResponderTest.java b/bolt/src/test/java/test_locally/response/ResponderTest.java index 6b7302987..8b331c8f3 100644 --- a/bolt/src/test/java/test_locally/response/ResponderTest.java +++ b/bolt/src/test/java/test_locally/response/ResponderTest.java @@ -27,6 +27,10 @@ public void test() throws Exception { WebhookResponse commandResponse = responder.sendToCommand(r -> r.text("Hi")); assertEquals(200L, commandResponse.getCode().longValue()); + + WebhookResponse modalResponse = responder.sendFromModal(r -> r.text("Hi")); + assertEquals(200L, modalResponse.getCode().longValue()); + } finally { slackApiServer.stop(); } diff --git a/slack-app-backend/src/main/java/com/slack/api/app_backend/views/InputBlockResponseSender.java b/slack-app-backend/src/main/java/com/slack/api/app_backend/views/InputBlockResponseSender.java new file mode 100644 index 000000000..f23adafd0 --- /dev/null +++ b/slack-app-backend/src/main/java/com/slack/api/app_backend/views/InputBlockResponseSender.java @@ -0,0 +1,59 @@ +package com.slack.api.app_backend.views; + +import com.slack.api.RequestConfigurator; +import com.slack.api.Slack; +import com.slack.api.app_backend.views.response.InputBlockResponse; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.util.http.SlackHttpClient; +import com.slack.api.webhook.WebhookResponse; +import okhttp3.Response; + +import java.io.IOException; +import java.util.List; + +public class InputBlockResponseSender { + + private final Slack slack; + private final String responseUrl; + + public InputBlockResponseSender(Slack slack) { + this(slack, null); + } + + public InputBlockResponseSender(Slack slack, String responseUrl) { + this.slack = slack; + this.responseUrl = responseUrl; + } + + public WebhookResponse send(String text) throws IOException { + return send(InputBlockResponse.builder().text(text).build()); + } + + public WebhookResponse send(List blocks) throws IOException { + return send(InputBlockResponse.builder().blocks(blocks).build()); + } + + public WebhookResponse send(RequestConfigurator configurator) throws IOException { + return send(configurator.configure(InputBlockResponse.builder()).build()); + } + + public WebhookResponse send(InputBlockResponse response) throws IOException { + if (this.responseUrl == null) { + throw new IllegalStateException("response_url is unexpectedly absent!"); + } + return send(responseUrl, response); + } + + public WebhookResponse send(String responseUrl, InputBlockResponse response) throws IOException { + SlackHttpClient httpClient = slack.getHttpClient(); + Response httpResponse = httpClient.postJsonBody(responseUrl, response); + String body = httpResponse.body().string(); + httpClient.runHttpResponseListeners(httpResponse, body); + + return WebhookResponse.builder() + .code(httpResponse.code()) + .message(httpResponse.message()) + .body(body) + .build(); + } +} diff --git a/slack-app-backend/src/main/java/com/slack/api/app_backend/views/payload/ViewSubmissionPayload.java b/slack-app-backend/src/main/java/com/slack/api/app_backend/views/payload/ViewSubmissionPayload.java index 79ccb798c..e0e265df2 100644 --- a/slack-app-backend/src/main/java/com/slack/api/app_backend/views/payload/ViewSubmissionPayload.java +++ b/slack-app-backend/src/main/java/com/slack/api/app_backend/views/payload/ViewSubmissionPayload.java @@ -6,6 +6,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + /** * @see Modals */ @@ -24,6 +26,12 @@ public class ViewSubmissionPayload { private View view; private boolean isCleared; + /** + * These response URLs are available only when having input block elements with response_url_enabled. + * (possible block element types: conversations_select, channels_select) + */ + private List responseUrls; + @Data public static class Team { private String id; @@ -40,4 +48,12 @@ public static class User { private String teamId; } + @Data + public static class ResponseUrl { + private String blockId; + private String actionId; + private String channelId; + private String responseUrl; + } + } diff --git a/slack-app-backend/src/main/java/com/slack/api/app_backend/views/response/InputBlockResponse.java b/slack-app-backend/src/main/java/com/slack/api/app_backend/views/response/InputBlockResponse.java new file mode 100644 index 000000000..384a0310e --- /dev/null +++ b/slack-app-backend/src/main/java/com/slack/api/app_backend/views/response/InputBlockResponse.java @@ -0,0 +1,21 @@ +package com.slack.api.app_backend.views.response; + +import com.slack.api.model.Attachment; +import com.slack.api.model.block.LayoutBlock; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class InputBlockResponse { + private String responseType; // ephemeral, in_channel + private String text; + private List attachments; + private List blocks; +} diff --git a/slack-app-backend/src/test/java/test_locally/app_backend/views/InputBlockResponseSenderTest.java b/slack-app-backend/src/test/java/test_locally/app_backend/views/InputBlockResponseSenderTest.java new file mode 100644 index 000000000..60b9f2e1b --- /dev/null +++ b/slack-app-backend/src/test/java/test_locally/app_backend/views/InputBlockResponseSenderTest.java @@ -0,0 +1,98 @@ +package test_locally.app_backend.views; + +import com.slack.api.Slack; +import com.slack.api.app_backend.views.InputBlockResponseSender; +import com.slack.api.app_backend.views.response.InputBlockResponse; +import com.slack.api.webhook.WebhookResponse; +import config.SlackTestConfig; +import org.junit.Test; +import util.MockWebhookServer; + +import static com.slack.api.model.block.Blocks.asBlocks; +import static com.slack.api.model.block.Blocks.divider; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class InputBlockResponseSenderTest { + + MockWebhookServer slackApiServer = new MockWebhookServer(); + + @Test + public void test() throws Exception { + slackApiServer.start(); + try { + Slack slack = Slack.getInstance(SlackTestConfig.get()); + InputBlockResponseSender sender = new InputBlockResponseSender(slack); + + InputBlockResponse data = InputBlockResponse.builder() + .text("Hi there!") + .build(); + WebhookResponse response = sender.send(slackApiServer.getWebhookUrl(), data); + assertNotNull(response); + assertEquals(200L, response.getCode().longValue()); + assertEquals("OK", response.getBody()); + } finally { + slackApiServer.stop(); + } + } + + @Test + public void test2() throws Exception { + slackApiServer.start(); + try { + Slack slack = Slack.getInstance(SlackTestConfig.get()); + InputBlockResponseSender sender = new InputBlockResponseSender(slack, slackApiServer.getWebhookUrl()); + WebhookResponse response = sender.send(r -> r.text("Hi")); + assertNotNull(response); + assertEquals(200L, response.getCode().longValue()); + assertEquals("OK", response.getBody()); + } finally { + slackApiServer.stop(); + } + } + + @Test + public void text() throws Exception { + slackApiServer.start(); + try { + Slack slack = Slack.getInstance(SlackTestConfig.get()); + InputBlockResponseSender sender = new InputBlockResponseSender(slack, slackApiServer.getWebhookUrl()); + WebhookResponse response = sender.send("Hello"); + assertNotNull(response); + assertEquals(200L, response.getCode().longValue()); + assertEquals("OK", response.getBody()); + } finally { + slackApiServer.stop(); + } + } + + @Test(expected = IllegalStateException.class) + public void text_error() throws Exception { + Slack slack = Slack.getInstance(SlackTestConfig.get()); + InputBlockResponseSender sender = new InputBlockResponseSender(slack); + sender.send("Hello"); + } + + @Test + public void blocks() throws Exception { + slackApiServer.start(); + try { + Slack slack = Slack.getInstance(SlackTestConfig.get()); + InputBlockResponseSender sender = new InputBlockResponseSender(slack, slackApiServer.getWebhookUrl()); + WebhookResponse response = sender.send(asBlocks(divider())); + assertNotNull(response); + assertEquals(200L, response.getCode().longValue()); + assertEquals("OK", response.getBody()); + } finally { + slackApiServer.stop(); + } + } + + @Test(expected = IllegalStateException.class) + public void blocks_error() throws Exception { + Slack slack = Slack.getInstance(SlackTestConfig.get()); + InputBlockResponseSender sender = new InputBlockResponseSender(slack); + sender.send(asBlocks(divider())); + } +} + diff --git a/slack-app-backend/src/test/java/test_locally/app_backend/views/payload/ViewClosedPayloadTest.java b/slack-app-backend/src/test/java/test_locally/app_backend/views/ViewClosedPayloadTest.java similarity index 98% rename from slack-app-backend/src/test/java/test_locally/app_backend/views/payload/ViewClosedPayloadTest.java rename to slack-app-backend/src/test/java/test_locally/app_backend/views/ViewClosedPayloadTest.java index 3aa4b5128..ebd49b9d7 100644 --- a/slack-app-backend/src/test/java/test_locally/app_backend/views/payload/ViewClosedPayloadTest.java +++ b/slack-app-backend/src/test/java/test_locally/app_backend/views/ViewClosedPayloadTest.java @@ -1,4 +1,4 @@ -package test_locally.app_backend.views.payload; +package test_locally.app_backend.views; import com.google.gson.Gson; import com.slack.api.app_backend.views.payload.ViewClosedPayload; diff --git a/slack-app-backend/src/test/java/test_locally/app_backend/views/payload/ViewSubmissionPayloadTest.java b/slack-app-backend/src/test/java/test_locally/app_backend/views/ViewSubmissionPayloadTest.java similarity index 99% rename from slack-app-backend/src/test/java/test_locally/app_backend/views/payload/ViewSubmissionPayloadTest.java rename to slack-app-backend/src/test/java/test_locally/app_backend/views/ViewSubmissionPayloadTest.java index f0af537f5..262d6b4ae 100644 --- a/slack-app-backend/src/test/java/test_locally/app_backend/views/payload/ViewSubmissionPayloadTest.java +++ b/slack-app-backend/src/test/java/test_locally/app_backend/views/ViewSubmissionPayloadTest.java @@ -1,4 +1,4 @@ -package test_locally.app_backend.views.payload; +package test_locally.app_backend.views; import com.google.gson.Gson; import com.slack.api.app_backend.views.payload.ViewSubmissionPayload;