diff --git a/connectors/slack/element-templates/hybrid/slack-outbound-connector-hybrid.json b/connectors/slack/element-templates/hybrid/slack-outbound-connector-hybrid.json index 187dcfddec..fbb82675dc 100644 --- a/connectors/slack/element-templates/hybrid/slack-outbound-connector-hybrid.json +++ b/connectors/slack/element-templates/hybrid/slack-outbound-connector-hybrid.json @@ -7,7 +7,7 @@ "keywords" : [ ] }, "documentationRef" : "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/slack/?slack=outbound", - "version" : 5, + "version" : 6, "category" : { "id" : "connectors", "name" : "Connectors" @@ -168,6 +168,23 @@ } ] }, "type" : "String" + }, { + "id" : "data.documents", + "label" : "attachments", + "description" : "Camunda documents can be added as attachments", + "optional" : true, + "feel" : "required", + "group" : "message", + "binding" : { + "name" : "data.documents", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "method", + "equals" : "chat.postMessage", + "type" : "simple" + }, + "type" : "String" }, { "id" : "data.channel", "label" : "Channel/user name/email", diff --git a/connectors/slack/element-templates/slack-outbound-connector.json b/connectors/slack/element-templates/slack-outbound-connector.json index 98d08e412b..386a3bdf16 100644 --- a/connectors/slack/element-templates/slack-outbound-connector.json +++ b/connectors/slack/element-templates/slack-outbound-connector.json @@ -7,7 +7,7 @@ "keywords" : [ ] }, "documentationRef" : "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/slack/?slack=outbound", - "version" : 5, + "version" : 6, "category" : { "id" : "connectors", "name" : "Connectors" @@ -163,6 +163,23 @@ } ] }, "type" : "String" + }, { + "id" : "data.documents", + "label" : "attachments", + "description" : "Camunda documents can be added as attachments", + "optional" : true, + "feel" : "required", + "group" : "message", + "binding" : { + "name" : "data.documents", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "method", + "equals" : "chat.postMessage", + "type" : "simple" + }, + "type" : "String" }, { "id" : "data.channel", "label" : "Channel/user name/email", diff --git a/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/SlackFunction.java b/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/SlackFunction.java index 88fb9e36fa..50c377ab3d 100644 --- a/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/SlackFunction.java +++ b/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/SlackFunction.java @@ -17,7 +17,7 @@ name = "Slack Outbound Connector", description = "Create a channel or send a message to a channel or user", inputDataClass = SlackRequest.class, - version = 5, + version = 6, propertyGroups = { @ElementTemplate.PropertyGroup(id = "authentication", label = "Authentication"), @ElementTemplate.PropertyGroup(id = "method", label = "Method"), diff --git a/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/caller/FileUploader.java b/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/caller/FileUploader.java new file mode 100644 index 0000000000..79da93dbf4 --- /dev/null +++ b/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/caller/FileUploader.java @@ -0,0 +1,138 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.slack.outbound.caller; + +import static java.util.stream.Collectors.toList; + +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.files.FilesCompleteUploadExternalRequest; +import com.slack.api.methods.request.files.FilesGetUploadURLExternalRequest; +import com.slack.api.methods.response.files.FilesCompleteUploadExternalResponse; +import com.slack.api.methods.response.files.FilesGetUploadURLExternalResponse; +import com.slack.api.model.File; +import com.slack.api.util.http.SlackHttpClient; +import io.camunda.document.Document; +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class FileUploader { + + public static final String GET_EXTERNAL_URL_EX = "Error during filesGetUploadURLExternal call"; + public static final String EXTERNAL_URL_CALL_EX = "Error during external call: "; + public static final String COMPLETE_UPLOAD_CALL_EX = + "Error during filesCompleteUploadExternal call"; + + private static final Logger LOGGER = LoggerFactory.getLogger(FileUploader.class); + + private MethodsClient methodsClient; + + public FileUploader(MethodsClient methodsClient) { + this.methodsClient = methodsClient; + } + + public List uploadDocuments(List documents) { + return documents.stream() + .map( + doc -> { + try { + return this.uploadDocument(methodsClient, doc); + } catch (SlackApiException | IOException e) { + throw new RuntimeException(e); + } + }) + .filter(Objects::nonNull) + .collect(toList()); + } + + private File uploadDocument(MethodsClient methodsClient, Document document) + throws SlackApiException, IOException { + FilesGetUploadURLExternalResponse externalURLUploadResponse = + getFileUploadURL(methodsClient, document); + uploadFileByURL(externalURLUploadResponse, document); + return completeFileUpload(externalURLUploadResponse, document); + } + + private FilesGetUploadURLExternalResponse getFileUploadURL( + MethodsClient methodsClient, Document document) throws SlackApiException, IOException { + var filesGetUploadURLExternalRequest = + FilesGetUploadURLExternalRequest.builder() + .filename(document.metadata().getFileName()) + .length(document.asByteArray().length) + .build(); + return methodsClient.filesGetUploadURLExternal(filesGetUploadURLExternalRequest); + } + + private void uploadFileByURL( + FilesGetUploadURLExternalResponse uploadFileURLResp, Document document) throws IOException { + if (!uploadFileURLResp.isOk()) { + String msg = GET_EXTERNAL_URL_EX + "\n Errors: " + uploadFileURLResp.getError(); + LOGGER.error(msg); + throw new RuntimeException(msg); + } + var config = methodsClient.getSlackHttpClient().getConfig(); + OkHttpClient okHttpClient = SlackHttpClient.buildOkHttpClient(config); + + var request = + new Request.Builder() + .url(uploadFileURLResp.getUploadUrl()) + .post(RequestBody.create(document.asByteArray())) + .build(); + + try (Response directCallResp = okHttpClient.newCall(request).execute()) { + if (directCallResp.code() != 200) { + String msg = EXTERNAL_URL_CALL_EX + directCallResp.message(); + LOGGER.error(msg); + throw new RuntimeException(msg); + } + } + } + + private File completeFileUpload( + FilesGetUploadURLExternalResponse uploadFileUrlResp, Document document) + throws SlackApiException, IOException { + FilesCompleteUploadExternalResponse completeUploadResp = + methodsClient.filesCompleteUploadExternal( + FilesCompleteUploadExternalRequest.builder() + .files( + List.of( + FilesCompleteUploadExternalRequest.FileDetails.builder() + .id(uploadFileUrlResp.getFileId()) + .title(document.metadata().getFileName()) + .build())) + .build()); + + if (completeUploadResp.isOk()) { + List files = completeUploadResp.getFiles(); + return getFirst(files); + } else { + String msg = COMPLETE_UPLOAD_CALL_EX + "\n Errors: " + completeUploadResp.getError(); + LOGGER.error(msg); + throw new RuntimeException(msg); + } + } + + // In fact, we always have only one file in List + private File getFirst(List files) { + return files == null || files.isEmpty() ? null : files.getFirst(); + } + + public void setMethodsClient(MethodsClient methodsClient) { + this.methodsClient = methodsClient; + } + + public MethodsClient getMethodsClient() { + return methodsClient; + } +} diff --git a/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/mapper/BlocksMapper.java b/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/mapper/BlocksMapper.java new file mode 100644 index 0000000000..d912ca3a00 --- /dev/null +++ b/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/mapper/BlocksMapper.java @@ -0,0 +1,75 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.slack.outbound.mapper; + +import static java.util.stream.Collectors.toList; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.slack.api.model.File; +import com.slack.api.model.block.ContextBlock; +import com.slack.api.model.block.FileBlock; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.composition.PlainTextObject; +import com.slack.api.util.json.GsonFactory; +import io.camunda.connector.api.error.ConnectorException; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class BlocksMapper { + + private static final Logger LOGGER = LoggerFactory.getLogger(BlocksMapper.class); + public static final String REMOTE_FILE_SOURCE = "remote"; + + private BlocksMapper() {} + + public static List mapBlocks(List files, String text, JsonNode blockContent) { + List blocks = mapBlocks(text, blockContent); + blocks.addAll(prepareFileBlocks(files)); + return blocks; + } + + public static List mapBlocks(String text, JsonNode blockContent) { + List blocks = new ArrayList<>(); + if (blockContent != null) { + blocks = parseBlockContent(blockContent); + } else { + blocks.add(prepareBlockFromMessage(text)); + } + + return blocks; + } + + private static LayoutBlock prepareBlockFromMessage(String text) { + return ContextBlock.builder() + .elements(List.of(PlainTextObject.builder().text(text).build())) + .build(); + } + + private static List prepareFileBlocks(List files) { + return files.stream() + .map(file -> FileBlock.builder().fileId(file.getId()).source(REMOTE_FILE_SOURCE).build()) + .collect(toList()); + } + + private static List parseBlockContent(JsonNode blockContent) { + if (!blockContent.isArray()) { + String msg = "Block section must be an array"; + LOGGER.warn(msg); + throw new ConnectorException(msg); + } + + ArrayNode arrayNode = (ArrayNode) blockContent; + List blocks = new ArrayList<>(); + for (JsonNode node : arrayNode) { + blocks.add(GsonFactory.createSnakeCase().fromJson(node.toString(), LayoutBlock.class)); + } + return blocks; + } +} diff --git a/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/model/ChatPostMessageData.java b/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/model/ChatPostMessageData.java index 181ffa4b38..b9e6a40183 100644 --- a/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/model/ChatPostMessageData.java +++ b/connectors/slack/src/main/java/io/camunda/connector/slack/outbound/model/ChatPostMessageData.java @@ -6,12 +6,16 @@ */ package io.camunda.connector.slack.outbound.model; +import static io.camunda.connector.slack.outbound.mapper.BlocksMapper.mapBlocks; + import com.fasterxml.jackson.databind.JsonNode; import com.slack.api.methods.MethodsClient; import com.slack.api.methods.SlackApiException; import com.slack.api.methods.request.chat.ChatPostMessageRequest; import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.model.File; import io.camunda.connector.api.error.ConnectorException; +import io.camunda.connector.generator.dsl.Property; import io.camunda.connector.generator.dsl.Property.FeelMode; import io.camunda.connector.generator.java.annotation.TemplateProperty; import io.camunda.connector.generator.java.annotation.TemplateProperty.PropertyBinding; @@ -19,10 +23,13 @@ import io.camunda.connector.generator.java.annotation.TemplateProperty.PropertyType; import io.camunda.connector.generator.java.annotation.TemplateSubType; import io.camunda.connector.slack.outbound.SlackResponse; +import io.camunda.connector.slack.outbound.caller.FileUploader; import io.camunda.connector.slack.outbound.utils.DataLookupService; +import io.camunda.document.Document; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import java.io.IOException; +import java.util.List; import org.apache.commons.lang3.StringUtils; @TemplateSubType(id = "chat.postMessage", label = "Post message") @@ -91,10 +98,21 @@ public record ChatPostMessageData( equals = "messageBlock"), defaultValue = "=[\n\t{\n\t\t\"type\": \"header\",\n\t\t\"text\": {\n\t\t\t\"type\": \"plain_text\",\n\t\t\t\"text\": \"New request\"\n\t\t}\n\t},\n\t{\n\t\t\"type\": \"section\",\n\t\t\"fields\": [\n\t\t\t{\n\t\t\t\t\"type\": \"mrkdwn\",\n\t\t\t\t\"text\": \"*Type:*\\nPaid Time Off\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"type\": \"mrkdwn\",\n\t\t\t\t\"text\": \"*Created by:*\\n\"\n\t\t\t}\n\t\t]\n\t},\n\t{\n\t\t\"type\": \"section\",\n\t\t\"fields\": [\n\t\t\t{\n\t\t\t\t\"type\": \"mrkdwn\",\n\t\t\t\t\"text\": \"*When:*\\nAug 10 - Aug 13\"\n\t\t\t}\n\t\t]\n\t},\n\t{\n\t\t\"type\": \"section\",\n\t\t\"text\": {\n\t\t\t\"type\": \"mrkdwn\",\n\t\t\t\"text\": \"\"\n\t\t}\n\t}\n]") - JsonNode blockContent) + JsonNode blockContent, + @TemplateProperty( + id = "data.documents", + group = "message", + label = "attachments", + feel = Property.FeelMode.required, + binding = @PropertyBinding(name = "data.documents"), + type = TemplateProperty.PropertyType.String, + optional = true, + description = + "Camunda documents can be added as attachments") + List documents) implements SlackRequestData { @Override - public SlackResponse invoke(MethodsClient methodsClient) throws SlackApiException, IOException { + public SlackResponse invoke(MethodsClient methodsClient) throws IOException, SlackApiException { if (!isContentSupplied()) { throw new ConnectorException("Text or block content required to post a message"); } @@ -108,20 +126,16 @@ public SlackResponse invoke(MethodsClient methodsClient) throws SlackApiExceptio var requestBuilder = ChatPostMessageRequest.builder().channel(filteredChannel); - // Note: both text and block content can co-exist - if (StringUtils.isNotBlank(text)) { - requestBuilder.text(text); - // Enables plain text message formatting - requestBuilder.linkNames(true); - } if (StringUtils.isNotBlank(thread)) { requestBuilder.threadTs(thread); } - if (blockContent != null) { - if (!blockContent.isArray()) { - throw new ConnectorException("Block section must be an array"); - } - requestBuilder.blocksAsString(blockContent.toString()); + + if (this.documents != null && !this.documents.isEmpty()) { + var fileUploader = new FileUploader(methodsClient); + List files = fileUploader.uploadDocuments(documents); + requestBuilder.blocks(mapBlocks(files, text, blockContent)); + } else { + requestBuilder.blocks(mapBlocks(text, blockContent)); } var request = requestBuilder.build(); diff --git a/connectors/slack/src/test/java/io/camunda/connector/slack/outbound/caller/FileUploaderTest.java b/connectors/slack/src/test/java/io/camunda/connector/slack/outbound/caller/FileUploaderTest.java new file mode 100644 index 0000000000..53c5ede33a --- /dev/null +++ b/connectors/slack/src/test/java/io/camunda/connector/slack/outbound/caller/FileUploaderTest.java @@ -0,0 +1,223 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.slack.outbound.caller; + +import static io.camunda.connector.slack.outbound.caller.FileUploader.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.slack.api.SlackConfig; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.files.FilesCompleteUploadExternalRequest; +import com.slack.api.methods.request.files.FilesGetUploadURLExternalRequest; +import com.slack.api.methods.response.files.FilesCompleteUploadExternalResponse; +import com.slack.api.methods.response.files.FilesGetUploadURLExternalResponse; +import com.slack.api.model.File; +import com.slack.api.util.http.SlackHttpClient; +import io.camunda.connector.document.annotation.jackson.DocumentReferenceModel; +import io.camunda.document.CamundaDocument; +import io.camunda.document.Document; +import io.camunda.document.reference.DocumentReference; +import io.camunda.document.store.CamundaDocumentStore; +import io.camunda.zeebe.client.api.response.DocumentMetadata; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import okhttp3.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class FileUploaderTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private MethodsClient methodsClient; + + @InjectMocks private FileUploader fileUploader; + + @Test + void uploadDocuments() throws SlackApiException, IOException { + List documents = List.of(prepareDocument()); + + mockGetExternalURL(); + when(methodsClient.getSlackHttpClient().getConfig()).thenReturn(SlackConfig.DEFAULT); + + OkHttpClient okHttpClient = Mockito.mock(OkHttpClient.class, Answers.RETURNS_DEEP_STUBS); + + try (MockedStatic slackHttpClient = + Mockito.mockStatic(SlackHttpClient.class)) { + slackHttpClient.when(() -> SlackHttpClient.buildOkHttpClient(any())).thenReturn(okHttpClient); + + var response = mock(Response.class); + when(response.code()).thenReturn(200); + + when(okHttpClient.newCall(any(Request.class)).execute()).thenReturn(response); + + List files = List.of(File.builder().id("id").build()); + mockCompleteUpload(files); + + List result = fileUploader.uploadDocuments(documents); + assertThat(result).hasSize(1); + assertThat(result).isEqualTo(files); + } + } + + @Test + void uploadDocumentsShouldFilterEmptyFiles() throws SlackApiException, IOException { + List documents = List.of(prepareDocument()); + mockGetExternalURL(); + when(methodsClient.getSlackHttpClient().getConfig()).thenReturn(SlackConfig.DEFAULT); + + OkHttpClient okHttpClient = Mockito.mock(OkHttpClient.class, Answers.RETURNS_DEEP_STUBS); + + try (MockedStatic slackHttpClient = + Mockito.mockStatic(SlackHttpClient.class)) { + slackHttpClient.when(() -> SlackHttpClient.buildOkHttpClient(any())).thenReturn(okHttpClient); + + var response = mock(Response.class); + when(response.code()).thenReturn(200); + + when(okHttpClient.newCall(any(Request.class)).execute()).thenReturn(response); + + List emptyFiles = List.of(); + mockCompleteUpload(emptyFiles); + + List result = fileUploader.uploadDocuments(documents); + assertThat(result).hasSize(0); + } + } + + @Test + void exceptionDuringGetExternalURLCall() throws SlackApiException, IOException { + List documents = List.of(prepareDocument()); + var uploadURLResp = new FilesGetUploadURLExternalResponse(); + uploadURLResp.setOk(false); + uploadURLResp.setError("Do not have Read permissions"); + + when(methodsClient.filesGetUploadURLExternal(any(FilesGetUploadURLExternalRequest.class))) + .thenReturn(uploadURLResp); + + Exception ex = + assertThrows(RuntimeException.class, () -> fileUploader.uploadDocuments(documents)); + + assertThat(ex.getMessage()).contains(GET_EXTERNAL_URL_EX); + assertThat(ex.getMessage()).contains(uploadURLResp.getError()); + } + + @Test + void exceptionDuringExternalURlCall() throws SlackApiException, IOException { + List documents = List.of(prepareDocument()); + + var uploadURLResp = new FilesGetUploadURLExternalResponse(); + uploadURLResp.setOk(true); + uploadURLResp.setUploadUrl("https:example.com"); + + when(methodsClient.filesGetUploadURLExternal(any(FilesGetUploadURLExternalRequest.class))) + .thenReturn(uploadURLResp); + when(methodsClient.getSlackHttpClient().getConfig()).thenReturn(SlackConfig.DEFAULT); + OkHttpClient okHttpClient = Mockito.mock(OkHttpClient.class, Answers.RETURNS_DEEP_STUBS); + + try (MockedStatic slackHttpClient = + Mockito.mockStatic(SlackHttpClient.class)) { + slackHttpClient.when(() -> SlackHttpClient.buildOkHttpClient(any())).thenReturn(okHttpClient); + + String msg = "Internal Server Error"; + + Response response = mock(Response.class); + when(response.code()).thenReturn(500); + when(response.message()).thenReturn(msg); + + when(okHttpClient.newCall(any(Request.class)).execute()).thenReturn(response); + + Exception ex = + assertThrows(RuntimeException.class, () -> fileUploader.uploadDocuments(documents)); + + assertThat(ex.getMessage()).contains(EXTERNAL_URL_CALL_EX); + assertThat(ex.getMessage()).contains(msg); + } + } + + @Test + void ExceptionDuringCompleteFileUploadCall() throws SlackApiException, IOException { + List documents = List.of(prepareDocument()); + var uploadURLResp = new FilesGetUploadURLExternalResponse(); + uploadURLResp.setOk(true); + uploadURLResp.setUploadUrl("https:example.com"); + + when(methodsClient.filesGetUploadURLExternal(any(FilesGetUploadURLExternalRequest.class))) + .thenReturn(uploadURLResp); + when(methodsClient.getSlackHttpClient().getConfig()).thenReturn(SlackConfig.DEFAULT); + + OkHttpClient okHttpClient = Mockito.mock(OkHttpClient.class, Answers.RETURNS_DEEP_STUBS); + + try (MockedStatic slackHttpClient = + Mockito.mockStatic(SlackHttpClient.class)) { + slackHttpClient.when(() -> SlackHttpClient.buildOkHttpClient(any())).thenReturn(okHttpClient); + + var response = mock(Response.class); + when(response.code()).thenReturn(200); + + when(okHttpClient.newCall(any(Request.class)).execute()).thenReturn(response); + + FilesCompleteUploadExternalResponse completeUploadResp = + new FilesCompleteUploadExternalResponse(); + completeUploadResp.setOk(false); + completeUploadResp.setError("somthing go wrong"); + + when(methodsClient.filesCompleteUploadExternal(any(FilesCompleteUploadExternalRequest.class))) + .thenReturn(completeUploadResp); + + Exception ex = + assertThrows(RuntimeException.class, () -> fileUploader.uploadDocuments(documents)); + + assertThat(ex.getMessage()).contains(completeUploadResp.getError()); + assertThat(ex.getMessage()).contains(COMPLETE_UPLOAD_CALL_EX); + } + } + + private Document prepareDocument() { + DocumentReference.CamundaDocumentReference documentReference = + Mockito.mock(DocumentReference.CamundaDocumentReference.class); + CamundaDocumentStore documentStore = Mockito.mock(CamundaDocumentStore.class); + + var byteInput = new ByteArrayInputStream(new byte[0]); + when(documentStore.getDocumentContent(any())).thenReturn(byteInput); + + DocumentMetadata documentMetadata = + new DocumentReferenceModel.CamundaDocumentMetadataModel( + "txt", OffsetDateTime.now(), 3000L, "fileName", "processId", 2000L, Map.of()); + + return new CamundaDocument(documentMetadata, documentReference, documentStore); + } + + private void mockGetExternalURL() throws SlackApiException, IOException { + var uploadURLResp = new FilesGetUploadURLExternalResponse(); + uploadURLResp.setOk(true); + uploadURLResp.setUploadUrl("https:example.com"); + + when(methodsClient.filesGetUploadURLExternal(any(FilesGetUploadURLExternalRequest.class))) + .thenReturn(uploadURLResp); + } + + private void mockCompleteUpload(List filesResponse) throws SlackApiException, IOException { + FilesCompleteUploadExternalResponse completeUploadResp = + new FilesCompleteUploadExternalResponse(); + completeUploadResp.setOk(true); + completeUploadResp.setFiles(filesResponse); + + when(methodsClient.filesCompleteUploadExternal(any(FilesCompleteUploadExternalRequest.class))) + .thenReturn(completeUploadResp); + } +} diff --git a/connectors/slack/src/test/java/io/camunda/connector/slack/outbound/mapper/BlocksMapperTest.java b/connectors/slack/src/test/java/io/camunda/connector/slack/outbound/mapper/BlocksMapperTest.java new file mode 100644 index 0000000000..ef67840c0f --- /dev/null +++ b/connectors/slack/src/test/java/io/camunda/connector/slack/outbound/mapper/BlocksMapperTest.java @@ -0,0 +1,120 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.slack.outbound.mapper; + +import static io.camunda.connector.slack.outbound.mapper.BlocksMapper.REMOTE_FILE_SOURCE; +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.slack.api.model.File; +import com.slack.api.model.block.*; +import com.slack.api.model.block.composition.MarkdownTextObject; +import com.slack.api.model.block.composition.PlainTextObject; +import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class BlocksMapperTest { + + private static final ObjectMapper objectMapper = ConnectorsObjectMapperSupplier.getCopy(); + + @Test + void mapBlocksWithText() { + String text = " just a text"; + + List result = BlocksMapper.mapBlocks(text, null); + + List expected = + List.of( + ContextBlock.builder() + .elements(List.of(PlainTextObject.builder().text(text).build())) + .build()); + + assertThat(result).isEqualTo(expected); + } + + @Test + void mapBlocksWithJsonNode() throws JsonProcessingException { + + JsonNode jsonNode = getBlocksAsJson(); + + List result = BlocksMapper.mapBlocks(null, jsonNode); + + List expected = getExpectedBlocks(); + + assertThat(result).isEqualTo(expected); + } + + @Test + void mapBlocksWithFilesAndJsonNode() throws JsonProcessingException { + String fileId = "id"; + List files = List.of(File.builder().id(fileId).build()); + + List expected = new ArrayList<>(getExpectedBlocks()); + expected.add(FileBlock.builder().fileId(fileId).source(REMOTE_FILE_SOURCE).build()); + + List result = BlocksMapper.mapBlocks(files, null, getBlocksAsJson()); + + assertThat(result).isEqualTo(expected); + } + + @Test + void mapBlocksWithFilesAndText() throws JsonProcessingException { + String fileId = "id"; + List files = List.of(File.builder().id(fileId).build()); + + String text = "just a text"; + + List expected = + List.of( + ContextBlock.builder() + .elements(List.of(PlainTextObject.builder().text(text).build())) + .build(), + FileBlock.builder().fileId(fileId).source(REMOTE_FILE_SOURCE).build()); + + List result = BlocksMapper.mapBlocks(files, text, null); + + assertThat(result).isEqualTo(expected); + } + + private JsonNode getBlocksAsJson() throws JsonProcessingException { + String blocks = + """ + [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "New request" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*When:* Aug 10 - Aug 13" + } + ] + } + ] + """; + + return objectMapper.readTree(blocks); + } + + private List getExpectedBlocks() { + return List.of( + HeaderBlock.builder().text(PlainTextObject.builder().text("New request").build()).build(), + SectionBlock.builder() + .fields(List.of(MarkdownTextObject.builder().text("*When:* Aug 10 - Aug 13").build())) + .build()); + } +} diff --git a/connectors/slack/src/test/java/io/camunda/connector/slack/outbound/model/ChatPostMessageDataTest.java b/connectors/slack/src/test/java/io/camunda/connector/slack/outbound/model/ChatPostMessageDataTest.java index 26c2739288..b28e8bcfc2 100644 --- a/connectors/slack/src/test/java/io/camunda/connector/slack/outbound/model/ChatPostMessageDataTest.java +++ b/connectors/slack/src/test/java/io/camunda/connector/slack/outbound/model/ChatPostMessageDataTest.java @@ -9,6 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.fasterxml.jackson.core.JsonProcessingException; @@ -16,27 +17,46 @@ import com.slack.api.methods.MethodsClient; import com.slack.api.methods.SlackApiException; import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.request.files.FilesCompleteUploadExternalRequest; +import com.slack.api.methods.request.files.FilesGetUploadURLExternalRequest; import com.slack.api.methods.request.users.UsersLookupByEmailRequest; import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.methods.response.files.FilesCompleteUploadExternalResponse; +import com.slack.api.methods.response.files.FilesGetUploadURLExternalResponse; import com.slack.api.methods.response.users.UsersLookupByEmailResponse; +import com.slack.api.model.File; import com.slack.api.model.Message; import com.slack.api.model.User; +import com.slack.api.util.http.SlackHttpClient; import io.camunda.connector.api.error.ConnectorException; import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import io.camunda.connector.document.annotation.jackson.DocumentReferenceModel; +import io.camunda.document.CamundaDocument; +import io.camunda.document.Document; +import io.camunda.document.reference.DocumentReference; +import io.camunda.document.store.CamundaDocumentStore; +import io.camunda.zeebe.client.api.response.DocumentMetadata; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; +import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class ChatPostMessageDataTest { - @Mock private MethodsClient methodsClient; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private MethodsClient methodsClient; + @Mock private UsersLookupByEmailResponse lookupByEmailResponse; @Mock private User user; @Mock private ChatPostMessageResponse chatPostMessageResponse; @@ -58,7 +78,8 @@ class ChatPostMessageDataTest { void invoke_shouldThrowExceptionWhenUserWithoutEmail() throws SlackApiException, IOException { // Given ChatPostMessageData chatPostMessageData = - new ChatPostMessageData("test@test.com", "thread_ts", "plainText", "Test text", EMPTY_JSON); + new ChatPostMessageData( + "test@test.com", "thread_ts", "plainText", "Test text", EMPTY_JSON, List.of()); when(methodsClient.usersLookupByEmail(any(UsersLookupByEmailRequest.class))).thenReturn(null); // When and then Throwable thrown = catchThrowable(() -> chatPostMessageData.invoke(methodsClient)); @@ -80,7 +101,7 @@ void invoke_shouldThrowExceptionWhenUserWithoutEmail() throws SlackApiException, void invoke_shouldFindUserIdByEmail(String email) throws SlackApiException, IOException { // Given ChatPostMessageData chatPostMessageData = - new ChatPostMessageData(email, "thread_ts", "plainText", "test", null); + new ChatPostMessageData(email, "thread_ts", "plainText", "test", null, List.of()); when(methodsClient.usersLookupByEmail(any(UsersLookupByEmailRequest.class))) .thenReturn(lookupByEmailResponse); @@ -102,22 +123,42 @@ void invoke_shouldFindUserIdByEmail(String email) throws SlackApiException, IOEx void invoke_WhenTextIsGiven_ShouldInvoke() throws SlackApiException, IOException { // Given ChatPostMessageData chatPostMessageData = - new ChatPostMessageData("test@test.com", "thread_ts", "plainText", "test", null); + new ChatPostMessageData( + "test@test.com", "thread_ts", "plainText", "test", null, List.of(prepareDocument())); when(methodsClient.usersLookupByEmail(any(UsersLookupByEmailRequest.class))) .thenReturn(lookupByEmailResponse); when(lookupByEmailResponse.isOk()).thenReturn(Boolean.TRUE); when(lookupByEmailResponse.getUser()).thenReturn(user); when(user.getId()).thenReturn(USERID); - when(methodsClient.chatPostMessage(chatPostMessageRequest.capture())) - .thenReturn(chatPostMessageResponse); - when(chatPostMessageResponse.isOk()).thenReturn(true); - when(chatPostMessageResponse.getMessage()).thenReturn(new Message()); - // When - chatPostMessageData.invoke(methodsClient); - // Then - ChatPostMessageRequest value = chatPostMessageRequest.getValue(); - assertThat(value.getChannel()).isEqualTo(USERID); + + // mock File uploading + mockGetExternalURL(); + + OkHttpClient okHttpClient = Mockito.mock(OkHttpClient.class, Answers.RETURNS_DEEP_STUBS); + + try (MockedStatic slackHttpClient = + Mockito.mockStatic(SlackHttpClient.class)) { + slackHttpClient.when(() -> SlackHttpClient.buildOkHttpClient(any())).thenReturn(okHttpClient); + + var response = mock(Response.class); + when(response.code()).thenReturn(200); + + when(okHttpClient.newCall(any(Request.class)).execute()).thenReturn(response); + + mockCompleteExternalURL(); + + when(methodsClient.chatPostMessage(chatPostMessageRequest.capture())) + .thenReturn(chatPostMessageResponse); + + when(chatPostMessageResponse.isOk()).thenReturn(true); + when(chatPostMessageResponse.getMessage()).thenReturn(new Message()); + // When + chatPostMessageData.invoke(methodsClient); + // Then + ChatPostMessageRequest value = chatPostMessageRequest.getValue(); + assertThat(value.getChannel()).isEqualTo(USERID); + } } @Test @@ -173,7 +214,8 @@ void invoke_WhenContentBlockIsGiven_ShouldInvoke() throws SlackApiException, IOE "thread_ts", "messageBlock", "test", - objectMapper.readTree(blockContent)); + objectMapper.readTree(blockContent), + List.of()); when(methodsClient.usersLookupByEmail(any(UsersLookupByEmailRequest.class))) .thenReturn(lookupByEmailResponse); @@ -209,7 +251,12 @@ void invoke_WhenContentBlockIsNotArray_ShouldThrow() throws SlackApiException, I ChatPostMessageData chatPostMessageData = new ChatPostMessageData( - "test@test.com", "thread_ts", "plainText", "test", objectMapper.readTree(blockContent)); + "test@test.com", + "thread_ts", + "plainText", + "test", + objectMapper.readTree(blockContent), + List.of()); when(methodsClient.usersLookupByEmail(any(UsersLookupByEmailRequest.class))) .thenReturn(lookupByEmailResponse); @@ -223,4 +270,39 @@ void invoke_WhenContentBlockIsNotArray_ShouldThrow() throws SlackApiException, I assertThat(thrown).hasMessageContaining("Block section must be an array"); assertThat(thrown).isInstanceOf(ConnectorException.class); } + + private Document prepareDocument() { + DocumentReference.CamundaDocumentReference documentReference = + Mockito.mock(DocumentReference.CamundaDocumentReference.class); + CamundaDocumentStore documentStore = Mockito.mock(CamundaDocumentStore.class); + + var byteInput = new ByteArrayInputStream(new byte[0]); + when(documentStore.getDocumentContent(any())).thenReturn(byteInput); + + DocumentMetadata documentMetadata = + new DocumentReferenceModel.CamundaDocumentMetadataModel( + "txt", OffsetDateTime.now(), 3000L, "fileName", "processId", 2000L, Map.of()); + + return new CamundaDocument(documentMetadata, documentReference, documentStore); + } + + private void mockGetExternalURL() throws SlackApiException, IOException { + var uploadURLResp = new FilesGetUploadURLExternalResponse(); + uploadURLResp.setOk(true); + uploadURLResp.setUploadUrl("https:example.com"); + + when(methodsClient.filesGetUploadURLExternal(any(FilesGetUploadURLExternalRequest.class))) + .thenReturn(uploadURLResp); + } + + private void mockCompleteExternalURL() throws SlackApiException, IOException { + List files = List.of(File.builder().id("id").build()); + FilesCompleteUploadExternalResponse completeUploadResp = + new FilesCompleteUploadExternalResponse(); + completeUploadResp.setOk(true); + completeUploadResp.setFiles(files); + + when(methodsClient.filesCompleteUploadExternal(any(FilesCompleteUploadExternalRequest.class))) + .thenReturn(completeUploadResp); + } }