(ns metabase.integrations.slack (:require [clj-http.client :as http] [clojure.java.io :as io] [clojure.string :as str] [java-time.api :as t] [medley.core :as m] [metabase.events :as events] [metabase.models.setting :as setting :refer [defsetting]] [metabase.util :as u] [metabase.util.date-2 :as u.date] [metabase.util.i18n :refer [deferred-tru tru]] [metabase.util.json :as json] [metabase.util.log :as log] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] [metabase.util.string :as u.str])) | |
(set! *warn-on-reflection* true) | |
(defsetting slack-token (deferred-tru (str "Deprecated Slack API token for connecting the Metabase Slack bot. " "Please use a new Slack app integration instead.")) :deprecated "0.42.0" :encryption :when-encryption-key-set :visibility :settings-manager :doc false :audit :never :export? false) | |
(defsetting slack-app-token (deferred-tru (str "Bot user OAuth token for connecting the Metabase Slack app. " "This should be used for all new Slack integrations starting in Metabase v0.42.0.")) :encryption :when-encryption-key-set :visibility :settings-manager :getter (fn [] (-> (setting/get-value-of-type :string :slack-app-token) (u.str/mask 9)))) | |
(defn- unobfuscated-slack-app-token [] (setting/get-value-of-type :string :slack-app-token)) | |
(defsetting slack-token-valid? (deferred-tru (str "Whether the current Slack app token, if set, is valid. " "Set to 'false' if a Slack API request returns an auth error.")) :type :boolean :visibility :settings-manager :doc false :audit :never) | |
Converts empty strings to | (defn process-files-channel-name [channel-name] (when-not (str/blank? channel-name) (if (str/starts-with? channel-name "#") (subs channel-name 1) channel-name))) |
A cache shared between instances for storing an instance's slack channels and users. | (defsetting slack-cached-channels-and-usernames :encryption :when-encryption-key-set :visibility :internal :type :json :doc false :audit :never :export? false) |
(def ^:private zoned-time-epoch (t/zoned-date-time 1970 1 1 0)) | |
The updated-at time for the [[slack-cached-channels-and-usernames]] setting. | (defsetting slack-channels-and-usernames-last-updated :visibility :internal :cache? false :type :timestamp :default zoned-time-epoch :doc false :audit :never :export? false) |
(defsetting slack-files-channel (deferred-tru "The name of the channel to which Metabase files should be initially uploaded") :default "metabase_files" :encryption :no :visibility :settings-manager :audit :getter :setter (fn [channel-name] (setting/set-value-of-type! :string :slack-files-channel (process-files-channel-name channel-name)))) | |
(defsetting slack-bug-report-channel (deferred-tru "The name of the channel where bug reports should be posted") :default "metabase-bugs" :encryption :no :visibility :settings-manager :audit :getter :export? false :setter (fn [channel-name] (setting/set-value-of-type! :string :slack-bug-report-channel (process-files-channel-name channel-name)))) | |
Is Slack integration configured? | (defn slack-configured? [] (boolean (or (seq (slack-app-token)) (seq (slack-token))))) |
List of error codes that indicate an invalid or revoked Slack token. | (def ^:private slack-token-error-codes ;; If any of these error codes are received from the Slack API, we send an email to all admins indicating that the ;; Slack integration is broken. In practice, the "account_inactive" error code is the one that is most likely to be ;; received. This would happen if access to the Slack workspace is manually revoked via the Slack UI. #{"invalid_auth", "account_inactive", "token_revoked", "token_expired"}) |
Whether to send an email to all admins when an invalid or revoked token error is received in response to a Slack
API call. Should be set to false when checking if an unsaved token is valid. (Default: | (def ^:private ^:dynamic *send-token-error-emails?* true) |
(defn- handle-error [body] (let [invalid-token? (slack-token-error-codes (:error body)) message (if invalid-token? "Invalid token" (format "Slack API error: %s" (:error body))) error (if invalid-token? {:error-code (:error body) :errors {:slack-token message}} {:error-code (:error body) :message message :response body})] (when (and invalid-token? *send-token-error-emails?*) ;; Check `slack-token-valid?` before sending emails to avoid sending repeat emails for the same invalid token. ;; We should send an email if `slack-token-valid?` is `true` or `nil` (i.e. a pre-existing bot integration is ;; being used) (when (slack-token-valid?) (events/publish-event! :event/slack-token-invalid {})) (slack-token-valid?! false)) (when invalid-token? (log/warn (u/colorize :red (str "🔒 Your Slack authorization token is invalid or has been revoked. Please" " update your integration in Admin Settings -> Slack.")))) (throw (ex-info message error)))) | |
(defn- handle-response [{:keys [status body]}] (with-open [reader (io/reader body)] (let [body (json/decode+kw reader)] (if (and (= 200 status) (:ok body)) body (handle-error body))))) | |
(defn- do-slack-request [request-fn endpoint request] (let [token (or (get-in request [:query-params :token]) (get-in request [:form-params :token]) (unobfuscated-slack-app-token) (slack-token))] (when token (let [url (str "https://slack.com/api/" (name endpoint)) _ (log/tracef "Slack API request: %s %s" (pr-str url) (pr-str request)) request (m/deep-merge {:headers {:authorization (str "Bearer\n" token)} :as :stream ;; use a relatively long connection timeout (10 seconds) in cases where we're fetching big ;; amounts of data -- see #11735 :conn-timeout 10000 :socket-timeout 10000} (m/dissoc-in request [:query-params :token]))] (try (handle-response (request-fn url request)) (catch Throwable e (throw (ex-info (.getMessage e) (merge (ex-data e) {:url url}) e)))))))) | |
Make a GET request to the Slack API. | (defn- GET [endpoint & {:as query-params}] (do-slack-request http/get endpoint {:query-params query-params})) |
Make a POST request to the Slack API. | (defn- POST [endpoint body] (do-slack-request http/post endpoint body)) |
Get a cursor for the next page of results in a Slack API response, if one exists. | (defn- next-cursor [response] (not-empty (get-in response [:response_metadata :next_cursor]))) |
Absolute maximum number of results to fetch from Slack API list endpoints. To prevent unbounded pagination of results. Don't set this too low -- some orgs have many thousands of channels (see #12978) | (def ^:private max-list-results 10000) |
Make a GET request to a Slack API list | (defn- paged-list-request [endpoint response->data params] ;; use default limit (page size) of 1000 instead of 100 so we don't end up making a hundred API requests for orgs ;; with a huge number of channels or users. (let [default-params {:limit 1000} response (m/mapply GET endpoint (merge default-params params)) data (response->data response)] (when (seq response) (take max-list-results (concat data (when-let [next-cursor (next-cursor response)] (lazy-seq (paged-list-request endpoint response->data (assoc params :cursor next-cursor))))))))) |
Transformation from slack's api representation of a channel to our own. | (defn channel-transform [channel] {:display-name (str \# (:name channel)) :name (:name channel) :id (:id channel) :type "channel"}) |
Calls Slack API | (defn conversations-list [& {:as query-parameters}] (let [params (merge {:exclude_archived true, :types "public_channel"} query-parameters)] (paged-list-request "conversations.list" ;; response -> channel names #(->> % :channels (map channel-transform)) params))) |
Returns a Boolean indicating whether a channel with a given name exists in the cache. | (defn channel-exists? [channel-name] (boolean (let [channel-names (into #{} (comp (map (juxt :name :id)) cat) (:channels (slack-cached-channels-and-usernames)))] (and channel-name (contains? channel-names channel-name))))) |
Check whether a Slack token is valid by checking if the | (mu/defn valid-token? [token :- ms/NonBlankString] (try (binding [*send-token-error-emails?* false] (boolean (take 1 (:channels (GET "conversations.list" :limit 1, :token token))))) (catch Throwable e (if (slack-token-error-codes (:error-code (ex-data e))) false (throw e))))) |
Tranformation from slack api user to our own internal representation. | (defn user-transform [member] {:display-name (str \@ (:name member)) :type "user" :name (:name member) :id (:id member)}) |
Calls Slack API | (defn users-list [& {:as query-parameters}] (->> (paged-list-request "users.list" ;; response -> user names #(->> % :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. (remove :deleted) (remove :is_bot))) |
(defonce ^:private refresh-lock (Object.)) | |
(defn- needs-refresh? [] (u.date/older-than? (slack-channels-and-usernames-last-updated) (t/minutes 10))) | |
Clear the Slack channels cache, and reset its last-updated timestamp to its default value (the Unix epoch). | (defn clear-channel-cache! [] (slack-channels-and-usernames-last-updated! zoned-time-epoch) (slack-cached-channels-and-usernames! {:channels []})) |
Refreshes users and conversations in slack-cache. finds both in parallel, sets [[slack-cached-channels-and-usernames]], and resets the [[slack-channels-and-usernames-last-updated]] time. | (defn refresh-channels-and-usernames! [] (when (slack-configured?) (log/info "Refreshing slack channels and usernames.") (let [users (future (vec (users-list))) conversations (future (vec (conversations-list)))] (slack-cached-channels-and-usernames! {:channels (concat @conversations @users)}) (slack-channels-and-usernames-last-updated! (t/zoned-date-time))))) |
Refreshes users and conversations in slack-cache on a per-instance lock. | (defn refresh-channels-and-usernames-when-needed! [] (when (needs-refresh?) (locking refresh-lock (when (needs-refresh?) (refresh-channels-and-usernames!))))) |
Looks in [[slack-cached-channels-and-usernames]] to check whether a channel exists with the expected name from the [[slack-files-channel]] setting with an # prefix. If it does, returns the channel details as a map. If it doesn't, throws an error that advises an admin to create it. | (defn files-channel [] (let [channel-name (slack-files-channel)] (if (channel-exists? channel-name) channel-name (let [message (str (tru "Slack channel named `{0}` is missing!" channel-name) " " (tru "Please create or unarchive the channel in order to complete the Slack integration.") " " (tru "The channel is used for storing images that are included in dashboard subscriptions."))] (log/error (u/format-color 'red message)) (throw (ex-info message {:status-code 400})))))) |
Looks in [[slack-cached-channels-and-usernames]] to check whether a channel exists with the expected name from the [[slack-bug-report-channel]] setting with an # prefix. If it does, returns the channel details as a map. If it doesn't, throws an error that advices an admin to create it. | (defn bug-report-channel [] (let [channel-name (slack-bug-report-channel)] (if (channel-exists? channel-name) channel-name (let [message (str (tru "Slack channel named `{0}` is missing!" channel-name) " " (tru "Please create or unarchive the channel in order to complete the Slack integration.") " " (tru "The channel is used for storing bug reports."))] (log/error (u/format-color 'red message)) (throw (ex-info message {:status-code 400})))))) |
(def ^:private NonEmptyByteArray [:and (ms/InstanceOfClass (Class/forName "[B")) [:fn not-empty]]) | |
Given a channel ID, calls Slack API | (mu/defn join-channel! [channel-id :- ms/NonBlankString] (POST "conversations.join" {:form-params {:channel channel-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. | (defn- maybe-lookup-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')) |
Returns | (defn- poll [{:keys [thunk done? timeout-ms ^long 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))))))))) |
Completes the file upload to a Slack channel by calling the | (defn complete! [& {:keys [channel-id file-id filename]}] (let [complete! (fn [] (POST "files.completeUploadExternal" {:query-params {:files (json/encode [{: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) (u/poll {:thunk complete! :done? uploaded-to-channel? ;; Cal 2024-04-30: this typically takes 1-2 seconds to succeed. ;; If it takes more than 20 seconds, something else is wrong and we should abort. :timeout-ms 20000 :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])))))) | |
Calls Slack API | (mu/defn upload-file! [file :- NonEmptyByteArray filename :- ms/NonBlankString channel-id :- ms/NonBlankString] {:pre [(slack-configured?)]} ;; 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 {:url file-url :id file_id} (log/debug "Uploaded image" (:url <>))))) |
Calls Slack API | (mu/defn post-chat-message! [channel-id :- ms/NonBlankString text-or-nil :- [:maybe :string] & [message-content]] ;; TODO: it would be nice to have an emoji or icon image to use here (let [base-params {:channel channel-id :username "MetaBot" :icon_url "http://static.metabase.com/metabot_slack_avatar_whitebg.png" :text text-or-nil} message-params (cond ;; If message-content contains :blocks key, it's a new-style message (:blocks message-content) {:blocks (json/encode (:blocks message-content))} ;; Otherwise treat it as notification attachments (seq message-content) {:attachments (json/encode message-content)} :else {})] (POST "chat.postMessage" {:form-params (merge base-params message-params)}))) |