Skip to content

Commit

Permalink
feat(slack): add attachments support (#4119) (#4133)
Browse files Browse the repository at this point in the history
(cherry picked from commit c95a1a9)

Co-authored-by: DenovVasil <vasil.denov-ext@camunda.com>
  • Loading branch information
1 parent 420f579 commit 5e24136
Show file tree
Hide file tree
Showing 9 changed files with 720 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -168,6 +168,23 @@
} ]
},
"type" : "String"
}, {
"id" : "data.documents",
"label" : "attachments",
"description" : "<a href=\"https://docs.camunda.io/docs/apis-tools/camunda-api-rest/specifications/upload-document-alpha/\">Camunda documents</a> 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",
Expand Down
19 changes: 18 additions & 1 deletion connectors/slack/element-templates/slack-outbound-connector.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -163,6 +163,23 @@
} ]
},
"type" : "String"
}, {
"id" : "data.documents",
"label" : "attachments",
"description" : "<a href=\"https://docs.camunda.io/docs/apis-tools/camunda-api-rest/specifications/upload-document-alpha/\">Camunda documents</a> 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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<File> uploadDocuments(List<Document> 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<File> 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<File> files) {
return files == null || files.isEmpty() ? null : files.getFirst();
}

public void setMethodsClient(MethodsClient methodsClient) {
this.methodsClient = methodsClient;
}

public MethodsClient getMethodsClient() {
return methodsClient;
}
}
Original file line number Diff line number Diff line change
@@ -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<LayoutBlock> mapBlocks(List<File> files, String text, JsonNode blockContent) {
List<LayoutBlock> blocks = mapBlocks(text, blockContent);
blocks.addAll(prepareFileBlocks(files));
return blocks;
}

public static List<LayoutBlock> mapBlocks(String text, JsonNode blockContent) {
List<LayoutBlock> 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<LayoutBlock> prepareFileBlocks(List<File> files) {
return files.stream()
.map(file -> FileBlock.builder().fileId(file.getId()).source(REMOTE_FILE_SOURCE).build())
.collect(toList());
}

private static List<LayoutBlock> 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<LayoutBlock> blocks = new ArrayList<>();
for (JsonNode node : arrayNode) {
blocks.add(GsonFactory.createSnakeCase().fromJson(node.toString(), LayoutBlock.class));
}
return blocks;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,30 @@
*/
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;
import io.camunda.connector.generator.java.annotation.TemplateProperty.PropertyConstraints;
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")
Expand Down Expand Up @@ -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<example.com|John Doe>\"\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\": \"<https://example.com|View request>\"\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 =
"<a href=\"https://docs.camunda.io/docs/apis-tools/camunda-api-rest/specifications/upload-document-alpha/\">Camunda documents</a> can be added as attachments")
List<Document> 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");
}
Expand All @@ -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<File> files = fileUploader.uploadDocuments(documents);
requestBuilder.blocks(mapBlocks(files, text, blockContent));
} else {
requestBuilder.blocks(mapBlocks(text, blockContent));
}

var request = requestBuilder.build();
Expand Down
Loading

0 comments on commit 5e24136

Please sign in to comment.