Code owners
Assign users and groups as approvers for specific file changes. Learn more.
metabot.clj 11.67 KiB
(ns metabase.metabot
(:refer-clojure :exclude [list +])
(:require (clojure [edn :as edn]
[string :as str])
[clojure.java.io :as io]
[clojure.tools.logging :as log]
[aleph.http :as aleph]
[cheshire.core :as json]
(manifold [bus :as bus]
[deferred :as d]
[stream :as s])
[throttle.core :as throttle]
[metabase.db :as db]
[metabase.integrations.slack :as slack]
[metabase.models.setting :refer [defsetting], :as setting]
(metabase [pulse :as pulse]
[util :as u])
[metabase.util.urls :as urls]))
(defsetting metabot-enabled
"Enable Metabot, which lets you search for and view your saved questions directly via Slack."
:type :boolean
:default true)
;;; # ------------------------------------------------------------ Metabot Command Handlers ------------------------------------------------------------
(def ^:private ^:dynamic *channel-id* nil)
(defn- keys-description
([message m]
(str message " " (keys-description m)))
([m]
(str/join ", " (sort (for [[k varr] m
:when (not (:unlisted (meta varr)))]
(str \` (name k) \`))))))
(defn- dispatch-fn [verb tag]
(let [fn-map (into {} (for [[symb varr] (ns-interns *ns*)
:let [dispatch-token (get (meta varr) tag)]
:when dispatch-token]
{(if (true? dispatch-token)
(keyword symb)
dispatch-token) varr}))]
(fn dispatch*
([]
(keys-description (format "Here's what I can %s:" verb) fn-map))
([what & args]
(if-let [f (fn-map (keyword what))]
(apply f args)
(format "I don't know how to %s `%s`.\n%s"
verb
(if (instance? clojure.lang.Named what)
(name what)
what)
(dispatch*)))))))
(defn- format-exception
"Format a `Throwable` the way we'd like for posting it on slack."
[^Throwable e]
(str "Uh oh! :cry:\n>" (.getMessage e)))
(defmacro ^:private do-async {:style/indent 0} [& body]
`(future (try ~@body
(catch Throwable e#
(log/error (u/format-color '~'red (u/filtered-stacktrace e#)))
(slack/post-chat-message! *channel-id* (format-exception e#))))))
(defn- format-cards
"Format a sequence of Cards as a nice multiline list for use in responses."
[cards]
(apply str (interpose "\n" (for [{id :id, card-name :name} cards]
(format "%d. <%s|\"%s\">" id (urls/card-url id) card-name)))))
(defn ^:metabot list
"Implementation of the `metabot list cards` command."
[& _]
(let [cards (db/select ['Card :id :name], {:order-by [[:id :desc]], :limit 20})]
(str "Here's your " (count cards) " most recent cards:\n" (format-cards cards))))
(defn- card-with-name [card-name]
(first (u/prog1 (db/select ['Card :id :name], :%lower.name [:like (str \% (str/lower-case card-name) \%)])
(when (> (count <>) 1)
(throw (Exception. (str "Could you be a little more specific? I found these cards with names that matched:\n"
(format-cards <>))))))))
(defn- id-or-name->card [card-id-or-name]
(cond
(integer? card-id-or-name) (db/select-one ['Card :id :name], :id card-id-or-name)
(or (string? card-id-or-name)
(symbol? card-id-or-name)) (card-with-name card-id-or-name)
:else (throw (Exception. (format "I don't know what Card `%s` is. Give me a Card ID or name." card-id-or-name)))))
(defn ^:metabot show
"Implementation of the `metabot show card <name-or-id>` command."
([]
"Show which card? Give me a part of a card name or its ID and I can show it to you. If you don't know which card you want, try `metabot list`.")
([card-id-or-name]
(if-let [{card-id :id} (id-or-name->card card-id-or-name)]
(do
(do-async (let [attachments (pulse/create-and-upload-slack-attachments! [(pulse/execute-card card-id)])]
(slack/post-chat-message! *channel-id*
nil
attachments)))
"Ok, just a second...")
(throw (Exception. "Not Found"))))
;; If the card name comes without spaces, e.g. (show 'my 'wacky 'card) turn it into a string an recur: (show "my wacky card")
([word & more]
(show (str/join " " (cons word more)))))
(defn meme:up-and-to-the-right
"Implementation of the `metabot meme up-and-to-the-right <title>` command."
{:meme :up-and-to-the-right}
[& _]
":chart_with_upwards_trend:")
(def ^:metabot ^:unlisted meme
"Dispatch function for the `metabot meme` family of commands."
(dispatch-fn "meme" :meme))
(declare apply-metabot-fn)
(defn ^:metabot help
"Implementation of the `metabot help` command."
[& _]
(apply-metabot-fn))
(def ^:private kanye-quotes
(delay (log/debug "Loading kanye quotes...")
(when-let [data (slurp (io/reader (io/resource "kanye-quotes.edn")))]
(edn/read-string data))))
(defn ^:metabot ^:unlisted kanye
"Implementation of the `metabot kanye` command."
[& _]
(str ":kanye:\n> " (rand-nth @kanye-quotes)))
;;; # ------------------------------------------------------------ Metabot Command Dispatch ------------------------------------------------------------
(def ^:private apply-metabot-fn
(dispatch-fn "understand" :metabot))
(defn- eval-command-str [s]
(when (string? s)
;; if someone just typed "metabot" (no command) act like they typed "metabot help"
(let [s (if (seq s)
s
"help")]
(log/debug "Evaluating Metabot command:" s)
(when-let [tokens (seq (edn/read-string (str "(" (-> s
(str/replace "“" "\"") ; replace smart quotes
(str/replace "”" "\"")) ")")))]
(apply apply-metabot-fn tokens)))))
;;; # ------------------------------------------------------------ Metabot Input Handling ------------------------------------------------------------
(defn- message->command-str
"Get the command portion of a message *event* directed at Metabot.
(message->command-str {:text \"metabot list\"}) -> \"list\""
[{:keys [text]}]
(when (seq text)
(second (re-matches #"^mea?ta?boa?t\s*(.*)$" text)))) ; handle typos like metaboat or meatbot
(defn- respond-to-message! [message response]
(when response
(let [response (if (coll? response) (str "```\n" (u/pprint-to-str response) "```")
(str response))]
(when (seq response)
(slack/post-chat-message! (:channel message) response)))))
(defn- handle-slack-message [message]
(respond-to-message! message (eval-command-str (message->command-str message))))
(defn- human-message?
"Was this Slack WebSocket event one about a *human* sending a message?"
[{event-type :type, subtype :subtype}]
(and (= event-type "message")
(not (contains? #{"bot_message" "message_changed" "message_deleted"} subtype))))
(defn- event-timestamp-ms
"Get the UNIX timestamp of a Slack WebSocket event, in milliseconds."
[{:keys [ts], :or {ts "0"}}]
(* (Double/parseDouble ts) 1000))
(defonce ^:private websocket (atom nil))
(defn- handle-slack-event [socket start-time event]
(when-not (= socket @websocket)
(log/debug "Go home websocket, you're drunk.")
(s/close! socket)
(throw (Exception.)))
(when-let [event (json/parse-string event keyword)]
;; Only respond to events where a *human* sends a message that have happened *after* the MetaBot launches
(when (and (human-message? event)
(> (event-timestamp-ms event) start-time))
(binding [*channel-id* (:channel event)]
(do (future (try
(handle-slack-message event)
(catch Throwable t
(slack/post-chat-message! *channel-id* (format-exception t)))))
nil)))))
;;; # ------------------------------------------------------------ Websocket Connection Stuff ------------------------------------------------------------
(defn- connect-websocket! []
(when-let [websocket-url (slack/websocket-url)]
(let [socket @(aleph/websocket-client websocket-url)]
(reset! websocket socket)
(d/catch (s/consume (partial handle-slack-event socket (System/currentTimeMillis))
socket)
(fn [error]
(log/error "Error launching metabot:" error))))))
(defn- disconnect-websocket! []
(when-let [socket @websocket]
(reset! websocket nil)
(when-not (s/closed? socket)
(s/close! socket))))
;;; Websocket monitor
;; Keep track of the Thread ID of the current monitor thread. Monitor threads should check this ID
;; and if it is no longer equal to theirs they should die
(defonce ^:private websocket-monitor-thread-id (atom nil))
;; we'll use a THROTTLER to implement exponential backoff for recconenction attempts, since THROTTLERS are designed with for this sort of thing
;; e.g. after the first failed connection we'll wait 2 seconds, then each that amount increases by the `:delay-exponent` of 1.3
;; so our reconnection schedule will look something like:
;; number of consecutive failed attempts | seconds before next try (rounded up to nearest multiple of 2 seconds)
;; --------------------------------------+----------------------------------------------------------------------
;; 0 | 2
;; 1 | 4
;; 2 | 4
;; 3 | 6
;; 4 | 8
;; 5 | 14
;; 6 | 30
;; we'll throttle this based on values of the `slack-token` setting; that way if someone changes its value they won't have to wait
;; whatever the exponential delay is before the connection is retried
(def ^:private reconnection-attempt-throttler
(throttle/make-throttler nil :attempts-threshold 1, :initial-delay-ms 2000, :delay-exponent 1.3))
(defn- should-attempt-to-reconnect? ^Boolean []
(boolean (u/ignore-exceptions
(throttle/check reconnection-attempt-throttler (slack/slack-token))
true)))
(defn- start-websocket-monitor! []
(future
(reset! websocket-monitor-thread-id (.getId (Thread/currentThread)))
;; Every 2 seconds check to see if websocket connection is [still] open, [re-]open it if not
(loop []
(while (not (should-attempt-to-reconnect?))
(Thread/sleep 2000))
(when (= (.getId (Thread/currentThread)) @websocket-monitor-thread-id)
(try
(when (or (not @websocket)
(s/closed? @websocket))
(log/debug "MetaBot WebSocket is closed. Reconnecting now.")
(connect-websocket!))
(catch Throwable e
(log/error "Error connecting websocket:" (.getMessage e))))
(recur)))))
(defn start-metabot!
"Start the MetaBot! :robot_face:
This will spin up a background thread that opens and maintains a Slack WebSocket connection."
[]
(when (and (slack/slack-token)
(metabot-enabled))
(log/info "Starting MetaBot WebSocket monitor thread...")
(start-websocket-monitor!)))
(defn stop-metabot!
"Stop the MetaBot! :robot_face:
This will stop the background thread that responsible for the Slack WebSocket connection."
[]
(log/info "Stopping MetaBot... 🤖")
(reset! websocket-monitor-thread-id nil)
(disconnect-websocket!))