From 34e22f90dc71e0f24f30b61066ff1b446d8e2e75 Mon Sep 17 00:00:00 2001 From: Cal Herries <39073188+calherries@users.noreply.github.com> Date: Tue, 30 Apr 2024 15:18:59 +0100 Subject: [PATCH] Move away from deprecated slack upload endpoints (#41974) --- src/metabase/integrations/slack.clj | 89 +++++++++-- src/metabase/pulse.clj | 2 + test/metabase/integrations/slack_test.clj | 100 +++++++------ .../slack_upload_file_response.json | 141 ++++++++++-------- 4 files changed, 210 insertions(+), 122 deletions(-) diff --git a/src/metabase/integrations/slack.clj b/src/metabase/integrations/slack.clj index 8dd8349fbbe..14326a3580a 100644 --- a/src/metabase/integrations/slack.clj +++ b/src/metabase/integrations/slack.clj @@ -318,27 +318,84 @@ channel-id' (get name->id channel-id channel-id)] channel-id')) +(defn- poll + "Returns `(thunk)` if the result satisfies the `done?` predicate within the timeout and nil otherwise." + [{:keys [thunk done? timeout-ms interval-ms]}] + (let [start-time (System/currentTimeMillis)] + (loop [] + (let [response (thunk)] + (if (done? response) + response + (let [current-time (System/currentTimeMillis) + elapsed-time (- current-time start-time)] + (if (>= elapsed-time timeout-ms) + nil ; timeout reached + (do + (Thread/sleep interval-ms) + (recur))))))))) + +(defn complete! + "Completes the file upload to a Slack channel by calling the `files.completeUploadExternal` endpoint, and polls the + same endpoint until the file is uploaded to the channel. Returns the URL of the uploaded file." + [& {:keys [channel-id file-id filename]}] + (let [complete! (fn [] + (POST "files.completeUploadExternal" + {:query-params {:files (json/generate-string [{:id file-id, :title filename}]) + :channel_id channel-id}})) + complete-response (try + (complete!) + (catch Throwable e + ;; If file upload fails with a "not_in_channel" error, we join the channel and try again. + ;; This is expected to happen the first time a Slack subscription is sent. + (if (= "not_in_channel" (:error-code (ex-data e))) + (do (join-channel! channel-id) + (complete!)) + (throw (ex-info (ex-message e) + (assoc (ex-data e) :channel-id channel-id, :filename filename)))))) + ;; Step 4: Poll the endpoint to confirm the file is uploaded to the channel + uploaded-to-channel? (fn [response] + (boolean (some-> response :files first :shares not-empty))) + _ (when-not (or + (uploaded-to-channel? complete-response) + (poll {:thunk complete! + :done? uploaded-to-channel? + ;; Cal 2024-04-30: this typically takes 1-2 seconds to succeed. + ;; If it takes more than 10 seconds, something else is wrong and we should abort. + :timeout-ms 3000 + :interval-ms 500})) + (throw (ex-info "Timed out waiting to confirm the file was uploaded to a Slack channel." + {:channel-id channel-id, :filename filename})))] + (get-in complete-response [:files 0 :url_private]))) + +(defn- get-upload-url! [filename file] + (POST "files.getUploadURLExternal" {:query-params {:filename filename + :length (count file)}})) + + +(defn- upload-file-to-url! [upload-url file] + (let [response (http/post upload-url {:multipart [{:name "file", :content file}]})] + (if (= (:status response) 200) + response + (throw (ex-info "Failed to upload file to Slack:" (select-keys response [:status :body])))))) + (mu/defn upload-file! - "Calls Slack API `files.upload` endpoint and returns the URL of the uploaded file." + "Calls Slack API `files.getUploadURLExternal` and `files.completeUploadExternal` endpoints to upload a file and returns + the URL of the uploaded file." [file :- NonEmptyByteArray filename :- ms/NonBlankString channel-id :- ms/NonBlankString] {:pre [(slack-configured?)]} - (let [request {:multipart [{:name "file", :content file} - {:name "filename", :content filename} - {:name "channels", :content channel-id}]} - response (try - (POST "files.upload" request) - (catch Throwable e - ;; If file upload fails with a "not_in_channel" error, we join the channel and try again. - ;; This is expected to happen the first time a Slack subscription is sent. - (if (= "not_in_channel" (:error-code (ex-data e))) - (do (-> channel-id - (maybe-lookup-id (slack-cached-channels-and-usernames)) - join-channel!) - (POST "files.upload" request)) - (throw e))))] - (u/prog1 (get-in response [:file :url_private]) + ;; TODO: we could make uploading files a lot faster by uploading the files in parallel. + ;; Steps 1 and 2 can be done for all files in parallel, and step 3 can be done once at the end. + (let [;; Step 1: Get the upload URL using files.getUploadURLExternal + {:keys [upload_url file_id]} (get-upload-url! filename file) + ;; Step 2: Upload the file to the obtained upload URL + _ (upload-file-to-url! upload_url file) + ;; Step 3: Complete the upload using files.completeUploadExternal + file-url (complete! {:channel-id (maybe-lookup-id channel-id (slack-cached-channels-and-usernames)) + :file-id file_id + :filename filename})] + (u/prog1 file-url (log/debug "Uploaded image" <>)))) (mu/defn post-chat-message! diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj index 43ff4778239..df0774978be 100644 --- a/src/metabase/pulse.clj +++ b/src/metabase/pulse.clj @@ -35,6 +35,8 @@ (:import (clojure.lang ExceptionInfo))) +(set! *warn-on-reflection* true) + ;;; ------------------------------------------------- PULSE SENDING -------------------------------------------------- (defn- is-card-empty? diff --git a/test/metabase/integrations/slack_test.clj b/test/metabase/integrations/slack_test.clj index ee96cefc1b8..f812e882488 100644 --- a/test/metabase/integrations/slack_test.clj +++ b/test/metabase/integrations/slack_test.clj @@ -174,56 +174,68 @@ (testing "upload-file!" (let [image-bytes (.getBytes "fake-picture") filename "wow.gif" - channel-id "C13372B6X"] - (http-fake/with-fake-routes {#"^https://slack.com/api/files\.upload.*" - (fn [_] (mock-200-response (slurp "./test_resources/slack_upload_file_response.json")))} + channel-id "C13372B6X" + upload-url "https://files.slack.com/upload/v1/CwABAAAAWgoAAZnBg" + fake-upload-routes {#"^https://slack.com/api/files\.getUploadURLExternal.*" + (fn [_] (mock-200-response {:ok true + :upload_url upload-url + :file_id "DDDDDDDDD-EEEEEEEEE"})) + + upload-url + (fn [_] (mock-200-response "OK")) + + #"^https://slack.com/api/files\.completeUploadExternal.*" + (fn [_] (mock-200-response (slurp "./test_resources/slack_upload_file_response.json")))}] + (http-fake/with-fake-routes fake-upload-routes (tu/with-temporary-setting-values [slack-token "test-token" slack-app-token nil] - (is (= "https://files.slack.com/files-pri/T078VLEET-F017C3TSBK6/wow.gif" + (is (= "https://files.slack.com/files-pri/DDDDDDDDD-EEEEEEEEE/wow.gif" (slack/upload-file! image-bytes filename channel-id))))) ;; Slack app token requires joining the `metabase_files` channel before uploading a file - (http-fake/with-fake-routes {#"^https://slack.com/api/files\.upload.*" - (fn [_] (mock-200-response (slurp "./test_resources/slack_upload_file_response.json"))) - #"^https://slack.com/api/conversations\.join.*" - (fn [_] (mock-200-response (slurp "./test_resources/slack_conversations_join_response.json")))} + (http-fake/with-fake-routes + (assoc fake-upload-routes + #"^https://slack.com/api/conversations\.join.*" + (fn [_] (mock-200-response (slurp "./test_resources/slack_conversations_join_response.json")))) (tu/with-temporary-setting-values [slack-token nil slack-app-token "test-token"] - (is (= "https://files.slack.com/files-pri/T078VLEET-F017C3TSBK6/wow.gif" - (slack/upload-file! image-bytes filename channel-id))))))) - (testing (str "upload-file! will attempt to join channels by internal slack id" - " but we can continue to use the channel name for posting") - (let [filename "wow.gif" - channel-id "metabase_files" - slack-id "CQXPZKNQ3RK" - joined? (atom false) - channel-info [{:display-name "#random", - :name "random", - :id "CT2FNGZSRPL", - :type "channel"} - {:display-name "#general", - :name "general", - :id "C4Q6LXLRA46", - :type "channel"} - {:display-name "#metabase_files", - :name channel-id, - ;; must look up "metabase_files" and find the id below - :id slack-id, - :type "channel"}]] - (tu/with-temporary-setting-values [slack/slack-app-token "slack-configured?" - slack/slack-cached-channels-and-usernames - {:channels channel-info}] - (with-redefs [slack/POST (fn [endpoint payload] - (case endpoint - "files.upload" - (if @joined? - {:file {:url_private filename}} - (throw (ex-info "Not in that channel" - {:error-code "not_in_channel"}))) - "conversations.join" - (reset! joined? (= (-> payload :form-params :channel) - slack-id))))] - (slack/upload-file! (.getBytes "fake-picture") filename channel-id) - (is @joined? (str "Did not attempt to join with slack-id " slack-id))))))) + (is (= "https://files.slack.com/files-pri/DDDDDDDDD-EEEEEEEEE/wow.gif" + (slack/upload-file! image-bytes filename channel-id)))) + (testing (str "upload-file! will attempt to join channels by internal slack id" + " but we can continue to use the channel name for posting") + (let [filename "wow.gif" + channel-id "metabase_files" + slack-id "CQXPZKNQ3RK" + joined? (atom false) + channel-info [{:display-name "#random", + :name "random", + :id "CT2FNGZSRPL", + :type "channel"} + {:display-name "#general", + :name "general", + :id "C4Q6LXLRA46", + :type "channel"} + {:display-name "#metabase_files", + :name channel-id, + ;; must look up "metabase_files" and find the id below + :id slack-id, + :type "channel"}] + post (var-get #'slack/POST)] + (with-redefs [slack/POST (fn [endpoint payload] + (case endpoint + "files.completeUploadExternal" + (if @joined? + (json/parse-string (slurp "./test_resources/slack_upload_file_response.json") keyword) + (throw (ex-info "Not in that channel" + {:error-code "not_in_channel"}))) + "conversations.join" + (reset! joined? (= (-> payload :form-params :channel) + slack-id)) + (post endpoint payload)))] + (tu/with-temporary-setting-values [slack/slack-app-token "slack-configured?" + slack/slack-cached-channels-and-usernames + {:channels channel-info}] + (slack/upload-file! (.getBytes "fake-picture") filename channel-id) + (is @joined? (str "Did not attempt to join with slack-id " slack-id)))))))))) (deftest maybe-lookup-id-test (let [f (var-get #'slack/maybe-lookup-id)] diff --git a/test_resources/slack_upload_file_response.json b/test_resources/slack_upload_file_response.json index b22a27bf5e3..930cf3a95cc 100644 --- a/test_resources/slack_upload_file_response.json +++ b/test_resources/slack_upload_file_response.json @@ -1,64 +1,81 @@ { - "ok" : true, - "file" : { - "ims" : [ ], - "thumb_80" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_80.png", - "thumb_360_h" : 152, - "channels" : [ "C94712B6X" ], - "editable" : false, - "is_external" : false, - "thumb_160" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_160.png", - "original_w" : 480, - "is_starred" : false, - "thumb_360_gif" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_360.gif", - "url_private_download" : "https://files.slack.com/files-pri/T078VLEET-F017C3TSBK6/download/wow.gif", - "name" : "wow.gif", - "permalink" : "https://metaboat.slack.com/files/U016YSX55QW/F017C3TSBK6/wow.gif", - "username" : "", - "mode" : "hosted", - "thumb_480_h" : 203, - "created" : 1594847102, - "display_as_bot" : false, - "thumb_480" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_480.png", - "deanimate_gif" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_deanimate_gif.png", - "mimetype" : "image/gif", - "size" : 1929620, - "title" : "wow", - "is_public" : true, - "id" : "F017C3TSBK6", - "original_h" : 203, - "comments_count" : 0, - "external_type" : "", - "thumb_480_w" : 480, - "thumb_360_w" : 360, - "thumb_tiny" : "AwAUADDOJ5qXeViABOSeuaYkLycqufxp8kTJGC/BzwM0gJrZQR8zZ+ppLnK5Az+eadZsPMU4HAOfpSvGbn/VFSfQnFJDZBA+HzV0zKFJPaoBZSxgs5QYGcbuaV9u00CY2yOFJqo7tIxZjkmrdn9xvpVKmBJvZEKqcBhg09ZGEisvBJzxUTdBTx96P/PemgZtSjMLPnkKTWdAv2gM8hOd2MDgVpSf8er/AO4f5Vn2P+qb/fNSB//Z", - "public_url_shared" : false, - "thumb_360" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_360.png", - "groups" : [ ], - "filetype" : "gif", - "url_private" : "https://files.slack.com/files-pri/T078VLEET-F017C3TSBK6/wow.gif", - "pretty_type" : "GIF", - "has_rich_preview" : false, - "timestamp" : 1594847102, - "thumb_480_gif" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_480.gif", - "user" : "U016YSX55QW", - "thumb_64" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_64.png", - "shares" : { - "public" : { - "C94712B6X" : [ { - "reply_users" : [ ], - "reply_users_count" : 0, - "reply_count" : 0, - "ts" : "1594847103.002500", - "channel_name" : "wow", - "team_id" : "T078VLEET" - } ] - } - }, - "permalink_public" : "https://slack-files.com/T078VLEET-F017C3TSBK6-d9c051e26f" - }, - "warning" : "superfluous_charset", - "response_metadata" : { - "warnings" : [ "superfluous_charset" ] - } + "ok": true, + "files": [ + { + "thumb_1024_w": 1024, + "ims": [], + "thumb_1024_h": 734, + "has_more_shares": false, + "thumb_1024": "https://files.slack.com/files-tmb/DDDDDDDDD-EEEEEEEEE-5e53e73e7a/image_1024.png", + "thumb_80": "https://files.slack.com/files-tmb/DDDDDDDDD-EEEEEEEEE-5e53e73e7a/image_80.png", + "thumb_360_h": 258, + "channels": [ + "C0713QAHUAX" + ], + "editable": false, + "is_external": false, + "thumb_160": "https://files.slack.com/files-tmb/DDDDDDDDD-EEEEEEEEE-5e53e73e7a/image_160.png", + "thumb_960": "https://files.slack.com/files-tmb/DDDDDDDDD-EEEEEEEEE-5e53e73e7a/image_960.png", + "thumb_960_w": 960, + "original_w": 1200, + "is_starred": false, + "url_private_download": "https://files.slack.com/files-pri/DDDDDDDDD-EEEEEEEEE/download/wow.gif", + "name": "wow.gif", + "permalink": "https://metaboat.slack.com/files/UUUUUUUUUU/EEEEEEEEE/wow.gif", + "username": "", + "mode": "hosted", + "thumb_480_h": 344, + "created": 1714470800, + "display_as_bot": false, + "thumb_480": "https://files.slack.com/files-tmb/DDDDDDDDD-EEEEEEEEE-5e53e73e7a/image_480.png", + "mimetype": "image/png", + "size": 89612, + "title": "wow.gif", + "media_display_type": "unknown", + "thumb_800": "https://files.slack.com/files-tmb/DDDDDDDDD-EEEEEEEEE-5e53e73e7a/image_800.png", + "is_public": true, + "id": "EEEEEEEEE", + "original_h": 860, + "comments_count": 0, + "external_type": "", + "thumb_480_w": 480, + "thumb_360_w": 360, + "thumb_720_h": 516, + "thumb_720_w": 720, + "thumb_tiny": "AwAiADDTNNYkKee1KTTW+6fpQBD5j/3jR5j/AN40z/P+eaKoB/mP/eNPidmfBYkVDUsH3ifagCc01vun6U401gSpHtUgVqKf5L+35/8A1qPJf2/P/wCtVAMqaD7pPvTPJf2/P/61SxKVXB9aTAfRRRSAKKKKACiiigD/2Q==", + "public_url_shared": false, + "user_team": "T078VCLCR", + "thumb_360": "https://files.slack.com/files-tmb/DDDDDDDDD-EEEEEEEEE-5e53e73e7a/image_360.png", + "groups": [], + "filetype": "png", + "url_private": "https://files.slack.com/files-pri/DDDDDDDDD-EEEEEEEEE/wow.gif", + "thumb_720": "https://files.slack.com/files-tmb/DDDDDDDDD-EEEEEEEEE-5e53e73e7a/image_720.png", + "thumb_960_h": 688, + "pretty_type": "PNG", + "has_rich_preview": false, + "timestamp": 1714470800, + "thumb_800_w": 800, + "user": "UUUUUUUUUU", + "thumb_64": "https://files.slack.com/files-tmb/DDDDDDDDD-EEEEEEEEE-5e53e73e7a/image_64.png", + "shares": { + "public": { + "C0713QAHUAX": [ + { + "reply_users": [], + "reply_users_count": 0, + "reply_count": 0, + "ts": "1714470801.942809", + "channel_name": "some-channel", + "team_id": "T078VCLCR", + "share_user_id": "UUUUUUUUUU", + "source": "UNKNOWN" + } + ] + } + }, + "thumb_800_h": 573, + "permalink_public": "https://slack-files.com/DDDDDDDDD-EEEEEEEEE-d533f610b6", + "file_access": "visible" + } + ] } \ No newline at end of file -- GitLab