diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj index 1dfc56e0856d666d965c217a6771de75465b444d..1479d56a4d742305805f4759b7ad389929c5438c 100644 --- a/src/metabase/api/pulse.clj +++ b/src/metabase/api/pulse.clj @@ -167,7 +167,9 @@ (future (slack/refresh-channels-and-usernames-when-needed!)) (assoc-in chan-types [:slack :fields 0 :options] - (slack/slack-cached-channels-and-usernames)) + (->> (slack/slack-cached-channels-and-usernames) + :channels + (map :display-name))) (catch Throwable e (assoc-in chan-types [:slack :error] (.getMessage e)))))})) diff --git a/src/metabase/integrations/slack.clj b/src/metabase/integrations/slack.clj index 57da89b21bc424f75cf267ff90409f00805d1d3f..f242c242faa6c0f6d3a151ac8d19ce52a554cd26 100644 --- a/src/metabase/integrations/slack.clj +++ b/src/metabase/integrations/slack.clj @@ -163,6 +163,14 @@ (lazy-seq (paged-list-request endpoint response->data (assoc params :cursor next-cursor))))))))) +(defn channel-transform + "Transformation from slack's api representation of a channel to our own." + [channel] + {:display-name (str \# (:name channel)) + :name (:name channel) + :id (:id channel) + :type "channel"}) + (defn conversations-list "Calls Slack API `conversations.list` and returns list of available 'conversations' (channels and direct messages). By default only fetches channels, and returns them with their # prefix. Note the call to [[paged-list-request]] will @@ -171,19 +179,16 @@ (let [params (merge {:exclude_archived true, :types "public_channel"} query-parameters)] (paged-list-request "conversations.list" ;; response -> channel names - #(->> % - :channels - (map (fn [channel] - (str \# (:name channel))))) + #(->> % :channels (map channel-transform)) params))) (defn channel-exists? "Returns true if the channel it exists." [channel-name] - (and channel-name - (contains? - (set (slack-cached-channels-and-usernames)) - (str \# channel-name)))) + (let [channel-names (into #{} (comp (map (juxt :name :id)) + cat) + (:channels (slack-cached-channels-and-usernames)))] + (and channel-name (contains? channel-names channel-name)))) (s/defn valid-token? "Check whether a Slack token is valid by checking if the `conversations.list` Slack api accepts it." @@ -196,16 +201,21 @@ false (throw e))))) +(defn user-transform + "Tranformation from slack api user to our own internal representation." + [member] + {:display-name (str \@ (:name member)) + :type "user" + :name (:name member) + :id (:id member)}) + (defn users-list "Calls Slack API `users.list` endpoint and returns the list of available users with their @ prefix. Note the call to [[paged-list-request]] will only fetch the first [[max-list-results]] items." [& {:as query-parameters}] (->> (paged-list-request "users.list" ;; response -> user names - #(->> % - :members - (map (fn [member] - (str \@ (:name member))))) + #(->> % :members (map user-transform)) query-parameters) ;; remove deleted users and bots. At the time of this writing there's no way to do this in the Slack API ;; itself so we need to do it after the fact. @@ -223,7 +233,7 @@ "Clear the Slack channels cache, and reset its last-updated timestamp to its default value (the Unix epoch)." [] (slack-channels-and-usernames-last-updated! zoned-time-epoch) - (slack-cached-channels-and-usernames! [])) + (slack-cached-channels-and-usernames! {:channels []})) (defn refresh-channels-and-usernames! "Refreshes users and conversations in slack-cache. finds both in parallel, sets @@ -233,7 +243,7 @@ (log/info "Refreshing slack channels and usernames.") (let [users (future (vec (users-list))) conversations (future (vec (conversations-list)))] - (slack-cached-channels-and-usernames! (concat @conversations @users)) + (slack-cached-channels-and-usernames! {:channels (concat @conversations @users)}) (slack-channels-and-usernames-last-updated! (t/zoned-date-time))))) (defn refresh-channels-and-usernames-when-needed! @@ -272,6 +282,18 @@ [channel-id :- su/NonBlankString] (POST "conversations.join" {:form-params {:channel channel-id}})) +(defn- maybe-lookup-id + "Slack requires the slack app to be in the channel that we post all of our attachments to. Slack changed (around June + 2022 #23229) the \"conversations.join\" api to require the internal slack id rather than the common name. This makes + a lot of sense to ensure we continue to operate despite channel renames. Attempt to look up the channel-id in the + list of channels to obtain the internal id. Fallback to using the current channel-id." + [channel-id cached-channels] + (let [name->id (into {} (comp (filter (comp #{"channel"} :type)) + (map (juxt :name :id))) + (:channels cached-channels)) + channel-id' (get name->id channel-id channel-id)] + channel-id')) + (s/defn upload-file! "Calls Slack API `files.upload` endpoint and returns the URL of the uploaded file." [file :- NonEmptyByteArray, filename :- su/NonBlankString, channel-id :- su/NonBlankString] @@ -285,7 +307,9 @@ ;; 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) + (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]) diff --git a/src/metabase/task/refresh_slack_channel_user_cache.clj b/src/metabase/task/refresh_slack_channel_user_cache.clj index abbef1e1554ad891236ab6ca17c8225f1a72c060..21259b68c996fc264ac84551244d887bcd019a41 100644 --- a/src/metabase/task/refresh_slack_channel_user_cache.clj +++ b/src/metabase/task/refresh_slack_channel_user_cache.clj @@ -14,7 +14,7 @@ start-ms (System/currentTimeMillis) _ (slack/refresh-channels-and-usernames!)] (log/info (trs "Slack user/channel startup cache refreshed with {0} entries, took {1}ms." - (count (slack/slack-cached-channels-and-usernames)) + (count (:channels (slack/slack-cached-channels-and-usernames))) (- (System/currentTimeMillis) start-ms)))) (log/info (trs "Slack is not configured, not refreshing slack user/channel cache.")))) diff --git a/test/metabase/api/pulse_test.clj b/test/metabase/api/pulse_test.clj index 8a97e676095bd0e4b956bd2348a2fae678cd4e8f..61d0836111857e9602cc18ffd8701824008f1e04 100644 --- a/test/metabase/api/pulse_test.clj +++ b/test/metabase/api/pulse_test.clj @@ -972,17 +972,28 @@ (deftest form-input-test (testing "GET /api/pulse/form_input" (testing "Check that Slack channels come back when configured" - (mt/with-temporary-setting-values [slack-token nil - slack-app-token "something"] - (with-redefs [slack/conversations-list (constantly ["#foo" "#two" "#general"]) - slack/users-list (constantly ["@bar" "@baz"])] - ;; set the cache to these values - (slack/slack-cached-channels-and-usernames! (concat (slack/conversations-list) (slack/users-list))) - ;; don't let the cache refresh itself (it will refetch if it is too old) - (slack/slack-channels-and-usernames-last-updated! (t/zoned-date-time)) - (is (= [{:name "channel", :type "select", :displayName "Post to", :options ["#foo" "#two" "#general" "@bar" "@baz"], :required true}] - (-> (mt/user-http-request :rasta :get 200 "pulse/form_input") - (get-in [:channels :slack :fields]))))))) + (mt/with-temporary-setting-values [slack/slack-channels-and-usernames-last-updated + (t/zoned-date-time) + + slack/slack-app-token "test-token" + + slack/slack-cached-channels-and-usernames + {:channels [{:type "channel" + :name "foo" + :display-name "#foo" + :id "CAAS3DD9XND"} + {:type "channel" + :name "general" + :display-name "#general" + :id "C3MJRZ9EUVA"} + {:type "user" + :name "user1" + :id "U1DYU9W3WZ2" + :display-name "@user1"}]}] + (is (= [{:name "channel", :type "select", :displayName "Post to", + :options ["#foo" "#general" "@user1"], :required true}] + (-> (mt/user-http-request :rasta :get 200 "pulse/form_input") + (get-in [:channels :slack :fields])))))) (testing "When slack is not configured, `form_input` returns no channels" (mt/with-temporary-setting-values [slack-token nil diff --git a/test/metabase/api/slack_test.clj b/test/metabase/api/slack_test.clj index adb38a34a84b8b92dd01262876577bca149d5d30..725d9e3bc74d5f125899ea4a0f63ff94683b3e85 100644 --- a/test/metabase/api/slack_test.clj +++ b/test/metabase/api/slack_test.clj @@ -10,9 +10,9 @@ (testing "PUT /api/slack/settings" (testing "An admin can set a valid Slack app token to the slack-app-token setting, and any value in the `slack-token` setting is cleared" - (with-redefs [slack/valid-token? (constantly true) - slack/channel-exists? (constantly true) - slack/refresh-channels-and-usernames! (constantly nil) + (with-redefs [slack/valid-token? (constantly true) + slack/channel-exists? (constantly true) + slack/refresh-channels-and-usernames! (constantly nil) slack/refresh-channels-and-usernames-when-needed! (constantly nil)] (mt/with-temporary-setting-values [slack-app-token nil slack-token "fake-token"] @@ -22,16 +22,17 @@ (testing "A 400 error is returned if the Slack app token is invalid" (mt/with-temporary-setting-values [slack-app-token nil] - (with-redefs [slack/valid-token? (constantly false) + (with-redefs [slack/valid-token? (constantly false) ;; Token validation is skipped by default in test environments; overriding `is-test?` ensures ;; that validation occurs - config/is-test? false - slack/refresh-channels-and-usernames! (constantly nil) + config/is-test? false + slack/refresh-channels-and-usernames! (constantly nil) slack/refresh-channels-and-usernames-when-needed! (constantly nil)] (let [response (mt/user-http-request :crowberto :put 400 "slack/settings" {:slack-app-token "fake-token"})] (is (= {:slack-app-token "invalid token"} (:errors response))) (is (= nil (slack/slack-app-token))) - (is (= [] (slack/slack-cached-channels-and-usernames))))))) + (is (= {:channels []} + (slack/slack-cached-channels-and-usernames))))))) (testing "The Slack files channel setting can be set by an admin, and the leading # is stripped if it is present" (mt/with-temporary-setting-values [slack-files-channel nil @@ -59,7 +60,8 @@ ;; The files channel is reset to its default value (is (= "metabase_files" (slack/slack-files-channel))) ;; The cache is empty, and its last-updated value is reset to its default value - (is (= [] (slack/slack-cached-channels-and-usernames))) + (is (= {:channels []} + (slack/slack-cached-channels-and-usernames))) (is (= @#'slack/zoned-time-epoch (slack/slack-channels-and-usernames-last-updated))))) diff --git a/test/metabase/integrations/slack_test.clj b/test/metabase/integrations/slack_test.clj index 5ffb4fd82f2109b341d1ff2a94ca1da4e82ffd1f..6e221f30d1b160a82aa8906fc67afe17fae61e74 100644 --- a/test/metabase/integrations/slack_test.clj +++ b/test/metabase/integrations/slack_test.clj @@ -1,7 +1,6 @@ (ns metabase.integrations.slack-test (:require [cheshire.core :as json] [clj-http.fake :as http-fake] - [clojure.java.io :as io] [clojure.test :refer :all] [medley.core :as m] [metabase.email.messages :as messages] @@ -10,7 +9,6 @@ [metabase.test.util :as tu] [schema.core :as s]) (:import java.nio.charset.Charset - org.apache.commons.io.IOUtils org.apache.http.client.utils.URLEncodedUtils org.apache.http.NameValuePair)) @@ -83,9 +81,8 @@ (testing "should be able to fetch channels and paginate" (http-fake/with-fake-routes {conversations-endpoint (comp mock-200-response mock-conversations-response-body)} - (let [expected-result (map - (comp #(str \# %) :name) - (concat (mock-conversations) (mock-conversations)))] + (let [expected-result (map slack/channel-transform + (concat (mock-conversations) (mock-conversations)))] (tu/with-temporary-setting-values [slack-token "test-token" slack-app-token nil] (is (= expected-result @@ -136,9 +133,8 @@ (testing "should be able to fetch list of users and page" (http-fake/with-fake-routes {users-endpoint (comp mock-200-response mock-users-response-body)} - (let [expected-result (map - (comp #(str \@ %) :name) - (concat (mock-users) (mock-users)))] + (let [expected-result (map slack/user-transform + (concat (mock-users) (mock-users)))] (tu/with-temporary-setting-values [slack-token nil slack-app-token "test-token"] (is (= expected-result @@ -152,10 +148,14 @@ (testing "files-channel" (testing "Should be able to get the files-channel from the cache (if it exists)" (tu/with-temporary-setting-values [slack-files-channel "general" - slack-cached-channels-and-usernames ["#general" "#random" "#off-topic" "#cooking" "@john" "@james" "@jordan"]] + slack-cached-channels-and-usernames + {:channels (mapv (fn [c] {:name c :id c}) + ["general" "random" "off-topic" + "cooking" "john" "james" "jordan"])}] (is (= "general" (slack/files-channel)))) (tu/with-temporary-setting-values [slack-files-channel "not_in_the_cache" - slack-cached-channels-and-usernames ["#general"]] + slack-cached-channels-and-usernames + {:channels [{:name "general" :id "C0G9QKBBL"}]}] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Slack channel named.*is missing.*" @@ -163,8 +163,7 @@ (deftest upload-file!-test (testing "upload-file!" - (let [image-bytes (with-open [is (io/input-stream (io/resource "frontend_client/favicon.ico"))] - (IOUtils/toByteArray is)) + (let [image-bytes (.getBytes "fake-picture") filename "wow.gif" channel-id "C13372B6X"] (http-fake/with-fake-routes {#"^https://slack.com/api/files\.upload.*" @@ -181,7 +180,65 @@ (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)))))))) + (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))))))) + +(deftest maybe-lookup-id-test + (let [f (var-get #'slack/maybe-lookup-id)] + (testing "On new v2 shape" + (testing "Returns original if not found" + (is (= "needle" + (f "needle" {:channels [{:display-name "#other1" + :name "other1" + :type "channel" + :id "CR65C4ZJVIW"} + {:display-name "#other2" + :name "other2" + :type "channel" + :id "C87LQNL0Y23"}]})))) + (testing "Returns the slack internal id if found" + (is (= "slack-id" + (f "needle" {:channels [{:display-name "#other1" + :name "other1" + :type "channel" + :id "CR65C4ZJVIW"} + {:display-name "#needle" + :name "needle" + :type "channel" + :id "slack-id"}]}))))))) (deftest post-chat-message!-test (testing "post-chat-message!"