Skip to content
Snippets Groups Projects
Unverified Commit 3e8121c2 authored by Cam Saul's avatar Cam Saul Committed by GitHub
Browse files

Merge pull request #9307 from metabase/reorganize-metabot

Reorganize MetaBot namespaces; dont show archived Cards for metabot list
parents a7f4bd57 a64424a5
Branches
Tags
No related merge requests found
......@@ -22,6 +22,8 @@
"eastwood" ["with-profile" "+eastwood" "eastwood"]
"check-reflection-warnings" ["with-profile" "+reflection-warnings" "check"]
"docstring-checker" ["with-profile" "+docstring-checker" "docstring-checker"]
;; `lein lint` will run all linters
"lint" ["do" ["eastwood"] ["bikeshed"] ["check-namespace-decls"] ["docstring-checker"]]
"strip-and-compress" ["with-profile" "+strip-and-compress" "run"]}
;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
......
This diff is collapsed.
(ns metabase.metabot.command
"Implementations of various MetaBot commands."
(:require [clojure
[edn :as edn]
[string :as str]]
[clojure.java.io :as io]
[clojure.tools.logging :as log]
[metabase
[pulse :as pulse]
[util :as u]]
[metabase.api.common :refer [*current-user-permissions-set* read-check]]
[metabase.metabot.slack :as metabot.slack]
[metabase.models
[card :refer [Card]]
[interface :as mi]
[permissions :refer [Permissions]]
[permissions-group :as perms-group]]
[metabase.util
[i18n :refer [trs tru]]
[urls :as urls]]
[toucan.db :as db]))
;;; ----------------------------------------------------- Perms ------------------------------------------------------
(defn- metabot-permissions
"Return the set of permissions granted to the MetaBot."
[]
(db/select-field :object Permissions, :group_id (u/get-id (perms-group/metabot))))
(defn- do-with-metabot-permissions [f]
(binding [*current-user-permissions-set* (delay (metabot-permissions))]
(f)))
(defmacro ^:private with-metabot-permissions
"Execute BODY with MetaBot's permissions bound to `*current-user-permissions-set*`."
{:style/indent 0}
[& body]
`(do-with-metabot-permissions (fn [] ~@body)))
(defn- filter-metabot-readable [coll]
(with-metabot-permissions
(filterv mi/can-read? coll)))
;;; ---------------------------------------------------- Commands ----------------------------------------------------
(defmulti command
"Run a MetaBot command.
This multimethod provides implementations of the various MetaBot commands. Slack messages that are interpreted as
MetaBot commands are split into tokens and passed to this method, e.g.
[In Slack]
User: metabot show 100
[In Metabase]
(command \"show\" \"100\") ; -> [some results]
[In Slack]
MetaBot: [some results]
The first argument is the command name, and that name, as a lower-cased keyword, is used as the dispatch value for
this multimethod.
The results are normally immediately posted directly to Slack; some commands also post additional messages
asynchronously, such as `show`."
(fn [command & _]
(keyword (str/lower-case command))))
(defmethod command :default [command-name & _]
(str
(tru "I don''t know how to `{0}`." command-name)
" "
(command :help)))
(defmulti ^:private unlisted?
"Whether this command should be unlisted in the `help` list. Default = `false`."
identity)
(defmethod unlisted? :default [_] false)
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | Command Implementations |
;;; +----------------------------------------------------------------------------------------------------------------+
;;; ------------------------------------------------------ list ------------------------------------------------------
(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- list-cards []
(filter-metabot-readable
(db/select [Card :id :name :dataset_query :collection_id]
:archived false
{:order-by [[:id :desc]]
:limit 20})))
(defmethod command :list [& _]
(let [cards (list-cards)]
(str (tru "Here''s your {0} most recent cards:\n{1}" (count cards) (format-cards cards)))))
;;; ------------------------------------------------------ show ------------------------------------------------------
(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 (tru "Could you be a little more specific? I found these cards with names that matched:\n{0}"
(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. (str (tru "I don''t know what Card `{0}` is. Give me a Card ID or name." card-id-or-name))))))
(defmethod command :show
([_]
(str (tru "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]
(let [{card-id :id} (id-or-name->card card-id-or-name)]
(when-not card-id
(throw (Exception. (str (tru "Not Found")))))
(with-metabot-permissions
(read-check Card card-id))
(metabot.slack/async
(let [attachments (pulse/create-and-upload-slack-attachments!
(pulse/create-slack-attachment-data
[(pulse/execute-card card-id, :context :metabot)]))]
(metabot.slack/post-chat-message! nil attachments)))
(str (tru "Ok, just a second..."))))
;; 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]
(command :show (str/join " " (cons word more)))))
;;; ------------------------------------------------------ help ------------------------------------------------------
(defn- listed-commands []
(sort (for [[k v] (methods command)
:when (and (not (unlisted? k))
(not= k :default))]
k)))
(defmethod command :help [& _]
(str
(tru "Here''s what I can do: ")
(str/join ", " (for [cmd (listed-commands)]
(str \` (name cmd) \`)))))
;;; -------------------------------------------------- easter eggs ---------------------------------------------------
(def ^:private kanye-quotes
(delay
(log/debug (trs "Loading Kanye quotes..."))
(when-let [data (slurp (io/reader (io/resource "kanye-quotes.edn")))]
(edn/read-string data))))
(defmethod command :kanye [& _]
(str ":kanye:\n> " (rand-nth @kanye-quotes)))
(defmethod unlisted? :kanye [_] true)
(ns metabase.metabot.events
"Logic related to handling Slack events, running commands for events that are messages to the MetaBot, and posting the
response on Slack."
(:require [cheshire.core :as json]
[clojure
[edn :as edn]
[string :as str]]
[clojure.tools.logging :as log]
[metabase.integrations.slack :as slack]
[metabase.metabot
[command :as metabot.cmd]
[slack :as metabot.slack]]
[metabase.util :as u]
[metabase.util.i18n :refer [trs]]))
(defn- str->tokens [s]
(edn/read-string (str "(" (-> s
(str/replace "“" "\"") ; replace smart quotes
(str/replace "”" "\"")) ")")))
(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 (trs "Evaluating Metabot command:") s)
(when-let [tokens (seq (str->tokens s))]
(apply metabot.cmd/command 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))
(defn handle-slack-event
"Handle a Slack `event`; if the event is a message that starts with `metabot`, parse the message, execute the
appropriate command, and reply with the results."
[start-time event]
(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))
(metabot.slack/with-channel-id (:channel event)
(metabot.slack/async
(handle-slack-message event))))))
(ns metabase.metabot.instance
"Logic for deciding which Metabase instance in a multi-instance (i.e., horizontally scaled) setup gets to be the
MetaBot.
Close your eyes, and imagine a scenario: someone is running multiple Metabase instances in a horizontal cluster.
Good for them, but how do we make sure one, and only one, of those instances, replies to incoming MetaBot commands?
It would certainly be too much if someone ran, say, 4 instances, and typing `metabot kanye` into Slack gave them 4
Kanye West quotes, wouldn't it?
Luckily, we have an \"elegant\" solution: we'll use the Settings framework to keep track of which instance is
currently serving as the MetaBot. We'll have that instance periodically check in; if it doesn't check in for some
timeout interval, we'll consider the job of MetaBot up for grabs. Each instance will periodically check if the
MetaBot job is open, and, if so, whoever discovers it first will take it.
How do we uniquiely identify each instance?
`local-process-uuid` is randomly-generated upon launch and used to identify this specific Metabase instance during
this specifc run. Restarting the server will change this UUID, and each server in a hortizontal cluster will have
its own ID, making this different from the `site-uuid` Setting. The local process UUID is used to differentiate
different horizontally clustered MB instances so we can determine which of them will handle MetaBot duties.
TODO - if we ever want to use this elsewhere, we need to move it to `metabase.config` or somewhere else central like
that."
(:require [clojure.tools.logging :as log]
[honeysql.core :as hsql]
[metabase.models.setting :as setting :refer [defsetting]]
[metabase.util :as u]
[metabase.util
[date :as du]
[i18n :refer [trs]]]
[toucan.db :as db])
(:import java.sql.Timestamp
java.util.UUID))
(defonce ^:private local-process-uuid
(str (UUID/randomUUID)))
(defsetting ^:private metabot-instance-uuid
"UUID of the active MetaBot instance (the Metabase process currently handling MetaBot duties.)"
;; This should be cached because we'll be checking it fairly often, basically every 2 seconds as part of the
;; websocket monitor thread to see whether we're MetaBot (the thread won't open the WebSocket unless that instance
;; is handling MetaBot duties)
:internal? true)
(defsetting ^:private metabot-instance-last-checkin
"Timestamp of the last time the active MetaBot instance checked in."
:internal? true
;; caching is disabled for this, since it is intended to be updated frequently (once a minute or so) If we use the
;; cache, it will trigger cache invalidation for all the other instances (wasteful), and possibly at any rate be
;; incorrect (for example, if another instance checked in a minute ago, our local cache might not get updated right
;; away, causing us to falsely assume the MetaBot role is up for grabs.)
:cache? false
:type :timestamp)
(defn- current-timestamp-from-db
"Fetch the current timestamp from the DB. Why do this from the DB? It's not safe to assume multiple instances have
clocks exactly in sync; but since each instance is using the same application DB, we can use it as a cannonical
source of truth."
^Timestamp []
(-> (db/query {:select [[(hsql/raw "current_timestamp") :current_timestamp]]})
first
:current_timestamp))
(defn- update-last-checkin!
"Update the last checkin timestamp recorded in the DB."
[]
(metabot-instance-last-checkin (current-timestamp-from-db)))
(defn- seconds-since-last-checkin
"Return the number of seconds since the active MetaBot instance last checked in (updated the
`metabot-instance-last-checkin` Setting). If a MetaBot instance has *never* checked in, this returns `nil`. (Since
`last-checkin` is one of the few Settings that isn't cached, this always requires a DB call.)"
[]
(when-let [last-checkin (metabot-instance-last-checkin)]
(u/prog1 (-> (- (.getTime (current-timestamp-from-db))
(.getTime last-checkin))
(/ 1000))
(log/debug (u/format-color 'magenta (trs "Last MetaBot checkin was {0} ago." (du/format-seconds <>)))))))
(def ^:private ^Integer recent-checkin-timeout-interval-seconds
"Number of seconds since the last MetaBot checkin that we will consider the MetaBot job to be 'up for grabs',
currently 3 minutes. (i.e. if the current MetaBot job holder doesn't check in for more than 3 minutes, it's up for
grabs.)"
(int (* 60 3)))
(defn- last-checkin-was-not-recent?
"`true` if the last checkin of the active MetaBot instance was more than 3 minutes ago, or if there has never been a
checkin. (This requires DB calls, so it should not be called too often -- once a minute [at the time of this
writing] should be sufficient.)"
[]
(if-let [seconds-since-last-checkin (seconds-since-last-checkin)]
(> seconds-since-last-checkin
recent-checkin-timeout-interval-seconds)
true))
(defn am-i-the-metabot?
"Does this instance currently have the MetaBot job? (Does not require any DB calls, so may safely be called
often (i.e. in the websocket monitor thread loop.)"
[]
(= (metabot-instance-uuid)
local-process-uuid))
(defn- become-metabot!
"Direct this instance to assume the duties of acting as MetaBot, and update the Settings we use to track assignment
accordingly."
[]
(log/info (u/format-color 'green (trs "This instance will now handle MetaBot duties.")))
(metabot-instance-uuid local-process-uuid)
(update-last-checkin!))
(defn- check-and-update-instance-status!
"Check whether the current instance is serving as the MetaBot; if so, update the last checkin timestamp; if not, check
whether we should become the MetaBot (and do so if we should)."
[]
(cond
;; if we're already the MetaBot instance, update the last checkin timestamp
(am-i-the-metabot?)
(do
(log/debug (trs "This instance is performing MetaBot duties."))
(update-last-checkin!))
;; otherwise if the last checkin was too long ago, it's time for us to assume the mantle of MetaBot
(last-checkin-was-not-recent?)
(become-metabot!)
;; otherwise someone else is the MetaBot and we're done here! woo
:else
(log/debug (u/format-color 'blue (trs "Another instance is already handling MetaBot duties.")))))
(defn start-instance-monitor!
"Start the thread that will monitor whether this Metabase instance should become, or cease being, the instance that
handles MetaBot functionality."
[]
(future
(loop []
(check-and-update-instance-status!)
(Thread/sleep (* 60 1000))
(recur))))
(ns metabase.metabot.slack
"Logic related to posting messages [synchronously and asynchronously] to Slack and handling errors."
(:require [clojure.tools.logging :as log]
[metabase.integrations.slack :as slack]
[metabase.util.i18n :refer [trs tru]]))
(def ^:private ^:dynamic *channel-id* nil)
(defn do-with-channel-id
"Impl for `with-channel-id` macro."
[channel-id f]
(binding [*channel-id* channel-id]
(f)))
(defn with-channel-id
"Execute `body` with `channel-id` as the current Slack channel; all messages will be posted to that channel. (This is
bound to the channel that recieved the MetaBot command we're currently handling by the
`metabase.metabot.events/handle-slack-event` event handler.)"
{:style/indent 1}
[channel-id & body]
`(do-with-channel-id ~channel-id (fn [] ~@body)))
(def ^{:arglists '([text-or-nil] [text-or-nil attachments])} post-chat-message!
"Post a MetaBot response Slack message. Goes to channel where the MetaBot command we're replying to was posted."
(partial slack/post-chat-message! *channel-id*))
(defn format-exception
"Format a `Throwable` the way we'd like for posting it on Slack."
[^Throwable e]
(str (tru "Uh oh! :cry:\n> {0}" (.getMessage e))))
(defn do-async
"Impl for `async` macro."
[f]
(future
(try
(f)
(catch Throwable e
(log/error (trs "Error in Metabot command") e)
(post-chat-message! (format-exception e))))
nil))
(defmacro async
"Execute `body` asynchronously, wrapped in a try-catch block. If an Exception is thrown, replies to the current Slack
channel with the exception message and logs the complete Exception to the logs."
{:style/indent 0}
[& body]
`(do-async (fn [] ~@body)))
(ns metabase.metabot.websocket
"Logic for managing the websocket MetaBot uses to monitor and reply to Slack messages, specifically a 'monitor thread'
that watches the websocket handling thread and disconnects/reconnects it when needed."
(:require [aleph.http :as aleph]
[clojure.tools.logging :as log]
[manifold
[deferred :as d]
[stream :as s]]
[metabase.integrations.slack :as slack]
[metabase.metabot
[events :as metabot.events]
[instance :as metabot.instance]]
[metabase.util :as u]
[metabase.util.i18n :refer [trs]]
[throttle.core :as throttle]))
(defonce ^:private websocket (atom nil))
(defn- handle-slack-event [socket start-time event]
;; if the websocket has changed, because we've decided to open a new connection for whatever reason, ignore events
;; that might come in from old ones.
(when-not (= socket @websocket)
(log/debug (trs "Websocket associated with this Slack event is different from the websocket we're currently using."))
(s/close! socket)
(throw (Exception.)))
(metabot.events/handle-slack-event start-time event))
(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 (trs "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))
(defn stop!
"Stop all MetaBot instances. Clear the current monitor thread ID, which will signal to any existing monitor threads to
stop running; disconnect the current websocket."
[]
(reset! websocket-monitor-thread-id nil)
(disconnect-websocket!))
(defn currently-running?
"Is the MetaBot running?
Checks whether there is currently a MetaBot websocket monitor thread running. (The monitor threads make sure the
WebSocket connections are open; if a monitor thread is open, it's should be maintaining an open WebSocket
connection.)"
[]
(boolean @websocket-monitor-thread-id))
;; 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- reopen-websocket-connection-if-needed!
"Check to see if websocket connection is [still] open, [re-]open it if not."
[]
;; Only open the Websocket connection if this instance is the MetaBot
(when (metabot.instance/am-i-the-metabot?)
(when (= (.getId (Thread/currentThread)) @websocket-monitor-thread-id)
(try
(when (or (not @websocket)
(s/closed? @websocket))
(log/debug (trs "MetaBot WebSocket is closed. Reconnecting now."))
(connect-websocket!))
(catch Throwable e
(log/error (trs "Error connecting websocket:") (.getMessage e)))))))
(defn start-websocket-monitor!
"Start the WebSocket monitor thread. This thread periodically checks to make sure the WebSocket connection should be
open, and if it should but is not, attempts to reconnect. If it is open but should not be, closes the current
connection."
[]
(future
(reset! websocket-monitor-thread-id (.getId (Thread/currentThread)))
(loop []
;; Every 2 seconds...
(while (not (should-attempt-to-reconnect?))
(Thread/sleep 2000))
(reopen-websocket-connection-if-needed!)
(recur))))
(ns metabase.metabot.command-test
(:require [expectations :refer [expect]]
[metabase.metabot.command :as metabot.cmd]
[metabase.models.card :refer [Card]]
[toucan.util.test :as tt]))
;; Check that `metabot/list` returns a string with card information and passes the permissions checks
(expect
#"2 most recent cards"
(tt/with-temp* [Card [_]
Card [_]]
(metabot.cmd/command "list")))
;; `metabot/list` shouldn't show archived Cards (#9283)
;; NOCOMMIT
(expect
#"1 most recent cards"
(tt/with-temp* [Card [_]
Card [_ {:archived true}]]
(metabot.cmd/command "list")))
(ns metabase.metabot.instance-test
(:require [expectations :refer [expect]]
[metabase.metabot.instance :as metabot.instance]
[metabase.util.date :as du]))
;; test that if we're not the MetaBot based on Settings, our function to check is working correctly
(expect
false
(do
(#'metabot.instance/metabot-instance-uuid nil)
(#'metabot.instance/metabot-instance-last-checkin nil)
(#'metabot.instance/am-i-the-metabot?)))
;; test that if nobody is currently the MetaBot, we will become the MetaBot
(expect
(do
(#'metabot.instance/metabot-instance-uuid nil)
(#'metabot.instance/metabot-instance-last-checkin nil)
(#'metabot.instance/check-and-update-instance-status!)
(#'metabot.instance/am-i-the-metabot?)))
;; test that if nobody has checked in as MetaBot for a while, we will become the MetaBot
(expect
(do
(#'metabot.instance/metabot-instance-uuid (str (java.util.UUID/randomUUID)))
(#'metabot.instance/metabot-instance-last-checkin (du/relative-date :minute -10 (#'metabot.instance/current-timestamp-from-db)))
(#'metabot.instance/check-and-update-instance-status!)
(#'metabot.instance/am-i-the-metabot?)))
;; check that if another instance has checked in recently, we will *not* become the MetaBot
(expect
false
(do
(#'metabot.instance/metabot-instance-uuid (str (java.util.UUID/randomUUID)))
(#'metabot.instance/metabot-instance-last-checkin (#'metabot.instance/current-timestamp-from-db))
(#'metabot.instance/check-and-update-instance-status!)
(#'metabot.instance/am-i-the-metabot?)))
(ns metabase.metabot-test
(:require [expectations :refer :all]
[metabase.metabot :as metabot]
[metabase.models.card :refer [Card]]
[metabase.util.date :as du]
[toucan.util.test :as tt]))
;; test that if we're not the MetaBot based on Settings, our function to check is working correctly
(expect
false
(do
(#'metabot/metabot-instance-uuid nil)
(#'metabot/metabot-instance-last-checkin nil)
(#'metabot/am-i-the-metabot?)))
;; test that if nobody is currently the MetaBot, we will become the MetaBot
(expect
(do
(#'metabot/metabot-instance-uuid nil)
(#'metabot/metabot-instance-last-checkin nil)
(#'metabot/check-and-update-instance-status!)
(#'metabot/am-i-the-metabot?)))
;; test that if nobody has checked in as MetaBot for a while, we will become the MetaBot
(expect
(do
(#'metabot/metabot-instance-uuid (str (java.util.UUID/randomUUID)))
(#'metabot/metabot-instance-last-checkin (du/relative-date :minute -10 (#'metabot/current-timestamp-from-db)))
(#'metabot/check-and-update-instance-status!)
(#'metabot/am-i-the-metabot?)))
;; check that if another instance has checked in recently, we will *not* become the MetaBot
(expect
false
(do
(#'metabot/metabot-instance-uuid (str (java.util.UUID/randomUUID)))
(#'metabot/metabot-instance-last-checkin (#'metabot/current-timestamp-from-db))
(#'metabot/check-and-update-instance-status!)
(#'metabot/am-i-the-metabot?)))
;; Check that `metabot/list` returns a string with card information and passes the permissions checks
(expect
#"2 most recent cards"
(tt/with-temp* [Card [_]
Card [_]]
(metabot/list)))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment