slackapi / java-slack-sdk

Slack Developer Kit (including Bolt for Java) for any JVM language
https://slack.dev/java-slack-sdk/
MIT License
571 stars 212 forks source link

Creating a message with image upload AND getting its ts #1347

Open marusic1514 opened 1 month ago

marusic1514 commented 1 month ago

I am trying to create a message with an image upload AND also have the ts (format "1723570494.731189", not just Unix ts) of when it happened so that I can use it as reference and update that message later if necessary or post in its thread.

I have tried the following methods and this is how each failed me:

1. Doing the filesGetUploadUrlExternal + postMultipart + filesCompleteUploadExternal

the approach roughly works as follows:

        val slack = Slack()
        val os = ByteArrayOutputStream()
        ImageIO.write(bufferedImage, "png", os)
        val imageByteArray = os.toByteArray()
        val imageBytes: ByteString = imageByteArray.toByteString()
        val byteArr = imageBytes.toRequestBody("image/png".toMediaTypeOrNull())
        val resGetUrl = slack.methods(token).filesGetUploadURLExternal {
            it.filename(fileName)
            it.token(token)
            it.length(imageBytes.size)
        }
        val uploadUrl = resGetUrl.uploadUrl
        val fileId = resGetUrl.fileId

        val res = MultipartBody.Builder()
            .setType(MultipartBody.FORM)
            .addFormDataPart("file", fileName, byteArr)
            .addFormDataPart("channels", channelId)
            .build()
        slack.httpClient.postMultipart(uploadUrl, chat.apiToken, res)
        slack.methods(chat.apiToken).filesCompleteUploadExternal {
            it.token(chat.apiToken)
            it.files(listOf(FilesCompleteUploadExternalRequest.FileDetails(fileId, "")))
            // note initial comment is ugly and is not in markdown
            it.initialComment(
                buildString {
                    appendLine("*${notice.header}*")
                    append(notice.textMain)
                }
            )
            it.channelId(channelId)
        }

This is great in terms of actually posting the whole text that I want to post along with the image. The problems with this approach, however are:

  1. The initialComment does not allow me to use blocks (as opposed to post to https://slack.com/api/chat.postMessage), so I cannot get it to format my message the way Id want it to. I am ok with this, though I wish initialComment had more flexibility.
  2. The result does not return the ts of the message via which I could later do an update to that message. This is a huge problem for me -- I want to be able to track the messages that I have posted and be able to modify them / send updates in a thread. I understand that this is due to async nature of the filesCompleteUploadExternal, but I nevertheless need a reference to the message I am posting. Going through message history and trying to later guess the message that was created with completeUploadExternal seems like a bad solution here.

2. filesGetUploadUrlExternal + postMultipart + filesCompleteUploadExternal + permaLink + ImageBlock

This works the same as above, except it does not post the file to a specific channel when it uploads it and it does not write the inital comment. Instead, it obtains the permalink of the file that got uploaded. It then makes a post message with an image block where the image is referenced by permaLink

        val permaLink = slack.methods(chat.apiToken).filesCompleteUploadExternal {
            it.token(chat.apiToken)
            it.files(listOf(FilesCompleteUploadExternalRequest.FileDetails(fileId, "")))
            // note initial comment is ugly
            it.initialComment(
                buildString {
                    appendLine("*${notice.header}*")
                    append(notice.textMain)
                }
            ) // note: this does not have rich text capabilities
            it.channelId(channelId)
        }.let {
            val firstFile = it.files.first()
            firstFile.permalink
        }
        rest.post(
            url = "https://slack.com/api/chat.postMessage",
            json = SlackUtil.Message(
                channelId = channelId,
                text = notice.title,
                blocks = listOfNotNull(
                    SlackUtil.Message.SectionBlock(
                        text = SlackUtil.Message.TextBlock(text = translate("**${notice.header}**"))
                    ),
                    SlackUtil.Message.SectionBlock(text = SlackUtil.Message.TextBlock(translate(notice.textMain))),
                    SlackUtil.Message.ImageBlock(image_url = permaLink, alt_text = "alt text"),
                ),
            ),
            headers = getHeaders(),
        )

The problem is that this fails because this runs immediately after upload, so it just errors out because it cannot obtain the file based on the permalink (as far as I understand at least). The response is the following:

{
  "ok" : false,
  "error" : "invalid_blocks",
  "errors" : [ "invalid slack file [json-pointer:/blocks/2/slack_file]" ],
  "response_metadata" : {
    "messages" : [ "[ERROR] invalid slack file [json-pointer:/blocks/2/slack_file]" ]
  }
}

If I were to wait a split second after uploadExternal is called and before the post with the permaLink happens, then the result is a successful posting of a message with the image (exactly how I'd want it! formatted, with the ts of the message which I can later use to update the message or post in its thread).

The response body of that looks like this:

{
  "ok" : true,
  "channel" : <channelId>,
  "ts" : "1723570494.731189",
  "message" : {
  ....

The problem with this is that I do not know how long to wait to be able to use the upload.

3. Finally, since I can obtain the permaLink but cannot use it in an image block, I tried doing the filesGetUploadUrlExternal + postMultipart + filesCompleteUploadExternal + permaLink + TextBlock

This is roughly the same as the second attempt, except I tried to bypass the image upload not being complete by using the permalink in the section text block instead of image block, hoping that it would unfurl and display the image when the image finally uploads. It does create a link to the image which can be clicked on and the file will be displayed, but it just does not display the image as part of the message as I'd hope it would

this is the code for the attempt:

 val permaLink = slack.methods(chat.apiToken).filesCompleteUploadExternal {
            it.token(chat.apiToken)
            it.files(listOf(FilesCompleteUploadExternalRequest.FileDetails(fileId, "")))
        }.let {
            val firstFile = it.files.first()
            firstFile.permalink
        }

        rest.post(
            url = "https://slack.com/api/chat.postMessage",
            json = SlackUtil.Message(
                channelId = channelId,
                text = notice.title,
                blocks = listOfNotNull(
                    SlackUtil.Message.SectionBlock(
                        text = SlackUtil.Message.TextBlock(text = translate("**${notice.header}**"))
                    ),
                    SlackUtil.Message.SectionBlock(text = SlackUtil.Message.TextBlock(translate(notice.textMain))),
                    SlackUtil.Message.SectionBlock(
                        text = SlackUtil.Message.TextBlock(
                            text = translate("**[my image]($permaLink)**")
                        )
                    )
                ),
            ),
            headers = getHeaders(),
        )

So it just has "my image" link at the bottom of the message that you can click on and it would display the image, which is not quite what I'd want.

The Slack SDK version

api("com.slack.api:slack-api-client-kotlin-extension:1.40.3")

Expected result:

I want to be able to post a message with an image in it (preferable, but not absolutely necessary that it is formatted using blocks) AND I absolutely need the reference to that message.

zimeg commented 1 month ago

Hi @marusic1514 👋 Thanks for sharing these attempts, it's super helpful to know what has been tried! 🙏

I can confirm that the timestamp portion of the response is from epoch and not the message_ts needed to follow the thread but this seems like expected behavior from these endpoints. Using the file ID of an uploaded file for a new message seems like it should work though, but I'm guessing asynchronous uploads on the server side of things might cause the errors you're finding... 😬

For now I'll mark this as a "server side issue" but a few options might work as a workaround! Polling for completed upload information can be alright when waiting for file information to propagate to at least the files.info method:

https://github.com/slackapi/java-slack-sdk/blob/71ed00d377ea5a65cc5412e1ca63e782b045d1b2/slack-api-client/src/test/java/test_with_remote_apis/methods/files_Test.java#L308-L319

This seems compatible with either option 1 or 2 depending on if the file is uploaded to a specific channel or not - option 2 might wait to upload the file with a block based message! 🧱

I do know this might not be the ideal answer, but I'm hoping it can offer some way of finding the message_ts! Please do let me know if this continues to cause problems with messaging or uploads in other ways though 😄

zimeg commented 1 month ago

This comment offers some more context on the file uploading that happens behind the scenes - https://github.com/slackapi/bolt-js/issues/2115#issuecomment-2282344744:

The files.completeUploadExternal API endpoint asynchronously completes the sharing of uploaded files because there are a few time-consuming tasks on the server side (e.g., security scans on the files).

marusic1514 commented 1 month ago

Thank you for the help! Actually, just tried to implement a workaround that I believe is what you are suggesting:

  1. I do the upload but do not specify the channel, so that it does not display the image prematurely
  2. I post a message without the image and take its timestamp so I can update it later
  3. I poll files info (like you suggested) and try to update the message in step 2 when I get that the file has been uploaded to the server. (I did in fact print the results and the file for the file id exists on the server, but it does not have all the same information had it been shared)

But now the problem is that the file is private and it just displays like so... I am assuming that is because the file is private. How do I make it not private so I can use the perma link in the image block? image

For completeness, here is the code:

 fun sendMessageWithImage(
        notice: Notice,
        channelId: String,
        fileName: String,
        bufferedImage: BufferedImage,
    ): String? {
        val slack = Slack()
        val os = ByteArrayOutputStream()
        ImageIO.write(bufferedImage, "png", os)
        val imageByteArray = os.toByteArray()
        val imageBytes: ByteString = imageByteArray.toByteString()
        val byteArr = imageBytes.toRequestBody("image/png".toMediaTypeOrNull())
        val resGetUrl = slack.methods(chat.apiToken).filesGetUploadURLExternal {
            it.filename(fileName)
            it.token(chat.apiToken)
            it.length(imageBytes.size)
        }
        val uploadUrl = resGetUrl.uploadUrl
        val fileId = resGetUrl.fileId

        val res = MultipartBody.Builder()
            .setType(MultipartBody.FORM)
            .addFormDataPart("file", fileName, byteArr)
            .addFormDataPart("channels", channelId)
            .build()
        slack.httpClient.postMultipart(uploadUrl, chat.apiToken, res)
        val permaLink = slack.methods(chat.apiToken).filesCompleteUploadExternal {
            it.token(chat.apiToken)
            it.files(listOf(FilesCompleteUploadExternalRequest.FileDetails(fileId, "")))
        }.let {
            val firstFile = it.files.first()
            firstFile.permalink
        }

        val allBlocks = listOf(
             SlackUtil.Message.SectionBlock(
                        text = SlackUtil.Message.TextBlock(text = translate("**${notice.header}**"))
                    ),
            SlackUtil.Message.SectionBlock(text = SlackUtil.Message.TextBlock(translate(notice.textMain))),
            SlackUtil.Message.ImageBlock(image_url = permaLink, alt_text = "alt text"),
        )

        return rest.post(
            url = "https://slack.com/api/chat.postMessage",
            json = SlackUtil.Message(
                channelId = channelId,
                text = notice.title,
                blocks = listOf(
                    SlackUtil.Message.SectionBlock(
                        text = SlackUtil.Message.TextBlock(text = translate("**${notice.header}**"))
                    ),
                    SlackUtil.Message.SectionBlock(text = SlackUtil.Message.TextBlock(translate(notice.textMain))),
                ),
            ),
            headers = getHeaders()
        ).let { response ->
            if (response.status != 200)
                logger.debug("sendMessageWithImageEmbedError Response=${response.status}, Body=${response.body}")
            if (response.status >= 400) null
            else response.jsonAs<SlackUtil.PostMessageResponse>().ts
                .also { messageId ->
                    // now we try to add image async by waiting until slack processes it
                    SlackUtil.delayedImageSend.sendAsync(
                        SlackUtil.ImageEmbedMessageInfo(
                            projectId = notice.projectId,
                            totalMessage = SlackUtil.Message(
                                channelId = channelId,
                                text = notice.title,
                                blocks = allBlocks,
                                ts = messageId
                            ),
                            fileId = fileId,
                        )
                    ) {
                        it.deliverAfter(Duration.ofSeconds(10).toMillis(), TimeUnit.MILLISECONDS)
                    }
                }
        }
    }

So the above code uploads the image to the server (but does not share it with the channel), creates a message without the image, and puts a message on the queue telling to go poll files info and once the file is there, update the original message with the new blocks.

The problem is that even though files info is there, the image does not show up (probably because it is private?)

I am reading this: https://slack.com/blog/developers/uploading-private-images-blockkit#:~:text=upload%20API%20states%3A,access%20to%20the%20underlying%20file. and I think I am following all the steps, except I do not know how to make the file public (I correctly use the same token everywhere)

zimeg commented 1 month ago

@marusic1514 Oh interesting! My guess would be related to a mismatched token, but if chat.apiToken is also used with the chat.postMessage and chat.update method I'm unsure... 🤔

Perhaps files aren't marked "public" when shared via chat.update but have to be shared with chat.postMessage instead? That'd seem like a bug to me but might be worth investigating! 🔍

I'll soon look into this myself, but please feel free to share updates or other findings!

marusic1514 commented 1 month ago

Yeah, additional update is that I tried doing

Slack().methods().filesSharedPublicURL {
            it.token(chat.userApiToken)
            it.file(fileId)
        }

Had to use user token because for the other token it would say invalid token. But this came back with FilesSharedPublicURLResponse(ok=false, warning=null, error=file_not_found,... which is disappointing given that the poll of files info returns FilesInfoResponse(ok=true, warning=null, error=null, needed=null,.... <permaLink + a bunch of other urls> just fine

And yes, you are right, if I were to do a postMessage right after waiting for the file to upload, then it would work fine (instead of doing the update later) -- all with the same information. Or if I completed Upload external by sharing it to channel id, it would also work. OR if I completed upload external by sharing it to a channel, posted a separate message, and then updated that message to have an image block, that would also correctly update the message.

So my impression is that doing the upload without sharing + updating the message with the private upload later somehow keeps the image private

marusic1514 commented 1 month ago

@zimeg Update:

So the issue in my last message with filesSharedPublicUrl was that it needs a user token, and I also had to initiate the upload with the same user token. Now that I am doing it, the response from filesSharedPublicUrl is "not_allowed" , which, according to this https://api.slack.com/methods/files.sharedPublicURL, means that "Public sharing has been disabled for this team. You may see this error if you are creating an external link from a free or trial workspace", which I think is actually the case for the test server I am using.

In other words, I have hit a wall with making this work in a testing environment. It looks like maybe if this were not a testing environment uploading the file + sharedPublicURL would have worked to make uploaded file public, and then the update could insert the shared message in the original post, but for now I will move forward with posting my image in a thread instead.

Posting the uploaded image a thread works and does not require the user token in any part of the process. One thing though: until I do the post, I do not think I can know if the image got uploaded or not because until the post is done, it has not been shared to a public channel (re what you were suggesting). I do notice that fields like mimeType and thumbnail do get populated seemingly around the same time when upload finishes.

If you identify the reason why update keeps the file private but postMessage doesn't, I would appreciate it! And also why sharedPublicURL is a paid-only feature when somehow posting a message with uploaded file isnt

zimeg commented 1 month ago

@marusic1514 Glad to hear the using the user token brought a different error - even if it's around needing a paid plan... 😬 Although the files.sharedPublicURL method shouldn't be required to share the file to thread since I believe this is moreso for sharing outside of Slack (which is the paid product decision AFAIK). Dropping a quick plug for the sandboxed enterprise workspaces the developer program has for testing features like this!

Posting the uploaded image a thread works and does not require the user token in any part of the process.

This is super interesting! Is this upload happening with chat.postMessage or files.completeUploadExternal? In either case polling files.info until a meaningful response is returned - like the mimetype - might be needed to be certain of a completed upload, but I'm finding that waiting a few seconds is often alright.

In this example the filesUploadV2 method is calling the same methods as both files.getUploadURLExternal and files.completeUploadExternal:

FilesUploadV2Response v2Response = slack.methods(token).filesUploadV2(r -> r
    .file(new File(filePath))
    .filename(fileName)
    .title(fileTitle)
);

com.slack.api.model.File v2File = v2Response.getFile();

TimeUnit.SECONDS.sleep(12);

SlackFileObject file = SlackFileObject.builder().id(v2File.getId()).build();

ChatPostMessageResponse message = slack.methods(token).chatPostMessage(r -> r
    .channel(channelId)
    .blocks(asBlocks(Blocks.image(i -> i.slackFile(file).altText("example")))));

System.out.println(message.getTs());

Posting the message with a completed file upload seems like the ideal approach to me as the message ts can be collected in the chat.postMessage response. I'm also finding that chat.update isn't changing file visibility and will share this with the team. Thanks for finding this!

Apologies for missing the filesSharedPublicURL in that earlier message too! Please let me know if the polling behavior without it seems to allow image uploads in saved messages or if another approach is still needed 🙏

zimeg commented 3 weeks ago

@marusic1514 We've done a bit of investigating into this and it seems like file permissions are being updated with chat.update but the client might not display the image correctly 🔍 AFAIK this can happen with chat.postMessage too, but I didn't notice it often.

I know this isn't a great workaround, but refreshing the page with CTRL+R should reload these images with the correct visibility 😅 I'm still in search of a more stable way to guarantee these uploads and updates, but am unsure of another fix at this time 🙏