Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backport release/8.7] feat(slack): add attachments support #4133

Merged
merged 1 commit into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Loading