Compare commits
114 Commits
python-run
...
slack-nati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ab5191a2 | ||
|
|
c62fbe8783 | ||
|
|
c88a4aab5a | ||
|
|
e6678e126e | ||
|
|
ecbe793f4b | ||
|
|
3406023905 | ||
|
|
d3d30d0a2c | ||
|
|
590bc552fd | ||
|
|
df589fc389 | ||
|
|
8c37f59145 | ||
|
|
09fb6281c9 | ||
|
|
da6db51779 | ||
|
|
981ca07308 | ||
|
|
51c841b69f | ||
|
|
c78a4f21df | ||
|
|
a26eadc223 | ||
|
|
374c2432cf | ||
|
|
4441f64703 | ||
|
|
34fc4c1df4 | ||
|
|
05c08ee5d6 | ||
|
|
d991563e76 | ||
|
|
17ed32fae8 | ||
|
|
2b6ea0cb5b | ||
|
|
b83b144e0a | ||
|
|
70cfb8ad11 | ||
|
|
35fb1db6dd | ||
|
|
01279cad55 | ||
|
|
8100e919e8 | ||
|
|
ab8eed92c7 | ||
|
|
960055f07c | ||
|
|
0792409437 | ||
|
|
f8517521aa | ||
|
|
68fcc6f2ba | ||
|
|
791539b9d2 | ||
|
|
d230cf86ed | ||
|
|
daddf34807 | ||
|
|
6b71b9a675 | ||
|
|
27eec0e565 | ||
|
|
e413380c1d | ||
|
|
8768bd104d | ||
|
|
f6c9ee2eb7 | ||
|
|
e3dcf70bf2 | ||
|
|
86e41d95f5 | ||
|
|
62adf62d18 | ||
|
|
37196eb22f | ||
|
|
e60fdf94ec | ||
|
|
3eba3ed18b | ||
|
|
df3733d6f3 | ||
|
|
18b6071fd1 | ||
|
|
8dcf0895f0 | ||
|
|
66c46b0a87 | ||
|
|
66940a924a | ||
|
|
56bfc925a6 | ||
|
|
8dd2c3452b | ||
|
|
ab0914a046 | ||
|
|
2a2dea86eb | ||
|
|
6a4f734cd3 | ||
|
|
ab0975e48c | ||
|
|
4018419dd8 | ||
|
|
0f4704d98c | ||
|
|
df9dff5114 | ||
|
|
b21c844ce7 | ||
|
|
3291c09647 | ||
|
|
ccc8dcbdef | ||
|
|
ce5064a41b | ||
|
|
6a84eccf17 | ||
|
|
5eaf8a8fc9 | ||
|
|
cc2b0de9e9 | ||
|
|
76ccdd1e66 | ||
|
|
f74b6efa24 | ||
|
|
7e8e39ee45 | ||
|
|
a997cb7936 | ||
|
|
13c7b165a6 | ||
|
|
c6732a29c8 | ||
|
|
8bfd913f8c | ||
|
|
1ca48d98fb | ||
|
|
03bab6a2c8 | ||
|
|
884c7d1a56 | ||
|
|
2a0cdc10f5 | ||
|
|
61cd2f43d3 | ||
|
|
25b668f867 | ||
|
|
f17218806c | ||
|
|
d462d3fd10 | ||
|
|
f08ad6040e | ||
|
|
887b8296d5 | ||
|
|
7bf94f99ee | ||
|
|
a8e8dcabb2 | ||
|
|
232fa7fe2a | ||
|
|
242f875476 | ||
|
|
88c5c8fb3b | ||
|
|
3850a6a9d9 | ||
|
|
c40221e955 | ||
|
|
0050daaf5c | ||
|
|
9c9c3fe8f5 | ||
|
|
380dc9ef3a | ||
|
|
099d32a2fc | ||
|
|
6f20e43d42 | ||
|
|
a636d42dfa | ||
|
|
af762bf464 | ||
|
|
debfd7019d | ||
|
|
36745b0599 | ||
|
|
e84f1554f4 | ||
|
|
3d72dd59d5 | ||
|
|
f2707dc830 | ||
|
|
a3a5075eb3 | ||
|
|
9f8ba5a7af | ||
|
|
f6d22ac259 | ||
|
|
65a2e9d5b6 | ||
|
|
29c7b19a9d | ||
|
|
d7aeab3acf | ||
|
|
111c047e49 | ||
|
|
8ca1ef1bf3 | ||
|
|
545b35ef37 | ||
|
|
aba9cced80 |
@@ -284,6 +284,7 @@
|
||||
channel
|
||||
{:team "Gadget"
|
||||
:api #{metabase.channel.api
|
||||
metabase.channel.api.slack
|
||||
metabase.channel.core
|
||||
metabase.channel.email
|
||||
metabase.channel.email.messages
|
||||
@@ -1978,7 +1979,8 @@
|
||||
|
||||
enterprise/api
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.api.core}
|
||||
:api #{metabase-enterprise.api.core
|
||||
metabase-enterprise.api.routes.common}
|
||||
:uses #{api
|
||||
premium-features
|
||||
util}}
|
||||
@@ -2247,15 +2249,18 @@
|
||||
api
|
||||
app-db
|
||||
audit-app
|
||||
channel
|
||||
collections
|
||||
config
|
||||
documents
|
||||
driver
|
||||
formatter
|
||||
legacy-mbql
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
native-query-snippets
|
||||
notification
|
||||
parameters
|
||||
permissions
|
||||
premium-features
|
||||
@@ -2272,10 +2277,13 @@
|
||||
task
|
||||
transforms
|
||||
transforms-base
|
||||
upload
|
||||
users
|
||||
util
|
||||
warehouses
|
||||
enterprise/api
|
||||
enterprise/dependencies
|
||||
enterprise/sso
|
||||
enterprise/transforms-python}}
|
||||
|
||||
enterprise/permission-debug
|
||||
|
||||
@@ -12,6 +12,7 @@ export const openSharingMenu = (menuItemText?: string) => {
|
||||
|
||||
export const removeNotificationHandlerChannel = (channel: string) => {
|
||||
modal()
|
||||
.findByTestId("alert-configured-channel")
|
||||
.findByText(channel)
|
||||
.closest('[data-testid="channel-block"]')
|
||||
.findByTestId("remove-channel-button")
|
||||
|
||||
@@ -269,17 +269,13 @@ describe("scenarios > admin > settings", () => {
|
||||
|
||||
describe(" > slack settings", () => {
|
||||
it("should present the form and display errors", () => {
|
||||
cy.visit("/admin/settings/notifications");
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.findByText("Connect to Slack")
|
||||
.click();
|
||||
cy.visit("/admin/settings/slack");
|
||||
|
||||
H.modal().findByText("Metabase on Slack");
|
||||
cy.findByRole("main").findByText("Create a Slack app and connect to it.");
|
||||
|
||||
cy.findByLabelText(/Slack Bot User OAuth Token/i).type("xoxb");
|
||||
cy.button("Save changes").click();
|
||||
|
||||
H.modal().findByText(/invalid token/i);
|
||||
cy.button("Connect").click();
|
||||
cy.findByRole("main").findByText(/invalid token/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1092,7 +1088,7 @@ describe("notifications", { tags: "@external" }, () => {
|
||||
|
||||
AUTH_METHODS.forEach((auth) => {
|
||||
it(`${auth.display} Auth`, () => {
|
||||
cy.visit("/admin/settings/notifications");
|
||||
cy.visit("/admin/settings/webhooks");
|
||||
cy.findByRole("heading", { name: "Add a webhook" }).click();
|
||||
|
||||
H.modal().within(() => {
|
||||
@@ -1121,7 +1117,7 @@ describe("notifications", { tags: "@external" }, () => {
|
||||
});
|
||||
|
||||
it("Should allow you to create and edit Notifications", () => {
|
||||
cy.visit("/admin/settings/notifications");
|
||||
cy.visit("/admin/settings/webhooks");
|
||||
|
||||
cy.findByRole("heading", { name: "Add a webhook" }).click();
|
||||
|
||||
|
||||
@@ -155,7 +155,8 @@ function createBasicAlert({ includeNormal } = {}) {
|
||||
H.popover().findByText("Create an alert").click();
|
||||
|
||||
if (includeNormal) {
|
||||
cy.findByText("Email")
|
||||
cy.findByTestId("alert-configured-channel")
|
||||
.findByText("Email")
|
||||
.closest('[data-testid="channel-block"]')
|
||||
.findByTestId("token-field")
|
||||
.click();
|
||||
|
||||
@@ -31,11 +31,11 @@ describe("scenarios > alert", () => {
|
||||
cy.findByText("Set up Slack")
|
||||
.should("be.visible")
|
||||
.closest("a")
|
||||
.should("have.attr", "href", "/admin/settings/notifications");
|
||||
.should("have.attr", "href", "/admin/settings/slack");
|
||||
cy.findByText("Add a webhook")
|
||||
.should("be.visible")
|
||||
.closest("a")
|
||||
.should("have.attr", "href", "/admin/settings/notifications");
|
||||
.should("have.attr", "href", "/admin/settings/webhooks");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("scenarios > alert > email_alert", { tags: "@external" }, () => {
|
||||
cy.log(
|
||||
"Should not display slack channel if it is not configured metabase#48407",
|
||||
);
|
||||
cy.findByTestId("alert-create").within(() => {
|
||||
cy.findByTestId("alert-configured-channel").within(() => {
|
||||
cy.findByTestId("loading-indicator").should("not.exist");
|
||||
cy.findByText("Slack").should("not.exist");
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("scenarios > dashboard > subscriptions", () => {
|
||||
cy.findByRole("link", { name: /configure Slack/i }).should(
|
||||
"have.attr",
|
||||
"href",
|
||||
"/admin/settings/notifications",
|
||||
"/admin/settings/slack",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
{:database-name (:name database)})))
|
||||
(when-let [existing-database-id (t2/select-one-pk :model/Database :engine (:engine database), :name (:name database))]
|
||||
(log/info (u/format-color :blue "Deleting Database %s %s" (:engine database) (pr-str (:name database))))
|
||||
;; TODO: remove, temp hack. see: https://metaboat.slack.com/archives/CKZEMT1MJ/p1770242308801029?thread_ts=1770241635.689819&cid=CKZEMT1MJ
|
||||
(t2/delete! :model/Transform :source_database_id existing-database-id)
|
||||
(t2/delete! :model/Database existing-database-id)))
|
||||
(do
|
||||
;; assert that we are able to connect to this Database. Otherwise, throw an Exception.
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"/library" (premium-handler metabase-enterprise.library.api/routes :library)
|
||||
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
|
||||
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
|
||||
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
|
||||
"/metabot-v3" metabase-enterprise.metabot-v3.api/routes
|
||||
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
|
||||
"/transforms-python" (premium-handler metabase-enterprise.transforms-python.api/routes :transforms-python)
|
||||
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
|
||||
|
||||
@@ -1,55 +1,26 @@
|
||||
(ns metabase-enterprise.metabot-v3.api
|
||||
"`/api/ee/metabot-v3/` routes"
|
||||
(:require
|
||||
[clj-http.client :as http]
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.api.routes.common :as ee.api.routes]
|
||||
[metabase-enterprise.metabot-v3.api.document]
|
||||
[metabase-enterprise.metabot-v3.api.metabot]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
|
||||
[metabase-enterprise.metabot-v3.envelope :as metabot-v3.envelope]
|
||||
[metabase-enterprise.metabot-v3.feedback :as metabot-v3.feedback]
|
||||
[metabase-enterprise.metabot-v3.persistence :as metabot-v3.persistence]
|
||||
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.app-db.core :as app-db]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.store-api.core :as store-api]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.i18n :refer [deferred-tru]]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- store-message! [conversation-id profile-id messages]
|
||||
(let [finish (let [m (u/last messages)]
|
||||
(when (= (:_type m) :FINISH_MESSAGE)
|
||||
m))
|
||||
state (u/seek #(and (= (:_type %) :DATA)
|
||||
(= (:type %) "state"))
|
||||
messages)
|
||||
messages (-> (remove #(or (= % state) (= % finish)) messages)
|
||||
vec)]
|
||||
(app-db/update-or-insert! :model/MetabotConversation {:id conversation-id}
|
||||
(constantly (cond-> {:user_id api/*current-user-id*}
|
||||
state (assoc :state state))))
|
||||
;; NOTE: this will need to be constrained at some point, see BOT-386
|
||||
(t2/insert! :model/MetabotMessage
|
||||
{:conversation_id conversation-id
|
||||
:data messages
|
||||
:usage (:usage finish)
|
||||
:role (:role (first messages))
|
||||
:profile_id profile-id
|
||||
:total_tokens (->> (vals (:usage finish))
|
||||
;; NOTE: this filter is supporting backward-compatible usage format, can be
|
||||
;; removed when ai-service does not give us `completionTokens` in `usage`
|
||||
(filter map?)
|
||||
(map #(+ (:prompt %) (:completion %)))
|
||||
(apply +))})))
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(defn- check-metabot-enabled!
|
||||
"Checks that the Metabot instance identified by `metabot-id` is enabled. Throws a 403 if it's not."
|
||||
@@ -68,7 +39,7 @@
|
||||
_ (check-metabot-enabled! metabot-id)
|
||||
profile-id (metabot-v3.config/resolve-dynamic-profile-id profile_id metabot-id)
|
||||
session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
|
||||
(store-message! conversation_id profile-id [message])
|
||||
(metabot-v3.persistence/store-message! conversation_id profile-id [message])
|
||||
(metabot-v3.client/streaming-request
|
||||
{:context (metabot-v3.context/create-context context)
|
||||
:metabot-id metabot-id
|
||||
@@ -79,7 +50,7 @@
|
||||
:history history
|
||||
:state state
|
||||
:on-complete (fn [lines]
|
||||
(store-message! conversation_id profile-id (metabot-v3.u/aisdk->messages :assistant lines))
|
||||
(metabot-v3.persistence/store-message! conversation_id profile-id (metabot-v3.u/aisdk->messages :assistant lines))
|
||||
:store-in-db)})))
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
@@ -110,23 +81,26 @@
|
||||
[_route-params
|
||||
_query-params
|
||||
feedback :- :map]
|
||||
(let [token (premium-features/premium-embedding-token)
|
||||
base-url (store-api/store-api-url)]
|
||||
(api/check-400 (not (or (str/blank? token) (str/blank? base-url)))
|
||||
"Cannot build a request. The license token and/or Store api url are missing!")
|
||||
(try
|
||||
(http/post (str base-url "/api/v2/metabot/feedback/" token)
|
||||
{:content-type :json
|
||||
:body (json/encode feedback)})
|
||||
api/generic-204-no-content
|
||||
(catch Exception e
|
||||
(log/error e "Failed to submit feedback to Harbormaster")
|
||||
(throw e)))))
|
||||
(try
|
||||
(api/check-400 (metabot-v3.feedback/submit-to-harbormaster! feedback)
|
||||
"Cannot submit feedback. The license token and/or Store API URL are missing!")
|
||||
api/generic-204-no-content
|
||||
(catch Exception e
|
||||
(log/error e "Failed to submit feedback to Harbormaster")
|
||||
(throw e))))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/metabot-v3` routes."
|
||||
(handlers/routes
|
||||
(api.macros/ns-handler *ns* +auth)
|
||||
(handlers/route-map-handler
|
||||
{"/metabot" metabase-enterprise.metabot-v3.api.metabot/routes
|
||||
"/document" metabase-enterprise.metabot-v3.api.document/routes})))
|
||||
{"/metabot" (ee.api.routes/+require-premium-feature
|
||||
:metabot-v3 (deferred-tru "MetaBot")
|
||||
metabase-enterprise.metabot-v3.api.metabot/routes)
|
||||
"/document" (ee.api.routes/+require-premium-feature
|
||||
:metabot-v3 (deferred-tru "MetaBot")
|
||||
metabase-enterprise.metabot-v3.api.document/routes)
|
||||
;; premium check happens in the route so we still ack events to prevent slack retrying
|
||||
"/slack" metabase-enterprise.metabot-v3.api.slackbot/routes})
|
||||
(ee.api.routes/+require-premium-feature
|
||||
:metabot-v3 (deferred-tru "MetaBot")
|
||||
(api.macros/ns-handler *ns* +auth))))
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.slackbot
|
||||
"`/api/ee/metabot-v3/slack` routes"
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[malli.core :as mc]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.client :as slackbot.client]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.config :as slackbot.config]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.events :as slackbot.events]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.persistence :as slackbot.persistence]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.streaming :as slackbot.streaming]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.uploads :as slackbot.uploads]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.feedback :as metabot-v3.feedback]
|
||||
[metabase-enterprise.metabot-v3.settings :as metabot.settings]
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.channel.api.slack :as channel.api.slack]
|
||||
[metabase.channel.settings :as channel.settings]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.premium-features.core :as premium-features :refer [defenterprise
|
||||
defenterprise-schema]]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.settings.core :as setting]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util.encryption :as encryption]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[ring.util.codec :as codec]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defenterprise clear-slack-bot-settings!
|
||||
"Clears all slackbot-related settings when Slack token is cleared.
|
||||
This ensures enable-sso-slack? becomes false."
|
||||
:feature :metabot-v3
|
||||
[]
|
||||
(setting/set-many! {:slack-connect-enabled false
|
||||
:slack-connect-client-id nil
|
||||
:slack-connect-client-secret nil
|
||||
:metabot-slack-signing-secret nil})
|
||||
nil)
|
||||
|
||||
(defenterprise-schema get-slack-manifest :- channel.api.slack/SlackManifest
|
||||
"Enterprise implementation - returns full MetaBot manifest with event subscriptions, slash commands, etc."
|
||||
:feature :metabot-v3
|
||||
[]
|
||||
(let [base-url (system/site-url)]
|
||||
(when-not base-url
|
||||
(throw (ex-info (tru "You must configure a site-url for Slack integration to work.") {:status-code 503})))
|
||||
(slackbot.config/build-slack-manifest base-url)))
|
||||
|
||||
;; ------------------------- VALIDATION ----------------------------------
|
||||
|
||||
(defn- assert-valid-slack-req
|
||||
"Asserts that incoming Slack request has a valid signature."
|
||||
[request]
|
||||
(when-not (metabot.settings/unobfuscated-metabot-slack-signing-secret)
|
||||
(throw (ex-info (str (tru "Slack integration is not fully configured.")) {:status-code 503})))
|
||||
(when-not (:slack/validated? request)
|
||||
(throw (ex-info (str (tru "Slack request signature is not valid.")) {:status-code 401}))))
|
||||
|
||||
;; ------------------------- AUTHENTICATION ------------------------------
|
||||
|
||||
(defn- slack-id->user-id
|
||||
"Look up a Metabase user ID from Slack user ID."
|
||||
[slack-user-id]
|
||||
(t2/select-one-fn :user_id
|
||||
:model/AuthIdentity
|
||||
:provider "slack-connect"
|
||||
:provider_id slack-user-id
|
||||
{:join [[:core_user :user] [:= :user.id :auth_identity.user_id]]
|
||||
:where [:= :user.is_active true]
|
||||
:order-by [[:created_at :desc]]}))
|
||||
|
||||
(defn- slack-user-authorize-link
|
||||
"Link to page where user can initiate SSO auth flow to authorize slackbot"
|
||||
[]
|
||||
(let [sso-url "/auth/sso/slack-connect?redirect=/slack-connect-success"]
|
||||
(str (system/site-url) "/auth/login?redirect=" (codec/url-encode sso-url))))
|
||||
|
||||
(defn- send-auth-link
|
||||
"Respond to an incoming slack message with a request to authorize.
|
||||
For DMs, always threads the reply. For channel @mentions, only threads
|
||||
if the original message was in a thread."
|
||||
[client event]
|
||||
(slackbot.client/post-ephemeral-message
|
||||
client
|
||||
(merge (slackbot.events/event->reply-context event)
|
||||
{:user (:user event)
|
||||
;; DMs: always thread. Channels: only thread if already in a thread.
|
||||
:thread_ts (if (slackbot.events/dm? event)
|
||||
(or (:thread_ts event) (:ts event))
|
||||
(:thread_ts event))
|
||||
:text "Connect your Slack account to Metabase. Once linked, I can use your permissions to query data on your behalf."
|
||||
:blocks [{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text "Connect your Slack account to Metabase. Once linked, I can use your permissions to query data on your behalf."}}
|
||||
{:type "actions"
|
||||
:elements [{:type "button"
|
||||
:text {:type "plain_text"
|
||||
:text ":link: Connect to Metabase"
|
||||
:emoji true}
|
||||
:url (slack-user-authorize-link)}]}]})))
|
||||
|
||||
(defn- require-authenticated-slack-user!
|
||||
"Returns Metabase user-id if authenticated, nil otherwise.
|
||||
Sends auth link as side-effect when not authenticated."
|
||||
[client event]
|
||||
(if-let [user-id (slack-id->user-id (:user event))]
|
||||
user-id
|
||||
(do (send-auth-link client event) nil)))
|
||||
|
||||
;; ------------------------- EVENT HANDLING ------------------------------
|
||||
|
||||
(mu/defn- handle-url-verification :- slackbot.events/SlackEventsResponse
|
||||
"Respond to a url_verification request (docs: https://docs.slack.dev/reference/events/url_verification)"
|
||||
[event :- slackbot.events/SlackUrlVerificationEvent]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body (:challenge event)})
|
||||
|
||||
(def ^:private ack-msg
|
||||
"Acknowledgement payload"
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "ok"})
|
||||
|
||||
(defn- all-files-skipped?
|
||||
"Returns true if all files were skipped (none were CSV/TSV)."
|
||||
[{:keys [upload-result]}]
|
||||
(let [{:keys [results skipped]} upload-result]
|
||||
(and (seq skipped)
|
||||
(empty? results))))
|
||||
|
||||
(mu/defn- handle-message-file-share
|
||||
"Process a file_share message - handles CSV uploads"
|
||||
[client :- slackbot.client/SlackClient
|
||||
event :- slackbot.events/SlackMessageFileShareEvent]
|
||||
(let [files (:files event)
|
||||
text (:text event)
|
||||
has-text? (not (str/blank? text))
|
||||
file-handling (when (seq files)
|
||||
(slackbot.uploads/handle-file-uploads files))
|
||||
extra-history (cond
|
||||
;; Pre-flight error (uploads disabled, no permission)
|
||||
(:error file-handling)
|
||||
[{:role :assistant
|
||||
:content (:error file-handling)}]
|
||||
|
||||
;; Upload results to communicate to AI
|
||||
(:system-messages file-handling)
|
||||
(:system-messages file-handling))
|
||||
all-skipped? (all-files-skipped? file-handling)
|
||||
should-skip-ai? (and (not has-text?)
|
||||
(not (:error file-handling))
|
||||
all-skipped?)]
|
||||
;; If all files were skipped (non-CSV) and there's no text, respond directly
|
||||
;; without calling the AI to avoid sending an empty prompt
|
||||
(if should-skip-ai?
|
||||
(let [skipped-files (get-in file-handling [:upload-result :skipped])]
|
||||
(slackbot.client/post-message client
|
||||
(merge (slackbot.events/event->reply-context event)
|
||||
{:text (format "I can only process CSV and TSV files. The following files were skipped: %s"
|
||||
(str/join ", " skipped-files))})))
|
||||
(slackbot.streaming/send-response client event extra-history))))
|
||||
|
||||
(defn- with-processing-reaction
|
||||
"Wrap work with a temporary :eyes: reaction on the triggering message for non-DMs."
|
||||
[client event f]
|
||||
(let [reaction-target (when-not (slackbot.events/dm? event)
|
||||
{:channel (:channel event)
|
||||
:ts (:ts event)
|
||||
:name "eyes"})]
|
||||
(when reaction-target
|
||||
(let [res (slackbot.client/add-reaction client reaction-target)]
|
||||
(when-not (:ok res)
|
||||
(log/warnf "[slackbot] Failed to add processing reaction: %s (channel=%s ts=%s name=%s)"
|
||||
(:error res) (:channel reaction-target) (:ts reaction-target) (:name reaction-target)))))
|
||||
(try
|
||||
(f)
|
||||
(finally
|
||||
(when reaction-target
|
||||
(let [res (slackbot.client/remove-reaction client reaction-target)]
|
||||
(when-not (:ok res)
|
||||
(log/warnf "[slackbot] Failed to remove processing reaction: %s (channel=%s ts=%s name=%s)"
|
||||
(:error res) (:channel reaction-target) (:ts reaction-target) (:name reaction-target)))))))))
|
||||
|
||||
(defn- process-async
|
||||
"Process an event asynchronously with logging and error handling.
|
||||
Authenticates the user and calls handler with [client event]."
|
||||
[handler client event]
|
||||
(let [event-type (or (:subtype event) (:channel_type event) (:type event))]
|
||||
(log/debugf "[slackbot] Processing %s event" event-type)
|
||||
(future
|
||||
(with-processing-reaction client event
|
||||
(fn []
|
||||
(try
|
||||
(when-let [user-id (require-authenticated-slack-user! client event)]
|
||||
(request/with-current-user user-id
|
||||
(handler client event)))
|
||||
(catch Exception e
|
||||
(log/errorf e "[slackbot] Error processing %s: %s" event-type (ex-message e)))))))))
|
||||
|
||||
(defn- ignore-event
|
||||
"Handle any event we don't care to process"
|
||||
[event]
|
||||
(log/debugf "[slackbot] Ignoring event type=%s channel_type=%s subtype=%s ts=%s"
|
||||
(:type event) (:channel_type event) (:subtype event) (:ts event)))
|
||||
|
||||
(def ^:private delete-reaction-names
|
||||
"Slack emoji names that trigger metabot-response deletion."
|
||||
#{"wastebasket" "x"})
|
||||
|
||||
(def ^:private removed-notice-blocks
|
||||
[{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text "_Message has been removed._"}}])
|
||||
|
||||
(defn- removed-notice-payload
|
||||
[channel-id message-ts]
|
||||
{:channel channel-id
|
||||
:ts message-ts
|
||||
:text "Message has been removed."
|
||||
:blocks removed-notice-blocks})
|
||||
|
||||
(defn- replace-response-with-removed-notice!
|
||||
"Replace the Slack message first, then mark the stored response as deleted once Slack confirms the update."
|
||||
[client channel-id message-ts deleter-user-id]
|
||||
(let [res (slackbot.client/update-message client (removed-notice-payload channel-id message-ts))]
|
||||
(if (:ok res)
|
||||
(do
|
||||
(when-not (slackbot.persistence/soft-delete-response! channel-id message-ts deleter-user-id)
|
||||
(log/warnf "[slackbot] Slack message updated but soft delete was not recorded (channel=%s ts=%s user_id=%s)"
|
||||
channel-id message-ts deleter-user-id))
|
||||
true)
|
||||
(do
|
||||
(log/warnf "[slackbot] Failed to replace metabot response with removed notice: %s (channel=%s ts=%s user_id=%s)"
|
||||
(:error res) channel-id message-ts deleter-user-id)
|
||||
false))))
|
||||
|
||||
(defn- authorize-delete-request
|
||||
"Resolve whether a Slack delete request is authorized for the target metabot response."
|
||||
[slack-user-id channel-id message-ts]
|
||||
(cond
|
||||
(nil? channel-id)
|
||||
{:status :ignored
|
||||
:reason :missing-channel
|
||||
:message-ts message-ts}
|
||||
|
||||
(nil? message-ts)
|
||||
{:status :ignored
|
||||
:reason :missing-message-ts
|
||||
:channel-id channel-id}
|
||||
|
||||
:else
|
||||
(let [request-user-id (slack-id->user-id slack-user-id)]
|
||||
(if (nil? request-user-id)
|
||||
{:status :ignored
|
||||
:reason :unlinked-user
|
||||
:slack-user-id slack-user-id
|
||||
:channel-id channel-id
|
||||
:message-ts message-ts}
|
||||
(if-let [owner-user-id (slackbot.persistence/response-owner-user-id channel-id message-ts)]
|
||||
(if (= request-user-id owner-user-id)
|
||||
{:status :authorized
|
||||
:channel-id channel-id
|
||||
:message-ts message-ts
|
||||
:request-user-id request-user-id}
|
||||
{:status :ignored
|
||||
:reason :not-owner
|
||||
:channel-id channel-id
|
||||
:message-ts message-ts
|
||||
:request-user-id request-user-id
|
||||
:owner-user-id owner-user-id})
|
||||
{:status :ignored
|
||||
:reason :untracked-message
|
||||
:channel-id channel-id
|
||||
:message-ts message-ts})))))
|
||||
|
||||
(defn- log-ignored-delete-request
|
||||
"Log expected delete-request ignore paths at debug level."
|
||||
[{:keys [source reason channel-id message-ts slack-user-id request-user-id owner-user-id reaction]}]
|
||||
(case reason
|
||||
:missing-channel
|
||||
(log/debugf "[slackbot] Ignoring %s delete request with missing channel for ts=%s reaction=%s"
|
||||
source message-ts reaction)
|
||||
|
||||
:missing-message-ts
|
||||
(log/debugf "[slackbot] Ignoring %s delete request with missing message ts in channel=%s reaction=%s"
|
||||
source channel-id reaction)
|
||||
|
||||
:unlinked-user
|
||||
(log/debugf "[slackbot] Ignoring %s delete request from unlinked slack user=%s reaction=%s channel=%s ts=%s"
|
||||
source slack-user-id reaction channel-id message-ts)
|
||||
|
||||
:not-owner
|
||||
(log/debugf "[slackbot] Ignoring %s delete request from non-owner user_id=%s owner_user_id=%s channel=%s ts=%s reaction=%s"
|
||||
source request-user-id owner-user-id channel-id message-ts reaction)
|
||||
|
||||
:untracked-message
|
||||
(log/debugf "[slackbot] Ignoring %s delete request for untracked message channel=%s ts=%s reaction=%s"
|
||||
source channel-id message-ts reaction)))
|
||||
|
||||
(defn- delete-reaction-event?
|
||||
"True when this event is a delete reaction added to a message."
|
||||
[event]
|
||||
(and (= "reaction_added" (:type event))
|
||||
(= "message" (get-in event [:item :type]))
|
||||
(contains? delete-reaction-names (:reaction event))))
|
||||
|
||||
(defn- handle-delete-reaction
|
||||
"Replace a metabot response with a removed notice when the original request user reacts with an approved emoji."
|
||||
[client event]
|
||||
(try
|
||||
(let [slack-user-id (:user event)
|
||||
reaction (:reaction event)
|
||||
channel-id (get-in event [:item :channel])
|
||||
message-ts (get-in event [:item :ts])
|
||||
authorization (authorize-delete-request slack-user-id channel-id message-ts)]
|
||||
(case (:status authorization)
|
||||
:authorized
|
||||
(when (replace-response-with-removed-notice! client channel-id message-ts (:request-user-id authorization))
|
||||
(log/debugf "[slackbot] Replaced metabot response via reaction=%s channel=%s ts=%s"
|
||||
reaction channel-id message-ts))
|
||||
|
||||
(log-ignored-delete-request (assoc authorization
|
||||
:source "reaction"
|
||||
:reaction reaction))))
|
||||
(catch Exception e
|
||||
(log/error e "[slackbot] Error handling delete reaction"))))
|
||||
|
||||
(defn- assert-setup-complete
|
||||
"Asserts that all required Slack settings have been configured."
|
||||
[]
|
||||
(when-not (slackbot.config/setup-complete?)
|
||||
(log/errorf "[slackbot] Slack setup incomplete (site_url=%s sso_enabled=%s client_id=%s client_secret=%s signing_secret=%s app_token=%s encryption=%s)"
|
||||
(boolean (system/site-url))
|
||||
(boolean (premium-features/enable-sso-slack?))
|
||||
(boolean (sso-settings/slack-connect-client-id))
|
||||
(boolean (sso-settings/slack-connect-client-secret))
|
||||
(boolean (metabot.settings/metabot-slack-signing-secret))
|
||||
(boolean (channel.settings/unobfuscated-slack-app-token))
|
||||
(boolean (encryption/default-encryption-enabled?)))
|
||||
(throw (ex-info (str (tru "Slack integration is not fully configured.")) {:status-code 503}))))
|
||||
|
||||
(mu/defn- handle-event-callback :- slackbot.events/SlackEventsResponse
|
||||
"Respond to an event_callback request"
|
||||
[payload :- slackbot.events/SlackEventCallbackEvent]
|
||||
(assert-setup-complete)
|
||||
(when (sso-settings/slack-connect-enabled)
|
||||
(let [client {:token (channel.settings/unobfuscated-slack-app-token)}
|
||||
event (:event payload)]
|
||||
(log/debugf "[slackbot] Event callback: event_type=%s user=%s channel=%s"
|
||||
(:type event) (:user event) (:channel event))
|
||||
(cond
|
||||
((some-fn
|
||||
slackbot.events/bot-message? ;; ignore the bot's own messages
|
||||
slackbot.events/edited-message? ;; ignore message edits
|
||||
slackbot.events/app-mention-with-files?) ;; processed via the separate file_share event
|
||||
event)
|
||||
(ignore-event event)
|
||||
|
||||
(and
|
||||
(slackbot.events/file-share? event)
|
||||
(slackbot.events/dm-or-channel-mention? event (slackbot.client/get-bot-user-id client)))
|
||||
(process-async handle-message-file-share client event)
|
||||
|
||||
(delete-reaction-event? event)
|
||||
(future (handle-delete-reaction client event))
|
||||
|
||||
(= "reaction_added" (:type event))
|
||||
(log/debugf "[slackbot] Ignoring reaction_added for non-delete emoji reaction=%s item_type=%s channel=%s ts=%s"
|
||||
(:reaction event) (get-in event [:item :type]) (get-in event [:item :channel]) (get-in event [:item :ts]))
|
||||
|
||||
(or (slackbot.events/app-mention? event)
|
||||
(slackbot.events/dm? event))
|
||||
(process-async slackbot.streaming/send-response client event)
|
||||
|
||||
:else
|
||||
(ignore-event event))))
|
||||
ack-msg)
|
||||
|
||||
;; ----------------------- ROUTES --------------------------
|
||||
;; NOTE: make sure to do premium-features/enable-metabot-v3? checks if you add new endpoints
|
||||
|
||||
(api.macros/defendpoint :post "/events" :- slackbot.events/SlackEventsResponse
|
||||
"Respond to activities in Slack"
|
||||
[_route-params
|
||||
_query-params
|
||||
body :- [:multi {:dispatch :type}
|
||||
["url_verification" slackbot.events/SlackUrlVerificationEvent]
|
||||
["event_callback" slackbot.events/SlackEventCallbackEvent]
|
||||
[::mc/default [:map [:type :string]]]]
|
||||
request]
|
||||
(log/debugf "[slackbot] Incoming Slack request type=%s slack_event_type=%s request_ts=%s"
|
||||
(:type body)
|
||||
(get-in body [:event :type])
|
||||
(get-in request [:headers "x-slack-request-timestamp"]))
|
||||
(assert-valid-slack-req request)
|
||||
(log/debugf "[slackbot] Received Slack event type=%s slack_event_type=%s" (:type body) (get-in body [:event :type]))
|
||||
;; all handlers must respond within 3 seconds or slack will retry
|
||||
(if (premium-features/enable-metabot-v3?)
|
||||
(case (:type body)
|
||||
"url_verification" (handle-url-verification body)
|
||||
"event_callback" (handle-event-callback body)
|
||||
ack-msg)
|
||||
ack-msg))
|
||||
|
||||
(def SlackBotSettingsRequest
|
||||
"Malli schema for the request body of PUT /api/ee/metabot-v3/slack/settings.
|
||||
All credential fields must be provided together (either all set or all nil)."
|
||||
[:map
|
||||
[:slack-connect-client-id [:maybe ms/NonBlankString]]
|
||||
[:slack-connect-client-secret [:maybe ms/NonBlankString]]
|
||||
[:metabot-slack-signing-secret [:maybe ms/NonBlankString]]])
|
||||
|
||||
(def SlackBotSettingsResponse
|
||||
"Malli schema for the response of PUT /api/ee/metabot-v3/slack/settings."
|
||||
[:map
|
||||
[:ok :boolean]])
|
||||
|
||||
(api.macros/defendpoint :put "/settings" :- SlackBotSettingsResponse
|
||||
"Update Metabot Slack settings atomically.
|
||||
All credential fields must be provided together.
|
||||
Setting values requires the metabot-v3 feature, but clearing values is always allowed.
|
||||
slack-connect-enabled is automatically set to true when credentials are provided, nil when cleared."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [slack-connect-client-id
|
||||
slack-connect-client-secret
|
||||
metabot-slack-signing-secret]} :- SlackBotSettingsRequest]
|
||||
(perms/check-has-application-permission :setting)
|
||||
(let [all-set? (and slack-connect-client-id
|
||||
slack-connect-client-secret
|
||||
metabot-slack-signing-secret)
|
||||
all-unset? (and (nil? slack-connect-client-id)
|
||||
(nil? slack-connect-client-secret)
|
||||
(nil? metabot-slack-signing-secret))]
|
||||
;; require metabot-v3 feature only when setting values (clearing is always allowed)
|
||||
(when (not (or all-unset? (premium-features/enable-metabot-v3?)))
|
||||
(throw (ex-info (tru "Metabot feature is not enabled.")
|
||||
{:status-code 402})))
|
||||
;; all values must be set together or unset together
|
||||
(when-not (or all-set? all-unset?)
|
||||
(throw (ex-info (tru "Must provide client id, client secret and signing secret together.")
|
||||
{:status-code 400})))
|
||||
(setting/set-many! {:slack-connect-client-id slack-connect-client-id
|
||||
:slack-connect-client-secret slack-connect-client-secret
|
||||
:metabot-slack-signing-secret metabot-slack-signing-secret
|
||||
:slack-connect-enabled (boolean all-set?)})
|
||||
{:ok true}))
|
||||
|
||||
;; ------------------------- FEEDBACK BUTTONS ------------------------------
|
||||
|
||||
(def ^:private issue-type-options
|
||||
[{:text {:type "plain_text" :text "Wrong or unhelpful chart"} :value "unhelpful-chart"}
|
||||
{:text {:type "plain_text" :text "Wrong chart type"} :value "wrong-chart-type"}
|
||||
{:text {:type "plain_text" :text "Incorrect data or results"} :value "not-factual"}
|
||||
{:text {:type "plain_text" :text "Did not follow request"} :value "did-not-follow-request"}
|
||||
{:text {:type "plain_text" :text "Incomplete response"} :value "incomplete-response"}
|
||||
{:text {:type "plain_text" :text "Other"} :value "other"}])
|
||||
|
||||
(defn- feedback-modal-view
|
||||
"Build a Slack modal view for collecting detailed feedback."
|
||||
[positive private-metadata]
|
||||
{:type "modal"
|
||||
:callback_id "metabot_feedback_modal"
|
||||
:notify_on_close false
|
||||
:title {:type "plain_text" :text "Metabot feedback"}
|
||||
:submit {:type "plain_text" :text "Submit"}
|
||||
:close {:type "plain_text" :text "Cancel"}
|
||||
:private_metadata (json/encode private-metadata)
|
||||
:blocks
|
||||
(let [freeform-block {:type "input"
|
||||
:block_id "freeform_feedback"
|
||||
:optional true
|
||||
:element {:type "plain_text_input"
|
||||
:action_id "freeform_input"
|
||||
:multiline true
|
||||
:placeholder {:type "plain_text"
|
||||
:text (if positive
|
||||
"Tell us what you liked!"
|
||||
"What could be improved about this response?")}}
|
||||
:label {:type "plain_text" :text "Any details you'd like to share? (optional)"}}]
|
||||
(if positive
|
||||
[freeform-block]
|
||||
[{:type "input"
|
||||
:block_id "issue_type"
|
||||
:optional true
|
||||
:element {:type "static_select"
|
||||
:action_id "issue_type_select"
|
||||
:placeholder {:type "plain_text" :text "Select issue type"}
|
||||
:options issue-type-options}
|
||||
:label {:type "plain_text" :text "What kind of issue are you reporting? (optional)"}}
|
||||
freeform-block]))})
|
||||
|
||||
(defn- replace-feedback-buttons-with-thanks
|
||||
"Update the Slack message to replace the feedback buttons with a confirmation."
|
||||
[client channel message-ts message-blocks]
|
||||
(let [updated-blocks (-> (into [] (remove #(= (:block_id %) "metabot_feedback")) message-blocks)
|
||||
(conj {:type "context"
|
||||
:elements [{:type "mrkdwn"
|
||||
:text "Thanks for your feedback!"}]}))]
|
||||
(slackbot.client/update-message client {:channel channel
|
||||
:ts message-ts
|
||||
:blocks updated-blocks})))
|
||||
|
||||
(defn- build-base-feedback
|
||||
"Build the common feedback payload fields."
|
||||
[user-id conversation-id positive]
|
||||
{:metabot_id (metabot-v3.config/normalize-metabot-id "slackbotmetabotmetabo")
|
||||
:feedback {:positive positive
|
||||
:message_id conversation-id
|
||||
:freeform_feedback ""}
|
||||
:conversation_data {}
|
||||
:version config/mb-version-info
|
||||
:submission_time (str (java.time.OffsetDateTime/now))
|
||||
:is_admin (boolean (t2/select-one-fn :is_superuser :model/User :id user-id))
|
||||
:source "slack"})
|
||||
|
||||
(defn- handle-feedback-action
|
||||
"Handle a metabot feedback button click from Slack.
|
||||
Opens the detail modal immediately (trigger_id expires in 3s). Feedback is
|
||||
submitted to Harbormaster only when the user submits the modal."
|
||||
[{:keys [action trigger-id slack-user-id channel-id message-ts]}]
|
||||
(let [{:keys [conversation_id positive]} (json/decode (:value action) true)
|
||||
client {:token (channel.settings/unobfuscated-slack-app-token)}
|
||||
user-id (slack-id->user-id slack-user-id)]
|
||||
(when user-id
|
||||
(try
|
||||
(slackbot.client/open-view
|
||||
client
|
||||
{:trigger_id trigger-id
|
||||
:view (feedback-modal-view positive {:conversation_id conversation_id
|
||||
:positive positive
|
||||
:user_id user-id
|
||||
:channel_id channel-id
|
||||
:message_ts message-ts})})
|
||||
(catch Exception e
|
||||
(log/errorf e "[slackbot] Error opening feedback modal: %s" (ex-data e)))))))
|
||||
|
||||
(defn- handle-delete-action
|
||||
"Handle replacing a metabot response message with a removed notice.
|
||||
Only the user who triggered the response can do this."
|
||||
[{:keys [slack-user-id channel-id message-ts]}]
|
||||
(let [authorization (authorize-delete-request slack-user-id channel-id message-ts)]
|
||||
(case (:status authorization)
|
||||
:authorized
|
||||
(let [client {:token (channel.settings/unobfuscated-slack-app-token)}]
|
||||
(future
|
||||
(try
|
||||
(replace-response-with-removed-notice! client channel-id message-ts (:request-user-id authorization))
|
||||
(catch Exception e
|
||||
(log/errorf e "[slackbot] Error replacing metabot response with removed notice: %s" (ex-data e))))))
|
||||
|
||||
(log-ignored-delete-request (assoc authorization :source "action")))))
|
||||
|
||||
(defn- handle-feedback-modal-submission
|
||||
"Handle submission of the feedback details modal.
|
||||
Always submits to Harbormaster (the modal opening already captured positive/negative),
|
||||
then replaces the feedback buttons with a thanks message."
|
||||
[payload]
|
||||
(let [private-metadata (json/decode (get-in payload [:view :private_metadata]) true)
|
||||
{:keys [conversation_id positive user_id channel_id message_ts]} private-metadata
|
||||
values (get-in payload [:view :state :values])
|
||||
issue-type (get-in values [:issue_type :issue_type_select :selected_option :value])
|
||||
freeform (get-in values [:freeform_feedback :freeform_input :value])]
|
||||
(future
|
||||
(try
|
||||
(metabot-v3.feedback/submit-to-harbormaster!
|
||||
(cond-> (build-base-feedback user_id conversation_id positive)
|
||||
true (assoc-in [:feedback :freeform_feedback] (or freeform ""))
|
||||
issue-type (assoc-in [:feedback :issue_type] issue-type)))
|
||||
(catch Exception e
|
||||
(log/error e "[slackbot] Error submitting feedback to Harbormaster")))
|
||||
(try
|
||||
(when (and channel_id message_ts)
|
||||
(let [client {:token (channel.settings/unobfuscated-slack-app-token)}
|
||||
message (slackbot.client/fetch-message client channel_id message_ts)]
|
||||
(when message
|
||||
(replace-feedback-buttons-with-thanks client channel_id message_ts (:blocks message)))))
|
||||
(catch Exception e
|
||||
(log/error e "[slackbot] Error replacing feedback buttons"))))))
|
||||
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/interactive"
|
||||
"Handle interactive payloads from Slack (button clicks, modal submissions)."
|
||||
[_route-params _query-params _body request]
|
||||
(assert-valid-slack-req request)
|
||||
(when (premium-features/enable-metabot-v3?)
|
||||
(let [payload (-> (get-in request [:params :payload])
|
||||
(json/decode true))]
|
||||
(case (:type payload)
|
||||
"block_actions"
|
||||
(let [actions (:actions payload)
|
||||
slack-user (get-in payload [:user :id])
|
||||
trigger-id (:trigger_id payload)
|
||||
channel-id (get-in payload [:channel :id])
|
||||
message-ts (get-in payload [:message :ts])]
|
||||
(doseq [action actions]
|
||||
(case (:action_id action)
|
||||
"metabot_feedback"
|
||||
(handle-feedback-action {:action action
|
||||
:trigger-id trigger-id
|
||||
:slack-user-id slack-user
|
||||
:channel-id channel-id
|
||||
:message-ts message-ts})
|
||||
|
||||
"metabot_delete_response"
|
||||
(handle-delete-action {:slack-user-id slack-user
|
||||
:channel-id channel-id
|
||||
:message-ts message-ts})
|
||||
nil)))
|
||||
|
||||
"view_submission"
|
||||
(when (= (get-in payload [:view :callback_id]) "metabot_feedback_modal")
|
||||
(handle-feedback-modal-submission payload))
|
||||
|
||||
nil)))
|
||||
{:status 200 :body ""})
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/metabot-v3/slack` routes."
|
||||
(api.macros/ns-handler *ns*))
|
||||
|
||||
;; -------------- LOCAL DEV SETUP -------------------
|
||||
|
||||
(comment
|
||||
;; New slack app
|
||||
;; 1. create a tunnel via `ngrok http 3000`
|
||||
;; 2. update your site url to the provided tunnel url
|
||||
(system/site-url! "https://<random-id>.ngrok-free.app")
|
||||
;; 4. visit this url in your browser, following the setup flow outlined there
|
||||
(str (system/site-url) "/admin/metabot/slackbot")
|
||||
;; 6. verify you've setup your instance correctly
|
||||
(slackbot.config/setup-complete?)
|
||||
|
||||
;; Updating an existing slack app
|
||||
;; 1. create a tunnel via `ngrok http 3000`
|
||||
;; 2. update your site url to the provided tunnel url
|
||||
(system/site-url! "https://3ee6-104-174-230-42.ngrok-free.app")
|
||||
;; 3. visit the app manifest slack settings page for your slack app (https://app.slack.com/app-settings/.../..../app-manifest)
|
||||
;; 4. execute this form to copy the manifest to clipboard, paste the result in the manifest page
|
||||
(do
|
||||
(require '[clojure.java.shell :refer [sh]])
|
||||
(sh "pbcopy" :in (json/encode (get-slack-manifest) {:pretty true}))))
|
||||
;; 5. there will be a notification at the top of the manifest page to verify your new site url, click verify
|
||||
|
||||
;; ----------------- DEV -----------------------
|
||||
|
||||
(comment
|
||||
(def user-id "XXXXXXXXXXX") ; your slack user id (not the bot's)
|
||||
(def channel "XXXXXXXXXXX") ; slack channel id (e.g. bot's dms)
|
||||
(def thread-ts "XXXXXXXX.XXXXXXX") ; thread id
|
||||
|
||||
(def client {:token (channel.settings/unobfuscated-slack-app-token)})
|
||||
(def message (slackbot.client/post-message client {:channel channel :text "_Thinking..._" :thread_ts thread-ts}))
|
||||
(slackbot.client/delete-message client message)
|
||||
(select-keys message [:channel :ts])
|
||||
|
||||
(slackbot.client/post-ephemeral-message client {:channel channel
|
||||
:user user-id
|
||||
:text "sssh"
|
||||
:thread_ts thread-ts})
|
||||
|
||||
(def thread (slackbot.client/fetch-thread client message)))
|
||||
@@ -0,0 +1,186 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.slackbot.channel
|
||||
"Visible Slack channel reply/update flow for metabot."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.client :as slackbot.client]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private min-flush-interval-ms
|
||||
"Minimum milliseconds between consecutive channel progress updates."
|
||||
600)
|
||||
|
||||
(def ^:private channel-response-style-suffix
|
||||
(str "\n\n"
|
||||
"For this Slack channel response: the user already saw tool progress updates. "
|
||||
"Do not narrate the steps you took, do not recap tool calls, and do not include preambles like "
|
||||
"\"I'll...\", \"Let me...\", or \"I found...\". Give a brief final response in "
|
||||
"1-2 sentences before any table or chart."))
|
||||
|
||||
(defn- channel-request-prompt
|
||||
"Append channel-specific response style guidance without changing the stored user prompt."
|
||||
[prompt]
|
||||
(str prompt channel-response-style-suffix))
|
||||
|
||||
(defn- final-text-blocks
|
||||
"Build the leading text block(s) for a finalized non-streaming Slack message."
|
||||
[text]
|
||||
(if (str/blank? text)
|
||||
[]
|
||||
[{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text text}}]))
|
||||
|
||||
(defn- progress-text
|
||||
"Build the in-progress text shown in a visible channel reply."
|
||||
[thinking-placeholder status]
|
||||
(if (seq status)
|
||||
(str "_" status "_")
|
||||
thinking-placeholder))
|
||||
|
||||
(defn- progress-blocks
|
||||
"Build blocks for an in-progress visible channel reply."
|
||||
[thinking-placeholder status]
|
||||
[{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text (progress-text thinking-placeholder status)}}])
|
||||
|
||||
(defn- update-channel-message!
|
||||
"Update an existing Slack message with the full intended payload."
|
||||
[client channel message-ts op-label text blocks]
|
||||
(let [res (slackbot.client/update-message client
|
||||
(cond-> {:channel channel
|
||||
:ts message-ts
|
||||
:text text}
|
||||
blocks (assoc :blocks blocks)))]
|
||||
(when-not (:ok res)
|
||||
(log/warnf "[slackbot] %s failed: %s (block_count=%d block_types=%s response_messages=%s)"
|
||||
op-label
|
||||
(:error res)
|
||||
(count (or blocks []))
|
||||
(pr-str (when blocks (mapv :type blocks)))
|
||||
(pr-str (get-in res [:response_metadata :messages]))))
|
||||
res))
|
||||
|
||||
(defn- make-channel-callbacks
|
||||
"Create callback functions that update a single visible threaded reply in channels.
|
||||
Channel replies only surface tool-status updates progressively; text is accumulated
|
||||
and sent once in the final message update."
|
||||
[client {:keys [channel message-ts thinking-placeholder tool-name->friendly]}]
|
||||
(let [rendered-text (atom thinking-placeholder)
|
||||
current-text (atom "")
|
||||
current-status (atom nil)
|
||||
update-failed? (atom false)
|
||||
slack-writer (agent nil
|
||||
:error-mode :continue
|
||||
:error-handler (fn [_ e] (log/warn e "[slackbot] Async Slack update failed")))
|
||||
last-flush-timer (volatile! nil)
|
||||
flush-progress! (fn []
|
||||
(when (and message-ts (not @update-failed?))
|
||||
(let [rendered (progress-text thinking-placeholder @current-status)]
|
||||
(when (not= rendered @rendered-text)
|
||||
(let [res (slackbot.client/update-message client {:channel channel
|
||||
:ts message-ts
|
||||
:text rendered
|
||||
:blocks (progress-blocks thinking-placeholder
|
||||
@current-status)})]
|
||||
(if (:ok res)
|
||||
(reset! rendered-text rendered)
|
||||
(do
|
||||
(reset! update-failed? true)
|
||||
(log/warnf "[slackbot] channel update failed: %s (channel=%s ts=%s)"
|
||||
(:error res) channel message-ts))))))))]
|
||||
(letfn [(request-flush! ([] (request-flush! false))
|
||||
([force?]
|
||||
(when (and message-ts (not @update-failed?))
|
||||
(when (or force?
|
||||
(nil? @last-flush-timer)
|
||||
(>= (u/since-ms @last-flush-timer) min-flush-interval-ms))
|
||||
(vreset! last-flush-timer (u/start-timer))
|
||||
(send-off slack-writer (bound-fn* (fn [_] (flush-progress!) nil)))))))
|
||||
(set-status! [status]
|
||||
(reset! current-status status)
|
||||
(request-flush! true))]
|
||||
{:on-text (bound-fn* (fn [text]
|
||||
(when (seq text)
|
||||
(swap! current-text str text))))
|
||||
:on-tool-start (fn [{:keys [tool-name]}]
|
||||
(set-status! (str (tool-name->friendly tool-name "Thinking") "...")))
|
||||
:request-flush! request-flush!
|
||||
:set-status! set-status!
|
||||
:slack-writer slack-writer
|
||||
:current-text current-text
|
||||
:update-failed? update-failed?})))
|
||||
|
||||
(defn send-channel-response
|
||||
"Send a visible threaded reply/update flow for non-DM Slack conversations."
|
||||
[client event extra-history {:keys [channel-id message-ctx channel thread-ts thread bot-user-id prompt conversation-id]}
|
||||
{:keys [thinking-placeholder tool-name->friendly
|
||||
make-streaming-ai-request collect-viz-blocks feedback-blocks post-viz-error!
|
||||
make-viz-prefetch-callback cancel-prefetched-viz!]}]
|
||||
(let [initial-response (slackbot.client/post-thread-reply client
|
||||
{:channel channel :thread_ts thread-ts}
|
||||
thinking-placeholder)
|
||||
message-ts (:ts initial-response)
|
||||
{:keys [on-text on-tool-start
|
||||
request-flush! set-status! slack-writer current-text update-failed?]}
|
||||
(make-channel-callbacks client {:channel channel
|
||||
:message-ts message-ts
|
||||
:thinking-placeholder thinking-placeholder
|
||||
:tool-name->friendly tool-name->friendly})
|
||||
prefetched-viz (atom {})
|
||||
on-data (make-viz-prefetch-callback prefetched-viz)]
|
||||
(when-not (:ok initial-response)
|
||||
(log/warnf "[slackbot] initial channel reply failed: %s (channel=%s thread_ts=%s)"
|
||||
(:error initial-response) channel thread-ts))
|
||||
(try
|
||||
(let [_data-parts (make-streaming-ai-request
|
||||
conversation-id
|
||||
prompt
|
||||
thread
|
||||
bot-user-id
|
||||
channel-id
|
||||
extra-history
|
||||
{:on-text on-text
|
||||
:on-tool-start on-tool-start
|
||||
:on-tool-end nil
|
||||
:on-data on-data
|
||||
:req-slack-msg-id (:ts event)
|
||||
:get-res-slack-msg-id (when message-ts (fn [] message-ts))
|
||||
:request-prompt (channel-request-prompt prompt)})]
|
||||
(request-flush! true)
|
||||
(when (seq @prefetched-viz)
|
||||
(set-status! "Rendering results..."))
|
||||
(await slack-writer)
|
||||
(let [{:keys [blocks errors]} (collect-viz-blocks @prefetched-viz)
|
||||
answer-text (str/trim @current-text)
|
||||
final-text (if (or (seq answer-text) (seq blocks))
|
||||
answer-text
|
||||
"I wasn't able to generate a response. Please try again.")
|
||||
final-blocks (into (into (final-text-blocks final-text) blocks)
|
||||
(feedback-blocks conversation-id))]
|
||||
(when (or (nil? message-ts)
|
||||
@update-failed?
|
||||
(not (:ok (update-channel-message! client channel message-ts "channel update-message" final-text final-blocks))))
|
||||
(let [res (slackbot.client/post-thread-reply client message-ctx
|
||||
"I generated a response, but Slack could not render it. Please try again.")]
|
||||
(when-not (:ok res)
|
||||
(log/errorf "[slackbot] channel fallback post-message failed: %s" (:error res)))))
|
||||
(doseq [e errors]
|
||||
(post-viz-error! client channel thread-ts e))))
|
||||
(catch Exception e
|
||||
(cancel-prefetched-viz! prefetched-viz)
|
||||
(log/error e "[slackbot] Error in channel response")
|
||||
(set-status! nil)
|
||||
(await slack-writer)
|
||||
(let [error-text "Something went wrong. Please try again."]
|
||||
(when (or (nil? message-ts)
|
||||
@update-failed?
|
||||
(not (:ok (slackbot.client/update-message client {:channel channel
|
||||
:ts message-ts
|
||||
:text error-text}))))
|
||||
(let [res (slackbot.client/post-thread-reply client message-ctx error-text)]
|
||||
(when-not (:ok res)
|
||||
(log/errorf "[slackbot] channel cleanup fallback post-message failed: %s" (:error res))))))))))
|
||||
@@ -0,0 +1,249 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.slackbot.client
|
||||
"Slack API client functions for Metabot slackbot."
|
||||
(:require
|
||||
[clj-http.client :as http]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private streaming-connection-timeout-ms
|
||||
"TCP connection timeout for streaming API calls."
|
||||
5000)
|
||||
|
||||
(def ^:private streaming-socket-timeout-ms
|
||||
"Socket (read) timeout for streaming API calls (start/append/stop)."
|
||||
5000)
|
||||
|
||||
(def SlackClient
|
||||
"Malli schema for a Slack client."
|
||||
[:map {:closed true}
|
||||
[:token ms/NonBlankString]])
|
||||
|
||||
(defn- slack-get
|
||||
"GET from slack."
|
||||
[client endpoint params]
|
||||
(let [response (http/get (str "https://slack.com/api" endpoint)
|
||||
{:headers {"Authorization" (str "Bearer " (:token client))}
|
||||
:query-params params})]
|
||||
{:body (json/decode (:body response) true)
|
||||
:headers (:headers response)}))
|
||||
|
||||
(defn- slack-post-json
|
||||
"POST to slack."
|
||||
[client endpoint payload & {:keys [socket-timeout connection-timeout]}]
|
||||
(let [response (http/post (str "https://slack.com/api" endpoint)
|
||||
(cond-> {:headers {"Authorization" (str "Bearer " (:token client))}
|
||||
:content-type "application/json; charset=utf-8"
|
||||
:body (json/encode payload)}
|
||||
connection-timeout (assoc :connection-timeout connection-timeout)
|
||||
socket-timeout (assoc :socket-timeout socket-timeout)))]
|
||||
{:body (json/decode (:body response) true)
|
||||
:headers (:headers response)}))
|
||||
|
||||
(defn- slack-post-form
|
||||
"POST form to slack."
|
||||
[client endpoint payload]
|
||||
(-> (http/post (str "https://slack.com/api" endpoint)
|
||||
{:headers {"Authorization" (str "Bearer " (:token client))}
|
||||
:content-type "application/x-www-form-urlencoded; charset=utf-8"
|
||||
:form-params payload})
|
||||
:body
|
||||
(json/decode true)))
|
||||
|
||||
(def ^:private slack-token-error-codes
|
||||
"Error codes from Slack that indicate an invalid or revoked token."
|
||||
#{"invalid_auth" "account_inactive" "token_revoked" "token_expired" "not_authed"})
|
||||
|
||||
(defn auth-test
|
||||
"Call auth.test and return the response including headers.
|
||||
Throws an exception with appropriate status code if the call fails:
|
||||
- 400 for invalid/revoked tokens
|
||||
- 502 for Slack API errors (e.g., Slack is down)"
|
||||
[client]
|
||||
(try
|
||||
(let [{:keys [body headers]} (slack-post-json client "/auth.test" {})]
|
||||
(if (:ok body)
|
||||
{:body body :headers headers}
|
||||
(let [error-code (:error body)
|
||||
invalid-token? (slack-token-error-codes error-code)]
|
||||
(throw (ex-info (if invalid-token?
|
||||
(tru "Invalid Slack bot token: {0}" error-code)
|
||||
(tru "Slack API error: {0}" error-code))
|
||||
{:status-code (if invalid-token? 400 502)
|
||||
:error-code error-code})))))
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(throw e))
|
||||
(catch Exception e
|
||||
(throw (ex-info (tru "Unable to connect to Slack API: {0}" (.getMessage e))
|
||||
{:status-code 502}
|
||||
e)))))
|
||||
|
||||
(defn get-bot-user-id
|
||||
"Get the bot's Slack user ID"
|
||||
[client]
|
||||
(:user_id (:body (auth-test client))))
|
||||
|
||||
(defn fetch-thread
|
||||
"Fetch a Slack thread"
|
||||
([client message]
|
||||
(fetch-thread client message 50))
|
||||
([client message limit]
|
||||
(:body (slack-get client "/conversations.replies"
|
||||
{:channel (:channel message)
|
||||
:ts (or (:thread_ts message) (:ts message))
|
||||
:limit limit}))))
|
||||
|
||||
(defn- get-upload-url
|
||||
"Get a URL we can upload to"
|
||||
[client args]
|
||||
(slack-post-form client "/files.getUploadURLExternal" args))
|
||||
|
||||
(defn post-message
|
||||
"Send a Slack message"
|
||||
[client message]
|
||||
(:body (slack-post-json client "/chat.postMessage" message)))
|
||||
|
||||
(defn post-thread-reply
|
||||
"Send a threaded Slack reply using an existing reply context plus text and optional blocks."
|
||||
[client message-ctx text & {:keys [blocks]}]
|
||||
(post-message client
|
||||
(cond-> (assoc message-ctx :text text)
|
||||
blocks (assoc :blocks blocks))))
|
||||
|
||||
(defn post-ephemeral-message
|
||||
"Send a Slack ephemeral message (visible only to the specified user)"
|
||||
[client message]
|
||||
(:body (slack-post-json client "/chat.postEphemeral" message)))
|
||||
|
||||
(defn post-image
|
||||
"Upload a PNG image and send in a message.
|
||||
Optional initial-comment adds context text alongside the image."
|
||||
[client image-bytes filename channel thread-ts & {:keys [initial-comment]}]
|
||||
(let [{:keys [ok upload_url file_id] :as res} (get-upload-url client {:filename filename
|
||||
:length (alength ^bytes image-bytes)})]
|
||||
(when ok
|
||||
(http/post upload_url
|
||||
{:headers {"Content-Type" "image/png"}
|
||||
:body image-bytes})
|
||||
(:body (slack-post-json client "/files.completeUploadExternal"
|
||||
(cond-> {:files [{:id file_id
|
||||
:title filename}]
|
||||
:channel_id channel
|
||||
:thread_ts thread-ts}
|
||||
initial-comment (assoc :initial_comment initial-comment))))
|
||||
res)))
|
||||
|
||||
(defn fetch-message
|
||||
"Fetch a single Slack message by channel and timestamp."
|
||||
[client channel ts]
|
||||
(let [{:keys [messages]} (:body (slack-get client "/conversations.history"
|
||||
{:channel channel
|
||||
:latest ts
|
||||
:limit 1
|
||||
:inclusive true}))]
|
||||
(first messages)))
|
||||
|
||||
(defn delete-message
|
||||
"Remove a Slack message"
|
||||
[client message]
|
||||
(:body (slack-post-json client "/chat.delete" (select-keys message [:channel :ts]))))
|
||||
|
||||
(defn add-reaction
|
||||
"Add a reaction to a Slack message."
|
||||
[client {:keys [channel ts name]}]
|
||||
(:body (slack-post-json client "/reactions.add" {:channel channel
|
||||
:timestamp ts
|
||||
:name name})))
|
||||
|
||||
(defn remove-reaction
|
||||
"Remove a reaction from a Slack message."
|
||||
[client {:keys [channel ts name]}]
|
||||
(:body (slack-post-json client "/reactions.remove" {:channel channel
|
||||
:timestamp ts
|
||||
:name name})))
|
||||
|
||||
(defn update-message
|
||||
"Update a Slack message"
|
||||
[client message]
|
||||
(:body (slack-post-json client "/chat.update" message)))
|
||||
|
||||
(defn open-view
|
||||
"Open a Slack modal view, triggered from an interaction."
|
||||
[client {:keys [trigger_id view]}]
|
||||
(:body (slack-post-json client "/views.open" {:trigger_id trigger_id
|
||||
:view view})))
|
||||
|
||||
(defn download-file
|
||||
"Download a file from Slack using the client's token for authentication.
|
||||
Returns byte array of file contents."
|
||||
[client url]
|
||||
(-> (http/get url {:headers {"Authorization" (str "Bearer " (:token client))}
|
||||
:as :byte-array})
|
||||
:body))
|
||||
|
||||
;; -------------------- SLACK STREAMING API --------------------
|
||||
;; These functions implement Slack's chat streaming API for progressive AI responses.
|
||||
;; See: https://docs.slack.dev/changelog/2025/10/7/chat-streaming/
|
||||
|
||||
(defn start-stream
|
||||
"Start a Slack message stream. Returns the stream timestamp on success (acts as an identifier)."
|
||||
[client {:keys [channel thread_ts team_id user_id]}]
|
||||
(let [payload {:channel channel
|
||||
:thread_ts thread_ts
|
||||
:recipient_team_id team_id
|
||||
:recipient_user_id user_id}
|
||||
body (:body (slack-post-json client "/chat.startStream"
|
||||
payload
|
||||
:connection-timeout streaming-connection-timeout-ms
|
||||
:socket-timeout streaming-socket-timeout-ms))]
|
||||
(if (:ok body)
|
||||
{:stream_ts (:ts body)
|
||||
:channel (:channel body)
|
||||
:thread_ts thread_ts}
|
||||
(do
|
||||
(log/warnf "[slackbot] start-stream failed: %s (channel=%s thread_ts=%s recipient_team_id=%s recipient_user_id=%s)"
|
||||
(:error body) channel thread_ts team_id user_id)
|
||||
(log/debugf "[slackbot] start-stream failure body=%s payload=%s"
|
||||
(pr-str body) (pr-str payload))))))
|
||||
|
||||
(defn append-stream
|
||||
"Append chunks to an active stream. Each chunk is a map with :type and type-specific keys,
|
||||
e.g. {:type \"markdown_text\" :text \"...\"} or {:type \"task_update\" :id \"...\" :title \"...\" :status \"...\"}."
|
||||
[client channel stream-ts chunks]
|
||||
(let [payload {:channel channel
|
||||
:ts stream-ts
|
||||
:chunks chunks}
|
||||
body (:body (slack-post-json client "/chat.appendStream" payload
|
||||
:connection-timeout streaming-connection-timeout-ms
|
||||
:socket-timeout streaming-socket-timeout-ms))]
|
||||
(when-not (:ok body)
|
||||
(log/warnf "[slackbot] append-stream failed: %s" (:error body)))
|
||||
body))
|
||||
|
||||
(defn append-markdown-text
|
||||
"Helper to append markdown text to a stream."
|
||||
[client channel stream-ts text]
|
||||
(append-stream client channel stream-ts [{:type "markdown_text" :text text}]))
|
||||
|
||||
(defn stop-stream
|
||||
"Stop a stream and finalize it into a regular message."
|
||||
([client channel stream-ts]
|
||||
(stop-stream client channel stream-ts nil))
|
||||
([client channel stream-ts blocks]
|
||||
(let [payload (cond-> {:channel channel
|
||||
:ts stream-ts}
|
||||
blocks (assoc :blocks blocks))
|
||||
block-types (when blocks (mapv :type blocks))
|
||||
body (:body (slack-post-json client "/chat.stopStream"
|
||||
payload
|
||||
:connection-timeout streaming-connection-timeout-ms
|
||||
:socket-timeout streaming-socket-timeout-ms))]
|
||||
(when-not (:ok body)
|
||||
(log/warnf "[slackbot] stop-stream failed: %s (channel=%s stream_ts=%s block_count=%d block_types=%s)"
|
||||
(:error body) channel stream-ts (count (or blocks [])) (pr-str block-types))
|
||||
(log/debugf "[slackbot] stop-stream failure body=%s payload=%s"
|
||||
(pr-str body) (pr-str (update payload :blocks #(when % "[redacted-blocks]")))))
|
||||
body)))
|
||||
@@ -0,0 +1,84 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.slackbot.config
|
||||
"Configuration, manifest, and validation helpers for slackbot."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.client :as slackbot.client]
|
||||
[metabase-enterprise.metabot-v3.settings :as metabot.settings]
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.channel.settings :as channel.settings]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util.encryption :as encryption]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn build-slack-manifest
|
||||
"Build the full Metabot Slack app manifest for the given base URL."
|
||||
[base-url]
|
||||
{:display_information {:name "Metabot"
|
||||
:description "Your AI-powered data assistant"
|
||||
:background_color "#509EE3"}
|
||||
:features {:app_home {:home_tab_enabled false
|
||||
:messages_tab_enabled true
|
||||
:messages_tab_read_only_enabled false}
|
||||
:bot_user {:display_name "Metabot"
|
||||
:always_online false}
|
||||
:assistant_view {:assistant_description "Your AI-powered data assistant"}
|
||||
:slash_commands [{:command "/metabot"
|
||||
:url (str base-url "/api/ee/metabot-v3/slack/commands")
|
||||
:description "Issue a Metabot command"
|
||||
:should_escape false}]}
|
||||
:oauth_config {:redirect_urls [(str base-url "/auth/sso/slack-connect/callback")]
|
||||
:scopes {:bot ["app_mentions:read"
|
||||
"assistant:write"
|
||||
"channels:history"
|
||||
"chat:write"
|
||||
"chat:write.customize"
|
||||
"chat:write.public"
|
||||
"channels:join"
|
||||
"channels:read"
|
||||
"commands"
|
||||
"groups:read"
|
||||
"groups:history"
|
||||
"im:history"
|
||||
"im:read"
|
||||
"files:read"
|
||||
"files:write"
|
||||
"mpim:read"
|
||||
"reactions:read"
|
||||
"reactions:write"
|
||||
"users:read"]}}
|
||||
:settings {:event_subscriptions {:request_url (str base-url "/api/ee/metabot-v3/slack/events")
|
||||
:bot_events ["app_home_opened"
|
||||
"app_mention"
|
||||
"message.channels"
|
||||
"message.im"
|
||||
"reaction_added"
|
||||
"assistant_thread_started"
|
||||
"assistant_thread_context_changed"]}
|
||||
:interactivity {:is_enabled true
|
||||
:request_url (str base-url "/api/ee/metabot-v3/slack/interactive")}
|
||||
:org_deploy_enabled true
|
||||
:socket_mode_enabled false
|
||||
:token_rotation_enabled false}})
|
||||
|
||||
(defn setup-complete?
|
||||
"Returns true if all required Slack settings are configured to process events."
|
||||
[]
|
||||
(boolean
|
||||
(and (some? (system/site-url))
|
||||
(premium-features/enable-sso-slack?)
|
||||
(sso-settings/slack-connect-client-id)
|
||||
(sso-settings/slack-connect-client-secret)
|
||||
(metabot.settings/metabot-slack-signing-secret)
|
||||
(channel.settings/unobfuscated-slack-app-token)
|
||||
(encryption/default-encryption-enabled?))))
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn validate-bot-token!
|
||||
"Validate a Slack bot token using the auth.test endpoint.
|
||||
Throws an exception with appropriate status code if validation fails:
|
||||
- 400 for invalid/revoked tokens
|
||||
- 502 for Slack API errors (e.g., Slack is down)
|
||||
Returns the response map on success."
|
||||
[token]
|
||||
(slackbot.client/auth-test {:token token}))
|
||||
@@ -0,0 +1,144 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.slackbot.events
|
||||
"Event definitions for slackbot."
|
||||
(:require
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def SlackEventsResponse
|
||||
"Malli schema for Slack events API response"
|
||||
[:map
|
||||
;; Response status is expected to be 2xx to indicate the event was received
|
||||
;; https://docs.slack.dev/apis/events-api/#error-handling
|
||||
[:status [:= 200]]
|
||||
[:headers [:map ["Content-Type" [:= "text/plain"]]]]
|
||||
[:body :string]])
|
||||
|
||||
(def SlackUrlVerificationEvent
|
||||
"Malli schema for Slack url_verification event"
|
||||
[:map
|
||||
[:type [:= "url_verification"]]
|
||||
[:challenge :string]])
|
||||
|
||||
(def SlackFile
|
||||
"Malli schema for a file attached to a Slack message"
|
||||
[:map
|
||||
[:id :string]
|
||||
[:name :string]
|
||||
[:mimetype {:optional true} [:maybe :string]]
|
||||
[:filetype {:optional true} [:maybe :string]]
|
||||
[:url_private :string]
|
||||
[:size :int]])
|
||||
|
||||
(def SlackMessageEvent
|
||||
"Base schema for Slack message events"
|
||||
[:map
|
||||
[:type [:= "message"]]
|
||||
[:channel :string]
|
||||
[:user :string]
|
||||
[:ts :string]
|
||||
[:event_ts :string]
|
||||
[:bot_id {:optional true} [:maybe :string]]])
|
||||
|
||||
(def SlackMessageFileShareEvent
|
||||
"Schema for file_share message events"
|
||||
[:merge SlackMessageEvent
|
||||
[:map
|
||||
[:subtype [:= "file_share"]]
|
||||
[:channel_type :string]
|
||||
[:files [:sequential SlackFile]]
|
||||
[:text {:optional true} [:maybe :string]]
|
||||
[:thread_ts {:optional true} [:maybe :string]]]])
|
||||
|
||||
(def SlackEventCallbackEvent
|
||||
"Malli schema for Slack event_callback event"
|
||||
[:map
|
||||
[:type [:= "event_callback"]]
|
||||
[:event [:map
|
||||
[:type :string]
|
||||
[:event_ts :string]]]])
|
||||
|
||||
(defn user-message?
|
||||
"Check if event is from a user (not a bot)."
|
||||
[event]
|
||||
(nil? (:bot_id event)))
|
||||
|
||||
(defn bot-message?
|
||||
"Check if event is from a bot."
|
||||
[event]
|
||||
(not (user-message? event)))
|
||||
|
||||
(defn dm?
|
||||
"Check if event is a direct message."
|
||||
[event]
|
||||
(= (:channel_type event) "im"))
|
||||
|
||||
(defn channel-message?
|
||||
"Check if event is a public channel message."
|
||||
[event]
|
||||
(= (:channel_type event) "channel"))
|
||||
|
||||
(defn file-share?
|
||||
"Check if event is a file share."
|
||||
[event]
|
||||
(= (:subtype event) "file_share"))
|
||||
|
||||
(defn has-files?
|
||||
"Check if event has files."
|
||||
[event]
|
||||
(boolean (seq (:files event))))
|
||||
|
||||
(defn app-mention?
|
||||
"Check if event is an app_mention event."
|
||||
[event]
|
||||
(= (:type event) "app_mention"))
|
||||
|
||||
(defn edited-message?
|
||||
"Check if event is an edited message (message_changed subtype or has :edited key)."
|
||||
[event]
|
||||
(or (= (:subtype event) "message_changed")
|
||||
(some? (:edited event))))
|
||||
|
||||
(defn app-mention-with-files?
|
||||
"Check if event is an app_mention with file attachments.
|
||||
These are skipped because file_share events handle file uploads."
|
||||
[event]
|
||||
(and (app-mention? event)
|
||||
(has-files? event)))
|
||||
|
||||
(defn bot-tag
|
||||
"Convert bot user id to a the tag template syntax used in message text"
|
||||
[bot-user-id]
|
||||
(str "<@" bot-user-id ">"))
|
||||
|
||||
(defn mentions-bot?
|
||||
"Check if event @mentions the bot.
|
||||
Some events, like file uploads in channels, don't come through as app_mention."
|
||||
[event bot-user-id]
|
||||
(some-> (:text event)
|
||||
(str/includes? (bot-tag bot-user-id))))
|
||||
|
||||
(defn dm-or-channel-mention?
|
||||
"Check if event is an dm or channel w/ mention of the bot."
|
||||
[event bot-id]
|
||||
(or
|
||||
(dm? event)
|
||||
(and (channel-message? event)
|
||||
(mentions-bot? event bot-id))))
|
||||
|
||||
(defn event->prompt
|
||||
"Prepare the message event as a prompt that can be sent to an agent"
|
||||
[event bot-user-id]
|
||||
(str/replace (:text event) (bot-tag bot-user-id) "@Metabot"))
|
||||
|
||||
(defn event->reply-context
|
||||
"Extract the necessary context for a reply from the given `event`"
|
||||
[event]
|
||||
{:channel (:channel event)
|
||||
:thread_ts (or (:thread_ts event)
|
||||
(:ts event))})
|
||||
|
||||
(defn strip-bot-mention
|
||||
"Remove bot mention prefix from text (e.g., '<@U123> hello' -> 'hello')"
|
||||
[text bot-user-id]
|
||||
(if bot-user-id
|
||||
(str/replace text (re-pattern (str (bot-tag bot-user-id) "\\s?")) "")
|
||||
text))
|
||||
@@ -0,0 +1,69 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.slackbot.persistence
|
||||
"Slack-specific persistence: reconstruct conversation history from stored messages."
|
||||
(:require
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private history-message-types
|
||||
"Message types to include in history sent to ai-service."
|
||||
#{"TOOL_CALL" "TOOL_RESULT"})
|
||||
|
||||
(defn- normalize-history-message
|
||||
"Keep only ai-service schema fields, keywordize role."
|
||||
[msg]
|
||||
(-> (select-keys msg [:role :content :tool_calls :tool_call_id])
|
||||
(update :role keyword)))
|
||||
|
||||
(defn- extract-history-messages
|
||||
"Filter and normalize stored message data for history."
|
||||
[message]
|
||||
(->> (:data message)
|
||||
(filter #(history-message-types (:_type %)))
|
||||
(mapv normalize-history-message)))
|
||||
|
||||
(defn message-history
|
||||
"Tool call history for Slack messages. Returns {slack-msg-id -> [messages...]}."
|
||||
[conversation-id slack-msg-ids]
|
||||
(when (seq slack-msg-ids)
|
||||
(->> (t2/select :model/MetabotMessage
|
||||
:conversation_id conversation-id
|
||||
:role "assistant"
|
||||
:deleted_at nil
|
||||
:slack_msg_id [:in slack-msg-ids])
|
||||
(keep (fn [{:keys [slack_msg_id] :as msg}]
|
||||
(when-let [parts (seq (extract-history-messages msg))]
|
||||
[slack_msg_id parts])))
|
||||
(into {}))))
|
||||
|
||||
(defn deleted-message-ids
|
||||
"Slack message ids for assistant responses that were soft-deleted."
|
||||
[conversation-id slack-msg-ids]
|
||||
(when (seq slack-msg-ids)
|
||||
(t2/select-fn-set :slack_msg_id
|
||||
:model/MetabotMessage
|
||||
:conversation_id conversation-id
|
||||
:role "assistant"
|
||||
:deleted_at [:not= nil]
|
||||
:slack_msg_id [:in slack-msg-ids])))
|
||||
|
||||
(defn response-owner-user-id
|
||||
"Find the Metabase user ID who triggered the assistant response for this Slack channel/message.
|
||||
Returns nil when the message is not tracked."
|
||||
[channel-id slack-msg-id]
|
||||
(t2/select-one-fn :user_id
|
||||
:model/MetabotMessage
|
||||
:channel_id channel-id
|
||||
:slack_msg_id slack-msg-id
|
||||
:role "assistant"))
|
||||
|
||||
(defn soft-delete-response!
|
||||
"Mark the stored assistant response for this Slack channel/message as soft-deleted."
|
||||
[channel-id slack-msg-id deleter-user-id]
|
||||
(when (and channel-id slack-msg-id deleter-user-id)
|
||||
(pos? (t2/update! :model/MetabotMessage
|
||||
{:channel_id channel-id
|
||||
:slack_msg_id slack-msg-id
|
||||
:role "assistant"}
|
||||
{:deleted_at (java.time.OffsetDateTime/now)
|
||||
:deleted_by_user_id deleter-user-id}))))
|
||||
@@ -0,0 +1,143 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.slackbot.query
|
||||
"Ad-hoc query execution and visualization for slackbot."
|
||||
(:require
|
||||
[metabase.api.common :as api]
|
||||
[metabase.channel.render.core :as channel.render]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.query-processor.middleware.permissions :as qp.perms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private render-base-height-px
|
||||
"Base height in pixels for rendered PNGs. Width is calculated from aspect ratio."
|
||||
640)
|
||||
|
||||
(def ^:private render-padding-x-px
|
||||
"Horizontal padding in pixels applied to rendered PNGs."
|
||||
32)
|
||||
|
||||
(def ^:private display-aspect-ratios
|
||||
"Non-default aspect ratios (width:height) for display types, derived from dashboard card size defaults.
|
||||
Most display types use the default 2:1 ratio."
|
||||
{:pie 3/2 ; 12:8
|
||||
:waterfall 7/3 ; 14:6
|
||||
:sankey 8/5}) ; 16:10
|
||||
|
||||
(def ^:private default-aspect-ratio
|
||||
"Default aspect ratio when display type not found."
|
||||
2/1)
|
||||
|
||||
(defn- render-dimensions
|
||||
"Calculate render dimensions for a display type based on aspect ratios.
|
||||
Keeps height consistent at render-base-height-px and scales width by aspect ratio."
|
||||
[display]
|
||||
(let [aspect-ratio (get display-aspect-ratios (keyword display) default-aspect-ratio)
|
||||
render-width (long (* render-base-height-px aspect-ratio))]
|
||||
{:width render-width
|
||||
:height render-base-height-px}))
|
||||
|
||||
(defn- throw-on-failed-query!
|
||||
[results]
|
||||
(when (= :failed (:status results))
|
||||
(throw (ex-info (or (:error results) "Query execution failed")
|
||||
(select-keys results [:error :status])))))
|
||||
|
||||
(defn execute-adhoc-query
|
||||
"Execute an ad-hoc MBQL query in the context of Slackbot and return results."
|
||||
[query]
|
||||
(qp/process-query
|
||||
(-> query
|
||||
qp/userland-query-with-default-constraints
|
||||
(update :info merge {:executed-by api/*current-user-id*
|
||||
:context :slackbot}))))
|
||||
|
||||
(defn- generate-adhoc-png
|
||||
"Render query results to PNG."
|
||||
[results display]
|
||||
(let [adhoc-card {:display display
|
||||
:visualization_settings {}}
|
||||
{:keys [width]} (render-dimensions display)]
|
||||
(channel.render/render-adhoc-card-to-png
|
||||
adhoc-card
|
||||
results
|
||||
width
|
||||
{:channel.render/padding-x render-padding-x-px})))
|
||||
|
||||
(def ^:private supported-png-display-types
|
||||
"Display types that should render as PNG images rather than Slack tables."
|
||||
#{:smartscalar :gauge :progress
|
||||
:bar :line :area :combo :row :pie
|
||||
:scatter :boxplot :waterfall :funnel :sankey})
|
||||
|
||||
(defn generate-adhoc-output
|
||||
"Generate output for an ad-hoc query based on display type.
|
||||
Returns a map with :type (:table or :image) and :content.
|
||||
Always executes the query directly.
|
||||
|
||||
- Chart display types (bar, line, pie, etc.) render as PNG
|
||||
- Table display (or nil) renders as native Slack table blocks"
|
||||
[query & {:keys [display]
|
||||
:or {display :table}}]
|
||||
(let [display (keyword display)
|
||||
results (execute-adhoc-query query)]
|
||||
(throw-on-failed-query! results)
|
||||
(if (and (contains? supported-png-display-types display)
|
||||
(seq (get-in results [:data :rows])))
|
||||
{:type :image
|
||||
:content (generate-adhoc-png results display)}
|
||||
{:type :table
|
||||
:content (channel.render/format-results-as-table-blocks results)}))
|
||||
|
||||
;;; ------------------------------------------ Saved Card Visualization ----------------------------------------------------
|
||||
|
||||
(defn pulse-card-query-results
|
||||
"Execute a query for a saved card, returning results suitable for rendering."
|
||||
{:arglists '([card])}
|
||||
[{query :dataset_query, card-id :id}]
|
||||
;; Use the same approach as pulse API - this works for accessible cards
|
||||
(binding [qp.perms/*card-id* card-id]
|
||||
(qp/process-query
|
||||
(qp/userland-query
|
||||
(assoc query
|
||||
:middleware {:process-viz-settings? true
|
||||
:js-int-to-string? false})
|
||||
{:executed-by api/*current-user-id*
|
||||
:context :pulse
|
||||
:card-id card-id}))))
|
||||
|
||||
(defn- render-saved-card-png
|
||||
"Render a saved card (already fetched from DB) and pre-fetched results to PNG bytes."
|
||||
[card results]
|
||||
(let [{:keys [width]} (render-dimensions (:display card))
|
||||
options {:channel.render/include-title? false
|
||||
:channel.render/padding-x render-padding-x-px
|
||||
:channel.render/padding-y 0}]
|
||||
;; TODO: should we use the user's timezone for this?
|
||||
(channel.render/render-pulse-card-to-png (channel.render/defaulted-timezone card)
|
||||
card
|
||||
results
|
||||
width
|
||||
options)))
|
||||
|
||||
(defn generate-card-output
|
||||
"Generate output for a saved card based on its display type.
|
||||
Returns a map with :type (:table or :image), :content, and :card-name.
|
||||
|
||||
- `table` and display types not supported by static viz render as native Slack table blocks
|
||||
- static viz chart types render as PNG"
|
||||
[card-id]
|
||||
(let [card (t2/select-one :model/Card :id card-id)
|
||||
_ (when-not card
|
||||
(throw (ex-info "Card not found" {:card-id card-id :type :card-not-found})))
|
||||
card-name (:name card)
|
||||
results (pulse-card-query-results card)]
|
||||
(throw-on-failed-query! results)
|
||||
(if (and (-> card :display keyword supported-png-display-types)
|
||||
(seq (get-in results [:data :rows])))
|
||||
{:type :image
|
||||
:content (render-saved-card-png card results)
|
||||
:card-name card-name}
|
||||
{:type :table
|
||||
:content (channel.render/format-results-as-table-blocks results)
|
||||
:card-name card-name})))
|
||||
@@ -0,0 +1,583 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.slackbot.streaming
|
||||
"Streaming AI responses to Slack using Slack's chat streaming API
|
||||
(startStream/appendStream/stopStream)."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[java-time.api :as t]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.channel :as slackbot.channel]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.client :as slackbot.client]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.events :as slackbot.events]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.persistence :as slackbot.persistence]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.query :as slackbot.query]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
|
||||
[metabase-enterprise.metabot-v3.envelope :as metabot-v3.envelope]
|
||||
[metabase-enterprise.metabot-v3.persistence :as metabot-v3.persistence]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.channel.slack :as channel.slack]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log])
|
||||
(:import
|
||||
(java.util.concurrent
|
||||
Callable
|
||||
ExecutionException
|
||||
ExecutorService
|
||||
Executors
|
||||
Future
|
||||
ThreadFactory)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private viz-prefetch-pool-size
|
||||
"Maximum number of visualization rendering threads. Limits concurrent query execution
|
||||
during streaming so we don't overwhelm the QP or database connections."
|
||||
8)
|
||||
|
||||
(defonce ^:private ^ExecutorService viz-prefetch-executor
|
||||
(let [counter (atom 0)
|
||||
factory (reify ThreadFactory
|
||||
(newThread [_ r]
|
||||
(doto (Thread. r (str "viz-prefetch-" (swap! counter inc)))
|
||||
(.setDaemon true))))]
|
||||
(Executors/newFixedThreadPool viz-prefetch-pool-size factory)))
|
||||
|
||||
(def ^:private thinking-placeholder
|
||||
"Displayed while waiting for AI response. Excluded from history."
|
||||
"_Thinking..._")
|
||||
|
||||
(defn- ignore-msg?
|
||||
"True for messages that should be excluded from chat history."
|
||||
[msg]
|
||||
(and (slackbot.events/bot-message? msg)
|
||||
(or (= (:text msg) thinking-placeholder)
|
||||
(str/blank? (:text msg)))))
|
||||
|
||||
(defn- thread->bot-msg-ids
|
||||
"Slack message ids produced by our bot."
|
||||
[thread]
|
||||
(->> (:messages thread)
|
||||
(filter slackbot.events/bot-message?)
|
||||
(keep :ts)
|
||||
set))
|
||||
|
||||
(defn- thread->history
|
||||
"Convert a Slack thread to an ai-service history object.
|
||||
For user messages: uses Slack text (respects edits), strips bot mentions.
|
||||
For bot messages: merges tool calls/data from DB with text from Slack.
|
||||
This preserves tool history that Slack doesn't store while respecting edits."
|
||||
[thread bot-user-id conversation-id]
|
||||
(let [bot-msg-ids (thread->bot-msg-ids thread)
|
||||
msg-history (slackbot.persistence/message-history conversation-id bot-msg-ids)
|
||||
deleted-ids (slackbot.persistence/deleted-message-ids conversation-id bot-msg-ids)]
|
||||
(->> (:messages thread)
|
||||
(filter :text)
|
||||
(remove ignore-msg?)
|
||||
(remove (fn [{:keys [ts] :as msg}]
|
||||
(and (slackbot.events/bot-message? msg)
|
||||
(contains? deleted-ids ts))))
|
||||
(mapcat (fn [{:keys [ts text] :as msg}]
|
||||
(if (slackbot.events/bot-message? msg)
|
||||
;; bot messages: merge on tool call info from db
|
||||
(conj (get msg-history ts []) {:role :assistant :content text})
|
||||
;; user messages: user slack history instead to respect user edits
|
||||
[{:role :user :content (slackbot.events/strip-bot-mention text bot-user-id)}])))
|
||||
vec)))
|
||||
|
||||
(defn- compute-capabilities
|
||||
"Compute capability strings for the current user based on their permissions."
|
||||
[]
|
||||
(let [{:keys [can-create-queries can-create-native-queries]}
|
||||
(perms/query-creation-capabilities api/*current-user-id*)]
|
||||
(cond-> []
|
||||
can-create-queries (conj "permission:save_questions")
|
||||
can-create-native-queries (conj "permission:write_sql_queries"))))
|
||||
|
||||
(defn- escape-slack-link-text
|
||||
"Escape characters that have structural meaning inside Slack mrkdwn links (`<URL|label>`)."
|
||||
[s]
|
||||
(-> s
|
||||
(str/replace "&" "&")
|
||||
(str/replace "<" "<")
|
||||
(str/replace ">" ">")
|
||||
(str/replace "|" "\u2502")))
|
||||
|
||||
(defn- format-viz-title
|
||||
"Build the title text for a visualization message.
|
||||
Combines the title with a link to the query in Metabase."
|
||||
[title link]
|
||||
(let [full-link (when link (str (system/site-url) link))]
|
||||
(cond
|
||||
(and title full-link) (str "📊 <" full-link "|" (escape-slack-link-text title) ">")
|
||||
title title
|
||||
full-link (str "📊 <" full-link "|Open in Metabase>")
|
||||
:else nil)))
|
||||
|
||||
(defn- viz-output->blocks
|
||||
"Build blocks for a visualization to be included in the finalized stop-stream message."
|
||||
[{:keys [type content]} filename title link]
|
||||
(let [text (or (format-viz-title title link) "Query results")]
|
||||
(case type
|
||||
:table (let [title-block {:type "section"
|
||||
:text {:type "mrkdwn" :text text}}
|
||||
blocks (into [title-block] content)]
|
||||
blocks)
|
||||
:image (let [{:keys [id]} (channel.slack/upload-file! content (str filename ".png"))
|
||||
blocks [{:type "section"
|
||||
:text {:type "mrkdwn" :text text}}
|
||||
{:type "image"
|
||||
:slack_file {:id id}
|
||||
:alt_text (or title filename "Visualization")}]]
|
||||
blocks))))
|
||||
|
||||
(def ^:private tool-friendly-names
|
||||
"Map of tool names to user-friendly gerund descriptions for slackbot profile tools."
|
||||
{"search" "Searching"
|
||||
"construct_notebook_query" "Building query"
|
||||
"list_available_fields" "Finding available fields"
|
||||
"get_field_values" "Getting field values"
|
||||
"static_viz" "Running query"})
|
||||
|
||||
(def ^:private min-text-batch-size
|
||||
"Minimum characters to accumulate before considering a flush."
|
||||
250)
|
||||
|
||||
(def ^:private min-flush-interval-ms
|
||||
"Minimum milliseconds between consecutive text flushes to Slack.
|
||||
At Slack's rate limit of ~100 calls/minute, 600ms spacing leaves
|
||||
headroom for tool-update and other non-text API calls."
|
||||
600)
|
||||
|
||||
(defn- make-streaming-ai-request
|
||||
"Make a streaming AI request with callbacks for each message type.
|
||||
Returns data-parts (visualizations) from the response.
|
||||
|
||||
Callbacks:
|
||||
- on-text: Called with text content (ai-service already buffers markdown links)
|
||||
- on-tool-start: Called with {:id :name} when a tool starts
|
||||
- on-tool-end: Called with {:id :result} when a tool completes
|
||||
- on-data: Called with (index, parsed-content) for each DATA line
|
||||
- req-slack-msg-id: The Slack message ts for the user's incoming message
|
||||
- get-response-ts: Function that returns the Slack message ts for the bot's response"
|
||||
[conversation-id prompt thread bot-user-id channel-id extra-history
|
||||
{:keys [on-text on-tool-start on-tool-end on-data req-slack-msg-id get-res-slack-msg-id request-prompt]}]
|
||||
(let [data-idx (volatile! -1)
|
||||
message (metabot-v3.envelope/user-message prompt)
|
||||
request-message (metabot-v3.envelope/user-message (or request-prompt prompt))
|
||||
metabot-id (metabot-v3.config/resolve-dynamic-metabot-id nil)
|
||||
profile-id (metabot-v3.config/resolve-dynamic-profile-id "slackbot" metabot-id)
|
||||
session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)
|
||||
capabilities (compute-capabilities)
|
||||
thread-history (thread->history thread bot-user-id conversation-id)
|
||||
history (into (vec thread-history) extra-history)
|
||||
handle-line (fn [line]
|
||||
(when-let [[type content] (metabot-v3.u/parse-aisdk-line line)]
|
||||
(case type
|
||||
:TEXT
|
||||
(when (and on-text (seq content))
|
||||
(on-text content))
|
||||
|
||||
:TOOL_CALL
|
||||
(when on-tool-start
|
||||
(on-tool-start {:id (:toolCallId content)
|
||||
:tool-name (:toolName content)}))
|
||||
|
||||
:TOOL_RESULT
|
||||
(when on-tool-end
|
||||
(on-tool-end {:id (:toolCallId content)
|
||||
:result (:result content)}))
|
||||
|
||||
:DATA
|
||||
(when on-data
|
||||
(on-data (vswap! data-idx inc) content))
|
||||
|
||||
(log/debugf "Ignoring AI SDK line of type %s" type))))
|
||||
|
||||
_ (metabot-v3.persistence/store-message! conversation-id profile-id [message]
|
||||
:channel-id channel-id
|
||||
:slack-msg-id req-slack-msg-id)
|
||||
lines (metabot-v3.client/streaming-request-with-callback
|
||||
|
||||
{:context (metabot-v3.context/create-context
|
||||
{:current_time_with_timezone (str (java.time.OffsetDateTime/now))
|
||||
:capabilities capabilities
|
||||
:slack_channel_id channel-id})
|
||||
:metabot-id metabot-id
|
||||
:profile-id profile-id
|
||||
:session-id session-id
|
||||
:conversation-id conversation-id
|
||||
:message request-message
|
||||
:history history
|
||||
:state {}
|
||||
:on-line handle-line
|
||||
:on-complete (fn [lines]
|
||||
(metabot-v3.persistence/store-message!
|
||||
conversation-id profile-id
|
||||
(metabot-v3.u/aisdk->messages :assistant lines)
|
||||
:channel-id channel-id
|
||||
:slack-msg-id (when get-res-slack-msg-id (get-res-slack-msg-id))
|
||||
:user-id api/*current-user-id*)
|
||||
:store-in-db)})]
|
||||
(->> (metabot-v3.u/aisdk->messages :assistant lines)
|
||||
(filter #(= (:_type %) :DATA)))))
|
||||
|
||||
(def ^:private viz-data-types
|
||||
"DATA part types that represent visualizations."
|
||||
#{"static_viz" "adhoc_viz"})
|
||||
|
||||
(defn- generate-viz-output
|
||||
"Generate visualization output for a data-part. Called by the prefetch executor during streaming."
|
||||
[{:keys [type value]}]
|
||||
(case type
|
||||
"static_viz"
|
||||
(let [card-id (or (:entity_id value)
|
||||
(throw (ex-info "static_viz missing entity_id" {:value value})))]
|
||||
(slackbot.query/generate-card-output card-id))
|
||||
"adhoc_viz"
|
||||
(let [query (or (:query value)
|
||||
(throw (ex-info "adhoc_viz missing query" {:value value})))
|
||||
display (or (some-> (:display value) keyword) :table)]
|
||||
(slackbot.query/generate-adhoc-output query :display display))))
|
||||
|
||||
(defn- viz-error-message
|
||||
"Classify a visualization exception into a user-friendly message."
|
||||
[^Exception e]
|
||||
(let [data (ex-data e)]
|
||||
(cond
|
||||
(:permissions-error? data)
|
||||
"I don't have permission to access this data on your behalf."
|
||||
|
||||
(= :card-not-found (:type data))
|
||||
"This saved question no longer exists or has been deleted."
|
||||
|
||||
:else
|
||||
"Query execution failed, please try again.")))
|
||||
|
||||
(defn- post-viz-error!
|
||||
"Post a user-friendly error message for a failed visualization."
|
||||
[client channel thread-ts e]
|
||||
(try
|
||||
(slackbot.client/post-message client {:channel channel
|
||||
:thread_ts thread-ts
|
||||
:text (viz-error-message e)})
|
||||
(catch Exception post-e
|
||||
(log/error post-e "Failed to post visualization error"))))
|
||||
|
||||
(defn- collect-viz-blocks
|
||||
"Wait for all in-flight visualization futures and return blocks to include in stop-stream.
|
||||
Returns {:blocks [...], :errors [Exception ...]}.
|
||||
For saved cards (static_viz), uses the actual card name as the title."
|
||||
[prefetched-viz]
|
||||
(reduce
|
||||
(fn [{:keys [blocks errors] :as acc} [idx {:keys [^Future future filename title link]}]]
|
||||
(try
|
||||
(let [output (.get future)
|
||||
resolved-title (or (:card-name output) title)
|
||||
filename (or (some-> resolved-title (u/slugify {:max-length 80})) filename)]
|
||||
(assoc acc :blocks (into blocks (viz-output->blocks output filename resolved-title link))))
|
||||
(catch ExecutionException e
|
||||
(let [cause (or (.getCause e) e)]
|
||||
(log/errorf cause "Visualization future %d failed" idx)
|
||||
{:blocks blocks :errors (conj errors cause)}))
|
||||
(catch Exception e
|
||||
(log/errorf e "Visualization future %d failed" idx)
|
||||
{:blocks blocks :errors (conj errors e)})))
|
||||
{:blocks [] :errors []}
|
||||
(sort-by first prefetched-viz)))
|
||||
|
||||
(defn- ensure-stream-started!
|
||||
"Ensure the Slack stream has been started, attempting once if not already tried."
|
||||
[client stream-opts stream-attempted? stream-state]
|
||||
(when-not @stream-attempted?
|
||||
(vreset! stream-attempted? true)
|
||||
(when-let [stream (slackbot.client/start-stream client stream-opts)]
|
||||
(vreset! stream-state stream))))
|
||||
|
||||
(defn- dismiss-thinking-msg!
|
||||
"Delete the 'Thinking...' placeholder message. Idempotent via reset-vals!.
|
||||
Called from agent actions only."
|
||||
[client channel thinking-ts]
|
||||
(let [[ts] (reset-vals! thinking-ts nil)]
|
||||
(when ts
|
||||
(slackbot.client/delete-message client {:channel channel :ts ts}))))
|
||||
|
||||
(defn- drain-pending-text!
|
||||
"Drain all accumulated text from pending-text and send to Slack in one call.
|
||||
Called from agent actions only."
|
||||
[client stream-state pending-text thinking-ts]
|
||||
(when-let [{:keys [stream_ts channel]} @stream-state]
|
||||
(let [[text] (reset-vals! pending-text "")]
|
||||
(when (seq text)
|
||||
(dismiss-thinking-msg! client channel thinking-ts)
|
||||
(slackbot.client/append-markdown-text client channel stream_ts text)))))
|
||||
|
||||
(defn- viz-metadata
|
||||
"Extract title, filename, and link metadata from a visualization data-part."
|
||||
[{:keys [type value]}]
|
||||
(let [title (:title value)
|
||||
filename (or (some-> title (u/slugify {:max-length 80}))
|
||||
(case type
|
||||
"static_viz" (str "chart-" (:entity_id value))
|
||||
"adhoc_viz" (str "adhoc-" (t/format "yyyy-MM-dd_HH-mm-ss" (t/zoned-date-time)))))
|
||||
link (case type
|
||||
"adhoc_viz" (:link value)
|
||||
"static_viz" (str "/question/" (:entity_id value)))]
|
||||
{:title title :filename filename :link link}))
|
||||
|
||||
(defn- make-viz-prefetch-callback
|
||||
"Create an `:on-data` callback that submits visualization rendering work eagerly."
|
||||
[prefetched-viz]
|
||||
(fn [idx content]
|
||||
(when (viz-data-types (:type content))
|
||||
(let [{:keys [title filename link]} (viz-metadata content)
|
||||
task (bound-fn* #(generate-viz-output content))]
|
||||
(swap! prefetched-viz assoc idx
|
||||
{:future (.submit viz-prefetch-executor ^Callable task)
|
||||
:filename filename
|
||||
:title title
|
||||
:link link})))))
|
||||
|
||||
(defn- cancel-prefetched-viz!
|
||||
"Cancel any in-flight visualization futures."
|
||||
[prefetched-viz]
|
||||
(run! #(.cancel ^Future (:future %) true) (vals @prefetched-viz)))
|
||||
|
||||
(defn- make-streaming-callbacks
|
||||
"Create streaming callback functions and associated control functions.
|
||||
Slack API writes are dispatched to a background agent so the AI stream reader is never
|
||||
blocked by Slack I/O. The agent serializes writes to preserve ordering.
|
||||
|
||||
Text is coalesced via a shared `pending-text` atom: the callback thread appends text there,
|
||||
and each agent action drains everything accumulated since the last write. This means multiple
|
||||
flushes that pile up while a Slack API call is in flight get combined into a single append,
|
||||
preventing the agent queue from falling further behind over time.
|
||||
|
||||
A 'Thinking...' placeholder message can be posted before the stream starts via
|
||||
`:start-with-thinking!`. It is automatically deleted when the first text or tool update
|
||||
is sent to the stream.
|
||||
|
||||
Visualization DATA parts are prefetched: when a `static_viz` or `adhoc_viz` DATA part
|
||||
arrives mid-stream, the full visualization pipeline (query execution + rendering) is
|
||||
submitted to the executor immediately, overlapping with remaining text generation.
|
||||
|
||||
Returns a map with:
|
||||
- `:on-text`, `:on-tool-start`, `:on-tool-end`, `:on-data` — callbacks for [[make-streaming-ai-request]]
|
||||
- `:start-with-thinking!` — posts a 'Thinking...' placeholder in the thread
|
||||
- `:request-flush!` — schedules a drain of pending text to Slack
|
||||
- `:stream-state` — volatile holding `{:stream_ts :channel}` once started, nil before
|
||||
- `:slack-writer` — agent; callers should `(await slack-writer)` before stopping the stream
|
||||
- `:prefetched-viz` — atom holding `{index -> {:future Future :filename str :title str :link str}}` for in-flight visualizations"
|
||||
[client {:keys [channel thread-ts team-id user-id]}]
|
||||
(let [stream-state (volatile! nil)
|
||||
stream-attempted? (volatile! false)
|
||||
tool-id->name (volatile! {})
|
||||
;; Text awaiting write to Slack. Callback thread appends here; agent thread drains.
|
||||
;; An atom because it's shared across threads (callback thread writes, agent thread reads).
|
||||
pending-text (atom "")
|
||||
;; Holds the ts of the "Thinking..." placeholder message, or nil if not posted / already dismissed.
|
||||
thinking-ts (atom nil)
|
||||
slack-writer (agent nil
|
||||
:error-mode :continue
|
||||
:error-handler (fn [_ e] (log/warn e "[slackbot] Async Slack write failed")))
|
||||
stream-opts {:channel channel
|
||||
:thread_ts thread-ts
|
||||
:team_id team-id
|
||||
:user_id user-id}
|
||||
|
||||
;; Nil means "no flush has happened yet", so the first flush should always pass.
|
||||
last-flush-timer (volatile! nil)
|
||||
|
||||
request-flush! (fn do-request-flush
|
||||
([] (do-request-flush false))
|
||||
([force?]
|
||||
(ensure-stream-started! client stream-opts stream-attempted? stream-state)
|
||||
(when @stream-state
|
||||
(when (or force?
|
||||
(nil? @last-flush-timer)
|
||||
(>= (u/since-ms @last-flush-timer) min-flush-interval-ms))
|
||||
(vreset! last-flush-timer (u/start-timer))
|
||||
(send-off slack-writer
|
||||
(bound-fn* (fn [_] (drain-pending-text! client stream-state pending-text thinking-ts) nil)))))))
|
||||
|
||||
start-with-thinking! (fn []
|
||||
(let [response (slackbot.client/post-message client {:channel channel
|
||||
:thread_ts thread-ts
|
||||
:text thinking-placeholder})]
|
||||
(when (:ok response)
|
||||
(reset! thinking-ts (:ts response)))))
|
||||
|
||||
on-text (fn [text]
|
||||
(when (seq text)
|
||||
(swap! pending-text str text)
|
||||
(when (>= (count @pending-text) min-text-batch-size)
|
||||
(request-flush!))))
|
||||
|
||||
send-task-update! (fn [id tool-name status]
|
||||
(when-let [{:keys [stream_ts channel]} @stream-state]
|
||||
(send-off slack-writer
|
||||
(fn [_]
|
||||
(drain-pending-text! client stream-state pending-text thinking-ts)
|
||||
(when (= status "in_progress")
|
||||
(dismiss-thinking-msg! client channel thinking-ts))
|
||||
(slackbot.client/append-stream client channel stream_ts
|
||||
[{:type "task_update"
|
||||
:id id
|
||||
:title (tool-friendly-names tool-name "Thinking")
|
||||
:status status}])
|
||||
(when (= status "complete")
|
||||
(slackbot.client/append-markdown-text client channel stream_ts "\n"))
|
||||
nil))))
|
||||
|
||||
on-tool-start (fn [{:keys [id tool-name]}]
|
||||
(request-flush!)
|
||||
(vswap! tool-id->name assoc id tool-name)
|
||||
(send-task-update! id tool-name "in_progress"))
|
||||
|
||||
on-tool-end (fn [{:keys [id]}]
|
||||
(send-task-update! id (get @tool-id->name id) "complete")
|
||||
(vswap! tool-id->name dissoc id))
|
||||
|
||||
prefetched-viz (atom {})
|
||||
on-data (make-viz-prefetch-callback prefetched-viz)]
|
||||
{:on-text (bound-fn* on-text)
|
||||
:on-tool-start on-tool-start
|
||||
:on-tool-end on-tool-end
|
||||
:on-data on-data
|
||||
:request-flush! (bound-fn* request-flush!)
|
||||
:start-with-thinking! start-with-thinking!
|
||||
:stream-state stream-state
|
||||
:slack-writer slack-writer
|
||||
:prefetched-viz prefetched-viz}))
|
||||
|
||||
(defn- slack-thread->conversation-id
|
||||
"Generate deterministic conversation ID from Slack thread identifiers.
|
||||
Same thread always produces the same UUID (v3)."
|
||||
[team-id channel thread-ts]
|
||||
(str (java.util.UUID/nameUUIDFromBytes
|
||||
(.getBytes (str "slack:" team-id ":" channel ":" thread-ts)))))
|
||||
|
||||
(defn- feedback-blocks [conversation-id]
|
||||
[{:type "context_actions"
|
||||
:block_id "metabot_feedback"
|
||||
:elements [{:type "feedback_buttons"
|
||||
:action_id "metabot_feedback"
|
||||
:positive_button {:text {:type "plain_text" :text "Good"}
|
||||
:value (json/encode {:conversation_id conversation-id :positive true})}
|
||||
:negative_button {:text {:type "plain_text" :text "Bad"}
|
||||
:value (json/encode {:conversation_id conversation-id :positive false})}}]}])
|
||||
|
||||
(defn- prepare-response-context
|
||||
"Fetch thread/auth context shared by DM and channel delivery paths."
|
||||
[client event]
|
||||
(let [channel-id (:channel event)
|
||||
message-ctx (slackbot.events/event->reply-context event)
|
||||
channel (:channel message-ctx)
|
||||
thread-ts (:thread_ts message-ctx)
|
||||
thread-future (future (-> (slackbot.client/fetch-thread client event)
|
||||
(update :messages #(remove (fn [m] (= (:ts m) (:ts event))) %))))
|
||||
auth-future (future (:body (slackbot.client/auth-test client)))
|
||||
auth-info @auth-future
|
||||
thread @thread-future
|
||||
bot-user-id (:user_id auth-info)
|
||||
prompt (slackbot.events/event->prompt event bot-user-id)
|
||||
conversation-id (slack-thread->conversation-id (:team_id auth-info) channel thread-ts)]
|
||||
{:channel-id channel-id
|
||||
:message-ctx message-ctx
|
||||
:channel channel
|
||||
:thread-ts thread-ts
|
||||
:auth-info auth-info
|
||||
:thread thread
|
||||
:bot-user-id bot-user-id
|
||||
:prompt prompt
|
||||
:conversation-id conversation-id}))
|
||||
|
||||
(defn- send-dm-response
|
||||
[client event extra-history {:keys [channel-id message-ctx channel thread-ts auth-info thread bot-user-id prompt conversation-id]}]
|
||||
(let [{:keys [on-text on-tool-start on-tool-end on-data
|
||||
request-flush! start-with-thinking! stream-state slack-writer prefetched-viz]}
|
||||
(make-streaming-callbacks client {:channel channel
|
||||
:thread-ts thread-ts
|
||||
:team-id (:team_id auth-info)
|
||||
:user-id (:user event)})]
|
||||
(start-with-thinking!)
|
||||
(try
|
||||
;; Start stream early so assistant persistence has :slack_msg_id.
|
||||
(request-flush! true)
|
||||
(let [_data-parts (make-streaming-ai-request
|
||||
conversation-id
|
||||
prompt
|
||||
thread
|
||||
bot-user-id
|
||||
channel-id
|
||||
extra-history
|
||||
{:on-text on-text
|
||||
:on-tool-start on-tool-start
|
||||
:on-tool-end on-tool-end
|
||||
:on-data on-data
|
||||
:req-slack-msg-id (:ts event)
|
||||
:get-res-slack-msg-id (fn [] (:stream_ts @stream-state))})]
|
||||
(request-flush! true)
|
||||
(await slack-writer)
|
||||
(if-let [{:keys [stream_ts channel]} @stream-state]
|
||||
;; Hold stop-stream until all viz futures finish so text + viz + controls
|
||||
;; finalize as a single message.
|
||||
(let [{:keys [blocks errors]} (collect-viz-blocks @prefetched-viz)
|
||||
final-blocks (into blocks (feedback-blocks conversation-id))
|
||||
stop-result (slackbot.client/stop-stream client channel stream_ts final-blocks)]
|
||||
(log/debugf "[slackbot] stop-stream finalize attempt channel=%s thread_ts=%s stream_ts=%s block_count=%d block_types=%s"
|
||||
channel thread-ts stream_ts (count final-blocks) (pr-str (mapv :type final-blocks)))
|
||||
(when-not (:ok stop-result)
|
||||
(log/warnf "[slackbot] stop-stream fallback to post-message: %s" (:error stop-result))
|
||||
(let [fallback-result (slackbot.client/post-thread-reply client
|
||||
message-ctx
|
||||
"I generated a response, but Slack could not render it. Please try again.")]
|
||||
(when-not (:ok fallback-result)
|
||||
(log/errorf "[slackbot] fallback post-message failed after stop-stream error: %s" (:error fallback-result)))))
|
||||
(doseq [e errors]
|
||||
(post-viz-error! client channel thread-ts e)))
|
||||
(slackbot.client/post-thread-reply client message-ctx "I wasn't able to generate a response. Please try again.")))
|
||||
(catch Exception e
|
||||
(cancel-prefetched-viz! prefetched-viz)
|
||||
(log/error e "[slackbot] Error in streaming response")
|
||||
(await slack-writer)
|
||||
(if-let [{:keys [stream_ts channel]} @stream-state]
|
||||
(try
|
||||
(slackbot.client/append-markdown-text client channel stream_ts
|
||||
"\nSomething went wrong. Please try again.")
|
||||
(let [stop-result (slackbot.client/stop-stream client channel stream_ts)]
|
||||
(when-not (:ok stop-result)
|
||||
(log/warnf "[slackbot] stop-stream during error cleanup failed: %s" (:error stop-result))
|
||||
(let [fallback-result (slackbot.client/post-thread-reply client
|
||||
message-ctx
|
||||
"Something went wrong. Please try again.")]
|
||||
(when-not (:ok fallback-result)
|
||||
(log/errorf "[slackbot] cleanup fallback post-message failed: %s" (:error fallback-result))))))
|
||||
(catch Exception stop-e
|
||||
(log/debug stop-e "[slackbot] Failed to stop stream during error cleanup")))
|
||||
(slackbot.client/post-thread-reply client message-ctx "Something went wrong. Please try again."))))))
|
||||
|
||||
(defn send-response
|
||||
"Send a metabot response using Slack delivery suited to the conversation type.
|
||||
DMs stream progressively; non-DMs use a visible post/update flow."
|
||||
([client event]
|
||||
(send-response client event nil))
|
||||
([client event extra-history]
|
||||
(let [ctx (prepare-response-context client event)]
|
||||
(if (slackbot.events/dm? event)
|
||||
(send-dm-response client event extra-history ctx)
|
||||
(slackbot.channel/send-channel-response client
|
||||
event
|
||||
extra-history
|
||||
ctx
|
||||
{:thinking-placeholder thinking-placeholder
|
||||
:tool-name->friendly tool-friendly-names
|
||||
:make-streaming-ai-request make-streaming-ai-request
|
||||
:collect-viz-blocks collect-viz-blocks
|
||||
:feedback-blocks feedback-blocks
|
||||
:post-viz-error! post-viz-error!
|
||||
:make-viz-prefetch-callback make-viz-prefetch-callback
|
||||
:cancel-prefetched-viz! cancel-prefetched-viz!})))))
|
||||
@@ -0,0 +1,121 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.slackbot.uploads
|
||||
"CSV upload handling for slackbot."
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.client :as slackbot.client]
|
||||
[metabase.channel.settings :as channel.settings]
|
||||
[metabase.upload.core :as upload]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private max-file-size-bytes
|
||||
"Maximum file size for CSV uploads (1GB, matches Slack's limit)"
|
||||
(* 1024 1024 1024))
|
||||
|
||||
(def ^:private allowed-csv-filetypes
|
||||
"File types that are allowed for CSV uploads"
|
||||
#{"csv" "tsv"})
|
||||
|
||||
(defn- csv-file?
|
||||
"Check if a Slack file is a CSV/TSV based on filetype."
|
||||
[{:keys [filetype]}]
|
||||
(contains? allowed-csv-filetypes filetype))
|
||||
|
||||
(defn- validate-file-size
|
||||
"Returns nil if valid, error string if too large."
|
||||
[{:keys [name size]}]
|
||||
(when (> size max-file-size-bytes)
|
||||
(format "File '%s' exceeds 1GB size limit" name)))
|
||||
|
||||
(defn- upload-settings
|
||||
"Get upload settings map. Returns nil if uploads are not enabled."
|
||||
[]
|
||||
(when-let [db (upload/current-database)]
|
||||
{:db_id (:id db)
|
||||
:schema_name (:uploads_schema_name db)
|
||||
:table_prefix (:uploads_table_prefix db)}))
|
||||
|
||||
(defn- process-csv-file
|
||||
"Process a single CSV file upload. Returns a result map with either
|
||||
:model-id/:model-name (success) or :error (failure)."
|
||||
[{:keys [db_id schema_name table_prefix]} {:keys [name url_private] :as file}]
|
||||
(if-let [size-error (validate-file-size file)]
|
||||
(do
|
||||
(log/warnf "[slackbot] File exceeds size limit: file=%s error=%s" name size-error)
|
||||
{:error size-error :filename name})
|
||||
(let [temp-file (java.io.File/createTempFile "slack-upload-" (str "-" name))]
|
||||
(try
|
||||
(let [content (slackbot.client/download-file {:token (channel.settings/unobfuscated-slack-app-token)} url_private)]
|
||||
(io/copy content temp-file)
|
||||
(let [result (upload/create-csv-upload!
|
||||
{:filename name
|
||||
:file temp-file
|
||||
:db-id db_id
|
||||
:schema-name schema_name
|
||||
:table-prefix table_prefix
|
||||
:collection-id nil})]
|
||||
(log/infof "[slackbot] File uploaded: file=%s model_id=%d" name (:id result))
|
||||
{:filename name
|
||||
:model-id (:id result)
|
||||
:model-name (:name result)}))
|
||||
(catch Exception e
|
||||
(log/warnf "[slackbot] File upload failed: file=%s error=%s" name (ex-message e))
|
||||
{:error (ex-message e) :filename name})
|
||||
(finally
|
||||
(io/delete-file temp-file true))))))
|
||||
|
||||
(defn- process-file-uploads
|
||||
"Process all files from a Slack event. Returns a map with:
|
||||
:results - seq of individual file results
|
||||
:skipped - seq of non-CSV filenames that were skipped"
|
||||
[settings files]
|
||||
(let [{csv-files true other-files false} (group-by csv-file? files)
|
||||
skipped (mapv :name other-files)]
|
||||
(when (seq skipped)
|
||||
(log/debugf "[slackbot] Skipping non-CSV files: %s" (pr-str skipped)))
|
||||
{:results (mapv (partial process-csv-file settings) csv-files)
|
||||
:skipped skipped}))
|
||||
|
||||
(defn- build-upload-system-messages
|
||||
"Build system messages to inject into AI request about uploads."
|
||||
[{:keys [results skipped]}]
|
||||
(let [successes (filter :model-id results)
|
||||
failures (filter :error results)]
|
||||
(cond-> []
|
||||
(seq successes)
|
||||
(conj {:role :assistant
|
||||
:content (format "The following message included 1 or more attached CSV files which are now available as models in Metabase: %s. You can help them query this data."
|
||||
(str/join ", " (map #(format "%s (model ID: %d)"
|
||||
(:filename %)
|
||||
(:model-id %))
|
||||
successes)))})
|
||||
|
||||
(seq failures)
|
||||
(conj {:role :assistant
|
||||
:content (format "The following message included 1 or more attached CSV files. Some file uploads failed: %s. Explain these errors to the user."
|
||||
(str/join ", " (map #(format "%s: %s"
|
||||
(:filename %)
|
||||
(:error %))
|
||||
failures)))})
|
||||
|
||||
(seq skipped)
|
||||
(conj {:role :assistant
|
||||
:content (format "The following message included 1 or more non-CSV files which are not supported: %s. Let them know only CSV files can be uploaded."
|
||||
(str/join ", " skipped))}))))
|
||||
|
||||
(defn handle-file-uploads
|
||||
"Handle file uploads if present. Returns nil if no files, otherwise
|
||||
returns upload result map and messages to inject into AI request."
|
||||
[files]
|
||||
(when (seq files)
|
||||
(if-let [{:keys [db_id schema_name] :as settings} (upload-settings)]
|
||||
(let [db (t2/select-one :model/Database :id db_id)]
|
||||
(if-not (upload/can-create-upload? db schema_name)
|
||||
{:error "You don't have permission to upload files. Contact your Metabase administrator."}
|
||||
(let [result (process-file-uploads settings files)]
|
||||
{:upload-result result
|
||||
:system-messages (build-upload-system-messages result)})))
|
||||
{:error "CSV uploads are not enabled. An administrator needs to configure a database for uploads in Admin > Settings > Uploads."})))
|
||||
@@ -12,7 +12,6 @@
|
||||
[malli.transform :as mtx]
|
||||
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
|
||||
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
@@ -131,6 +130,36 @@
|
||||
(close []
|
||||
(.close ^Closeable (:http-client response)))))
|
||||
|
||||
(defn- open-ai-stream!
|
||||
"Make a streaming request to the AI service. Returns the HTTP response.
|
||||
Caller is responsible for reading from the response body and closing it."
|
||||
[{:keys [context message history profile-id conversation-id session-id state]}]
|
||||
(premium-features/assert-has-feature :metabot-v3 "MetaBot")
|
||||
(let [url (ai-url "/v2/agent/stream")
|
||||
body {:messages (conj (vec history) message)
|
||||
:context context
|
||||
:conversation_id conversation-id
|
||||
:profile_id profile-id
|
||||
:user_id api/*current-user-id*
|
||||
:state state}
|
||||
_ (log/debugf "V2 request to AI Proxy:\n%s" (u/pprint-to-str body))
|
||||
options (cond-> {:headers {"Accept" "text/event-stream"
|
||||
"Content-Type" "application/json;charset=UTF-8"
|
||||
"x-metabase-instance-token" (premium-features/premium-embedding-token)
|
||||
"x-metabase-session-token" session-id
|
||||
"x-metabase-url" (system/site-url)
|
||||
"Connection" "close"}
|
||||
:body (->json-bytes body)
|
||||
:throw-exceptions false
|
||||
:as :stream
|
||||
:decompress-body false}
|
||||
*debug* (assoc :debug true))
|
||||
response (post! url options)]
|
||||
(log/debugf "Response from AI Proxy:\n%s" (u/pprint-to-str (select-keys response #{:body :status :headers})))
|
||||
(when-not (#{200 202} (:status response))
|
||||
(check-response! response body))
|
||||
response))
|
||||
|
||||
(mu/defn streaming-request :- :any
|
||||
"Make a streaming V2 request to the AI Service
|
||||
|
||||
@@ -147,7 +176,7 @@
|
||||
Response chunks are encoded in the format understood by the frontend and AI Service, Clojure backend doesn't
|
||||
know anything about it and just shuttles them over.
|
||||
"
|
||||
[{:keys [context message history profile-id conversation-id session-id state on-complete]}
|
||||
[{:keys [on-complete] :as opts}
|
||||
:- [:map
|
||||
[:context :map]
|
||||
[:message ::metabot-v3.client.schema/message]
|
||||
@@ -157,46 +186,12 @@
|
||||
[:session-id :string]
|
||||
[:state :map]
|
||||
[:on-complete {:optional true} [:function [:=> [:cat :any] :any]]]]]
|
||||
(premium-features/assert-has-feature :metabot-v3 "MetaBot")
|
||||
(try
|
||||
(let [url (ai-url "/v2/agent/stream")
|
||||
body {:messages (conj (vec history) message)
|
||||
:context context
|
||||
:conversation_id conversation-id
|
||||
:profile_id profile-id
|
||||
:user_id api/*current-user-id*
|
||||
:state state}
|
||||
_ (metabot-v3.context/log body :llm.log/be->llm)
|
||||
_ (log/debugf "V2 request to AI Proxy:\n%s" (u/pprint-to-str body))
|
||||
options (cond-> {:headers {"Accept" "text/event-stream"
|
||||
"Content-Type" "application/json;charset=UTF-8"
|
||||
"x-metabase-instance-token" (premium-features/premium-embedding-token)
|
||||
"x-metabase-session-token" session-id
|
||||
"x-metabase-url" (system/site-url)
|
||||
;; close conn so it's not reused so that when we use
|
||||
;; `quick-closing-body` there are no weird problems
|
||||
"Connection" "close"}
|
||||
:body (->json-bytes body)
|
||||
:throw-exceptions false
|
||||
:as :stream
|
||||
;; Don't compress streaming responses - adds latency and breaks
|
||||
;; cancellation when streams are incomplete
|
||||
:decompress-body false}
|
||||
*debug* (assoc :debug true))
|
||||
response (post! url options)
|
||||
lines (when on-complete
|
||||
(atom []))
|
||||
;; NOTE: this atom is unused right now, but we potentially might put its value in database (see
|
||||
;; `on-complete`) to indicate which requests were canceled in flight
|
||||
(let [response (open-ai-stream! opts)
|
||||
lines (when on-complete (atom []))
|
||||
canceled (atom nil)]
|
||||
(metabot-v3.context/log (:body response) :llm.log/llm->be)
|
||||
(log/debugf "Response from AI Proxy:\n%s" (u/pprint-to-str (select-keys response #{:body :status :headers})))
|
||||
(when-not (#{200 202} (:status response))
|
||||
(check-response! response body))
|
||||
(sr/streaming-response {:content-type "text/event-stream; charset=utf-8"} [os canceled-chan]
|
||||
;; see `quick-closing-body` docs and see `Connection` header supporting this behavior
|
||||
(with-open [^BufferedReader response-reader (io/reader (quick-closing-body response))]
|
||||
;; Response from the AI Service will send response parts separated by newline
|
||||
(loop [^String line (.readLine response-reader)]
|
||||
(cond
|
||||
(nil? line) nil
|
||||
@@ -208,22 +203,50 @@
|
||||
(swap! lines conj line))
|
||||
(try
|
||||
(doto os
|
||||
;; `line-seq` strips newlines, so we need to append it back.
|
||||
(.write (.getBytes line "UTF-8"))
|
||||
(.write (.getBytes "\n"))
|
||||
;; Immediately flush so it feels fluid on the frontend
|
||||
(.flush))
|
||||
(catch EofException _
|
||||
(reset! canceled true)
|
||||
(log/debug "Request cancelled, through exception")))
|
||||
(when-not @canceled
|
||||
;; `recur` cannot be inside of `try`, so we have to signal stop somehow
|
||||
(recur (.readLine response-reader)))))))
|
||||
(when on-complete
|
||||
(on-complete @lines))))
|
||||
(catch Throwable e
|
||||
(throw (ex-info (format "Error in request to AI Proxy: %s" (ex-message e)) {} e)))))
|
||||
|
||||
(mu/defn streaming-request-with-callback :- [:sequential :string]
|
||||
"Make a streaming V2 request to the AI Service, invoking a callback for each line.
|
||||
Unlike `streaming-request`, this doesn't return a StreamingResponse - it processes
|
||||
the stream internally and calls `on-line` for each line as it arrives.
|
||||
Returns the collected lines when complete."
|
||||
[{:keys [on-line on-complete] :as opts}
|
||||
:- [:map
|
||||
[:context :map]
|
||||
[:message ::metabot-v3.client.schema/message]
|
||||
[:history ::metabot-v3.client.schema/messages]
|
||||
[:profile-id :string]
|
||||
[:conversation-id :string]
|
||||
[:session-id :string]
|
||||
[:state :map]
|
||||
[:on-line {:optional true} [:function [:=> [:cat :string] :any]]]
|
||||
[:on-complete {:optional true} [:function [:=> [:cat :any] :any]]]]]
|
||||
(let [response (open-ai-stream! opts)
|
||||
lines (atom [])]
|
||||
(try
|
||||
(with-open [^BufferedReader response-reader (io/reader (quick-closing-body response))]
|
||||
(loop [^String line (.readLine response-reader)]
|
||||
(when line
|
||||
(swap! lines conj line)
|
||||
(when on-line (on-line line))
|
||||
(recur (.readLine response-reader)))))
|
||||
(when on-complete
|
||||
(on-complete @lines))
|
||||
(catch java.io.IOException e
|
||||
(log/debugf e "Stream closed while reading AI response (%d lines collected)" (count @lines))))
|
||||
@lines))
|
||||
|
||||
(mu/defn select-metric-request
|
||||
"Make a request to AI Service to select a metric."
|
||||
[metrics :- [:sequential ::metabot-v3.client.schema/metric]
|
||||
|
||||
@@ -12,12 +12,18 @@
|
||||
"The ID of the embedded Metabot instance."
|
||||
"c61bf5f5-1025-47b6-9298-bf1827105bb6")
|
||||
|
||||
(def slackbot-metabot-id
|
||||
"The ID of the Slack Metabot instance."
|
||||
"9a89fe64-54b9-4ab2-8022-eccd772e5073")
|
||||
|
||||
(def metabot-config
|
||||
"The name of the collection exposed by the answer-sources tool."
|
||||
{internal-metabot-id {:profile-id "internal"
|
||||
:entity-id "metabotmetabotmetabot"}
|
||||
:entity-id "metabotmetabotmetabot"}
|
||||
embedded-metabot-id {:profile-id "embedding"
|
||||
:entity-id "embeddedmetabotmetabo"}})
|
||||
:entity-id "embeddedmetabotmetabo"}
|
||||
slackbot-metabot-id {:profile-id "slackbot"
|
||||
:entity-id "slackbotmetabotmetabo"}})
|
||||
|
||||
(defn metabot-id->profile-id
|
||||
"Return the profile-id for the Metabot instance with ID `metabot-id` or \"default\" if no profile-id is configured."
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
(ns metabase-enterprise.metabot-v3.feedback
|
||||
"Shared feedback submission to Harbormaster via the Store API."
|
||||
(:require
|
||||
[clj-http.client :as http]
|
||||
[clojure.string :as str]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.store-api.core :as store-api]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(defn submit-to-harbormaster!
|
||||
"Submit metabot feedback to Harbormaster via the Store API.
|
||||
Returns the HTTP response on success, or nil if the token or Store API URL is missing."
|
||||
[feedback]
|
||||
(let [token (premium-features/premium-embedding-token)
|
||||
base-url (store-api/store-api-url)]
|
||||
(when-not (or (str/blank? token) (str/blank? base-url))
|
||||
(http/post (str base-url "/api/v2/metabot/feedback/" token)
|
||||
{:content-type :json
|
||||
:body (json/encode feedback)}))))
|
||||
@@ -0,0 +1,38 @@
|
||||
(ns metabase-enterprise.metabot-v3.persistence
|
||||
"Persistence for Metabot conversations and messages."
|
||||
(:require
|
||||
[metabase.api.common :as api]
|
||||
[metabase.app-db.core :as app-db]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn store-message!
|
||||
"Persist messages to MetabotConversation and MetabotMessage tables."
|
||||
[conversation-id profile-id messages & {:keys [slack-msg-id channel-id user-id]}]
|
||||
(let [finish (let [m (u/last messages)]
|
||||
(when (= (:_type m) :FINISH_MESSAGE)
|
||||
m))
|
||||
state (u/seek #(and (= (:_type %) :DATA)
|
||||
(= (:type %) "state"))
|
||||
messages)
|
||||
messages (-> (remove #(or (= % state) (= % finish)) messages)
|
||||
vec)]
|
||||
(app-db/update-or-insert! :model/MetabotConversation {:id conversation-id}
|
||||
(constantly (cond-> {:user_id api/*current-user-id*}
|
||||
state (assoc :state state))))
|
||||
;; NOTE: this will need to be constrained at some point, see BOT-386
|
||||
(t2/insert! :model/MetabotMessage
|
||||
(cond-> {:conversation_id conversation-id
|
||||
:data messages
|
||||
:usage (:usage finish)
|
||||
:role (:role (first messages))
|
||||
:profile_id profile-id
|
||||
:total_tokens (->> (vals (:usage finish))
|
||||
;; NOTE: this filter is supporting backward-compatible usage format, can be
|
||||
;; removed when ai-service does not give us `completionTokens` in `usage`
|
||||
(filter map?)
|
||||
(map #(+ (:prompt %) (:completion %)))
|
||||
(apply +))}
|
||||
channel-id (assoc :channel_id channel-id)
|
||||
slack-msg-id (assoc :slack_msg_id slack-msg-id)
|
||||
user-id (assoc :user_id user-id)))))
|
||||
@@ -1,7 +1,9 @@
|
||||
(ns metabase-enterprise.metabot-v3.settings
|
||||
(:require
|
||||
[metabase.premium-features.core :refer [defenterprise]]
|
||||
[metabase.settings.core :as setting :refer [defsetting]]
|
||||
[metabase.util.i18n :refer [deferred-tru]]))
|
||||
[metabase.util.i18n :refer [deferred-tru]]
|
||||
[metabase.util.string :as u.str]))
|
||||
|
||||
(defsetting ai-service-base-url
|
||||
(deferred-tru "URL for the a AI Service")
|
||||
@@ -67,3 +69,26 @@
|
||||
:feature :metabot-v3
|
||||
:export? true
|
||||
:doc false)
|
||||
|
||||
(defsetting metabot-slack-signing-secret
|
||||
(deferred-tru "Signing secret for verifying requests from the Metabot Slack app")
|
||||
:type :string
|
||||
:visibility :admin
|
||||
:encryption :when-encryption-key-set
|
||||
:feature :metabot-v3
|
||||
:export? false
|
||||
:audit :no-value
|
||||
:getter (fn []
|
||||
(-> (setting/get-value-of-type :string :metabot-slack-signing-secret)
|
||||
(u.str/mask 4))))
|
||||
|
||||
(defn unobfuscated-metabot-slack-signing-secret
|
||||
"Get the unobfuscated value of [[metabot-slack-signing-secret]]."
|
||||
[]
|
||||
(setting/get-value-of-type :string :metabot-slack-signing-secret))
|
||||
|
||||
(defenterprise metabot-slack-signing-secret-setting
|
||||
"Returns the Slack signing secret for Metabot."
|
||||
:feature :metabot-v3
|
||||
[]
|
||||
(unobfuscated-metabot-slack-signing-secret))
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
[metabase-enterprise.metabot-v3.reactions]
|
||||
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
|
||||
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
|
||||
[metabase-enterprise.metabot-v3.tools.create-alert
|
||||
:as metabot-v3.tools.create-alert]
|
||||
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription
|
||||
:as metabot-v3.tools.create-dashboard-subscription]
|
||||
[metabase-enterprise.metabot-v3.tools.deftool :refer [deftool]]
|
||||
@@ -503,16 +505,34 @@
|
||||
[:and
|
||||
[:map
|
||||
[:dashboard_id :int]
|
||||
[:email :string]
|
||||
[:slack_channel :string]
|
||||
[:schedule ::subscription-schedule]]
|
||||
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
|
||||
|
||||
(deftool "/create-dashboard-subscription"
|
||||
"Create a dashboard subscription."
|
||||
"Create a dashboard subscription and send it to a slack channel."
|
||||
{:args-schema ::create-dashboard-subscription-arguments
|
||||
:result-schema [:map [:output :string]]
|
||||
:result-schema [:map [:error {:optional true} :string
|
||||
:output {:optional true} :string]]
|
||||
:handler metabot-v3.tools.create-dashboard-subscription/create-dashboard-subscription})
|
||||
|
||||
(mr/def ::create-alert-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:card_id :int]
|
||||
[:send_condition [:enum {:encode/tool-api-request keyword} "has_result" "goal_above" "goal_below"]]
|
||||
[:send_once {:optional true, :default false} :boolean]
|
||||
[:schedule ::subscription-schedule]
|
||||
[:slack_channel :string]]
|
||||
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
|
||||
|
||||
(deftool "/create-alert"
|
||||
"Create an alert for a saved question."
|
||||
{:args-schema ::create-alert-arguments
|
||||
:result-schema [:map [:error {:optional true} :string
|
||||
:output {:optional true} :string]]
|
||||
:handler metabot-v3.tools.create-alert/create-alert})
|
||||
|
||||
;;; ---------------------------------------------------- Analytics ----------------------------------------------------
|
||||
|
||||
(mr/def ::field-values-arguments
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.create-alert
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.channel.settings :as channel.settings]
|
||||
[metabase.notification.api :as notification.api]
|
||||
[metabase.util.cron :as u.cron]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- schedule->cron
|
||||
"Convert the tool schedule format to a Quartz cron string.
|
||||
The schedule comes in as e.g. {:frequency :daily :hour 9} and needs to be
|
||||
converted to a Quartz cron string like \"0 0 9 * * ? *\"."
|
||||
[schedule]
|
||||
(u.cron/schedule-map->cron-string (metabot-v3.tools.u/schedule->schedule-map schedule)))
|
||||
|
||||
(defn- create-alert*
|
||||
"Private helper for create-alert (call that instead)."
|
||||
[{:keys [card-id slack-channel schedule send-condition send-once]
|
||||
:or {send-once false}}]
|
||||
(let [card (some-> (t2/select-one :model/Card card-id) api/read-check)
|
||||
channel-name (some->> slack-channel
|
||||
channel.settings/find-cached-slack-channel-or-username
|
||||
:display-name)]
|
||||
(cond
|
||||
(nil? card)
|
||||
{:error "no saved question with this card_id found"}
|
||||
|
||||
(nil? channel-name)
|
||||
{:error "no slack channel found with this name"}
|
||||
|
||||
:else
|
||||
(do
|
||||
(notification.api/create-notification!
|
||||
{:payload_type :notification/card
|
||||
:active true
|
||||
:creator_id api/*current-user-id*
|
||||
:payload {:card_id card-id
|
||||
:send_condition send-condition
|
||||
:send_once send-once}
|
||||
:subscriptions [{:type :notification-subscription/cron
|
||||
:cron_schedule (schedule->cron schedule)}]
|
||||
:handlers [{:channel_type :channel/slack
|
||||
:recipients [{:type :notification-recipient/raw-value
|
||||
:details {:value channel-name}}]}]})
|
||||
{:output "success"}))))
|
||||
|
||||
(defn create-alert
|
||||
"Create an alert (notification when a saved question returns results)."
|
||||
[{:keys [card-id send-condition send-once slack-channel]
|
||||
:as args}]
|
||||
(cond
|
||||
(not (int? card-id))
|
||||
{:error "invalid card_id"}
|
||||
|
||||
(and (some? send-once) (not (boolean? send-once)))
|
||||
{:error "send_once must be a boolean"}
|
||||
|
||||
(not (#{:has_result :goal_above :goal_below} send-condition))
|
||||
{:error (str "unsupported send_condition: " (some-> send-condition name))}
|
||||
|
||||
(not (channel.settings/slack-configured?))
|
||||
{:error "slack is not configured. Ask an admin to set up slack notifications in Metabase settings."}
|
||||
|
||||
(empty? slack-channel)
|
||||
{:error "slack_channel is required"}
|
||||
|
||||
:else
|
||||
(try
|
||||
(create-alert* args)
|
||||
(catch Exception e
|
||||
(-> (metabot-v3.tools.u/handle-agent-error e)
|
||||
(set/rename-keys {:output :error}))))))
|
||||
@@ -1,55 +1,71 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.create-dashboard-subscription
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.permissions.core :as perms]
|
||||
^{:clj-kondo/ignore [:deprecated-namespace]}
|
||||
[metabase.pulse.core :as pulse]
|
||||
[metabase.util :as u]
|
||||
[metabase.channel.settings :as channel.settings]
|
||||
[metabase.pulse.api :as pulse.api]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- make-slack-channel
|
||||
"Build a pulse channel for Slack delivery."
|
||||
[schedule slack-channel]
|
||||
(merge {:channel_type :slack
|
||||
:enabled true
|
||||
:details {:channel slack-channel}}
|
||||
(metabot-v3.tools.u/schedule->schedule-map schedule)))
|
||||
|
||||
(defn- create-dashboard-subscription*
|
||||
"Private helper for create-dashboard-subscription (call that instead)."
|
||||
[{:keys [dashboard-id slack-channel schedule]}]
|
||||
(let [dashboard (some-> (t2/select-one :model/Dashboard dashboard-id)
|
||||
api/read-check
|
||||
(t2/hydrate [:dashcards :card]))
|
||||
cards (for [{:keys [id card]} (:dashcards dashboard)
|
||||
:when (-> card :id int?)]
|
||||
(-> card
|
||||
api/read-check
|
||||
(select-keys [:id :name :collection_id :description :display :parameter_mappings])
|
||||
(assoc :dashboard_card_id id :dashboard_id dashboard-id)))
|
||||
channel-name (some->> slack-channel
|
||||
channel.settings/find-cached-slack-channel-or-username
|
||||
;; match existing code which stores display names like "#some-channel"
|
||||
:display-name)
|
||||
pulse-data (-> dashboard
|
||||
(select-keys [:collection_id :collection_position :name :parameters])
|
||||
(assoc :dashboard_id dashboard-id
|
||||
:creator_id api/*current-user-id*
|
||||
:skip_if_empty false))]
|
||||
(cond
|
||||
(nil? dashboard)
|
||||
{:error "no dashboard with this dashboard_id found"}
|
||||
|
||||
(nil? channel-name)
|
||||
{:error "no slack channel found with this name"}
|
||||
|
||||
:else
|
||||
(do (pulse.api/create-pulse-with-perm-checks!
|
||||
cards
|
||||
[(make-slack-channel schedule channel-name)]
|
||||
pulse-data)
|
||||
{:output "success"}))))
|
||||
|
||||
(defn create-dashboard-subscription
|
||||
"Create a dashboard subscription."
|
||||
[{:keys [dashboard-id email schedule]}]
|
||||
(perms/check-has-application-permission :subscription false)
|
||||
(if (int? dashboard-id)
|
||||
(let [dashboard (some-> (t2/select-one :model/Dashboard dashboard-id)
|
||||
api/read-check
|
||||
(t2/hydrate [:dashcards :card]))
|
||||
cards (for [{:keys [id card]} (:dashcards dashboard)
|
||||
:when (-> card :id int?)]
|
||||
(-> card
|
||||
api/read-check
|
||||
(select-keys [:id :name :collection_id :description :display :parameter_mappings])
|
||||
(assoc :dashboard_card_id id :dashboard_id dashboard-id)))
|
||||
recipient-id (t2/select-one-fn :id :model/User :email email)
|
||||
recipient {:id recipient-id}
|
||||
{:keys [frequency hour day-of-week day-of-month]} schedule
|
||||
channel {:channel_type :email
|
||||
:enabled true
|
||||
:recipients [recipient]
|
||||
:schedule_day (or (some-> day-of-week name (subs 0 3) u/lower-case-en)
|
||||
(some->> day-of-month
|
||||
name
|
||||
u/lower-case-en
|
||||
(re-find #"^(?:first|last)-(mon|tue|wed|thu|fri|sat|sun)")
|
||||
second))
|
||||
:schedule_frame (some->> day-of-month name (re-find #"^(?:first|mid|last)"))
|
||||
:schedule_hour hour
|
||||
:schedule_type frequency}
|
||||
pulse-data (-> dashboard
|
||||
(select-keys [:collection_id :collection_position :name :parameters])
|
||||
(assoc :dashboard_id dashboard-id
|
||||
:creator_id api/*current-user-id*
|
||||
:skip_if_empty false))]
|
||||
{:output
|
||||
(cond
|
||||
(nil? recipient-id)
|
||||
"no user with this email found"
|
||||
"Create a dashboard subscription and send it to a slack channel."
|
||||
[{:keys [dashboard-id slack-channel] :as args}]
|
||||
(cond
|
||||
(not (int? dashboard-id))
|
||||
{:error "invalid dashboard_id"}
|
||||
|
||||
(nil? dashboard)
|
||||
"no dashboard with this dashboard_id found"
|
||||
(not (channel.settings/slack-configured?))
|
||||
{:error "slack is not configured. Ask an admin to connect slack in Metabase settings."}
|
||||
|
||||
:else
|
||||
(do (pulse/create-pulse! (map pulse/card->ref cards) [channel] pulse-data)
|
||||
"success"))})
|
||||
{:output "invalid dashboard_id"}))
|
||||
(empty? slack-channel)
|
||||
{:error "slack_channel is required"}
|
||||
|
||||
:else
|
||||
(try
|
||||
(create-dashboard-subscription* args)
|
||||
(catch Exception e
|
||||
(-> (metabot-v3.tools.u/handle-agent-error e)
|
||||
(set/rename-keys {:output :error}))))))
|
||||
|
||||
@@ -406,10 +406,14 @@
|
||||
(let [options (cond-> arguments
|
||||
(= (:with-field-values? arguments) false) (assoc :field-values-fn identity))
|
||||
details (if (int? report-id)
|
||||
(let [details (card-details report-id options)]
|
||||
(let [card (t2/hydrate (metabot-v3.tools.u/get-card report-id)
|
||||
:average_query_time)
|
||||
mp (lib-be/application-database-metadata-provider (:database_id card))
|
||||
details (card-details card mp options)]
|
||||
(-> details
|
||||
(select-keys [:id :type :description :name :verified])
|
||||
(assoc :result-columns (:fields details))))
|
||||
(assoc :result-columns (:fields details))
|
||||
(m/assoc-some :average_query_time (:average_query_time card))))
|
||||
(throw (ex-info "Invalid report_id format"
|
||||
{:agent-error? true :status-code 400})))]
|
||||
{:structured-output details}))
|
||||
|
||||
@@ -175,9 +175,9 @@
|
||||
query-id (u/generate-nano-id)
|
||||
query-field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)
|
||||
returned-cols (lib/returned-columns query)]
|
||||
{:type :query
|
||||
:query-id query-id
|
||||
:query query
|
||||
{:type :query
|
||||
:query-id query-id
|
||||
:query query
|
||||
:result-columns (into []
|
||||
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 query-field-id-prefix))
|
||||
returned-cols)}))
|
||||
@@ -365,9 +365,9 @@
|
||||
query-id (u/generate-nano-id)
|
||||
query-field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)
|
||||
returned-cols (lib/returned-columns query)]
|
||||
{:type :query
|
||||
:query-id query-id
|
||||
:query query
|
||||
{:type :query
|
||||
:query-id query-id
|
||||
:query query
|
||||
:result-columns (into []
|
||||
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 query-field-id-prefix))
|
||||
returned-cols)}))
|
||||
|
||||
@@ -148,6 +148,20 @@
|
||||
:status-code 400
|
||||
:field-id field-id}))))
|
||||
|
||||
(defn schedule->schedule-map
|
||||
"Convert a tool schedule map to the schedule-map format used by cron and pulse channels.
|
||||
E.g. {:frequency :daily :hour 9} => {:schedule_type \"daily\" :schedule_hour 9 ...}"
|
||||
[{:keys [frequency hour day-of-week day-of-month]}]
|
||||
{:schedule_type (name frequency)
|
||||
:schedule_hour hour
|
||||
:schedule_day (or (some-> day-of-week name (subs 0 3) u/lower-case-en)
|
||||
(some->> day-of-month
|
||||
name
|
||||
u/lower-case-en
|
||||
(re-find #"^(?:first|last)-(mon|tue|wed|thu|fri|sat|sun)")
|
||||
second))
|
||||
:schedule_frame (some->> day-of-month name (re-find #"^(?:first|mid|last)"))})
|
||||
|
||||
(defn get-database
|
||||
"Get the `fields` of the database with ID `id`."
|
||||
[id & fields]
|
||||
|
||||
@@ -40,11 +40,17 @@
|
||||
|
||||
(def prefix-type "AI SDK prefix to type" (set/map-invert type-prefix))
|
||||
|
||||
(defn parse-aisdk-line
|
||||
"Parse a single AI SDK stream line into [type content].
|
||||
Each line has a 2-char type prefix followed by a JSON payload."
|
||||
[line]
|
||||
[(get prefix-type (subs line 0 2)) (json/decode+kw (subs line 2))])
|
||||
|
||||
(defn aisdk->messages
|
||||
"Convert AI SDK line format into an array of parsed messages."
|
||||
[role lines]
|
||||
(into [] (comp
|
||||
(map (fn [line] [(get prefix-type (subs line 0 2)) (json/decode+kw (subs line 2))]))
|
||||
(map parse-aisdk-line)
|
||||
(partition-by first)
|
||||
(mapcat (fn [block]
|
||||
(let [type (ffirst block)]
|
||||
|
||||
@@ -10,10 +10,9 @@
|
||||
preferred-method (case preferred-method
|
||||
"jwt" :jwt
|
||||
"saml" :saml
|
||||
"slack-connect" :slack-connect
|
||||
(throw (ex-info "Invalid auth method"
|
||||
{:preferred-method preferred-method
|
||||
:available [:jwt :saml :slack-connect]})))
|
||||
:available [:jwt :saml]})))
|
||||
(contains? (:params req) :jwt) :jwt
|
||||
:else :saml)))
|
||||
|
||||
@@ -24,8 +23,7 @@
|
||||
[req]
|
||||
(let [enabled-count (count (filter identity
|
||||
[(sso-settings/saml-enabled)
|
||||
(sso-settings/jwt-enabled)
|
||||
(sso-settings/slack-connect-enabled)]))]
|
||||
(sso-settings/jwt-enabled)]))]
|
||||
(cond
|
||||
;; Multiple SSO methods enabled - use preferred_method or selection logic
|
||||
(> enabled-count 1) (select-sso-backend req)
|
||||
@@ -33,7 +31,6 @@
|
||||
;; Single SSO method enabled
|
||||
(sso-settings/saml-enabled) :saml
|
||||
(sso-settings/jwt-enabled) :jwt
|
||||
(sso-settings/slack-connect-enabled) :slack-connect
|
||||
|
||||
;; No SSO method enabled
|
||||
:else nil)))
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[metabase-enterprise.sso.integrations.jwt :as jwt]
|
||||
[metabase-enterprise.sso.integrations.oidc :as oidc-integration]
|
||||
[metabase-enterprise.sso.integrations.saml]
|
||||
[metabase-enterprise.sso.integrations.slack-connect]
|
||||
[metabase-enterprise.sso.integrations.slack-connect :as slack-connect-integration]
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.request.core :as request]
|
||||
@@ -151,6 +151,30 @@
|
||||
(log/error e "Error handling SLO")
|
||||
(sso-error-page e :out))))
|
||||
|
||||
;; GET /auth/sso/slack-connect
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :get "/slack-connect"
|
||||
"Initiate Slack Connect SSO flow."
|
||||
[_route-params _query-params _body request]
|
||||
(try
|
||||
(slack-connect-integration/sso-initiate request)
|
||||
(catch Throwable e
|
||||
(log/error e "Error initiating Slack Connect SSO")
|
||||
(throw e))))
|
||||
|
||||
;; GET /auth/sso/slack-connect/callback
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :get "/slack-connect/callback"
|
||||
"Slack Connect OIDC callback."
|
||||
[_route-params _query-params _body request]
|
||||
(try
|
||||
(slack-connect-integration/sso-callback request)
|
||||
(catch Throwable e
|
||||
(log/error e "Error handling Slack Connect callback")
|
||||
(throw e))))
|
||||
|
||||
;; Key schema that excludes `/` so /:key does not greedily match /:key/callback
|
||||
(def ^:private ProviderKey
|
||||
[:string {:api/regex #"[a-z0-9][a-z0-9-]*"}])
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
Both the initial authorization request and the callback are handled via GET.
|
||||
|
||||
Flow:
|
||||
1. User accesses GET /auth/sso?preferred_method=slack-connect
|
||||
1. User accesses GET /auth/sso/slack-connect
|
||||
2. Metabase redirects to Slack authorization endpoint
|
||||
3. User authenticates with Slack
|
||||
4. Slack redirects back to GET /auth/sso?code=...&state=...
|
||||
4. Slack redirects back to GET /auth/sso/slack-connect/callback?code=...&state=...
|
||||
5. Metabase exchanges code for tokens and creates session"
|
||||
(:require
|
||||
[java-time.api :as t]
|
||||
[metabase-enterprise.sso.api.interface :as sso.i]
|
||||
[metabase-enterprise.sso.integrations.sso-utils :as sso-utils]
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.api.common :as api]
|
||||
@@ -30,49 +29,64 @@
|
||||
(defn- slack-redirect-uri
|
||||
"Generate the redirect URI for Slack OIDC callback."
|
||||
[]
|
||||
(str (system/site-url) "/auth/sso"))
|
||||
(str (system/site-url) "/auth/sso/slack-connect/callback"))
|
||||
|
||||
(defmethod sso.i/sso-get :slack-connect
|
||||
[{{:keys [code state redirect]} :params
|
||||
:as request}]
|
||||
;; Check premium feature is available
|
||||
(defn- check-slack-connect-prereqs!
|
||||
"Check that Slack Connect is available and enabled. Throws on failure."
|
||||
[]
|
||||
(premium-features/assert-has-feature :sso-slack (tru "Slack Connect authentication"))
|
||||
|
||||
(when-not (sso-settings/slack-connect-enabled)
|
||||
(throw (ex-info (tru "Slack Connect is not enabled")
|
||||
{:status-code 400})))
|
||||
{:status-code 400}))))
|
||||
|
||||
(cond
|
||||
;; Case 1: Initial request (no code) - start auth flow
|
||||
(not code)
|
||||
(let [_ (when (and (= "link-only" (sso-settings/slack-connect-authentication-mode))
|
||||
(not api/*current-user-id*))
|
||||
(throw (ex-info (tru "Account linking requires an authenticated session")
|
||||
{:status-code 401})))
|
||||
redirect-url (if redirect
|
||||
(sso-utils/check-sso-redirect redirect)
|
||||
"/")
|
||||
auth-result (auth-identity/authenticate :provider/slack-connect
|
||||
(assoc request
|
||||
:authenticated-user api/*current-user*
|
||||
:redirect-uri (slack-redirect-uri)
|
||||
:final-redirect redirect-url))]
|
||||
(defn sso-initiate
|
||||
"Initiate Slack Connect SSO flow. Redirects to Slack authorization endpoint."
|
||||
[request]
|
||||
(check-slack-connect-prereqs!)
|
||||
(log/infof "Slack Connect SSO initiate: auth-mode=%s, has-session-cookie=%s, current-user-id=%s, redirect-uri=%s"
|
||||
(sso-settings/slack-connect-authentication-mode)
|
||||
(boolean (get-in request [:cookies request/metabase-session-cookie :value]))
|
||||
api/*current-user-id*
|
||||
(slack-redirect-uri))
|
||||
(let [{:keys [redirect]} (:params request)
|
||||
_ (when (and (= "link-only" (sso-settings/slack-connect-authentication-mode))
|
||||
(not api/*current-user-id*))
|
||||
(throw (ex-info (tru "Account linking requires an authenticated session")
|
||||
{:status-code 401})))
|
||||
redirect-url (if redirect
|
||||
(sso-utils/check-sso-redirect redirect)
|
||||
"/")
|
||||
auth-result (auth-identity/authenticate :provider/slack-connect
|
||||
(assoc request
|
||||
:authenticated-user api/*current-user*
|
||||
:redirect-uri (slack-redirect-uri)
|
||||
:final-redirect redirect-url))]
|
||||
(log/infof "Slack Connect SSO initiate auth result: success=%s, has-redirect-url=%s, browser-id=%s"
|
||||
(:success? auth-result)
|
||||
(boolean (:redirect-url auth-result))
|
||||
(:browser-id request))
|
||||
(cond
|
||||
(= :redirect (:success? auth-result))
|
||||
(sso/wrap-oidc-redirect auth-result
|
||||
request
|
||||
:slack-connect
|
||||
redirect-url
|
||||
{:browser-id (:browser-id request)})
|
||||
:else
|
||||
(throw (ex-info (or (:message auth-result) (tru "Failed to initiate Slack authentication"))
|
||||
{:status-code 500})))))
|
||||
|
||||
(cond
|
||||
(= :redirect (:success? auth-result))
|
||||
;; Use encrypted cookie for state storage
|
||||
(sso/wrap-oidc-redirect auth-result
|
||||
request
|
||||
:slack-connect
|
||||
redirect-url
|
||||
{:browser-id (:browser-id request)})
|
||||
:else
|
||||
(throw (ex-info (or (:message auth-result) (tru "Failed to initiate Slack authentication"))
|
||||
{:status-code 500}))))
|
||||
|
||||
;; Case 2: Callback with code - complete authentication
|
||||
;; State validation happens inside login! via the OIDC provider
|
||||
code
|
||||
(defn sso-callback
|
||||
"Handle Slack Connect OIDC callback with authorization code."
|
||||
[request]
|
||||
(check-slack-connect-prereqs!)
|
||||
(let [{:keys [code state error error_description]} (:params request)
|
||||
oidc-state-cookie (get-in request [:cookies "metabase.OIDC_STATE" :value])]
|
||||
(log/infof "Slack Connect SSO callback: has-code=%s, state=%s, oidc-state-cookie=%s, error=%s, error_description=%s"
|
||||
(boolean code) state oidc-state-cookie error error_description)
|
||||
(when-not oidc-state-cookie
|
||||
(log/warnf "Slack Connect callback missing OIDC state cookie. Request cookies: %s"
|
||||
(keys (:cookies request))))
|
||||
(let [login-result (auth-identity/login! :provider/slack-connect
|
||||
(assoc request
|
||||
:authenticated-user api/*current-user*
|
||||
@@ -81,14 +95,19 @@
|
||||
:oidc-provider :slack-connect
|
||||
:redirect-uri (slack-redirect-uri)
|
||||
:device-info (request/device-info request)))]
|
||||
|
||||
(log/infof "Slack Connect SSO callback login result: success=%s, has-session=%s, has-user=%s, redirect-url=%s"
|
||||
(:success? login-result)
|
||||
(boolean (:session login-result))
|
||||
(boolean (:user login-result))
|
||||
(:redirect-url login-result))
|
||||
(cond
|
||||
(:success? login-result)
|
||||
(let [final-redirect (or (:redirect-url login-result) "/")
|
||||
base-response (-> (response/redirect final-redirect)
|
||||
(sso/clear-oidc-state-cookie))]
|
||||
(log/infof "Slack authentication successful for user %s" (get-in login-result [:user :email]))
|
||||
;; In link-only mode, there's no session to set - just return the redirect
|
||||
(log/infof "Slack authentication successful for user %s, setting session cookie=%s"
|
||||
(get-in login-result [:user :email])
|
||||
(boolean (:session login-result)))
|
||||
(if-let [session (:session login-result)]
|
||||
(request/set-session-cookies request
|
||||
base-response
|
||||
@@ -96,16 +115,7 @@
|
||||
(t/zoned-date-time (t/zone-id "GMT")))
|
||||
base-response))
|
||||
|
||||
;; Login failed (includes state validation failures)
|
||||
:else
|
||||
(let [error-msg (or (:message login-result) (tru "Slack authentication failed"))]
|
||||
(log/errorf "Slack authentication failed: %s" error-msg)
|
||||
(log/errorf "Slack authentication failed: %s (full result: %s)" error-msg (pr-str login-result))
|
||||
(throw (ex-info error-msg {:status-code 401})))))))
|
||||
|
||||
;; OIDC only uses GET requests, so POST should not be used.
|
||||
;; Return 405 Method Not Allowed.
|
||||
(defmethod sso.i/sso-post :slack-connect
|
||||
[_request]
|
||||
{:status 405
|
||||
:headers {"Allow" "GET"}
|
||||
:body (tru "POST not supported for OIDC authentication. Use GET instead.")})
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
(when (and (sso-settings/slack-connect-client-id)
|
||||
(sso-settings/slack-connect-client-secret))
|
||||
{:client-id (sso-settings/slack-connect-client-id)
|
||||
:client-secret (sso-settings/slack-connect-client-secret)
|
||||
:client-secret (sso-settings/unobfuscated-slack-connect-client-secret)
|
||||
:issuer-uri slack-issuer-uri
|
||||
:scopes ["openid" "profile" "email"]
|
||||
:redirect-uri (get request :redirect-uri)}))
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[metabase.util.string :as u.str]
|
||||
[saml20-clj.core :as saml]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -360,9 +361,16 @@ using, this usually looks like `https://your-org-name.example.com` or `https://e
|
||||
(deferred-tru "Client Secret for your Slack app")
|
||||
:encryption :when-encryption-key-set
|
||||
:export? false
|
||||
:sensitive? true
|
||||
:feature :sso-slack
|
||||
:audit :no-value)
|
||||
:audit :no-value
|
||||
:getter (fn []
|
||||
(-> (setting/get-value-of-type :string :slack-connect-client-secret)
|
||||
(u.str/mask 4))))
|
||||
|
||||
(defn unobfuscated-slack-connect-client-secret
|
||||
"Get the unobfuscated value of [[slack-connect-client-secret]]."
|
||||
[]
|
||||
(setting/get-value-of-type :string :slack-connect-client-secret))
|
||||
|
||||
(def slack-connect-auth-mode-sso
|
||||
"Authentication mode for full SSO login."
|
||||
@@ -373,10 +381,10 @@ using, this usually looks like `https://your-org-name.example.com` or `https://e
|
||||
"link-only")
|
||||
|
||||
(defsetting slack-connect-authentication-mode
|
||||
(deferred-tru "Controls whether Slack can be used for SSO login or just account linking. Valid values: \"sso\" (default) or \"link-only\"")
|
||||
(deferred-tru "Controls whether Slack can be used for SSO login or just account linking. Valid values: \"sso\" or \"link-only\" (default)")
|
||||
:type :string
|
||||
:export? false
|
||||
:default slack-connect-auth-mode-sso
|
||||
:default slack-connect-auth-mode-link-only
|
||||
:feature :sso-slack
|
||||
:audit :getter
|
||||
:encryption :no
|
||||
@@ -502,8 +510,8 @@ using, this usually looks like `https://your-org-name.example.com` or `https://e
|
||||
:audit :getter)
|
||||
|
||||
(defsetting other-sso-enabled?
|
||||
"Are we using an SSO integration other than LDAP or Google Auth or ODIC? These integrations use the `/auth/sso` endpoint for
|
||||
authorization rather than the normal login form or Google Auth button."
|
||||
"Are we using an SSO integration other than LDAP or Google Auth or OIDC? These integrations use the `/auth/sso` endpoint
|
||||
(SAML/JWT) or `/auth/sso/slack-connect` (Slack Connect) for authorization rather than the normal login form or Google Auth button."
|
||||
:visibility :public
|
||||
:setter :none
|
||||
:getter (fn [] (or (saml-enabled) (jwt-enabled) (slack-connect-enabled))))
|
||||
:getter (fn [] (or (saml-enabled) (jwt-enabled))))
|
||||
|
||||
@@ -75,8 +75,7 @@
|
||||
slack/channel-exists? (constantly true)
|
||||
slack/refresh-channels-and-usernames! (constantly true)
|
||||
slack/refresh-channels-and-usernames-when-needed! (constantly true)]
|
||||
(mt/with-temporary-setting-values [slack-app-token nil
|
||||
slack-token "fake-token"]
|
||||
(mt/with-temporary-setting-values [slack-app-token nil]
|
||||
(mt/user-http-request user :put status "slack/settings" {:slack-app-token "fake-token"})))))
|
||||
|
||||
(get-manifest [user status]
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.slackbot.query-test
|
||||
"Integration tests for ad-hoc query execution and visualization."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.api.slackbot.query :as slackbot.query]
|
||||
[metabase.channel.render.table-data :as table-data]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.test :as mt])
|
||||
(:import
|
||||
(java.awt.image BufferedImage)
|
||||
(java.io ByteArrayInputStream)
|
||||
(javax.imageio ImageIO)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- bytes->image
|
||||
"Parse PNG bytes into a BufferedImage. Returns nil if bytes are not a valid image."
|
||||
^BufferedImage [^bytes b]
|
||||
(with-open [input-stream (ByteArrayInputStream. b)]
|
||||
(ImageIO/read input-stream)))
|
||||
|
||||
;;; ------------------------------------------------ Query Execution -------------------------------------------------
|
||||
|
||||
(deftest execute-adhoc-query-test
|
||||
(testing "execute-adhoc-query runs a query and returns results"
|
||||
(mt/with-current-user (mt/user->id :rasta)
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues)))
|
||||
(lib/limit 5))
|
||||
results (slackbot.query/execute-adhoc-query query)]
|
||||
(testing "returns query results with expected structure"
|
||||
(is (map? results))
|
||||
(is (contains? results :data))
|
||||
(is (contains? (:data results) :rows))
|
||||
(is (contains? (:data results) :cols)))
|
||||
(testing "returns correct number of rows"
|
||||
(is (= 5 (count (get-in results [:data :rows])))))))))
|
||||
|
||||
(deftest execute-adhoc-query-aggregation-test
|
||||
(testing "execute-adhoc-query handles aggregation queries"
|
||||
(mt/with-current-user (mt/user->id :rasta)
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues)))
|
||||
(lib/aggregate (lib/count))
|
||||
(lib/breakout (lib.metadata/field mp (mt/id :venues :category_id))))
|
||||
results (slackbot.query/execute-adhoc-query query)]
|
||||
(testing "returns aggregated results"
|
||||
(is (seq (get-in results [:data :rows]))))
|
||||
(testing "has correct columns for aggregation"
|
||||
(let [col-names (map :name (get-in results [:data :cols]))]
|
||||
(is (some #(re-find #"(?i)category" %) col-names))
|
||||
(is (some #(re-find #"(?i)count" %) col-names))))))))
|
||||
|
||||
;;; ------------------------------------------------ PNG Generation --------------------------------------------------
|
||||
|
||||
(deftest generate-adhoc-png-table-test
|
||||
(testing "generate-adhoc-png renders table visualization as PNG"
|
||||
(mt/with-current-user (mt/user->id :rasta)
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues)))
|
||||
(lib/limit 10))
|
||||
results (slackbot.query/execute-adhoc-query query)
|
||||
png (#'slackbot.query/generate-adhoc-png results :table)
|
||||
image (bytes->image png)]
|
||||
(testing "returns parseable PNG"
|
||||
(is (some? image)))
|
||||
(testing "image has reasonable dimensions"
|
||||
(is (< 100 (.getWidth image))))))))
|
||||
|
||||
(deftest generate-adhoc-png-bar-chart-test
|
||||
(testing "generate-adhoc-png renders bar chart visualization as PNG"
|
||||
(mt/with-current-user (mt/user->id :rasta)
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues)))
|
||||
(lib/aggregate (lib/count))
|
||||
(lib/breakout (lib.metadata/field mp (mt/id :venues :category_id))))
|
||||
results (slackbot.query/execute-adhoc-query query)
|
||||
png (#'slackbot.query/generate-adhoc-png results :bar)
|
||||
image (bytes->image png)]
|
||||
(testing "returns parseable PNG"
|
||||
(is (some? image)))))))
|
||||
|
||||
(deftest generate-adhoc-png-line-chart-test
|
||||
(testing "generate-adhoc-png renders line chart visualization as PNG"
|
||||
(mt/dataset test-data
|
||||
(mt/with-current-user (mt/user->id :rasta)
|
||||
(let [mp (mt/metadata-provider)
|
||||
created-at (lib.metadata/field mp (mt/id :orders :created_at))
|
||||
query (-> (lib/query mp (lib.metadata/table mp (mt/id :orders)))
|
||||
(lib/aggregate (lib/count))
|
||||
(lib/breakout (lib/with-temporal-bucket created-at :month))
|
||||
(lib/limit 12))
|
||||
results (slackbot.query/execute-adhoc-query query)
|
||||
png (#'slackbot.query/generate-adhoc-png results :line)
|
||||
image (bytes->image png)]
|
||||
(testing "returns parseable PNG"
|
||||
(is (some? image))))))))
|
||||
|
||||
(deftest render-dimensions-test
|
||||
(testing "render-dimensions calculates width from display type aspect ratios"
|
||||
(testing "bar chart uses 2:1 ratio (12:6 default)"
|
||||
(is (= {:width 1280 :height 640}
|
||||
(#'slackbot.query/render-dimensions :bar))))
|
||||
(testing "pie chart uses 3:2 ratio (12:8 default)"
|
||||
(is (= {:width 960 :height 640}
|
||||
(#'slackbot.query/render-dimensions :pie))))
|
||||
(testing "unknown display type falls back to 2:1 ratio"
|
||||
(is (= {:width 1280 :height 640}
|
||||
(#'slackbot.query/render-dimensions :unknown-type))))))
|
||||
|
||||
;;; ------------------------------------------------ Table Blocks ----------------------------------------------------
|
||||
|
||||
(deftest format-results-as-table-blocks-test
|
||||
(testing "format-results-as-table-blocks creates valid Slack table block structure"
|
||||
(let [results {:data {:rows [[1 "Alice" 100.50]
|
||||
[2 "Bob" 200.75]]
|
||||
:cols [{:name "id" :display_name "ID" :base_type :type/Integer}
|
||||
{:name "name" :display_name "Name" :base_type :type/Text}
|
||||
{:name "amount" :display_name "Amount" :base_type :type/Float}]}}
|
||||
blocks (table-data/format-results-as-table-blocks results)]
|
||||
|
||||
(testing "returns a vector of blocks"
|
||||
(is (vector? blocks))
|
||||
(is (pos? (count blocks))))
|
||||
|
||||
(testing "first block is a table block"
|
||||
(let [table-block (first blocks)]
|
||||
(is (= "table" (:type table-block)))
|
||||
(is (contains? table-block :rows))
|
||||
(is (contains? table-block :column_settings))))
|
||||
|
||||
(testing "table has header row plus data rows"
|
||||
(let [table-block (first blocks)
|
||||
rows (:rows table-block)]
|
||||
(is (= 3 (count rows))) ; 1 header + 2 data rows
|
||||
(testing "header row has correct values"
|
||||
(let [header-row (first rows)]
|
||||
(is (= "ID" (get-in header-row [0 :text])))
|
||||
(is (= "Name" (get-in header-row [1 :text])))
|
||||
(is (= "Amount" (get-in header-row [2 :text])))))))
|
||||
|
||||
(testing "column settings align numeric columns right"
|
||||
(let [table-block (first blocks)
|
||||
settings (:column_settings table-block)]
|
||||
(is (= "right" (:align (nth settings 0)))) ; ID is integer
|
||||
(is (= "left" (:align (nth settings 1)))) ; Name is text
|
||||
(is (= "right" (:align (nth settings 2)))))))) ; Amount is float
|
||||
|
||||
(testing "format-results-as-table-blocks truncates large results"
|
||||
(let [many-rows (vec (repeat 150 [1 "test"]))
|
||||
results {:data {:rows many-rows
|
||||
:cols [{:name "id"} {:name "name"}]}}
|
||||
blocks (table-data/format-results-as-table-blocks results)]
|
||||
|
||||
(testing "table is truncated to 99 data rows plus header"
|
||||
(let [table-block (first blocks)
|
||||
rows (:rows table-block)]
|
||||
(is (= 100 (count rows)))))
|
||||
|
||||
(testing "includes truncation message"
|
||||
(is (= 2 (count blocks)))
|
||||
(let [context-block (second blocks)]
|
||||
(is (= "context" (:type context-block)))
|
||||
(is (= "Showing 99 of 150 rows"
|
||||
(get-in context-block [:elements 0 :text])))))))
|
||||
|
||||
(testing "format-results-as-table-blocks handles scalar (single-cell) results"
|
||||
(let [results {:data {:rows [[42]]
|
||||
:cols [{:name "count" :display_name "Count" :base_type :type/Integer}]}}
|
||||
blocks (table-data/format-results-as-table-blocks results)]
|
||||
(testing "returns a 1x1 table with header"
|
||||
(let [table-block (first blocks)
|
||||
rows (:rows table-block)]
|
||||
(is (= 2 (count rows))) ; 1 header + 1 data row
|
||||
(is (= "Count" (get-in rows [0 0 :text])))
|
||||
(is (= "42" (get-in rows [1 0 :text])))))))
|
||||
|
||||
(testing "format-results-as-table-blocks handles empty results"
|
||||
(let [results {:data {:rows []
|
||||
:cols [{:name "count" :display_name "Count" :base_type :type/Integer}]}}
|
||||
blocks (table-data/format-results-as-table-blocks results)]
|
||||
(testing "returns a table with only header row"
|
||||
(let [table-block (first blocks)
|
||||
rows (:rows table-block)]
|
||||
(is (= 1 (count rows))) ; header only
|
||||
(is (= "Count" (get-in rows [0 0 :text])))))))
|
||||
|
||||
(testing "format-results-as-table-blocks truncates long cell values"
|
||||
(binding [table-data/*slack-table-max-cell-length* 20]
|
||||
(let [long-text (apply str (repeat 50 "x"))
|
||||
results {:data {:rows [[1 long-text]]
|
||||
:cols [{:name "id" :display_name "ID" :base_type :type/Integer}
|
||||
{:name "desc" :display_name "Description" :base_type :type/Text}]}}
|
||||
blocks (table-data/format-results-as-table-blocks results)
|
||||
cell-text (get-in (first blocks) [:rows 1 1 :text])]
|
||||
(is (<= (count cell-text) 20))
|
||||
(is (str/ends-with? cell-text "…")))))
|
||||
|
||||
(testing "format-results-as-table-blocks truncates rows to fit character budget"
|
||||
(binding [table-data/*slack-table-max-chars* 100]
|
||||
(let [results {:data {:rows (vec (repeat 20 ["abcdefghij"]))
|
||||
:cols [{:name "val" :display_name "Value" :base_type :type/Text}]}}
|
||||
blocks (table-data/format-results-as-table-blocks results)
|
||||
data-rows (rest (:rows (first blocks)))]
|
||||
(testing "does not include all rows"
|
||||
(is (< (count data-rows) 20)))
|
||||
(testing "includes truncation context block"
|
||||
(is (= 2 (count blocks)))
|
||||
(is (= "context" (:type (second blocks)))))
|
||||
(testing "total cell text fits within character budget"
|
||||
(let [total-chars (transduce (comp cat (map (comp count :text)))
|
||||
+ (:rows (first blocks)))]
|
||||
(is (<= total-chars 100)))))))
|
||||
|
||||
(testing "format-results-as-table-blocks handles FK remapped columns"
|
||||
(let [;; Simulate FK remapping: USER_ID is remapped to show USER.NAME
|
||||
;; The data has both USER_ID (raw FK) and NAME (human-readable value from FK target)
|
||||
results {:data {:rows [[1 100 "Alice"]
|
||||
[2 200 "Bob"]]
|
||||
:cols [{:name "id"
|
||||
:display_name "ID"
|
||||
:base_type :type/Integer}
|
||||
{:name "user_id"
|
||||
:display_name "User ID"
|
||||
:base_type :type/Integer
|
||||
:remapped_to "user_name"}
|
||||
{:name "user_name"
|
||||
:display_name "User Name"
|
||||
:base_type :type/Text
|
||||
:remapped_from "user_id"}]}}
|
||||
blocks (table-data/format-results-as-table-blocks results)]
|
||||
|
||||
(testing "skips remapped_from column (the duplicate)"
|
||||
(let [table-block (first blocks)
|
||||
header-row (first (:rows table-block))]
|
||||
;; Should only have 2 columns: ID and User Name (not 3)
|
||||
(is (= 2 (count header-row)))))
|
||||
|
||||
(testing "uses remapped column's display name in header"
|
||||
(let [table-block (first blocks)
|
||||
header-row (first (:rows table-block))]
|
||||
(is (= "ID" (get-in header-row [0 :text])))
|
||||
(is (= "User Name" (get-in header-row [1 :text])))))
|
||||
|
||||
(testing "substitutes remapped values in data rows"
|
||||
(let [table-block (first blocks)
|
||||
data-rows (rest (:rows table-block))]
|
||||
;; First row should show "1" and "Alice" (not "100")
|
||||
(is (= "1" (get-in (first data-rows) [0 :text])))
|
||||
(is (= "Alice" (get-in (first data-rows) [1 :text])))
|
||||
;; Second row should show "2" and "Bob" (not "200")
|
||||
(is (= "2" (get-in (second data-rows) [0 :text])))
|
||||
(is (= "Bob" (get-in (second data-rows) [1 :text]))))))))
|
||||
|
||||
;;; -------------------------------------------- generate-adhoc-output -----------------------------------------------
|
||||
|
||||
(deftest generate-adhoc-output-chart-display-test
|
||||
(testing "generate-adhoc-output renders chart display types as PNG images"
|
||||
(mt/with-current-user (mt/user->id :rasta)
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues)))
|
||||
(lib/aggregate (lib/count))
|
||||
(lib/breakout (lib.metadata/field mp (mt/id :venues :category_id))))]
|
||||
|
||||
(testing "bar chart renders as image"
|
||||
(let [{:keys [type content]} (slackbot.query/generate-adhoc-output query :display :bar)]
|
||||
(is (= :image type))
|
||||
(is (bytes? content))
|
||||
(is (some? (bytes->image content)))))
|
||||
|
||||
(testing "line chart renders as image"
|
||||
(let [{:keys [type content]} (slackbot.query/generate-adhoc-output query :display :line)]
|
||||
(is (= :image type))
|
||||
(is (bytes? content))))
|
||||
|
||||
(testing "pie chart renders as image"
|
||||
(let [{:keys [type content]} (slackbot.query/generate-adhoc-output query :display :pie)]
|
||||
(is (= :image type))
|
||||
(is (bytes? content))))))))
|
||||
|
||||
(deftest generate-adhoc-output-table-display-test
|
||||
(testing "generate-adhoc-output renders table display as Slack table blocks"
|
||||
(mt/with-current-user (mt/user->id :rasta)
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues)))
|
||||
(lib/limit 5))]
|
||||
|
||||
(testing "table display renders as table blocks"
|
||||
(let [{:keys [type content]} (slackbot.query/generate-adhoc-output query :display :table)]
|
||||
(is (= :table type))
|
||||
(is (vector? content))
|
||||
(is (= "table" (:type (first content))))))
|
||||
|
||||
(testing "scalar display renders as table blocks"
|
||||
(let [scalar-query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues)))
|
||||
(lib/aggregate (lib/count)))
|
||||
{:keys [type content]} (slackbot.query/generate-adhoc-output scalar-query :display :scalar)]
|
||||
(is (= :table type))
|
||||
(is (= "table" (:type (first content))))))))))
|
||||
|
||||
(deftest generate-adhoc-output-default-display-test
|
||||
(testing "generate-adhoc-output defaults to table display"
|
||||
(mt/with-current-user (mt/user->id :rasta)
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues)))
|
||||
(lib/limit 3))
|
||||
{:keys [type content]} (slackbot.query/generate-adhoc-output query)]
|
||||
(is (= :table type))
|
||||
(is (vector? content))
|
||||
(is (= "table" (:type (first content)))))))) ; header only
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,10 @@
|
||||
[clojure.test :refer :all]
|
||||
[compojure.response]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.api :as api]
|
||||
[metabase-enterprise.metabot-v3.client :as client]
|
||||
[metabase-enterprise.metabot-v3.client-test :as client-test]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.persistence :as persistence]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot.u]
|
||||
[metabase.search.test-util :as search.tu]
|
||||
[metabase.server.instance :as server.instance]
|
||||
@@ -101,8 +101,8 @@
|
||||
(search.tu/with-index-disabled
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(with-redefs [client/ai-url (constantly ai-url)
|
||||
api/store-message! (fn [_conv-id _prof-id msgs]
|
||||
(reset! messages msgs))
|
||||
persistence/store-message! (fn [_conv-id _prof-id msgs]
|
||||
(reset! messages msgs))
|
||||
sr/async-cancellation-poll-interval-ms 5]
|
||||
(testing "Closing body stream drops connection"
|
||||
(let [body (mt/user-real-request :rasta :post 202 "ee/metabot-v3/agent-streaming"
|
||||
@@ -168,6 +168,22 @@
|
||||
(mt/with-temporary-setting-values [premium-embedding-token nil]
|
||||
(mt/user-http-request :rasta :post 400 "ee/metabot-v3/feedback" {:foo "bar"})))))))
|
||||
|
||||
(deftest endpoints-require-authentication-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(testing "Metabot v3 endpoints require authentication"
|
||||
(testing "/agent-streaming"
|
||||
(is (= "Unauthenticated"
|
||||
(mt/client :post 401 "ee/metabot-v3/agent-streaming"
|
||||
{:message "Test"
|
||||
:context {}
|
||||
:conversation_id (str (random-uuid))
|
||||
:history []
|
||||
:state {}}))))
|
||||
(testing "/feedback"
|
||||
(is (= "Unauthenticated"
|
||||
(mt/client :post 401 "ee/metabot-v3/feedback"
|
||||
{:feedback {}})))))))
|
||||
|
||||
(deftest metabot-enabled-setting-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [mock-response (client-test/make-mock-text-stream-response
|
||||
|
||||
@@ -109,6 +109,60 @@
|
||||
{:_type :FINISH_MESSAGE :finish_reason "error"}]
|
||||
(metabot-v3.util/aisdk->messages :assistant (str/split-lines body))))))))))
|
||||
|
||||
(deftest streaming-request-with-callback-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [input ["a1" "a2" "a3"]
|
||||
mock-response (make-mock-text-stream-response input {"some-model" {:prompt 8 :completion 2}})
|
||||
req {:context {:some "context"}
|
||||
:message {:role :user :content "stuff"}
|
||||
:history []
|
||||
:profile-id "test-profile"
|
||||
:conversation-id (str (random-uuid))
|
||||
:session-id "test-session"
|
||||
:state {:some "state"}}]
|
||||
(testing "collects and returns all lines"
|
||||
(mt/with-dynamic-fn-redefs [http/post (mock-post! mock-response)]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [lines (metabot-v3.client/streaming-request-with-callback req)]
|
||||
(is (sequential? lines))
|
||||
(is (pos? (count lines)))
|
||||
(is (=? [{:_type :TEXT :content "a1a2a3"}
|
||||
{:_type :FINISH_MESSAGE :finish_reason "stop"}]
|
||||
(metabot-v3.util/aisdk->messages :assistant lines)))))))
|
||||
(testing "invokes on-line callback for each line"
|
||||
(mt/with-dynamic-fn-redefs [http/post (mock-post! mock-response)]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [callback-lines (atom [])
|
||||
lines (metabot-v3.client/streaming-request-with-callback
|
||||
(assoc req :on-line #(swap! callback-lines conj %)))]
|
||||
(is (= lines @callback-lines))))))
|
||||
(testing "handles stream errors gracefully"
|
||||
(mt/with-dynamic-fn-redefs [http/post (mock-post! mock-response {:delay-ms 10})]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [error-lines (atom [])
|
||||
lines (metabot-v3.client/streaming-request-with-callback
|
||||
(assoc req :on-line #(swap! error-lines conj %)))]
|
||||
(is (sequential? lines))
|
||||
(is (= lines @error-lines)))))))))
|
||||
|
||||
(deftest streaming-request-with-callback-on-complete-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [mock-response (make-mock-text-stream-response ["a" "b"] {"m" {:prompt 1 :completion 1}})
|
||||
req {:context {} :message {:role :user :content "x"} :history []
|
||||
:profile-id "p" :conversation-id (str (random-uuid))
|
||||
:session-id "s" :state {}}]
|
||||
(testing "on-complete receives collected lines"
|
||||
(mt/with-dynamic-fn-redefs [http/post (mock-post! mock-response)]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [result (atom nil)
|
||||
lines (metabot-v3.client/streaming-request-with-callback
|
||||
(assoc req :on-complete #(reset! result %)))]
|
||||
(is (= lines @result))))))
|
||||
(testing "works without on-complete"
|
||||
(mt/with-dynamic-fn-redefs [http/post (mock-post! mock-response)]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(is (sequential? (metabot-v3.client/streaming-request-with-callback req)))))))))
|
||||
|
||||
(deftest streaming-request-error-excludes-headers-test
|
||||
(testing "When streaming-request gets an error response, the exception should not include headers"
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
(ns metabase-enterprise.metabot-v3.middleware.auth-test
|
||||
"EE-specific tests for auth middleware, particularly Slack signature verification."
|
||||
(:require
|
||||
[buddy.core.codecs :as codecs]
|
||||
[buddy.core.mac :as mac]
|
||||
[clojure.test :refer :all]
|
||||
[metabase.server.middleware.auth :as mw.auth]
|
||||
[metabase.test :as mt]
|
||||
[ring.mock.request :as ring.mock]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; ------------------------------------------ TEST verify-slack-request middleware ------------------------------------------
|
||||
|
||||
(def ^:private test-signing-secret "test-slack-signing-secret-12345")
|
||||
(def ^:private test-timestamp 1700000000)
|
||||
|
||||
(defn- compute-slack-signature
|
||||
"Compute a valid Slack signature for testing"
|
||||
[body timestamp signing-secret]
|
||||
(let [message (str "v0:" timestamp ":" body)
|
||||
signature (-> (mac/hash message {:key signing-secret :alg :hmac+sha256})
|
||||
codecs/bytes->hex)]
|
||||
(str "v0=" signature)))
|
||||
|
||||
(defn- wrapped-slack-handler
|
||||
"Invoke verify-slack-request middleware and return the modified request"
|
||||
[request]
|
||||
((mw.auth/verify-slack-request
|
||||
(fn [request respond _] (respond request)))
|
||||
request
|
||||
identity
|
||||
(fn [e] (throw e))))
|
||||
|
||||
(defn- slack-request
|
||||
"Create a mock request with Slack signature headers and a body"
|
||||
^java.util.Map [^String body ^String timestamp ^String signature]
|
||||
(-> (ring.mock/request :post "/anyurl")
|
||||
(ring.mock/header "x-slack-signature" signature)
|
||||
(ring.mock/header "x-slack-request-timestamp" timestamp)
|
||||
(assoc :body (java.io.ByteArrayInputStream. (.getBytes body "UTF-8")))))
|
||||
|
||||
(deftest verify-slack-request-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(with-redefs [mw.auth/current-unix-timestamp (constantly test-timestamp)]
|
||||
(testing "Valid signature w/ signing secret configured"
|
||||
(mt/with-temporary-raw-setting-values [metabot-slack-signing-secret test-signing-secret]
|
||||
(let [body "test-body"
|
||||
timestamp (str test-timestamp)
|
||||
signature (compute-slack-signature body timestamp test-signing-secret)
|
||||
result (wrapped-slack-handler (slack-request body timestamp signature))]
|
||||
(is (true? (:slack/validated? result))))))
|
||||
|
||||
(testing "Invalid signature"
|
||||
(mt/with-temporary-raw-setting-values [metabot-slack-signing-secret test-signing-secret]
|
||||
(let [body "test-body"
|
||||
timestamp (str test-timestamp)
|
||||
signature "v0=invalid-signature"
|
||||
result (wrapped-slack-handler (slack-request body timestamp signature))]
|
||||
(is (false? (:slack/validated? result))))))
|
||||
|
||||
(testing "No signature header present - request passes through unchanged"
|
||||
(let [body "test-body"
|
||||
request (-> (ring.mock/request :post "/anyurl")
|
||||
(assoc :body (java.io.ByteArrayInputStream. (.getBytes ^String body "UTF-8"))))
|
||||
result (wrapped-slack-handler request)]
|
||||
(is (not (contains? result :slack/validated?)))))
|
||||
|
||||
(testing "No signing secret configured"
|
||||
(mt/with-temporary-raw-setting-values [metabot-slack-signing-secret nil]
|
||||
(let [body "test-body"
|
||||
timestamp (str test-timestamp)
|
||||
signature "v0=some-signature"
|
||||
result (wrapped-slack-handler (slack-request body timestamp signature))]
|
||||
(is (nil? (:slack/validated? result)))))))))
|
||||
|
||||
(defn- validate-request-with-timestamp
|
||||
"Helper to test timestamp validation. Returns :slack/validated? result."
|
||||
[timestamp]
|
||||
(let [body "test-body"
|
||||
signature (compute-slack-signature body timestamp test-signing-secret)
|
||||
result (wrapped-slack-handler (slack-request body timestamp signature))]
|
||||
(:slack/validated? result)))
|
||||
|
||||
(deftest verify-slack-request-timestamp-validation-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(mt/with-temporary-raw-setting-values [metabot-slack-signing-secret test-signing-secret]
|
||||
(with-redefs [mw.auth/current-unix-timestamp (constantly test-timestamp)]
|
||||
(testing "Replay attack prevention - rejects timestamps outside 5 minute window"
|
||||
(doseq [[expected offset-or-val description]
|
||||
[[true 0 "current time"]
|
||||
[true -300 "exactly 5 min ago"]
|
||||
[true 300 "exactly 5 min ahead"]
|
||||
[false -301 "5+ min ago"]
|
||||
[false 301 "5+ min ahead"]
|
||||
[false "abc" "malformed"]
|
||||
[false nil "missing"]]]
|
||||
(testing description
|
||||
(is (= expected (validate-request-with-timestamp
|
||||
(if (number? offset-or-val)
|
||||
(str (+ test-timestamp offset-or-val))
|
||||
offset-or-val)))))))))))
|
||||
@@ -6,6 +6,7 @@
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.tools.api :as metabot-v3.tools.api]
|
||||
[metabase-enterprise.metabot-v3.tools.create-alert :as metabot-v3.tools.create-alert]
|
||||
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as metabot-v3.tools.create-dashboard-subscription]
|
||||
[metabase-enterprise.metabot-v3.tools.dependencies-test :as metabot-v3.tools.dependencies-test]
|
||||
[metabase-enterprise.metabot-v3.tools.entity-details :as metabot-v3.tools.entity-details]
|
||||
@@ -44,6 +45,34 @@
|
||||
([user metabot-id]
|
||||
(-> user mt/user->id (#'metabot-v3.client/get-ai-service-token metabot-id))))
|
||||
|
||||
(deftest create-alert-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [tool-requests (atom [])
|
||||
conversation-id (str (random-uuid))
|
||||
output (str (random-uuid))
|
||||
ai-token (ai-session-token)]
|
||||
(with-redefs [metabot-v3.tools.create-alert/create-alert
|
||||
(fn [arguments]
|
||||
(swap! tool-requests conj arguments)
|
||||
{:output output})]
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/create-alert"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:card_id 42
|
||||
:send_condition "has_result"
|
||||
:slack_channel "data-team"
|
||||
:schedule {:frequency "daily"
|
||||
:hour 9}}
|
||||
:conversation_id conversation-id})]
|
||||
(is (=? [{:card-id 42
|
||||
:send-condition :has_result
|
||||
:slack-channel "data-team"
|
||||
:schedule {:frequency :daily
|
||||
:hour 9}}]
|
||||
@tool-requests))
|
||||
(is (=? {:output output
|
||||
:conversation_id conversation-id}
|
||||
response)))))))
|
||||
|
||||
(deftest create-dashboard-subscription-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [tool-requests (atom [])
|
||||
@@ -56,17 +85,17 @@
|
||||
{:output output})]
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/create-dashboard-subscription"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:dashboard_id 1
|
||||
:email "user@example.com"
|
||||
:schedule {:frequency "monthly"
|
||||
:hour 15
|
||||
:day_of_month "middle-of-month"}}
|
||||
{:arguments {:dashboard_id 1
|
||||
:slack_channel "data-team"
|
||||
:schedule {:frequency "monthly"
|
||||
:hour 15
|
||||
:day_of_month "middle-of-month"}}
|
||||
:conversation_id conversation-id})]
|
||||
(is (=? [{:dashboard-id 1
|
||||
:email "user@example.com"
|
||||
:schedule {:frequency :monthly
|
||||
:hour 15
|
||||
:day-of-month :middle-of-month}}]
|
||||
(is (=? [{:dashboard-id 1
|
||||
:slack-channel "data-team"
|
||||
:schedule {:frequency :monthly
|
||||
:hour 15
|
||||
:day-of-month :middle-of-month}}]
|
||||
@tool-requests))
|
||||
(is (=? {:output output
|
||||
:conversation_id conversation-id}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.create-alert-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.tools.create-alert
|
||||
:as metabot-v3.tools.create-alert]
|
||||
[metabase.channel.settings :as channel.settings]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(defn- alert-data [card-id]
|
||||
{:card-id card-id
|
||||
:send-condition :has_result
|
||||
:slack-channel "data-team"
|
||||
:schedule {:frequency :daily
|
||||
:hour 9}})
|
||||
|
||||
(def ^:private fake-channels
|
||||
{:channels [{:display-name "#data-team" :name "data-team" :id "C123"}]})
|
||||
|
||||
(deftest create-alert-test
|
||||
(mt/with-model-cleanup [:model/Notification]
|
||||
(mt/with-temp [:model/Card {card-id :id} {}]
|
||||
(let [invoke-tool #(mt/with-current-user (mt/user->id :rasta)
|
||||
(metabot-v3.tools.create-alert/create-alert %))
|
||||
base-data (alert-data card-id)]
|
||||
(with-redefs [channel.settings/slack-configured? (constantly true)
|
||||
channel.settings/slack-cached-channels-and-usernames (constantly fake-channels)]
|
||||
(testing "Slack alert can be created"
|
||||
(is (= {:output "success"}
|
||||
(invoke-tool base-data))))
|
||||
(testing "Alert with goal_above send-condition can be created"
|
||||
(is (= {:output "success"}
|
||||
(invoke-tool (assoc base-data :send-condition :goal_above)))))
|
||||
(testing "Alert with goal_below send-condition can be created"
|
||||
(is (= {:output "success"}
|
||||
(invoke-tool (assoc base-data :send-condition :goal_below)))))
|
||||
(testing "Alert with send-once can be created"
|
||||
(is (= {:output "success"}
|
||||
(invoke-tool (assoc base-data :send-once true))))))))))
|
||||
|
||||
(deftest create-alert-validation-test
|
||||
(mt/with-temp [:model/Card {card-id :id} {}]
|
||||
(let [invoke-tool #(mt/with-current-user (mt/user->id :rasta)
|
||||
(metabot-v3.tools.create-alert/create-alert %))
|
||||
base-data (alert-data card-id)]
|
||||
(with-redefs [channel.settings/slack-configured? (constantly true)
|
||||
channel.settings/slack-cached-channels-and-usernames (constantly fake-channels)]
|
||||
(testing "Return an error message if the card-id is invalid"
|
||||
(is (= {:error "invalid card_id"}
|
||||
(invoke-tool (update base-data :card-id str)))))
|
||||
(testing "Return an error message if the card cannot be found"
|
||||
(is (= {:error "no saved question with this card_id found"}
|
||||
(invoke-tool (update base-data :card-id + 100)))))
|
||||
(testing "Slack alert requires slack-channel"
|
||||
(is (= {:error "slack_channel is required"}
|
||||
(invoke-tool (dissoc base-data :slack-channel)))))
|
||||
(testing "Invalid send-condition returns an error"
|
||||
(is (= {:error "unsupported send_condition: always"}
|
||||
(invoke-tool (assoc base-data :send-condition :always)))))
|
||||
(testing "Invalid send-once returns an error"
|
||||
(is (= {:error "send_once must be a boolean"}
|
||||
(invoke-tool (assoc base-data :send-once "true")))))))))
|
||||
|
||||
(deftest create-alert-channel-not-configured-test
|
||||
(mt/with-temp [:model/Card {card-id :id} {}]
|
||||
(let [invoke-tool #(mt/with-current-user (mt/user->id :rasta)
|
||||
(metabot-v3.tools.create-alert/create-alert %))
|
||||
base-data (alert-data card-id)]
|
||||
(testing "Slack alert fails when Slack is not configured"
|
||||
(with-redefs [channel.settings/slack-configured? (constantly false)]
|
||||
(is (= {:error "slack is not configured. Ask an admin to set up slack notifications in Metabase settings."}
|
||||
(invoke-tool base-data)))))
|
||||
(testing "Slack alert fails when channel does not exist"
|
||||
(with-redefs [channel.settings/slack-configured? (constantly true)
|
||||
channel.settings/slack-cached-channels-and-usernames (constantly {:channels []})]
|
||||
(is (= {:error "no slack channel found with this name"}
|
||||
(invoke-tool (assoc base-data :slack-channel "no-such-channel")))))))))
|
||||
@@ -3,11 +3,12 @@
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription
|
||||
:as metabot-v3.tools.create-dashboard-subscription]
|
||||
[metabase.channel.settings :as channel.settings]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest create-dashboard-subscription-test
|
||||
(mt/with-model-cleanup [:model/Pulse]
|
||||
(mt/with-temp [:model/User {:keys [email] user-id :id} {:email "email@example.com"}
|
||||
(mt/with-temp [:model/User {user-id :id} {:email "email@example.com"}
|
||||
:model/Collection {collection-id :id} {}
|
||||
:model/Dashboard {dashboard-id :id} {:collection_id collection-id}
|
||||
:model/Card {card-id-0 :id} {}
|
||||
@@ -33,19 +34,35 @@
|
||||
(let [invoke-tool #(mt/with-current-user user-id
|
||||
(metabot-v3.tools.create-dashboard-subscription/create-dashboard-subscription %))
|
||||
base-data {:dashboard-id dashboard-id
|
||||
:email email
|
||||
:schedule {:frequency "monthly"
|
||||
:day-of-month "last-sunday"
|
||||
:hour 7}}]
|
||||
(testing "Subscription can be created"
|
||||
(is (= {:output "success"}
|
||||
(invoke-tool base-data))))
|
||||
(testing "Return an error message if the user cannot be found"
|
||||
(is (= {:output "no user with this email found"}
|
||||
(invoke-tool (update base-data :email str ".hu")))))
|
||||
(testing "Return an error message if the dashboard-id is invalid"
|
||||
(is (= {:output "invalid dashboard_id"}
|
||||
(invoke-tool (update base-data :dashboard-id str)))))
|
||||
(testing "Return an error message if the dashboard cannot be found"
|
||||
(is (= {:output "no dashboard with this dashboard_id found"}
|
||||
(invoke-tool (update base-data :dashboard-id -)))))))))
|
||||
:slack-channel "data-team"
|
||||
:schedule {:frequency :daily
|
||||
:hour 9}}
|
||||
fake-channels {:channels [{:display-name "#data-team" :name "data-team" :id "C123"}]}]
|
||||
(with-redefs [channel.settings/slack-configured? (constantly true)
|
||||
channel.settings/slack-cached-channels-and-usernames (constantly fake-channels)]
|
||||
(testing "Slack subscription can be created"
|
||||
(is (= {:output "success"}
|
||||
(invoke-tool base-data))))
|
||||
(testing "Slack subscription can be created with monthly schedule"
|
||||
(is (= {:output "success"}
|
||||
(invoke-tool (assoc base-data :schedule {:frequency "monthly"
|
||||
:day-of-month "last-sunday"
|
||||
:hour 7})))))
|
||||
(testing "Return an error message if the dashboard-id is invalid"
|
||||
(is (= {:error "invalid dashboard_id"}
|
||||
(invoke-tool (update base-data :dashboard-id str)))))
|
||||
(testing "Return an error message if the dashboard cannot be found"
|
||||
(is (= {:error "no dashboard with this dashboard_id found"}
|
||||
(invoke-tool (update base-data :dashboard-id -)))))
|
||||
(testing "Slack subscription requires slack-channel"
|
||||
(is (= {:error "slack_channel is required"}
|
||||
(invoke-tool (dissoc base-data :slack-channel))))))
|
||||
(testing "Slack subscription fails when Slack is not configured"
|
||||
(with-redefs [channel.settings/slack-configured? (constantly false)]
|
||||
(is (= {:error "slack is not configured. Ask an admin to connect slack in Metabase settings."}
|
||||
(invoke-tool base-data)))))
|
||||
(testing "Slack subscription fails when channel does not exist"
|
||||
(with-redefs [channel.settings/slack-configured? (constantly true)
|
||||
channel.settings/slack-cached-channels-and-usernames (constantly {:channels []})]
|
||||
(is (= {:error "no slack channel found with this name"}
|
||||
(invoke-tool (assoc base-data :slack-channel "no-such-channel"))))))))))
|
||||
|
||||
@@ -10,6 +10,66 @@
|
||||
[metabase.permissions.models.permissions :as perms]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest ^:parallel schedule->schedule-map-test
|
||||
(testing "hourly schedule"
|
||||
(is (= {:schedule_type "hourly"
|
||||
:schedule_hour nil
|
||||
:schedule_day nil
|
||||
:schedule_frame nil}
|
||||
(metabot-v3.tools.util/schedule->schedule-map
|
||||
{:frequency :hourly}))))
|
||||
(testing "daily schedule"
|
||||
(is (= {:schedule_type "daily"
|
||||
:schedule_hour 9
|
||||
:schedule_day nil
|
||||
:schedule_frame nil}
|
||||
(metabot-v3.tools.util/schedule->schedule-map
|
||||
{:frequency :daily
|
||||
:hour 9}))))
|
||||
(testing "weekly schedule"
|
||||
(is (= {:schedule_type "weekly"
|
||||
:schedule_hour 8
|
||||
:schedule_day "mon"
|
||||
:schedule_frame nil}
|
||||
(metabot-v3.tools.util/schedule->schedule-map
|
||||
{:frequency :weekly
|
||||
:hour 8
|
||||
:day-of-week :monday}))))
|
||||
(testing "weekly schedule truncates day name to 3 chars"
|
||||
(is (= "wed"
|
||||
(:schedule_day
|
||||
(metabot-v3.tools.util/schedule->schedule-map
|
||||
{:frequency :weekly
|
||||
:hour 10
|
||||
:day-of-week :wednesday})))))
|
||||
(testing "monthly schedule with first-mon"
|
||||
(is (= {:schedule_type "monthly"
|
||||
:schedule_hour 6
|
||||
:schedule_day "mon"
|
||||
:schedule_frame "first"}
|
||||
(metabot-v3.tools.util/schedule->schedule-map
|
||||
{:frequency :monthly
|
||||
:hour 6
|
||||
:day-of-month :first-mon}))))
|
||||
(testing "monthly schedule with last-fri"
|
||||
(is (= {:schedule_type "monthly"
|
||||
:schedule_hour 17
|
||||
:schedule_day "fri"
|
||||
:schedule_frame "last"}
|
||||
(metabot-v3.tools.util/schedule->schedule-map
|
||||
{:frequency :monthly
|
||||
:hour 17
|
||||
:day-of-month :last-fri}))))
|
||||
(testing "monthly schedule with mid"
|
||||
(is (= {:schedule_type "monthly"
|
||||
:schedule_hour 12
|
||||
:schedule_day nil
|
||||
:schedule_frame "mid"}
|
||||
(metabot-v3.tools.util/schedule->schedule-map
|
||||
{:frequency :monthly
|
||||
:hour 12
|
||||
:day-of-month :mid})))))
|
||||
|
||||
(deftest metabot-scope-query-test
|
||||
(testing "metabot-scope-query with collection hierarchy"
|
||||
(mt/dataset test-data
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"see. A segmented user should not be able to send messages to those channels. This tests that a "
|
||||
"segmented user doesn't see any slack channels.")
|
||||
(met/with-gtaps! {:gtaps {:venues {}}}
|
||||
(mt/with-temporary-setting-values [slack-token nil]
|
||||
(mt/with-temporary-setting-values [slack-app-token nil]
|
||||
(is (= nil
|
||||
(-> (mt/user-http-request :rasta :get 200 "pulse/form_input")
|
||||
(get-in [:channels :slack])))))))))
|
||||
|
||||
@@ -979,9 +979,10 @@
|
||||
(deftest jwt-token-not-configured-test
|
||||
(testing "should not return a session token when jwt is not configured"
|
||||
(mt/with-temporary-setting-values
|
||||
[jwt-enabled true
|
||||
[jwt-enabled true
|
||||
jwt-identity-provider-uri nil
|
||||
jwt-shared-secret nil]
|
||||
jwt-shared-secret nil
|
||||
slack-connect-enabled false]
|
||||
(mt/with-temporary-setting-values [enable-embedding-sdk true]
|
||||
(let [jwt-iat-time (buddy-util/now)
|
||||
jwt-exp-time (+ (buddy-util/now) 3600)
|
||||
|
||||
@@ -65,81 +65,52 @@
|
||||
;;; -------------------------------------------------- Prerequisites Tests --------------------------------------------------
|
||||
|
||||
(deftest sso-prereqs-test
|
||||
(sso.test-setup/do-with-other-sso-types-disabled!
|
||||
(fn []
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(testing "SSO requests fail if Slack Connect hasn't been configured or enabled"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled false
|
||||
slack-connect-client-id nil
|
||||
slack-connect-client-secret nil]
|
||||
(is
|
||||
(partial=
|
||||
{:cause "SSO has not been enabled and/or configured",
|
||||
:data {:status "error-sso-disabled", :status-code 400},
|
||||
:message "SSO has not been enabled and/or configured",
|
||||
:status "error-sso-disabled"}
|
||||
(mt/client :get 400 "/auth/sso"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"))))
|
||||
(with-test-encryption!
|
||||
(sso.test-setup/do-with-other-sso-types-disabled!
|
||||
(fn []
|
||||
(mt/with-additional-premium-features #{:sso-slack}
|
||||
(testing "SSO requests fail if Slack Connect hasn't been configured or enabled"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled false
|
||||
slack-connect-client-id nil
|
||||
slack-connect-client-secret nil]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})))))
|
||||
|
||||
(testing "SSO requests fail if they don't have a valid premium-features token"
|
||||
(sso.test-setup/call-with-default-slack-config!
|
||||
(fn []
|
||||
(mt/with-premium-features #{}
|
||||
(is
|
||||
(partial=
|
||||
{:cause "SSO has not been enabled and/or configured",
|
||||
:data {:status "error-sso-disabled", :status-code 400},
|
||||
:message "SSO has not been enabled and/or configured",
|
||||
:status "error-sso-disabled"}
|
||||
(mt/client :get 400 "/auth/sso"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"))))))))
|
||||
(testing "SSO requests fail if Slack Connect is enabled but hasn't been configured"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id nil]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})))))
|
||||
|
||||
(testing "SSO requests fail if Slack Connect is enabled but hasn't been configured"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id nil]
|
||||
(is
|
||||
(partial=
|
||||
{:cause "SSO has not been enabled and/or configured",
|
||||
:data {:status "error-sso-disabled", :status-code 400},
|
||||
:message "SSO has not been enabled and/or configured",
|
||||
:status "error-sso-disabled"}
|
||||
(mt/client :get 400 "/auth/sso"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect")))))
|
||||
(testing "SSO requests fail if Slack Connect is configured but hasn't been enabled"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled false
|
||||
slack-connect-client-id "test-slack-client-id"
|
||||
slack-connect-client-secret "test-slack-client-secret"]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})))))
|
||||
|
||||
(testing "SSO requests fail if Slack Connect is configured but hasn't been enabled"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled false
|
||||
slack-connect-client-id "test-slack-client-id"
|
||||
slack-connect-client-secret "test-slack-client-secret"]
|
||||
(is
|
||||
(partial=
|
||||
{:cause "SSO has not been enabled and/or configured",
|
||||
:data {:status "error-sso-disabled", :status-code 400},
|
||||
:message "SSO has not been enabled and/or configured",
|
||||
:status "error-sso-disabled"}
|
||||
(mt/client :get 400 "/auth/sso"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect")))))
|
||||
(testing "The client secret must also be included for SSO to be configured"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-slack-client-id"
|
||||
slack-connect-client-secret nil]
|
||||
(is (= "Slack Connect is not enabled"
|
||||
(mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}))))))
|
||||
|
||||
(testing "The client secret must also be included for SSO to be configured"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-enabled true
|
||||
slack-connect-client-id "test-slack-client-id"
|
||||
slack-connect-client-secret nil]
|
||||
(is
|
||||
(partial=
|
||||
{:cause "SSO has not been enabled and/or configured",
|
||||
:data {:status "error-sso-disabled", :status-code 400},
|
||||
:message "SSO has not been enabled and/or configured",
|
||||
:status "error-sso-disabled"}
|
||||
(mt/client :get 400 "/auth/sso"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect")))))))))
|
||||
(testing "SSO requests fail if they don't have a valid premium-features token"
|
||||
(sso.test-setup/call-with-default-slack-config!
|
||||
(fn []
|
||||
(mt/with-premium-features #{}
|
||||
(let [response (mt/client :get 402 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}})]
|
||||
(is (str/includes? (str (:message response) response) "paid feature")))))))))))
|
||||
|
||||
;;; -------------------------------------------------- Redirect Tests --------------------------------------------------
|
||||
|
||||
@@ -148,9 +119,8 @@
|
||||
(with-test-encryption!
|
||||
(sso.test-setup/with-slack-default-setup!
|
||||
(with-successful-oidc!
|
||||
(let [result (mt/client-full-response :get 302 "/auth/sso"
|
||||
(let [result (mt/client-full-response :get 302 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"
|
||||
:redirect default-redirect-uri)
|
||||
redirect-url (get-in result [:headers "Location"])
|
||||
oidc-state-cookie (->> (get-in result [:headers "Set-Cookie"])
|
||||
@@ -170,7 +140,7 @@
|
||||
(is (= "slack-connect" (:provider state-data))))))))))))
|
||||
|
||||
(deftest multiple-sso-methods-test
|
||||
(testing "with SAML and Slack Connect configured, a GET request with preferred_method=slack-connect should redirect to Slack"
|
||||
(testing "with SAML and Slack Connect configured, a GET request to /auth/sso/slack-connect should redirect to Slack"
|
||||
(with-test-encryption!
|
||||
(sso.test-setup/with-slack-default-setup!
|
||||
(mt/with-temporary-setting-values
|
||||
@@ -178,9 +148,8 @@
|
||||
saml-identity-provider-uri "http://test.idp.metabase.com"
|
||||
saml-identity-provider-certificate (slurp "test_resources/sso/auth0-public-idp.cert")]
|
||||
(with-successful-oidc!
|
||||
(let [result (mt/client-full-response :get 302 "/auth/sso"
|
||||
(let [result (mt/client-full-response :get 302 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"
|
||||
:redirect default-redirect-uri)
|
||||
redirect-url (get-in result [:headers "Location"])]
|
||||
(is (str/starts-with? redirect-url "http://example.com/slack")))))))))
|
||||
@@ -188,12 +157,15 @@
|
||||
;;; -------------------------------------------------- POST Not Allowed Tests --------------------------------------------------
|
||||
|
||||
(deftest post-not-allowed-test
|
||||
(testing "POST requests should return 405 Method Not Allowed for OIDC"
|
||||
(testing "slack-connect is no longer available via the old POST /auth/sso multimethod dispatch"
|
||||
(sso.test-setup/with-slack-default-setup!
|
||||
(let [response (mt/client-full-response :post 405 "/auth/sso"
|
||||
{:request-options {:content-type :x-www-form-urlencoded
|
||||
:form-params {:preferred_method "slack-connect"}}})]
|
||||
(is (= "GET" (get-in response [:headers "Allow"])))))))
|
||||
;; The old endpoint returned 405 via the defmethod sso-post :slack-connect.
|
||||
;; Now that slack-connect has been removed from the multimethod dispatch and
|
||||
;; uses dedicated GET routes, POSTing with preferred_method=slack-connect
|
||||
;; should no longer reach slack-connect code. With no other SSO enabled,
|
||||
;; the default dispatch returns "SSO has not been enabled and/or configured".
|
||||
(is (some? (mt/client :post 400 "/auth/sso"
|
||||
{:request-options {:content-type :x-www-form-urlencoded}}))))))
|
||||
|
||||
;;; -------------------------------------------------- Callback Tests --------------------------------------------------
|
||||
|
||||
@@ -201,7 +173,7 @@
|
||||
(testing "callback should fail if state cookie is missing"
|
||||
(with-test-encryption!
|
||||
(sso.test-setup/with-slack-default-setup!
|
||||
(let [response (mt/client-full-response :get 401 "/auth/sso"
|
||||
(let [response (mt/client-full-response :get 401 "/auth/sso/slack-connect/callback"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:code "test-code"
|
||||
:state "some-state")]
|
||||
@@ -214,16 +186,15 @@
|
||||
(sso.test-setup/with-slack-default-setup!
|
||||
(with-successful-oidc!
|
||||
;; First, initiate auth to set state cookie
|
||||
(let [init-response (mt/client-full-response :get 302 "/auth/sso"
|
||||
(let [init-response (mt/client-full-response :get 302 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"
|
||||
:redirect default-redirect-uri)
|
||||
;; Convert Set-Cookie headers to Cookie header format (extract name=value parts)
|
||||
set-cookies (get-in init-response [:headers "Set-Cookie"])
|
||||
cookie-header (->> set-cookies
|
||||
(map #(first (str/split % #";"))) ; Extract name=value before first ;
|
||||
(str/join "; "))
|
||||
response (mt/client-real-response :get 401 "/auth/sso"
|
||||
response (mt/client-real-response :get 401 "/auth/sso/slack-connect/callback"
|
||||
{:request-options {:redirect-strategy :none
|
||||
:headers {"Cookie" cookie-header}}}
|
||||
:code "test-code"
|
||||
@@ -237,16 +208,15 @@
|
||||
(sso.test-setup/with-slack-default-setup!
|
||||
(with-successful-oidc!
|
||||
;; First, initiate auth to set state cookie
|
||||
(let [init-response (mt/client-full-response :get 302 "/auth/sso"
|
||||
(let [init-response (mt/client-full-response :get 302 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"
|
||||
:redirect default-redirect-uri)
|
||||
;; Convert Set-Cookie headers to Cookie header format
|
||||
set-cookies (get-in init-response [:headers "Set-Cookie"])
|
||||
cookie-header (->> set-cookies
|
||||
(map #(first (str/split % #";")))
|
||||
(str/join "; "))
|
||||
response (mt/client-real-response :get 302 "/auth/sso"
|
||||
response (mt/client-real-response :get 302 "/auth/sso/slack-connect/callback"
|
||||
{:request-options {:redirect-strategy :none
|
||||
:headers {"Cookie" cookie-header}}}
|
||||
:code "test-code"
|
||||
@@ -262,9 +232,8 @@
|
||||
(sso.test-setup/with-slack-default-setup!
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-authentication-mode "link-only"]
|
||||
(let [response (mt/client-full-response :get 401 "/auth/sso"
|
||||
(let [response (mt/client-full-response :get 401 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"
|
||||
:redirect default-redirect-uri)]
|
||||
(is (str/includes? (str (get response :body)) "authenticated session"))))))))
|
||||
|
||||
@@ -276,9 +245,8 @@
|
||||
[slack-connect-authentication-mode "link-only"]
|
||||
(with-successful-oidc!
|
||||
(let [response (mt/user-http-request-full-response
|
||||
:rasta :get 302 "/auth/sso"
|
||||
:rasta :get 302 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"
|
||||
:redirect default-redirect-uri)]
|
||||
(is (str/starts-with? (get-in response [:headers "Location"]) "http://example.com/slack")))))))))
|
||||
|
||||
@@ -290,15 +258,10 @@
|
||||
(doseq [redirect-uri ["https://badsite.com"
|
||||
"//badsite.com"
|
||||
"https:///badsite.com"]]
|
||||
(is
|
||||
(= "Invalid redirect URL"
|
||||
(->
|
||||
(mt/client
|
||||
:get 400 "/auth/sso"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"
|
||||
:redirect redirect-uri)
|
||||
:message)))))))
|
||||
(let [result (mt/client :get 400 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:redirect redirect-uri)]
|
||||
(is (str/includes? (str (or (:message result) result)) "Invalid redirect URL")))))))
|
||||
|
||||
;;; -------------------------------------------------- User Provisioning Tests --------------------------------------------------
|
||||
|
||||
@@ -313,16 +276,15 @@
|
||||
(boolean (seq (t2/select :model/User :%lower.email "example@slack.com"))))]
|
||||
(is (false? (new-user-exists?)))
|
||||
;; Initiate auth
|
||||
(let [init-response (mt/client-full-response :get 302 "/auth/sso"
|
||||
(let [init-response (mt/client-full-response :get 302 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"
|
||||
:redirect default-redirect-uri)
|
||||
;; Convert Set-Cookie headers to Cookie header format
|
||||
set-cookies (get-in init-response [:headers "Set-Cookie"])
|
||||
cookie-header (->> set-cookies
|
||||
(map #(first (str/split % #";")))
|
||||
(str/join "; "))
|
||||
response (mt/client-real-response :get 302 "/auth/sso"
|
||||
response (mt/client-real-response :get 302 "/auth/sso/slack-connect/callback"
|
||||
{:request-options {:redirect-strategy :none
|
||||
:headers {"Cookie" cookie-header}}}
|
||||
:code "test-code"
|
||||
@@ -365,16 +327,15 @@
|
||||
:error :user-provisioning-disabled
|
||||
:message "Sorry, but you'll need a test account to view this page. Please contact your administrator."})]
|
||||
;; Initiate auth
|
||||
(let [init-response (mt/client-full-response :get 302 "/auth/sso"
|
||||
(let [init-response (mt/client-full-response :get 302 "/auth/sso/slack-connect"
|
||||
{:request-options {:redirect-strategy :none}}
|
||||
:preferred_method "slack-connect"
|
||||
:redirect default-redirect-uri)
|
||||
set-cookies (get-in init-response [:headers "Set-Cookie"])
|
||||
cookie-header (->> set-cookies
|
||||
(map #(first (str/split % #";")))
|
||||
(str/join "; "))]
|
||||
;; Try callback - should fail
|
||||
(mt/client-real-response :get 401 "/auth/sso"
|
||||
(mt/client-real-response :get 401 "/auth/sso/slack-connect/callback"
|
||||
{:request-options {:redirect-strategy :none
|
||||
:headers {"Cookie" cookie-header}}}
|
||||
:code "test-code"
|
||||
|
||||
@@ -40,19 +40,19 @@
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret "test-secret"]
|
||||
(let [request {:redirect-uri "https://metabase.example.com/auth/sso"}
|
||||
(let [request {:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
|
||||
config (#'metabase-enterprise.sso.providers.slack-connect/build-slack-oidc-config request)]
|
||||
(is (= "test-client-id" (:client-id config)))
|
||||
(is (= "test-secret" (:client-secret config)))
|
||||
(is (= "https://slack.com" (:issuer-uri config)))
|
||||
(is (= ["openid" "profile" "email"] (:scopes config)))
|
||||
(is (= "https://metabase.example.com/auth/sso" (:redirect-uri config))))))
|
||||
(is (= "https://metabase.example.com/auth/sso/slack-connect/callback" (:redirect-uri config))))))
|
||||
|
||||
(testing "Returns nil when client ID is missing"
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-client-id nil
|
||||
slack-connect-client-secret "test-secret"]
|
||||
(let [request {:redirect-uri "https://metabase.example.com/auth/sso"}
|
||||
(let [request {:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
|
||||
config (#'metabase-enterprise.sso.providers.slack-connect/build-slack-oidc-config request)]
|
||||
(is (nil? config)))))
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
(mt/with-temporary-setting-values
|
||||
[slack-connect-client-id "test-client-id"
|
||||
slack-connect-client-secret nil]
|
||||
(let [request {:redirect-uri "https://metabase.example.com/auth/sso"}
|
||||
(let [request {:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
|
||||
config (#'metabase-enterprise.sso.providers.slack-connect/build-slack-oidc-config request)]
|
||||
(is (nil? config))))))
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
(let [request {:code "test-code"
|
||||
:state "test-state"
|
||||
:oidc-nonce "test-nonce"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:authenticated-user (delay nil)}
|
||||
result (auth-identity/authenticate :provider/slack-connect request)]
|
||||
;; The presence of :code indicates callback, so should not error even without authenticated-user
|
||||
@@ -200,7 +200,7 @@
|
||||
(let [request {:code "test-code"
|
||||
:state "test-state"
|
||||
:oidc-nonce "test-nonce"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso"}
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
|
||||
result (auth-identity/authenticate :provider/slack-connect request)]
|
||||
(is (= :slack (get-in result [:user-data :sso_source])))
|
||||
(is (= "U12345678" (get-in result [:user-data :login_attributes "slack-user-id"])))
|
||||
@@ -242,7 +242,7 @@
|
||||
(let [initial-session-count (t2/count :model/Session :user_id (:id user))
|
||||
request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:authenticated-user (delay user)
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
@@ -291,7 +291,7 @@
|
||||
(try
|
||||
(let [request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
(is (true? (:success? result)) "Login should succeed")
|
||||
@@ -343,7 +343,7 @@
|
||||
:email "existing-slack@example.com"}})]
|
||||
(let [request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
(is (true? (:success? result)) "Login should succeed")
|
||||
@@ -391,7 +391,7 @@
|
||||
(let [initial-session-count (t2/count :model/Session :user_id (:id user))
|
||||
request {:code "test-code"
|
||||
:state "test-state"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso"
|
||||
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
|
||||
:authenticated-user (delay user)
|
||||
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
|
||||
result (auth-identity/login! :provider/slack-connect request)]
|
||||
|
||||
@@ -40,13 +40,14 @@
|
||||
(t2/update! :model/User {:email "rasta@metabase.com"} {:first_name "Rasta" :last_name "Toucan" :sso_source nil})))))
|
||||
|
||||
(defn do-with-other-sso-types-disabled!
|
||||
"Execute `thunk` with LDAP, SAML, and JWT SSO types disabled.
|
||||
"Execute `thunk` with LDAP, SAML, JWT, and Slack Connect SSO types disabled.
|
||||
Useful when testing a specific SSO provider in isolation."
|
||||
[thunk]
|
||||
(mt/with-temporary-setting-values
|
||||
[ldap-enabled false
|
||||
saml-enabled false
|
||||
jwt-enabled false]
|
||||
[ldap-enabled false
|
||||
saml-enabled false
|
||||
jwt-enabled false
|
||||
slack-connect-enabled false]
|
||||
(thunk)))
|
||||
|
||||
;;; -------------------------------------------------- SAML Setup --------------------------------------------------
|
||||
|
||||
@@ -2,4 +2,3 @@ export {
|
||||
aiStreamingQuery,
|
||||
findMatchingInflightAiStreamingRequests,
|
||||
} from "./requests";
|
||||
export type { JSONValue } from "./types";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isMatching, match } from "ts-pattern";
|
||||
import _ from "underscore";
|
||||
|
||||
import type { MetabotHistory } from "metabase-types/api";
|
||||
import type { JSONValue, MetabotHistory } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
type KnownDataPart,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
toolCallPartSchema,
|
||||
toolResultPartSchema,
|
||||
} from "./schemas";
|
||||
import type { JSONValue } from "./types";
|
||||
|
||||
const StreamingPartTypeRegistry = {
|
||||
TEXT: "0",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { nanoid } from "@reduxjs/toolkit";
|
||||
|
||||
import api from "metabase/lib/api";
|
||||
import type { JSONValue } from "metabase-types/api";
|
||||
|
||||
import { type AIStreamingConfig, processChatResponse } from "./process-stream";
|
||||
import type { JSONValue } from "./types";
|
||||
|
||||
// keep track of inflight requests so that can be cancelled programmitcally from other
|
||||
// places in the app w/o passing references to the abort controller directly
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
A JSON value can be a string, number, boolean, object, array, or null.
|
||||
JSON values can be serialized and deserialized by the JSON.stringify and JSON.parse methods.
|
||||
*/
|
||||
export type JSONValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| { [value: string]: JSONValue }
|
||||
| Array<JSONValue>;
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
MetabotGenerateContentResponse,
|
||||
MetabotId,
|
||||
MetabotInfo,
|
||||
MetabotSlackSettings,
|
||||
SuggestedMetabotPromptsRequest,
|
||||
SuggestedMetabotPromptsResponse,
|
||||
} from "metabase-types/api";
|
||||
@@ -95,6 +96,17 @@ export const metabotApi = EnterpriseApi.injectEndpoints({
|
||||
body: params,
|
||||
}),
|
||||
}),
|
||||
updateMetabotSlackSettings: builder.mutation<
|
||||
{ ok: boolean },
|
||||
MetabotSlackSettings
|
||||
>({
|
||||
query: (settings) => ({
|
||||
method: "PUT",
|
||||
url: "/api/ee/metabot-v3/slack/settings",
|
||||
body: settings,
|
||||
}),
|
||||
invalidatesTags: ["session-properties"],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -106,4 +118,5 @@ export const {
|
||||
useRegenerateSuggestedMetabotPromptsMutation,
|
||||
useLazyMetabotGenerateContentQuery,
|
||||
useSubmitMetabotFeedbackMutation,
|
||||
useUpdateMetabotSlackSettingsMutation,
|
||||
} = metabotApi;
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { IndexRoute, Route } from "react-router";
|
||||
import { push } from "react-router-redux";
|
||||
import { P, match } from "ts-pattern";
|
||||
import { c, jt, t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import ErrorBoundary from "metabase/ErrorBoundary";
|
||||
import {
|
||||
AdminNavItem,
|
||||
AdminNavWrapper,
|
||||
} from "metabase/admin/components/AdminNav";
|
||||
import { SettingsSection } from "metabase/admin/components/SettingsSection";
|
||||
import { SettingHeader } from "metabase/admin/settings/components/SettingHeader";
|
||||
import { skipToken, useGetCollectionQuery } from "metabase/api";
|
||||
@@ -22,7 +15,6 @@ import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErr
|
||||
import { CollectionPickerModal } from "metabase/common/components/Pickers/CollectionPicker";
|
||||
import { useToast } from "metabase/common/hooks";
|
||||
import { getIcon } from "metabase/lib/icon";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -50,6 +42,7 @@ import type {
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { MetabotPromptSuggestionPane } from "./MetabotAdminSuggestedPrompts";
|
||||
import { MetabotNavPane } from "./MetabotNavPane";
|
||||
import { useMetabotIdPath } from "./utils";
|
||||
|
||||
export function getAdminRoutes() {
|
||||
@@ -130,41 +123,6 @@ export function MetabotAdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function MetabotNavPane() {
|
||||
const { data, isLoading } = useListMetabotsQuery();
|
||||
const metabotId = useMetabotIdPath();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const metabots = useMemo(() => _.sortBy(data?.items ?? [], "id"), [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasMetabotId = metabots?.some((metabot) => metabot.id === metabotId);
|
||||
|
||||
if (!hasMetabotId && metabots?.length) {
|
||||
dispatch(push(`/admin/metabot/${metabots[0]?.id}`));
|
||||
}
|
||||
}, [metabots, metabotId, dispatch]);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="column" flex="0 0 auto">
|
||||
<AdminNavWrapper>
|
||||
{metabots?.map((metabot) => (
|
||||
<AdminNavItem
|
||||
key={metabot.id}
|
||||
icon="metabot"
|
||||
label={metabot.name}
|
||||
path={`/admin/metabot/${metabot.id}`}
|
||||
/>
|
||||
))}
|
||||
</AdminNavWrapper>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function MetabotEnabledToggle({
|
||||
settingKey,
|
||||
isEmbedMetabot = false,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
AdminNavItem,
|
||||
AdminNavWrapper,
|
||||
} from "metabase/admin/components/AdminNav";
|
||||
import { Flex } from "metabase/ui";
|
||||
import { FIXED_METABOT_IDS } from "metabase-enterprise/metabot/constants";
|
||||
|
||||
export function MetabotNavPane() {
|
||||
return (
|
||||
<Flex direction="column" flex="0 0 auto">
|
||||
<AdminNavWrapper>
|
||||
<AdminNavItem
|
||||
icon="metabot"
|
||||
label={t`Metabot`}
|
||||
path={`/admin/metabot/${FIXED_METABOT_IDS.DEFAULT}`}
|
||||
/>
|
||||
<AdminNavItem
|
||||
icon="embed"
|
||||
label={t`Embedded Metabot`}
|
||||
path={`/admin/metabot/${FIXED_METABOT_IDS.EMBEDDED}`}
|
||||
/>
|
||||
</AdminNavWrapper>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.copyButton {
|
||||
display: inline-flex;
|
||||
background-color: var(--mb-color-brand);
|
||||
color: var(--mb-color-text-primary-inverse);
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.88;
|
||||
transition: all 200ms linear;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useEffect, useState } from "react";
|
||||
import { P, match } from "ts-pattern";
|
||||
import { c, t } from "ttag";
|
||||
import * as Yup from "yup";
|
||||
|
||||
import { SettingsSection } from "metabase/admin/components/SettingsSection";
|
||||
import { BasicAdminSettingInput } from "metabase/admin/settings/components/widgets/AdminSettingInput";
|
||||
import {
|
||||
useGetSlackAppInfoQuery,
|
||||
useGetSlackManifestQuery,
|
||||
} from "metabase/api/slack";
|
||||
import { useAdminSetting, useAdminSettings } from "metabase/api/utils/settings";
|
||||
import { ConfirmModal } from "metabase/common/components/ConfirmModal";
|
||||
import { ExternalLink } from "metabase/common/components/ExternalLink";
|
||||
import { useDocsUrl, useSetting } from "metabase/common/hooks";
|
||||
import {
|
||||
Form,
|
||||
FormErrorMessage,
|
||||
FormProvider,
|
||||
FormSubmitButton,
|
||||
FormTextInput,
|
||||
} from "metabase/forms";
|
||||
import * as Errors from "metabase/lib/errors";
|
||||
import { Accordion, Button, Flex, Stack, Text } from "metabase/ui";
|
||||
import { useUpdateMetabotSlackSettingsMutation } from "metabase-enterprise/api/metabot";
|
||||
import type { SlackAppInfo } from "metabase-types/api/slack";
|
||||
|
||||
import {
|
||||
EncryptionRequiredAlert,
|
||||
MissingScopesAlert,
|
||||
} from "./MetabotSlackSetupAlerts";
|
||||
|
||||
const VALIDATION_SCHEMA = Yup.object({
|
||||
"slack-connect-client-id": Yup.string().required(Errors.required),
|
||||
"slack-connect-client-secret": Yup.string().required(Errors.required),
|
||||
"metabot-slack-signing-secret": Yup.string().required(Errors.required),
|
||||
});
|
||||
|
||||
type MetabotSlackSettingsFormValues = {
|
||||
"slack-connect-client-id": string | null | undefined;
|
||||
"slack-connect-client-secret": string | null | undefined;
|
||||
"metabot-slack-signing-secret": string | null | undefined;
|
||||
};
|
||||
|
||||
const MetabotSlackSettingsForm = ({
|
||||
appInfo,
|
||||
values,
|
||||
isConfigured,
|
||||
}: {
|
||||
appInfo?: SlackAppInfo;
|
||||
values: MetabotSlackSettingsFormValues;
|
||||
isConfigured: boolean;
|
||||
}) => {
|
||||
const [updateMetabotSlackSettings] = useUpdateMetabotSlackSettingsMutation();
|
||||
|
||||
const basicInfoUrl = appInfo?.app_id
|
||||
? `https://api.slack.com/apps/${appInfo.app_id}/general`
|
||||
: `https://api.slack.com/apps`;
|
||||
|
||||
const basicInfoLink = (
|
||||
<ExternalLink key="link" href={basicInfoUrl}>
|
||||
{t`Basic Information`}
|
||||
</ExternalLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text c="text-secondary">
|
||||
{c("{0} is a link that says 'Basic Information'.")
|
||||
.jt`You'll find this in your Slack app's ${basicInfoLink} settings.`}
|
||||
</Text>
|
||||
<FormProvider
|
||||
initialValues={{
|
||||
"slack-connect-client-id": values["slack-connect-client-id"] ?? "",
|
||||
"slack-connect-client-secret":
|
||||
values["slack-connect-client-secret"] ?? "",
|
||||
"metabot-slack-signing-secret":
|
||||
values["metabot-slack-signing-secret"] ?? "",
|
||||
}}
|
||||
validationSchema={VALIDATION_SCHEMA}
|
||||
onSubmit={(values) => updateMetabotSlackSettings(values).unwrap()}
|
||||
enableReinitialize
|
||||
>
|
||||
<Form>
|
||||
<Stack gap="sm">
|
||||
<FormTextInput
|
||||
name="slack-connect-client-id"
|
||||
label={t`Client ID`}
|
||||
placeholder="123456789012.123456789012"
|
||||
disabled={isConfigured}
|
||||
/>
|
||||
<FormTextInput
|
||||
name="slack-connect-client-secret"
|
||||
label={t`Client Secret`}
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
disabled={isConfigured}
|
||||
/>
|
||||
<FormTextInput
|
||||
name="metabot-slack-signing-secret"
|
||||
label={t`Signing Secret`}
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
disabled={isConfigured}
|
||||
/>
|
||||
<FormErrorMessage />
|
||||
{!isConfigured && (
|
||||
<Flex justify="flex-end" mt="md">
|
||||
<FormSubmitButton label={t`Save changes`} variant="filled" />
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export function MetabotSlackSetup() {
|
||||
const isSlackTokenValid = useSetting("slack-token-valid?") ?? false;
|
||||
const isEncryptionEnabled = useSetting("encryption-enabled");
|
||||
|
||||
const { values } = useAdminSettings([
|
||||
"slack-connect-client-id",
|
||||
"slack-connect-client-secret",
|
||||
"metabot-slack-signing-secret",
|
||||
] as const);
|
||||
const isConfigured = Object.values(values).every(Boolean);
|
||||
|
||||
const { value: isEnabled, updateSetting: updateEnabledSetting } =
|
||||
useAdminSetting("slack-connect-enabled");
|
||||
|
||||
const [updateMetabotSlackSettings] = useUpdateMetabotSlackSettingsMutation();
|
||||
const [isOpened, { open: handleOpen, close: handleClose }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const handleRemove = () => {
|
||||
updateMetabotSlackSettings({
|
||||
"slack-connect-client-id": null,
|
||||
"slack-connect-client-secret": null,
|
||||
"metabot-slack-signing-secret": null,
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const [shouldPollAppInfo, setShouldPollAppInfo] = useState(false);
|
||||
const { data: manifest } = useGetSlackManifestQuery();
|
||||
const { data: appInfo } = useGetSlackAppInfoQuery(undefined, {
|
||||
skip: !isSlackTokenValid,
|
||||
pollingInterval: shouldPollAppInfo ? 10 * 1000 : 0,
|
||||
});
|
||||
|
||||
const hasMissingScopes = (appInfo?.scopes?.missing?.length ?? 0) > 0;
|
||||
const notification = match({ isEncryptionEnabled, hasMissingScopes })
|
||||
.with({ isEncryptionEnabled: false }, () => "encryption" as const)
|
||||
.with({ hasMissingScopes: true }, () => "scopes" as const)
|
||||
.otherwise(() => null);
|
||||
useEffect(() => {
|
||||
setShouldPollAppInfo(notification === "scopes");
|
||||
}, [notification]);
|
||||
|
||||
// eslint-disable-next-line metabase/no-unconditional-metabase-links-render -- admin only page
|
||||
const { url: encryptionDocsUrl } = useDocsUrl(
|
||||
"operations-guide/encrypting-database-details-at-rest",
|
||||
);
|
||||
|
||||
if (!isSlackTokenValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t`Natural language questions in Slack`}
|
||||
description={t`Add a few more details to unlock the full power of Metabot in Slack.`}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{notification === "encryption" && (
|
||||
<EncryptionRequiredAlert docsUrl={encryptionDocsUrl} />
|
||||
)}
|
||||
|
||||
{notification === "scopes" && (
|
||||
<MissingScopesAlert manifest={manifest} appInfo={appInfo} />
|
||||
)}
|
||||
|
||||
{match({ notification, isConfigured })
|
||||
.with({ isConfigured: true }, () => (
|
||||
<>
|
||||
<BasicAdminSettingInput
|
||||
name="slack-connect-enabled"
|
||||
inputType="boolean"
|
||||
value={isEnabled}
|
||||
switchLabel={t`Let people chat with Metabot`}
|
||||
onChange={(next) =>
|
||||
updateEnabledSetting({
|
||||
key: "slack-connect-enabled",
|
||||
value: !!next,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ConnectionDetails>
|
||||
<MetabotSlackSettingsForm
|
||||
appInfo={appInfo}
|
||||
values={values}
|
||||
isConfigured={isConfigured}
|
||||
/>
|
||||
</ConnectionDetails>
|
||||
<Flex justify="flex-end">
|
||||
<Button c="danger" onClick={handleOpen}>{t`Remove`}</Button>
|
||||
</Flex>
|
||||
</>
|
||||
))
|
||||
.with({ notification: P.string }, () => null)
|
||||
.with({ isConfigured: false }, () => (
|
||||
<MetabotSlackSettingsForm
|
||||
appInfo={appInfo}
|
||||
values={values}
|
||||
isConfigured={isConfigured}
|
||||
/>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Stack>
|
||||
</SettingsSection>
|
||||
<ConfirmModal
|
||||
opened={isOpened}
|
||||
onClose={handleClose}
|
||||
title={t`Clear Metabot settings?`}
|
||||
message={t`This will remove the configuration needed to power Metabot. People will no longer be able to chat with Metabot in Slack until you reconfigure these settings.`}
|
||||
confirmButtonText={t`Clear settings`}
|
||||
onConfirm={handleRemove}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionDetails({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="connection">
|
||||
<Accordion.Control>{t`View connection details`}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack pt="md" pb="sm">
|
||||
{children}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {
|
||||
findRequests,
|
||||
setupMetabotSlackSettingsEndpoint,
|
||||
setupMetabotSlackSettingsEndpointWithError,
|
||||
setupPropertiesEndpoints,
|
||||
setupSettingsEndpoints,
|
||||
setupSlackAppInfoEndpoint,
|
||||
setupSlackManifestEndpoint,
|
||||
setupUpdateSettingEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import type { SlackAppInfo } from "metabase-types/api";
|
||||
import {
|
||||
createMockSettingDefinition,
|
||||
createMockSettings,
|
||||
createMockSlackAppInfo,
|
||||
} from "metabase-types/api/mocks";
|
||||
import { createMockSettingsState } from "metabase-types/store/mocks";
|
||||
|
||||
import { MetabotSlackSetup } from "./MetabotSlackSetup";
|
||||
|
||||
const CONFIGURED = {
|
||||
clientId: "123.456",
|
||||
clientSecret: "secret123",
|
||||
signingSecret: "signing123",
|
||||
} as const;
|
||||
|
||||
const MISSING_SCOPES = createMockSlackAppInfo({
|
||||
scopes: {
|
||||
actual: ["chat:write"],
|
||||
required: ["chat:write", "im:history"],
|
||||
missing: ["im:history"],
|
||||
extra: [],
|
||||
},
|
||||
});
|
||||
|
||||
interface SetupOptions {
|
||||
isSlackTokenValid?: boolean;
|
||||
isEncryptionEnabled?: boolean;
|
||||
clientId?: string | null;
|
||||
clientSecret?: string | null;
|
||||
signingSecret?: string | null;
|
||||
isEnabled?: boolean;
|
||||
appInfo?: Partial<SlackAppInfo>;
|
||||
}
|
||||
|
||||
const setup = async ({
|
||||
isSlackTokenValid = true,
|
||||
isEncryptionEnabled = true,
|
||||
clientId = null,
|
||||
clientSecret = null,
|
||||
signingSecret = null,
|
||||
isEnabled = false,
|
||||
appInfo = {},
|
||||
}: SetupOptions = {}) => {
|
||||
const settings = createMockSettings({
|
||||
"slack-token-valid?": isSlackTokenValid,
|
||||
"encryption-enabled": isEncryptionEnabled,
|
||||
"slack-connect-client-id": clientId,
|
||||
"slack-connect-client-secret": clientSecret,
|
||||
"metabot-slack-signing-secret": signingSecret,
|
||||
"slack-connect-enabled": isEnabled,
|
||||
});
|
||||
|
||||
setupPropertiesEndpoints(settings);
|
||||
setupSettingsEndpoints([
|
||||
createMockSettingDefinition({
|
||||
key: "slack-connect-client-id",
|
||||
value: clientId,
|
||||
}),
|
||||
createMockSettingDefinition({
|
||||
key: "slack-connect-client-secret",
|
||||
value: clientSecret,
|
||||
}),
|
||||
createMockSettingDefinition({
|
||||
key: "metabot-slack-signing-secret",
|
||||
value: signingSecret,
|
||||
}),
|
||||
createMockSettingDefinition({
|
||||
key: "slack-connect-enabled",
|
||||
value: isEnabled,
|
||||
}),
|
||||
]);
|
||||
setupUpdateSettingEndpoint();
|
||||
setupMetabotSlackSettingsEndpoint();
|
||||
setupSlackManifestEndpoint();
|
||||
setupSlackAppInfoEndpoint(appInfo);
|
||||
|
||||
renderWithProviders(<MetabotSlackSetup />, {
|
||||
storeInitialState: { settings: createMockSettingsState(settings) },
|
||||
});
|
||||
|
||||
// Wait for component to fully render
|
||||
if (isSlackTokenValid) {
|
||||
await screen.findByText("Natural language questions in Slack");
|
||||
}
|
||||
};
|
||||
|
||||
const clientIdInput = () => screen.findByLabelText("Client ID");
|
||||
const clientSecretInput = () => screen.findByLabelText("Client Secret");
|
||||
const signingSecretInput = () => screen.findByLabelText("Signing Secret");
|
||||
const saveButton = () => screen.findByRole("button", { name: "Save changes" });
|
||||
|
||||
describe("MetabotSlackSetup", () => {
|
||||
it("renders nothing when slack-token-valid? is false", async () => {
|
||||
await setup({ isSlackTokenValid: false });
|
||||
expect(
|
||||
screen.queryByText("Natural language questions in Slack"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows unconfigured form with all fields when secrets are missing", async () => {
|
||||
await setup();
|
||||
await screen.findByText("Natural language questions in Slack");
|
||||
expect(await clientIdInput()).toBeInTheDocument();
|
||||
expect(await clientSecretInput()).toBeInTheDocument();
|
||||
expect(await signingSecretInput()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows configured state with toggle, accordion, and Remove button", async () => {
|
||||
await setup(CONFIGURED);
|
||||
expect(
|
||||
await screen.findByText("Let people chat with Metabot"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("View connection details")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays error message when submission fails", async () => {
|
||||
await setup();
|
||||
setupMetabotSlackSettingsEndpointWithError(400, "Invalid credentials");
|
||||
|
||||
await userEvent.type(await clientIdInput(), "123.456");
|
||||
await userEvent.type(await clientSecretInput(), "secret");
|
||||
await userEvent.type(await signingSecretInput(), "signing");
|
||||
|
||||
await userEvent.click(await saveButton());
|
||||
|
||||
expect(await screen.findByText(/Invalid credentials/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("encryption alert", () => {
|
||||
it("shows alert with docs link and hides form when encryption is disabled", async () => {
|
||||
await setup({ isEncryptionEnabled: false });
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"You must enable encryption for your instance in order to use this feature",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Learn how to enable encryption" }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("encrypting-database-details-at-rest"),
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("Client ID")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing scopes alert", () => {
|
||||
it("shows alert with copy button, Slack link, and hides form", async () => {
|
||||
await setup({
|
||||
appInfo: createMockSlackAppInfo({
|
||||
app_id: "A123ABC",
|
||||
team_id: "T456DEF",
|
||||
scopes: MISSING_SCOPES.scopes,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("Metabot needs more Slack permissions"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Copy manifest/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Open Slack settings/i }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://app.slack.com/app-settings/T456DEF/A123ABC/app-manifest",
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("Client ID")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unconfigured form", () => {
|
||||
it("disables submit button when fields are empty", async () => {
|
||||
await setup();
|
||||
await screen.findByText("Natural language questions in Slack");
|
||||
expect(await saveButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("submits settings via PUT /api/ee/metabot-v3/slack/settings", async () => {
|
||||
await setup();
|
||||
|
||||
await userEvent.type(await clientIdInput(), "123.456");
|
||||
await userEvent.type(await clientSecretInput(), "secret123");
|
||||
await userEvent.type(await signingSecretInput(), "signing123");
|
||||
|
||||
await userEvent.click(await saveButton());
|
||||
|
||||
await waitFor(async () => {
|
||||
const puts = await findRequests("PUT");
|
||||
const req = puts.find((r) =>
|
||||
r.url.includes("/api/ee/metabot-v3/slack/settings"),
|
||||
);
|
||||
expect(req?.body).toEqual({
|
||||
"slack-connect-client-id": "123.456",
|
||||
"slack-connect-client-secret": "secret123",
|
||||
"metabot-slack-signing-secret": "signing123",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("configured state", () => {
|
||||
it("shows disabled inputs inside accordion", async () => {
|
||||
await setup(CONFIGURED);
|
||||
await userEvent.click(await screen.findByText("View connection details"));
|
||||
|
||||
expect(await clientIdInput()).toBeDisabled();
|
||||
expect(await clientSecretInput()).toBeDisabled();
|
||||
expect(await signingSecretInput()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows Basic Information link with app_id", async () => {
|
||||
await setup({ ...CONFIGURED, appInfo: { app_id: "A123ABC" } });
|
||||
await userEvent.click(await screen.findByText("View connection details"));
|
||||
|
||||
const link = await screen.findByRole("link", {
|
||||
name: "Basic Information",
|
||||
});
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://api.slack.com/apps/A123ABC/general",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates slack-connect-enabled setting when toggle is clicked", async () => {
|
||||
await setup({ ...CONFIGURED, isEnabled: false });
|
||||
|
||||
const toggle = await screen.findByRole("switch", {
|
||||
name: "Let people chat with Metabot",
|
||||
});
|
||||
expect(toggle).not.toBeChecked();
|
||||
await userEvent.click(toggle);
|
||||
|
||||
await waitFor(async () => {
|
||||
const puts = await findRequests("PUT");
|
||||
const req = puts.find((r) =>
|
||||
r.url.includes("/api/setting/slack-connect-enabled"),
|
||||
);
|
||||
expect(req?.body).toEqual({ value: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove flow", () => {
|
||||
it("opens modal and clears settings on confirm", async () => {
|
||||
await setup(CONFIGURED);
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Remove" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("Clear Metabot settings?"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Clear settings" }),
|
||||
);
|
||||
|
||||
await waitFor(async () => {
|
||||
const puts = await findRequests("PUT");
|
||||
const req = puts.find((r) =>
|
||||
r.url.includes("/api/ee/metabot-v3/slack/settings"),
|
||||
);
|
||||
expect(req?.body).toEqual({
|
||||
"slack-connect-client-id": null,
|
||||
"slack-connect-client-secret": null,
|
||||
"metabot-slack-signing-secret": null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("shows unconfigured form when only some secrets are set", async () => {
|
||||
await setup({ clientId: "123.456", signingSecret: "signing123" });
|
||||
await screen.findByText("Natural language questions in Slack");
|
||||
|
||||
expect(await clientIdInput()).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Let people chat with Metabot"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows configured UI alongside missing scopes alert", async () => {
|
||||
await setup({ ...CONFIGURED, appInfo: MISSING_SCOPES });
|
||||
|
||||
expect(
|
||||
await screen.findByText("Metabot needs more Slack permissions"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Let people chat with Metabot"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows fallback Slack apps URL when app_id is missing", async () => {
|
||||
await setup({ ...CONFIGURED, appInfo: { app_id: null } });
|
||||
await userEvent.click(await screen.findByText("View connection details"));
|
||||
|
||||
const link = await screen.findByRole("link", {
|
||||
name: "Basic Information",
|
||||
});
|
||||
expect(link).toHaveAttribute("href", "https://api.slack.com/apps");
|
||||
});
|
||||
|
||||
it("fetches slack manifest on mount", async () => {
|
||||
await setup();
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls();
|
||||
expect(
|
||||
calls.find((c) => c.request?.url.includes("/api/slack/manifest")),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { CopyButton } from "metabase/common/components/CopyButton";
|
||||
import {
|
||||
ButtonLink,
|
||||
ExternalLink,
|
||||
} from "metabase/common/components/ExternalLink";
|
||||
import { Alert, Group, Icon, Stack, Text } from "metabase/ui";
|
||||
|
||||
import S from "./MetabotSlackSetup.module.css";
|
||||
|
||||
interface MissingScopesAlertProps {
|
||||
manifest: Record<string, unknown> | undefined;
|
||||
appInfo?: { app_id: string | null; team_id: string | null };
|
||||
}
|
||||
|
||||
export function MissingScopesAlert({
|
||||
manifest,
|
||||
appInfo,
|
||||
}: MissingScopesAlertProps) {
|
||||
const slackUrl =
|
||||
appInfo?.app_id && appInfo?.team_id
|
||||
? `https://app.slack.com/app-settings/${appInfo.team_id}/${appInfo.app_id}/app-manifest`
|
||||
: "https://api.slack.com/apps";
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color="brand"
|
||||
title={t`Metabot needs more Slack permissions`}
|
||||
styles={{ label: { fontSize: "var(--mantine-font-size-md)" } }}
|
||||
>
|
||||
<Stack gap="md" mt="sm">
|
||||
<Text>
|
||||
{t`Your Slack app is missing OAuth scopes required for Metabot. Copy the updated manifest and paste it in your Slack app settings. You will need to re-install your application to your workspace after.`}
|
||||
</Text>
|
||||
<Group gap="md">
|
||||
<CopyButton
|
||||
value={JSON.stringify(manifest, null, 2)}
|
||||
target={
|
||||
<button type="button" className={S.copyButton}>
|
||||
<span>{t`Copy manifest`}</span>
|
||||
<Icon name="copy" size={16} ml="sm" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<ButtonLink href={slackUrl}>
|
||||
<span>{t`Open Slack settings`}</span>
|
||||
<Icon name="external" size={16} ml="sm" />
|
||||
</ButtonLink>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
interface EncryptionRequiredAlertProps {
|
||||
docsUrl: string;
|
||||
}
|
||||
|
||||
export function EncryptionRequiredAlert({
|
||||
docsUrl,
|
||||
}: EncryptionRequiredAlertProps) {
|
||||
return (
|
||||
<Alert
|
||||
color="brand"
|
||||
title={t`You must enable encryption for your instance in order to use this feature`}
|
||||
styles={{ label: { fontSize: "var(--mantine-font-size-md)" } }}
|
||||
>
|
||||
<Text mt="sm">
|
||||
<ExternalLink
|
||||
href={docsUrl}
|
||||
>{t`Learn how to enable encryption`}</ExternalLink>
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { replace } from "react-router-redux";
|
||||
import { useMount } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
|
||||
export const SlackConnectSuccess = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useMount(() => {
|
||||
dispatch(
|
||||
addUndo({
|
||||
message: t`Your Slack account has been connected successfully.`,
|
||||
icon: "check",
|
||||
}),
|
||||
);
|
||||
dispatch(replace("/"));
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export const LONG_CONVO_MSG_LENGTH_THRESHOLD = 120000;
|
||||
export const FIXED_METABOT_IDS = {
|
||||
DEFAULT: 1 as const,
|
||||
EMBEDDED: 2 as const,
|
||||
SLACKBOT: 3 as const,
|
||||
};
|
||||
|
||||
export const METABOT_REQUEST_IDS = {
|
||||
@@ -15,6 +16,7 @@ export const METABOT_REQUEST_IDS = {
|
||||
export const FIXED_METABOT_ENTITY_IDS = {
|
||||
DEFAULT: "metabotmetabotmetabot" as const,
|
||||
EMBEDDED: "embeddedmetabotmetabo" as const,
|
||||
SLACKBOT: "slackbotmetabotmetabo" as const,
|
||||
};
|
||||
|
||||
export const METABOT_PROFILE_OVERRIDES = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { hasPremiumFeature } from "metabase-enterprise/settings";
|
||||
import { Metabot } from "./components/Metabot";
|
||||
import { getAdminRoutes } from "./components/MetabotAdmin/MetabotAdminPage";
|
||||
import { getAdminRoutes as getAdminUpsellRoutes } from "./components/MetabotAdmin/MetabotPurchasePage";
|
||||
import { MetabotSlackSetup } from "./components/MetabotAdmin/MetabotSlackSetup";
|
||||
import { MetabotAppBarButton } from "./components/MetabotAppBarButton";
|
||||
import { MetabotChat } from "./components/MetabotChat";
|
||||
import MetabotThinkingStyles from "./components/MetabotChat/MetabotThinking.module.css";
|
||||
@@ -18,6 +19,7 @@ import { MetabotDataStudioSidebar } from "./components/MetabotDataStudioSidebar"
|
||||
import { MetabotQueryBuilder } from "./components/MetabotQueryBuilder";
|
||||
import { getMetabotQuickLinks } from "./components/MetabotQuickLinks";
|
||||
import { getNewMenuItemAIExploration } from "./components/NewMenuItemAIExploration";
|
||||
import { SlackConnectSuccess } from "./components/SlackConnectSuccess";
|
||||
import { MetabotContext, MetabotProvider, defaultContext } from "./context";
|
||||
import { useMetabotSQLSuggestion as useMetabotSQLSuggestionEE } from "./hooks";
|
||||
import {
|
||||
@@ -65,7 +67,12 @@ export function initializePlugin() {
|
||||
getMetabotVisible,
|
||||
// routes
|
||||
getAdminRoutes,
|
||||
getMetabotRoutes: getMetabotQuickLinks,
|
||||
getMetabotRoutes: () => (
|
||||
<>
|
||||
{getMetabotQuickLinks()}
|
||||
<Route path="slack-connect-success" component={SlackConnectSuccess} />
|
||||
</>
|
||||
),
|
||||
getMetabotQueryBuilderRoute: () => (
|
||||
<Route path="ask" component={MetabotQueryBuilderOrFallback} />
|
||||
),
|
||||
@@ -75,6 +82,7 @@ export function initializePlugin() {
|
||||
MetabotAppBarButton,
|
||||
MetabotDataStudioButton,
|
||||
MetabotDataStudioSidebar,
|
||||
MetabotSlackSetup,
|
||||
MetabotThinkingStyles,
|
||||
// hooks
|
||||
useMetabotSQLSuggestion: useMetabotSQLSuggestionEE,
|
||||
|
||||
@@ -13,12 +13,12 @@ import { addUndo } from "metabase/redux/undo";
|
||||
import { getIsWorkspace } from "metabase/selectors/routing";
|
||||
import { getUser } from "metabase/selectors/user";
|
||||
import {
|
||||
type JSONValue,
|
||||
aiStreamingQuery,
|
||||
findMatchingInflightAiStreamingRequests,
|
||||
} from "metabase-enterprise/api/ai-streaming";
|
||||
import type { ProcessedChatResponse } from "metabase-enterprise/api/ai-streaming/process-stream";
|
||||
import type {
|
||||
JSONValue,
|
||||
MetabotAgentRequest,
|
||||
MetabotAgentResponse,
|
||||
MetabotChatContext,
|
||||
|
||||
@@ -277,3 +277,17 @@ export type MetabotTodoItem = {
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||
priority: "high" | "medium" | "low";
|
||||
};
|
||||
|
||||
/* Metabot v3 - Slack Settings */
|
||||
|
||||
export type MetabotSlackSettings =
|
||||
| {
|
||||
"slack-connect-client-id": string;
|
||||
"slack-connect-client-secret": string;
|
||||
"metabot-slack-signing-secret": string;
|
||||
}
|
||||
| {
|
||||
"slack-connect-client-id": null;
|
||||
"slack-connect-client-secret": null;
|
||||
"metabot-slack-signing-secret": null;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export * from "./segment";
|
||||
export * from "./series";
|
||||
export * from "./session";
|
||||
export * from "./settings";
|
||||
export * from "./slack";
|
||||
export * from "./snippets";
|
||||
export * from "./store";
|
||||
export * from "./support-access-grants";
|
||||
|
||||
@@ -120,6 +120,7 @@ export const createMockTokenFeatures = (
|
||||
sso_ldap: false,
|
||||
sso_oidc: false,
|
||||
sso_saml: false,
|
||||
sso_slack: false,
|
||||
session_timeout_config: false,
|
||||
whitelabel: false,
|
||||
dashboard_subscription_filters: false,
|
||||
@@ -378,7 +379,6 @@ export const createMockSettings = (
|
||||
"site-url": "http://localhost:3000",
|
||||
"site-uuid": "1234",
|
||||
"slack-app-token": null,
|
||||
"slack-token": null,
|
||||
"slack-token-valid?": false,
|
||||
"start-of-week": "sunday",
|
||||
"store-url": "https://store.staging.metabase.com",
|
||||
@@ -408,5 +408,6 @@ export const createMockSettings = (
|
||||
"license-token-missing-banner-dismissal-timestamp": [],
|
||||
"sdk-iframe-embed-setup-settings": null,
|
||||
"use-tenants": false,
|
||||
"encryption-enabled": false,
|
||||
...opts,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SlackSettings } from "metabase-types/api";
|
||||
import type { SlackAppInfo, SlackSettings } from "metabase-types/api";
|
||||
|
||||
export const createMockSlackSettings = (
|
||||
opts?: Partial<SlackSettings>,
|
||||
@@ -7,3 +7,17 @@ export const createMockSlackSettings = (
|
||||
"slack-bug-report-channel": null,
|
||||
...opts,
|
||||
});
|
||||
|
||||
export const createMockSlackAppInfo = (
|
||||
opts?: Partial<SlackAppInfo>,
|
||||
): SlackAppInfo => ({
|
||||
app_id: "test-app-id",
|
||||
team_id: "test-team-id",
|
||||
scopes: {
|
||||
actual: [],
|
||||
required: [],
|
||||
missing: [],
|
||||
extra: [],
|
||||
},
|
||||
...opts,
|
||||
});
|
||||
|
||||
@@ -335,6 +335,7 @@ export const tokenFeatures = [
|
||||
"sso_ldap",
|
||||
"sso_oidc",
|
||||
"sso_saml",
|
||||
"sso_slack",
|
||||
"session_timeout_config",
|
||||
"whitelabel",
|
||||
"serialization",
|
||||
@@ -477,6 +478,7 @@ export type EmbeddingHomepageStatus =
|
||||
interface AdminSettings {
|
||||
"active-users-count"?: number;
|
||||
"custom-geojson-enabled": boolean;
|
||||
"encryption-enabled": boolean;
|
||||
"deprecation-notice-version"?: string;
|
||||
"disable-cors-on-localhost": boolean;
|
||||
"embedding-secret-key"?: string;
|
||||
@@ -514,7 +516,6 @@ interface SettingsManagerSettings {
|
||||
"session-cookie-samesite": SessionCookieSameSite;
|
||||
"slack-app-token": string | null;
|
||||
"slack-bug-report-channel": string | null;
|
||||
"slack-token": string | null;
|
||||
"slack-token-valid?": boolean;
|
||||
}
|
||||
|
||||
@@ -763,6 +764,10 @@ export interface EnterpriseSettings extends Settings {
|
||||
"python-runner-test-run-timeout-seconds"?: number | null;
|
||||
"llm-anthropic-api-key"?: string | null;
|
||||
"llm-anthropic-model": string;
|
||||
"metabot-slack-signing-secret"?: string | null;
|
||||
"slack-connect-enabled"?: boolean | null;
|
||||
"slack-connect-client-id"?: string | null;
|
||||
"slack-connect-client-secret"?: string | null;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
|
||||
@@ -2,3 +2,14 @@ export interface SlackSettings {
|
||||
"slack-app-token": string | null;
|
||||
"slack-bug-report-channel": string | null;
|
||||
}
|
||||
|
||||
export interface SlackAppInfo {
|
||||
app_id: string | null;
|
||||
team_id: string | null;
|
||||
scopes: {
|
||||
actual: string[];
|
||||
required: string[];
|
||||
missing: string[];
|
||||
extra: string[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -43,3 +43,11 @@ export interface BugReportDetails {
|
||||
}
|
||||
|
||||
export type LongTaskStatus = "incomplete" | "complete" | "aborted";
|
||||
|
||||
export type JSONValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| { [value: string]: JSONValue }
|
||||
| Array<JSONValue>;
|
||||
|
||||
@@ -18,7 +18,6 @@ export const Default: StoryObj<DeprecationNoticeProps> = {
|
||||
},
|
||||
|
||||
args: {
|
||||
hasSlackBot: true,
|
||||
hasDeprecatedDatabase: true,
|
||||
isEnabled: true,
|
||||
},
|
||||
|
||||
@@ -6,76 +6,40 @@ import { Icon } from "metabase/ui";
|
||||
import S from "./DeprecationNotice.module.css";
|
||||
|
||||
export interface DeprecationNoticeProps {
|
||||
hasSlackBot: boolean;
|
||||
hasDeprecatedDatabase: boolean;
|
||||
isEnabled: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DeprecationNotice = ({
|
||||
hasSlackBot,
|
||||
hasDeprecatedDatabase,
|
||||
isEnabled,
|
||||
onClose,
|
||||
}: DeprecationNoticeProps): JSX.Element | null => {
|
||||
if ((!hasSlackBot && !hasDeprecatedDatabase) || !isEnabled) {
|
||||
if (!hasDeprecatedDatabase || !isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const databaseListUrl = "/admin/databases";
|
||||
|
||||
return (
|
||||
<div className={S.NoticeRoot} role="status">
|
||||
<Icon name="warning" className={S.NoticeWarningIcon} />
|
||||
<div className={S.NoticeContent}>
|
||||
{getBannerContent(hasSlackBot, hasDeprecatedDatabase)}
|
||||
{jt`You're using a ${(
|
||||
<Link
|
||||
variant="brandBold"
|
||||
key="database"
|
||||
to={databaseListUrl}
|
||||
>{t`Database driver`}</Link>
|
||||
)} which is now deprecated and will be removed in the next release. We recommend you ${(
|
||||
<strong key="upgrade">{t`upgrade`}</strong>
|
||||
)}.`}
|
||||
</div>
|
||||
<Icon name="close" className={S.NoticeCloseIcon} onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getBannerContent = (
|
||||
hasSlackBot: boolean,
|
||||
hasDeprecatedDatabase: boolean,
|
||||
) => {
|
||||
const databaseListUrl = "/admin/databases";
|
||||
const slackSettingsUrl = "/admin/settings/notifications";
|
||||
|
||||
if (hasSlackBot && hasDeprecatedDatabase) {
|
||||
return jt`You’re using a ${(
|
||||
<Link
|
||||
variant="brandBold"
|
||||
key="database"
|
||||
to={databaseListUrl}
|
||||
>{t`Database driver`}</Link>
|
||||
)} and a ${(
|
||||
<Link
|
||||
variant="brandBold"
|
||||
key="slack"
|
||||
to={slackSettingsUrl}
|
||||
>{t`Slack bot integration`}</Link>
|
||||
)} which are now deprecated and will be removed in the next release. We recommend you ${(
|
||||
<strong key="upgrade">{t`upgrade`}</strong>
|
||||
)}.`;
|
||||
} else if (hasSlackBot) {
|
||||
return jt`Your Slack bot was deprecated but is still working. We recommend you delete the existing connection and ${(
|
||||
<Link
|
||||
variant="brandBold"
|
||||
key="slack"
|
||||
to={slackSettingsUrl}
|
||||
>{t`upgrade to Slack Apps`}</Link>
|
||||
)}.`;
|
||||
} else if (hasDeprecatedDatabase) {
|
||||
return jt`You’re using a ${(
|
||||
<Link
|
||||
variant="brandBold"
|
||||
key="database"
|
||||
to={databaseListUrl}
|
||||
>{t`Database driver`}</Link>
|
||||
)} which is now deprecated and will be removed in the next release. We recommend you ${(
|
||||
<strong key="upgrade">{t`upgrade`}</strong>
|
||||
)}.`;
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default DeprecationNotice;
|
||||
|
||||
@@ -9,7 +9,6 @@ import { disableNotice } from "../../actions";
|
||||
import DeprecationNotice from "../../components/DeprecationNotice";
|
||||
import {
|
||||
hasDeprecatedDatabase,
|
||||
hasSlackBot,
|
||||
isDeprecationNoticeEnabled,
|
||||
} from "../../selectors";
|
||||
|
||||
@@ -18,7 +17,6 @@ interface Props {
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: State, props: Props) => ({
|
||||
hasSlackBot: hasSlackBot(state),
|
||||
hasDeprecatedDatabase: hasDeprecatedDatabase(state, props),
|
||||
isEnabled: isDeprecationNoticeEnabled(state),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getEngines } from "metabase/databases/selectors";
|
||||
import { isDeprecatedEngine } from "metabase/lib/engine";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type { State } from "metabase-types/store";
|
||||
|
||||
@@ -8,10 +7,6 @@ interface Props {
|
||||
databases?: Database[];
|
||||
}
|
||||
|
||||
export const hasSlackBot = (state: State): boolean => {
|
||||
return getSetting(state, "slack-token") != null;
|
||||
};
|
||||
|
||||
export const hasDeprecatedDatabase = (state: State, props: Props): boolean => {
|
||||
const engines = getEngines(state);
|
||||
return (
|
||||
|
||||
@@ -56,12 +56,16 @@ export function SettingsPageWrapper({
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
} & StackProps) {
|
||||
} & Omit<StackProps, "title">) {
|
||||
return (
|
||||
<Stack gap="lg" {...stackProps}>
|
||||
{(title || description) && (
|
||||
<Box>
|
||||
{title && <Title order={1}>{title}</Title>}
|
||||
{title && (
|
||||
<Title order={1} display="flex" style={{ alignItems: "center" }}>
|
||||
{title}
|
||||
</Title>
|
||||
)}
|
||||
{description && (
|
||||
<Text c="text-secondary" maw="40rem">
|
||||
{description}
|
||||
|
||||
@@ -53,11 +53,8 @@ export function SettingsNav() {
|
||||
<PLUGIN_REMOTE_SYNC.LibraryNav />
|
||||
<NavDivider />
|
||||
<SettingsNavItem path="email" label={t`Email`} icon="mail" />
|
||||
<SettingsNavItem
|
||||
path="notifications"
|
||||
label={t`Notification channels`}
|
||||
icon="bell"
|
||||
/>
|
||||
<SettingsNavItem path="slack" label={t`Slack`} icon="slack" />
|
||||
<SettingsNavItem path="webhooks" label={t`Webhooks`} icon="webhook" />
|
||||
{!hasHosting && <UpdatesNavItem />}
|
||||
<NavDivider />
|
||||
<SettingsNavItem
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
setupPropertiesEndpoints,
|
||||
setupSettingsEndpoints,
|
||||
setupSlackManifestEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import { setupWebhookChannelsEndpoint } from "__support__/server-mocks/channel";
|
||||
import { renderWithProviders, screen } from "__support__/ui";
|
||||
import type { SettingKey } from "metabase-types/api";
|
||||
import {
|
||||
createMockSettingDefinition,
|
||||
createMockSettings,
|
||||
} from "metabase-types/api/mocks";
|
||||
import { createMockSettingsState } from "metabase-types/store/mocks";
|
||||
|
||||
import { NotificationSettingsPage } from "./NotificationSettingsPage";
|
||||
|
||||
const setup = async ({
|
||||
isSlackTokenValid = true,
|
||||
slackAppToken = "xoxb-test-token",
|
||||
}: {
|
||||
isSlackTokenValid?: boolean;
|
||||
slackAppToken?: string | null;
|
||||
} = {}) => {
|
||||
const settings = createMockSettings({
|
||||
"slack-token-valid?": isSlackTokenValid,
|
||||
"slack-app-token": slackAppToken,
|
||||
});
|
||||
|
||||
setupPropertiesEndpoints(settings);
|
||||
setupSettingsEndpoints(
|
||||
Object.entries(settings).map(([key, value]) =>
|
||||
createMockSettingDefinition({ key: key as SettingKey, value }),
|
||||
),
|
||||
);
|
||||
setupSlackManifestEndpoint();
|
||||
setupWebhookChannelsEndpoint();
|
||||
|
||||
renderWithProviders(<NotificationSettingsPage />, {
|
||||
storeInitialState: {
|
||||
settings: createMockSettingsState(settings),
|
||||
},
|
||||
});
|
||||
|
||||
await screen.findByText("Notifications");
|
||||
};
|
||||
|
||||
describe("NotificationSettingsPage", () => {
|
||||
it("shows Slack status inside the card when connected", async () => {
|
||||
await setup();
|
||||
|
||||
expect(screen.getByText("Slack app is working")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Slack error status when the token is invalid", async () => {
|
||||
await setup({ isSlackTokenValid: false });
|
||||
|
||||
expect(screen.getByText("Slack app is not working.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows connect CTA when the Slack app is not configured", async () => {
|
||||
await setup({ slackAppToken: null });
|
||||
|
||||
expect(screen.getByText("Connect to Slack")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { SettingsPageWrapper } from "metabase/admin/components/SettingsSection";
|
||||
import { PLUGIN_METABOT } from "metabase/plugins";
|
||||
import { Icon } from "metabase/ui";
|
||||
|
||||
import { SlackSetup } from "../../slack/SlackSetup";
|
||||
|
||||
export const SlackSettingsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<SettingsPageWrapper
|
||||
title={
|
||||
<>
|
||||
<Icon name="slack_colorized" size={22} mr="sm" /> {t`Slack`}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SlackSetup />
|
||||
<PLUGIN_METABOT.MetabotSlackSetup />
|
||||
</SettingsPageWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useState } from "react";
|
||||
import { c, t } from "ttag";
|
||||
|
||||
@@ -7,35 +6,27 @@ import {
|
||||
SettingsSection,
|
||||
} from "metabase/admin/components/SettingsSection";
|
||||
import { useListChannelsQuery } from "metabase/api/channel";
|
||||
import { useSetting } from "metabase/common/hooks";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
type IconName,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "metabase/ui";
|
||||
import type { NotificationChannel } from "metabase-types/api";
|
||||
|
||||
import { SlackSettingsModal } from "../../slack/SlackSettingsModal";
|
||||
import { SlackSetup } from "../../slack/SlackSetup";
|
||||
import { SlackStatus } from "../../slack/SlackStatus";
|
||||
import { CreateWebhookModal } from "../widgets/Notifications/CreateWebhookModal";
|
||||
import { EditWebhookModal } from "../widgets/Notifications/EditWebhookModal";
|
||||
|
||||
type NotificationModals = null | "create" | "edit";
|
||||
type WebhookModals = null | "create" | "edit";
|
||||
|
||||
export const NotificationSettingsPage = () => {
|
||||
const [showSlackModal, { open: openSlackModal, close: closeSlackModal }] =
|
||||
useDisclosure(false);
|
||||
const [webhookModal, setWebhookModal] = useState<NotificationModals>(null);
|
||||
export const WebhooksSettingsPage = () => {
|
||||
const [webhookModal, setWebhookModal] = useState<WebhookModals>(null);
|
||||
const [currentChannel, setCurrentChannel] = useState<NotificationChannel>();
|
||||
const slackAppToken = useSetting("slack-app-token");
|
||||
const slackBotToken = useSetting("slack-token");
|
||||
|
||||
const { data: channels } = useListChannelsQuery();
|
||||
|
||||
@@ -43,48 +34,21 @@ export const NotificationSettingsPage = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPageWrapper title={t`Notifications`}>
|
||||
<SettingsSection title={t`Slack`}>
|
||||
{slackAppToken || slackBotToken ? (
|
||||
<Paper shadow="0" withBorder p="lg">
|
||||
<Flex gap="0.5rem" align="center" mb="1rem">
|
||||
<Icon name="slack_colorized" />
|
||||
<Title order={3}>{t`Slack`}</Title>
|
||||
</Flex>
|
||||
{slackAppToken ? <SlackStatus /> : <SlackSetup />}
|
||||
</Paper>
|
||||
) : (
|
||||
<UnstyledButton
|
||||
onClick={openSlackModal}
|
||||
variant="unstyled"
|
||||
w="100%"
|
||||
>
|
||||
<Paper shadow="0" withBorder p="lg">
|
||||
<Flex gap="0.5rem" align="center" mb="0.5rem">
|
||||
<Icon name="slack_colorized" />
|
||||
<Title order={3}>{t`Connect to Slack`}</Title>
|
||||
</Flex>
|
||||
<Text>
|
||||
{t`If your team uses Slack, you can send dashboard subscriptions and
|
||||
alerts there`}
|
||||
</Text>
|
||||
</Paper>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsPageWrapper title={t`Webhooks`}>
|
||||
<SettingsSection>
|
||||
<Flex justify="space-between" align="center" mb="1.5rem">
|
||||
<Title order={2}>{t`Webhooks for alerts`}</Title>{" "}
|
||||
<Group justify="space-between" align="center">
|
||||
<Text c="text-secondary">
|
||||
{t`Configure webhooks to send alert results to a destination of your choice.`}
|
||||
</Text>
|
||||
{hasChannels && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-md"
|
||||
variant="filled"
|
||||
size="md"
|
||||
leftSection={<Icon name="add" />}
|
||||
onClick={() => setWebhookModal("create")}
|
||||
>{c("Short for 'Add another webhook'").t`Add another`}</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Group>
|
||||
{hasChannels ? (
|
||||
<Stack>
|
||||
{channels?.map((c) => (
|
||||
@@ -110,12 +74,11 @@ export const NotificationSettingsPage = () => {
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsPageWrapper>
|
||||
<NotificationSettingsModals
|
||||
<WebhookSettingsModals
|
||||
modal={webhookModal}
|
||||
channel={currentChannel}
|
||||
onClose={() => setWebhookModal(null)}
|
||||
/>
|
||||
<SlackSettingsModal isOpen={showSlackModal} onClose={closeSlackModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -146,12 +109,12 @@ const ChannelBox = ({
|
||||
</Paper>
|
||||
);
|
||||
|
||||
const NotificationSettingsModals = ({
|
||||
const WebhookSettingsModals = ({
|
||||
modal,
|
||||
channel,
|
||||
onClose,
|
||||
}: {
|
||||
modal: NotificationModals;
|
||||
modal: WebhookModals;
|
||||
channel?: NotificationChannel;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
@@ -1,35 +0,0 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Box, Center, type CenterProps, Text } from "metabase/ui";
|
||||
|
||||
import S from "./slack.module.css";
|
||||
|
||||
export interface SlackBadgeProps extends CenterProps {
|
||||
isBot?: boolean;
|
||||
isValid?: boolean;
|
||||
}
|
||||
|
||||
export const SlackBadge = ({
|
||||
isBot,
|
||||
isValid,
|
||||
...props
|
||||
}: SlackBadgeProps): JSX.Element => {
|
||||
const color = isValid ? "success" : "error";
|
||||
|
||||
return (
|
||||
<Center {...props}>
|
||||
<Box className={S.StatusBadge} bg={color} />
|
||||
<Text fw="bold" c={color}>
|
||||
{getMessage(isBot, isValid)}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
const getMessage = (isBot?: boolean, isValid?: boolean): string => {
|
||||
if (isBot) {
|
||||
return isValid ? t`Slack bot is working.` : t`Slack bot is not working.`;
|
||||
} else {
|
||||
return isValid ? t`Slack app is working` : t`Slack app is not working.`;
|
||||
}
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { render, screen } from "__support__/ui";
|
||||
|
||||
import { SlackBadge } from "./SlackBadge";
|
||||
|
||||
describe("SlackBadge", () => {
|
||||
it("should render when there is a bot and the token is valid", () => {
|
||||
render(<SlackBadge isBot={true} isValid={true} />);
|
||||
|
||||
expect(screen.getByText("Slack bot is working.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render when there is an app and the token is valid", () => {
|
||||
render(<SlackBadge isBot={false} isValid={true} />);
|
||||
|
||||
expect(screen.getByText("Slack app is working")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render when there is a bot and the token is invalid", () => {
|
||||
render(<SlackBadge isBot={true} isValid={false} />);
|
||||
|
||||
expect(screen.getByText("Slack bot is not working.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render when there is an app and the token is invalid", () => {
|
||||
render(<SlackBadge isBot={false} isValid={false} />);
|
||||
|
||||
expect(screen.getByText("Slack app is not working.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useUpdateSlackSettingsMutation } from "metabase/api";
|
||||
import { useSetting, useToast } from "metabase/common/hooks";
|
||||
import { Box, Text, TextInput } from "metabase/ui";
|
||||
|
||||
import { SettingHeader } from "../components/SettingHeader";
|
||||
|
||||
const getSlackError = (err: unknown): string =>
|
||||
(err as { data?: { errors?: { "slack-bug-report-channel"?: string } } })?.data
|
||||
?.errors?.["slack-bug-report-channel"] ?? t`Failed to update channel`;
|
||||
|
||||
export const SlackBugReportChannelInput = () => {
|
||||
const initialValue = useSetting("slack-bug-report-channel");
|
||||
const [updateSlackSettings] = useUpdateSlackSettingsMutation();
|
||||
const [sendToast] = useToast();
|
||||
const [localValue, setLocalValue] = useState(initialValue ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(initialValue ?? "");
|
||||
}, [initialValue]);
|
||||
|
||||
const handleBlur = async () => {
|
||||
const trimmedValue = localValue.replace(/^#+/, "");
|
||||
setLocalValue(trimmedValue);
|
||||
|
||||
const value = trimmedValue === "" ? null : trimmedValue.toLowerCase();
|
||||
if (value === initialValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
await updateSlackSettings({ "slack-bug-report-channel": value }).unwrap();
|
||||
sendToast({
|
||||
message: t`Slack bug report channel updated`,
|
||||
toastColor: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
setError(getSlackError(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box data-testid="slack-bug-report-channel-setting">
|
||||
<SettingHeader
|
||||
id="slack-bug-report-channel"
|
||||
title={t`Slack bug report channel`}
|
||||
description={t`This channel will receive bug reports submitted by users.`}
|
||||
/>
|
||||
<TextInput
|
||||
id="slack-bug-report-channel"
|
||||
value={localValue}
|
||||
placeholder="metabase-bugs"
|
||||
onChange={(e) => {
|
||||
setLocalValue(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
leftSection={<Text c="text-secondary">#</Text>}
|
||||
error={error}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useGetSlackAppInfoQuery } from "metabase/api";
|
||||
import { ExternalLink } from "metabase/common/components/ExternalLink";
|
||||
import { useSetting } from "metabase/common/hooks";
|
||||
import { Box, Button, Flex, Stack, Text } from "metabase/ui";
|
||||
|
||||
import { SlackBugReportChannelInput } from "./SlackBugReportChannelInput";
|
||||
|
||||
export const SlackConfiguration = () => {
|
||||
const bugReportingEnabled = useSetting("bug-reporting-enabled") ?? false;
|
||||
const { data: appInfo } = useGetSlackAppInfoQuery();
|
||||
|
||||
const iconUrl = "/app/assets/img/metabot-slackbot.png";
|
||||
const basicInfoUrl = appInfo?.app_id
|
||||
? `https://api.slack.com/apps/${appInfo.app_id}/general#edit`
|
||||
: `https://api.slack.com/apps`;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">{t`Slack app icon`}</Text>
|
||||
<Text c="text-secondary">
|
||||
{t`Give your app all the bells and whistles.`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Flex gap="md" align="center">
|
||||
<Box
|
||||
component="img"
|
||||
src={iconUrl}
|
||||
alt="Metabot icon"
|
||||
w={80}
|
||||
h={80}
|
||||
bdrs="sm"
|
||||
bd="1px solid var(--mb-color-border)"
|
||||
/>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Button
|
||||
variant="filled"
|
||||
component="a"
|
||||
href={iconUrl}
|
||||
display="inline-block"
|
||||
download="metabot-slack-icon.png"
|
||||
>
|
||||
{t`Download App Icon`}
|
||||
</Button>
|
||||
<Text c="text-secondary">
|
||||
{t`Add this icon to your App's `}
|
||||
<ExternalLink
|
||||
href={basicInfoUrl}
|
||||
>{t`Basic Information`}</ExternalLink>
|
||||
{t` settings`}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
|
||||
{bugReportingEnabled && <SlackBugReportChannelInput />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useAdminSetting } from "metabase/api/utils";
|
||||
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
|
||||
import { Modal } from "metabase/ui";
|
||||
|
||||
import { SlackSetup } from "./SlackSetup";
|
||||
|
||||
export const SlackSettingsModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { value: isApp, isLoading } = useAdminSetting("slack-app-token");
|
||||
|
||||
useEffect(() => {
|
||||
if (isApp) {
|
||||
onClose();
|
||||
}
|
||||
}, [isApp, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
title={t`Metabase on Slack`}
|
||||
onClose={onClose}
|
||||
padding="xl"
|
||||
>
|
||||
<LoadingAndErrorWrapper loading={isLoading}>
|
||||
<SlackSetup />
|
||||
</LoadingAndErrorWrapper>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import {
|
||||
findRequests,
|
||||
setupPropertiesEndpoints,
|
||||
setupSettingsEndpoints,
|
||||
setupSlackManifestEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import { createMockSettings } from "metabase-types/api/mocks";
|
||||
|
||||
import { SlackSettingsModal } from "./SlackSettingsModal";
|
||||
|
||||
const setup = async ({
|
||||
isApp,
|
||||
isBot,
|
||||
isValid,
|
||||
}: {
|
||||
isApp: boolean;
|
||||
isBot: boolean;
|
||||
isValid: boolean;
|
||||
}) => {
|
||||
const settings = createMockSettings({
|
||||
"slack-app-token": isApp ? "app-token" : null,
|
||||
"slack-token-valid?": isValid,
|
||||
"slack-token": isBot ? "bot-token" : null,
|
||||
});
|
||||
|
||||
setupPropertiesEndpoints(settings);
|
||||
setupSettingsEndpoints([]);
|
||||
|
||||
setupSlackManifestEndpoint();
|
||||
|
||||
const onClose = jest.fn();
|
||||
|
||||
renderWithProviders(<SlackSettingsModal isOpen onClose={onClose} />);
|
||||
|
||||
await screen.findByText("Metabase on Slack");
|
||||
|
||||
return { onClose };
|
||||
};
|
||||
|
||||
describe("SlackSettingsModal", () => {
|
||||
it("should request closing when the app is configured", async () => {
|
||||
const { onClose } = await setup({
|
||||
isApp: true,
|
||||
isBot: false,
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Metabase on Slack")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the setup display and load the manifest when the app is not configured", async () => {
|
||||
await setup({ isApp: false, isBot: false, isValid: false });
|
||||
|
||||
expect(screen.getByText("Metabase on Slack")).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText("1. Click the button below and create your Slack App"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await waitFor(async () => {
|
||||
const gets = await findRequests("GET");
|
||||
expect(gets).toHaveLength(3);
|
||||
});
|
||||
|
||||
const gets = await findRequests("GET");
|
||||
const manifestRequest = gets.find((request) =>
|
||||
request.url.includes("manifest"),
|
||||
);
|
||||
expect(manifestRequest).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,101 +1,157 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "ttag";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { jt, t } from "ttag";
|
||||
|
||||
import { useGetSlackManifestQuery } from "metabase/api";
|
||||
import { ButtonLink } from "metabase/common/components/ExternalLink";
|
||||
import { SettingsSection } from "metabase/admin/components/SettingsSection";
|
||||
import {
|
||||
useGetSlackManifestQuery,
|
||||
useUpdateSlackSettingsMutation,
|
||||
} from "metabase/api";
|
||||
import { ConfirmModal } from "metabase/common/components/ConfirmModal";
|
||||
import {
|
||||
ButtonLink,
|
||||
ExternalLink,
|
||||
} from "metabase/common/components/ExternalLink";
|
||||
import { Markdown } from "metabase/common/components/Markdown";
|
||||
import { useSetting } from "metabase/common/hooks";
|
||||
import { Box, Divider, Icon, Stack, Text, Title } from "metabase/ui";
|
||||
import { useDocsUrl, useSetting } from "metabase/common/hooks";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Flex,
|
||||
HoverCard,
|
||||
Icon,
|
||||
Stack,
|
||||
Text,
|
||||
} from "metabase/ui";
|
||||
|
||||
import { SlackBadge } from "./SlackBadge";
|
||||
import { SlackConfiguration } from "./SlackConfiguration";
|
||||
import { SlackSetupForm } from "./SlackSetupForm";
|
||||
import S from "./slack.module.css";
|
||||
|
||||
const SlackConnectionStatus = ({
|
||||
isValid,
|
||||
docsUrl,
|
||||
token,
|
||||
}: {
|
||||
isValid: boolean;
|
||||
docsUrl: string;
|
||||
token: string | null;
|
||||
}) => {
|
||||
const [updateSlackSettings] = useUpdateSlackSettingsMutation();
|
||||
const [isOpened, { open: handleOpen, close: handleClose }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const handleDisconnect = () => {
|
||||
updateSlackSettings({ "slack-app-token": null });
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex align="center" gap="sm">
|
||||
<Badge
|
||||
circle
|
||||
size="12"
|
||||
bg={isValid ? "success" : "error"}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text>
|
||||
{isValid ? t`Slack app is working` : t`Slack app is not working.`}
|
||||
</Text>
|
||||
{token && (
|
||||
<HoverCard position="bottom">
|
||||
<HoverCard.Target>
|
||||
<Icon
|
||||
name="info"
|
||||
c="text-secondary"
|
||||
size={14}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Stack gap="xs" p="md">
|
||||
<Text c="text-secondary">{t`Slack Bot OAuth Token`}</Text>
|
||||
<Text fw="bold">{token}</Text>
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
)}
|
||||
{!isValid && (
|
||||
<Text ml="sm" inline>
|
||||
{jt`Need help? ${(<ExternalLink key="link" href={docsUrl}>{t`See our docs`}</ExternalLink>)}.`}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Button c="danger" onClick={handleOpen}>{t`Disconnect`}</Button>
|
||||
</Flex>
|
||||
<ConfirmModal
|
||||
opened={isOpened}
|
||||
onClose={handleClose}
|
||||
title={t`Disconnect Slack?`}
|
||||
message={t`This will stop dashboard subscriptions from appearing in Slack until you reconnect.`}
|
||||
confirmButtonText={t`Disconnect`}
|
||||
onConfirm={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const SlackSetup = () => {
|
||||
const botToken = useSetting("slack-token");
|
||||
const isValid = useSetting("slack-token-valid?");
|
||||
return (
|
||||
<Stack>
|
||||
<SetupHeader isBot={!!botToken} isValid={isValid} />
|
||||
<SetupSection
|
||||
title={t`1. Click the button below and create your Slack App`}
|
||||
>
|
||||
<Text>
|
||||
<Markdown>
|
||||
{t`First, **click the button below to create your Slack App** using the Metabase configuration. Once created, click "**Install to workspace**" to authorize it.`}
|
||||
</Markdown>
|
||||
</Text>
|
||||
<SlackAppsLink />
|
||||
</SetupSection>
|
||||
<SetupSection
|
||||
title={t`2. Activate the OAuth token and create a new slack channel`}
|
||||
>
|
||||
<Text mb="md">
|
||||
<Markdown>
|
||||
{t`Click on "**OAuth and Permissions**" in the sidebar, copy the "**Bot User OAuth Token**" and paste it here.`}
|
||||
</Markdown>
|
||||
</Text>
|
||||
<SlackSetupForm />
|
||||
</SetupSection>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
const slackAppToken = useSetting("slack-app-token");
|
||||
const hasCompletedSetup = !!slackAppToken;
|
||||
const isValid = useSetting("slack-token-valid?") ?? false;
|
||||
const { url: docsUrl } = useDocsUrl("configuring-metabase/slack");
|
||||
|
||||
const SetupHeader = ({
|
||||
isBot,
|
||||
isValid,
|
||||
}: {
|
||||
isBot?: boolean;
|
||||
isValid?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Box>
|
||||
{isBot ? (
|
||||
<Text>
|
||||
<SlackBadge isBot={isBot} isValid={isValid} />{" "}
|
||||
<Markdown>
|
||||
{t`We recommend you **upgrade to Slack Apps** see the instructions below:`}
|
||||
</Markdown>
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="text-secondary">
|
||||
{t`Bring the power of Metabase to your Slack #channels.`}{" "}
|
||||
{t`Follow these steps to connect to Slack:`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const SetupSection = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Box className={S.SetupSection}>
|
||||
<Title order={4} p="md" c="brand">
|
||||
{title}
|
||||
</Title>
|
||||
<Divider />
|
||||
<Box p="md">{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const SlackAppsLink = () => {
|
||||
const { data: manifest } = useGetSlackManifestQuery();
|
||||
|
||||
const link = manifest
|
||||
? `/apps?new_app=1&manifest_yaml=${encodeURIComponent(manifest)}`
|
||||
: "/apps";
|
||||
const link = useMemo(() => {
|
||||
if (!manifest) {
|
||||
return "/apps";
|
||||
}
|
||||
const encodedManifest = encodeURIComponent(JSON.stringify(manifest));
|
||||
return `/apps?new_app=1&manifest_json=${encodedManifest}`;
|
||||
}, [manifest]);
|
||||
|
||||
if (!hasCompletedSetup) {
|
||||
return (
|
||||
<SettingsSection title={t`Create a Slack app and connect to it.`}>
|
||||
<Stack gap="md">
|
||||
<Markdown>
|
||||
{t`First, **click the button below** to create your Slack App using the Metabase configuration.`}
|
||||
</Markdown>
|
||||
<Box>
|
||||
<ButtonLink href={`https://api.slack.com${link}`}>
|
||||
<span>{t`Create Slack App`}</span>
|
||||
<Icon name="external" opacity={0.7} ml="md" />
|
||||
</ButtonLink>
|
||||
</Box>
|
||||
<Markdown>
|
||||
{t`First, click "**Install to workspace**" to authorize it, then copy the **Bot User OAuth Token** and paste it here.`}
|
||||
</Markdown>
|
||||
<SlackSetupForm
|
||||
initialValues={{
|
||||
"slack-app-token": slackAppToken ?? "",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonLink href={`https://api.slack.com${link}`}>
|
||||
<span>{t`Create Slack App`}</span>
|
||||
<Icon name="external" opacity={0.7} ml="md" />
|
||||
</ButtonLink>
|
||||
<SettingsSection stackProps={{ pt: "lg" }}>
|
||||
<Box>
|
||||
<SlackConnectionStatus
|
||||
isValid={isValid}
|
||||
docsUrl={docsUrl}
|
||||
token={slackAppToken}
|
||||
/>
|
||||
<Divider w="calc(100% + 4rem)" ml="-2rem" my="lg" />
|
||||
<SlackConfiguration />
|
||||
</Box>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,41 +4,59 @@ import {
|
||||
findRequests,
|
||||
setupPropertiesEndpoints,
|
||||
setupSettingsEndpoints,
|
||||
setupSlackAppInfoEndpoint,
|
||||
setupSlackManifestEndpoint,
|
||||
setupSlackSettingsEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import { createMockSettings } from "metabase-types/api/mocks";
|
||||
import { createMockSettingsState } from "metabase-types/store/mocks";
|
||||
|
||||
import { SlackSetup } from "./SlackSetup";
|
||||
|
||||
const setup = async ({
|
||||
bugReporting,
|
||||
botToken,
|
||||
}: {
|
||||
interface SetupOptions {
|
||||
configured?: boolean;
|
||||
isValid?: boolean;
|
||||
bugReporting?: boolean;
|
||||
botToken?: boolean;
|
||||
}) => {
|
||||
}
|
||||
|
||||
const setup = async ({
|
||||
configured = false,
|
||||
isValid = true,
|
||||
bugReporting = false,
|
||||
}: SetupOptions = {}) => {
|
||||
const settings = createMockSettings({
|
||||
"slack-app-token": null,
|
||||
"slack-token": botToken ? "bot-token" : null,
|
||||
"bug-reporting-enabled": !!bugReporting,
|
||||
"slack-app-token": configured ? "xoxb-test-token" : null,
|
||||
"slack-token-valid?": configured && isValid,
|
||||
"bug-reporting-enabled": bugReporting,
|
||||
});
|
||||
|
||||
setupPropertiesEndpoints(settings);
|
||||
setupSettingsEndpoints([]);
|
||||
|
||||
setupSlackSettingsEndpoint();
|
||||
setupSlackManifestEndpoint();
|
||||
setupSlackAppInfoEndpoint();
|
||||
|
||||
renderWithProviders(<SlackSetup />);
|
||||
renderWithProviders(<SlackSetup />, {
|
||||
storeInitialState: {
|
||||
settings: createMockSettingsState(settings),
|
||||
},
|
||||
});
|
||||
|
||||
await screen.findByText(/Activate the OAuth token/i);
|
||||
if (configured) {
|
||||
if (isValid) {
|
||||
await screen.findByText(/Slack app is working/i);
|
||||
} else {
|
||||
await screen.findByText(/Slack app is not working/i);
|
||||
}
|
||||
} else {
|
||||
await screen.findByText(/Create a Slack app and connect to it/i);
|
||||
}
|
||||
};
|
||||
|
||||
describe("SlackSetup", () => {
|
||||
it("should fetch the slack manifest", async () => {
|
||||
await setup({ bugReporting: false });
|
||||
await setup();
|
||||
|
||||
await waitFor(async () => {
|
||||
const gets = await findRequests("GET");
|
||||
@@ -49,62 +67,31 @@ describe("SlackSetup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should show notice about legacy bot tokens", async () => {
|
||||
await setup({ bugReporting: false, botToken: true });
|
||||
expect(
|
||||
await screen.findByText(/upgrade to Slack Apps/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show instructions to set up a slack app", async () => {
|
||||
await setup({ bugReporting: false });
|
||||
await setup();
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"1. Click the button below and create your Slack App",
|
||||
),
|
||||
await screen.findByText("Create a Slack app and connect to it."),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Create Slack App")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show token input", async () => {
|
||||
await setup({ bugReporting: false });
|
||||
await setup();
|
||||
|
||||
const input = await screen.findByLabelText("Slack bot user OAuth token");
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show bug reporting channel if bug reporting is enabled", async () => {
|
||||
await setup({ bugReporting: true });
|
||||
|
||||
expect(
|
||||
await screen.findByText("Public channel for bug reports"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show bug reporting channel if bug reporting is disabled", async () => {
|
||||
await setup({ bugReporting: false });
|
||||
|
||||
await screen.findByLabelText("Slack bot user OAuth token");
|
||||
expect(
|
||||
screen.queryByText("Public channel for bug reports"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should submit new slack settings", async () => {
|
||||
setup({ bugReporting: true });
|
||||
await setup();
|
||||
|
||||
const tokenInput = await screen.findByLabelText(
|
||||
"Slack bot user OAuth token",
|
||||
);
|
||||
await userEvent.type(tokenInput, "new-bot-token");
|
||||
|
||||
const channelInput = await screen.findByLabelText(
|
||||
"Public channel for bug reports",
|
||||
);
|
||||
await userEvent.type(channelInput, "new-bot-channel");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Save changes" });
|
||||
const submitButton = screen.getByRole("button", { name: "Connect" });
|
||||
expect(submitButton).toBeEnabled();
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
@@ -114,7 +101,89 @@ describe("SlackSetup", () => {
|
||||
expect(url).toContain("/api/slack/settings");
|
||||
expect(body).toEqual({
|
||||
"slack-app-token": "new-bot-token",
|
||||
"slack-bug-report-channel": "new-bot-channel",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show bug reporting channel if bug reporting is enabled", async () => {
|
||||
await setup({ configured: true, bugReporting: true });
|
||||
|
||||
expect(
|
||||
await screen.findByText("Slack bug report channel"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show bug reporting channel if bug reporting is disabled", async () => {
|
||||
await setup({ configured: true });
|
||||
|
||||
expect(
|
||||
screen.queryByText("Slack bug report channel"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show app icon configuration", async () => {
|
||||
await setup({ configured: true });
|
||||
|
||||
expect(screen.getByText("Slack app icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Download App Icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show invalid status badge", async () => {
|
||||
await setup({ configured: true, isValid: false });
|
||||
|
||||
expect(
|
||||
await screen.findByText("Slack app is not working."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show docs link when slack app is not working", async () => {
|
||||
await setup({ configured: true, isValid: false });
|
||||
expect(screen.getByText("See our docs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable submit button when token is empty", async () => {
|
||||
await setup();
|
||||
const submitButton = screen.getByRole("button", { name: "Connect" });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should have correct download link for app icon", async () => {
|
||||
await setup({ configured: true });
|
||||
const downloadLink = screen.getByRole("link", {
|
||||
name: "Download App Icon",
|
||||
});
|
||||
expect(downloadLink).toHaveAttribute(
|
||||
"href",
|
||||
"/app/assets/img/metabot-slackbot.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("should show link to Slack app settings", async () => {
|
||||
await setup({ configured: true });
|
||||
const link = screen.getByRole("link", { name: "Basic Information" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringMatching(/^https:\/\/api\.slack\.com\/apps/),
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow disconnecting the slack connection", async () => {
|
||||
await setup({ configured: true });
|
||||
|
||||
const disconnectButton = await screen.findByRole("button", {
|
||||
name: "Disconnect",
|
||||
});
|
||||
await userEvent.click(disconnectButton);
|
||||
|
||||
// Modal opens with another Disconnect button - get all and click the second one (confirm)
|
||||
const disconnectButtons = await screen.findAllByRole("button", {
|
||||
name: "Disconnect",
|
||||
});
|
||||
await userEvent.click(disconnectButtons[1]);
|
||||
|
||||
const puts = await findRequests("PUT");
|
||||
expect(puts).toHaveLength(1);
|
||||
const [{ url, body }] = puts;
|
||||
expect(url).toContain("api/slack/settings");
|
||||
expect(body).toEqual({ "slack-app-token": null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { t } from "ttag";
|
||||
import * as Yup from "yup";
|
||||
|
||||
import { useUpdateSlackSettingsMutation } from "metabase/api";
|
||||
import { useAdminSetting } from "metabase/api/utils";
|
||||
import {
|
||||
Form,
|
||||
FormErrorMessage,
|
||||
@@ -12,33 +11,21 @@ import {
|
||||
} from "metabase/forms";
|
||||
import * as Errors from "metabase/lib/errors";
|
||||
import { Flex, Stack } from "metabase/ui";
|
||||
import type { SlackSettings } from "metabase-types/api";
|
||||
|
||||
type FormValues = { "slack-app-token": string };
|
||||
|
||||
const SLACK_SCHEMA = Yup.object({
|
||||
"slack-app-token": Yup.string().ensure().required(Errors.required),
|
||||
"slack-bug-report-channel": Yup.string()
|
||||
.nullable()
|
||||
.default(null)
|
||||
.transform((value, originalValue) => (originalValue === "" ? null : value))
|
||||
.lowercase(),
|
||||
});
|
||||
|
||||
const DEFAULT_SETTINGS: SlackSettings = {
|
||||
"slack-app-token": "",
|
||||
"slack-bug-report-channel": "",
|
||||
};
|
||||
|
||||
export const SlackSetupForm = ({
|
||||
initialValues = DEFAULT_SETTINGS,
|
||||
initialValues = { "slack-app-token": "" },
|
||||
}: {
|
||||
initialValues?: SlackSettings;
|
||||
initialValues?: FormValues;
|
||||
}) => {
|
||||
const { value: isBugReportingEnabled } = useAdminSetting(
|
||||
"bug-reporting-enabled",
|
||||
);
|
||||
const [updateSlackSettings] = useUpdateSlackSettingsMutation();
|
||||
const handleSubmit = (values: SlackSettings) =>
|
||||
updateSlackSettings(SLACK_SCHEMA.cast(values) as SlackSettings).unwrap();
|
||||
const handleSubmit = (values: FormValues) =>
|
||||
updateSlackSettings(SLACK_SCHEMA.cast(values) as FormValues).unwrap();
|
||||
|
||||
return (
|
||||
<FormProvider
|
||||
@@ -48,24 +35,17 @@ export const SlackSetupForm = ({
|
||||
enableReinitialize
|
||||
>
|
||||
<Form>
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<FormTextInput
|
||||
name="slack-app-token"
|
||||
label={t`Slack bot user OAuth token`}
|
||||
placeholder="xoxb-123..."
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{isBugReportingEnabled && (
|
||||
<FormTextInput
|
||||
name="slack-bug-report-channel"
|
||||
label={t`Public channel for bug reports`}
|
||||
description={t`This channel will receive bug reports submitted by users.`}
|
||||
placeholder="metabase-bugs"
|
||||
/>
|
||||
)}
|
||||
<Flex justify="end">
|
||||
<FormSubmitButton label={t`Save changes`} />
|
||||
<Flex direction="row-reverse" justify="space-between">
|
||||
<FormSubmitButton label={t`Connect`} />
|
||||
<FormErrorMessage />
|
||||
</Flex>
|
||||
<FormErrorMessage />
|
||||
</Stack>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { jt, t } from "ttag";
|
||||
|
||||
import { AdminSettingInput } from "metabase/admin/settings/components/widgets/AdminSettingInput";
|
||||
import {
|
||||
useGetSettingsQuery,
|
||||
useUpdateSlackSettingsMutation,
|
||||
} from "metabase/api";
|
||||
import { ConfirmModal } from "metabase/common/components/ConfirmModal";
|
||||
import { ExternalLink } from "metabase/common/components/ExternalLink";
|
||||
import { useDocsUrl } from "metabase/common/hooks";
|
||||
import { Box, Button, Flex, Stack, Text } from "metabase/ui";
|
||||
|
||||
import { SlackBadge } from "./SlackBadge";
|
||||
|
||||
export const SlackStatus = () => {
|
||||
const [isOpened, { open: handleOpen, close: handleClose }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const { url: docsUrl } = useDocsUrl("configuring-metabase/slack");
|
||||
|
||||
const { data: settings } = useGetSettingsQuery();
|
||||
const isValid = settings?.["slack-token-valid?"] ?? false;
|
||||
const bugReportChannel = settings?.["slack-bug-report-channel"] ?? "";
|
||||
const bugReportingEnabled = settings?.["bug-reporting-enabled"] ?? false;
|
||||
|
||||
const [updateSlackSettings] = useUpdateSlackSettingsMutation();
|
||||
const handleDelete = () => {
|
||||
updateSlackSettings({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="space-between" align="center">
|
||||
<SlackBadge isValid={isValid} />
|
||||
{!isValid && (
|
||||
<Text ml="sm" inline>
|
||||
{jt`Need help? ${(
|
||||
<ExternalLink
|
||||
key="link"
|
||||
href={docsUrl}
|
||||
>{t`See our docs`}</ExternalLink>
|
||||
)}.`}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Stack my="xl" gap="lg">
|
||||
<AdminSettingInput
|
||||
name="slack-app-token"
|
||||
title={t`Slack token`}
|
||||
description={""}
|
||||
inputType="text"
|
||||
disabled
|
||||
/>
|
||||
<AdminSettingInput
|
||||
name="slack-bug-report-channel"
|
||||
title={t`Slack bug report channel`}
|
||||
inputType="text"
|
||||
hidden={!bugReportingEnabled || !bugReportChannel}
|
||||
disabled
|
||||
/>
|
||||
</Stack>
|
||||
<Box mt="xl">
|
||||
<Button onClick={handleOpen}>{t`Delete Slack App`}</Button>
|
||||
<ConfirmModal
|
||||
opened={isOpened}
|
||||
onClose={handleClose}
|
||||
title={t`Are you sure you want to delete your Slack App?`}
|
||||
message={t`Doing this may stop your dashboard subscriptions from appearing in Slack until a new connection is set up. Are you sure you want to delete your Slack App integration?`}
|
||||
confirmButtonText={t`Delete`}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import {
|
||||
findRequests,
|
||||
setupPropertiesEndpoints,
|
||||
setupSettingsEndpoints,
|
||||
setupSlackSettingsEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import { renderWithProviders, screen } from "__support__/ui";
|
||||
import { createMockSettings } from "metabase-types/api/mocks";
|
||||
|
||||
import { SlackStatus } from "./SlackStatus";
|
||||
|
||||
const setup = async ({
|
||||
isValid,
|
||||
bugReporting,
|
||||
}: {
|
||||
isValid: boolean;
|
||||
bugReporting?: boolean;
|
||||
}) => {
|
||||
const settings = createMockSettings({
|
||||
"slack-app-token": "app-token",
|
||||
"slack-token-valid?": isValid,
|
||||
"bug-reporting-enabled": !!bugReporting,
|
||||
"slack-bug-report-channel": bugReporting ? "bug-reporting-channel" : null,
|
||||
});
|
||||
|
||||
setupPropertiesEndpoints(settings);
|
||||
setupSettingsEndpoints([]);
|
||||
|
||||
setupSlackSettingsEndpoint();
|
||||
|
||||
renderWithProviders(<SlackStatus />);
|
||||
|
||||
await screen.findByText("Slack token");
|
||||
};
|
||||
|
||||
describe("SlackStatus", () => {
|
||||
it("should show valid status badge", async () => {
|
||||
await setup({ isValid: true });
|
||||
expect(await screen.findByText("Slack app is working")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should show invalid status badge", async () => {
|
||||
await setup({ isValid: false });
|
||||
expect(
|
||||
await screen.findByText("Slack app is not working."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should show disabled token input", async () => {
|
||||
await setup({ isValid: true });
|
||||
|
||||
const input = await screen.findByLabelText("Slack token");
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show bug reporting channel if bug reporting is enabled", async () => {
|
||||
await setup({ isValid: true, bugReporting: true });
|
||||
await screen.findByLabelText("Slack token");
|
||||
expect(
|
||||
await screen.findByText("Slack bug report channel"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show bug reporting channel if bug reporting is disabled", async () => {
|
||||
await setup({ isValid: true, bugReporting: false });
|
||||
await screen.findByLabelText("Slack token");
|
||||
expect(
|
||||
screen.queryByText("Slack Bug report channel"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should allow deleting the slack connection", async () => {
|
||||
await setup({ isValid: true, bugReporting: false });
|
||||
|
||||
const deleteButton = await screen.findByRole("button", {
|
||||
name: "Delete Slack App",
|
||||
});
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// modal confirm
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete" });
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
const puts = await findRequests("PUT");
|
||||
expect(puts).toHaveLength(1);
|
||||
const [{ url, body }] = puts;
|
||||
expect(url).toContain("api/slack/settings");
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
.SetupSection {
|
||||
border: 1px solid var(--mb-color-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.StatusBadge {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
margin-right: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
setupPropertiesEndpoints,
|
||||
setupSettingEndpoint,
|
||||
setupSettingsEndpoints,
|
||||
setupSlackAppInfoEndpoint,
|
||||
setupSlackManifestEndpoint,
|
||||
setupTokenStatusEndpoint,
|
||||
setupUploadManagementEndpoint,
|
||||
@@ -39,7 +40,11 @@ export const ossRoutes: RouteMap = {
|
||||
root: { path: "", testPattern: /site name/i },
|
||||
general: { path: "/general", testPattern: /site name/i },
|
||||
email: { path: "/email", testPattern: /SMTP/i },
|
||||
notifications: { path: "/notifications", testPattern: /Connect to Slack/i },
|
||||
slack: {
|
||||
path: "/slack",
|
||||
testPattern: /Create a Slack app and connect to it/i,
|
||||
},
|
||||
webhooks: { path: "/webhooks", testPattern: /Configure webhooks/i },
|
||||
authentication: {
|
||||
path: "/authentication",
|
||||
testPattern: /Sign in with Google/i,
|
||||
@@ -137,6 +142,7 @@ export const setup = async ({
|
||||
setupGroupsEndpoint([]);
|
||||
setupDatabasesEndpoints([]);
|
||||
setupSlackManifestEndpoint();
|
||||
setupSlackAppInfoEndpoint();
|
||||
setupUploadManagementEndpoint([]);
|
||||
setupUserKeyValueEndpoints({
|
||||
namespace: "user_acknowledgement",
|
||||
|
||||
@@ -19,10 +19,11 @@ import { GeneralSettingsPage } from "./settings/components/SettingsPages/General
|
||||
import { LicenseSettingsPage } from "./settings/components/SettingsPages/LicenseSettingsPage";
|
||||
import { LocalizationSettingsPage } from "./settings/components/SettingsPages/LocalizationSettingsPage";
|
||||
import { MapsSettingsPage } from "./settings/components/SettingsPages/MapsSettingsPage";
|
||||
import { NotificationSettingsPage } from "./settings/components/SettingsPages/NotificationSettingsPage";
|
||||
import { PublicSharingSettingsPage } from "./settings/components/SettingsPages/PublicSharingSettingsPage";
|
||||
import { SlackSettingsPage } from "./settings/components/SettingsPages/SlackSettingsPage";
|
||||
import { UpdatesSettingsPage } from "./settings/components/SettingsPages/UpdatesSettingsPage";
|
||||
import { UploadSettingsPage } from "./settings/components/SettingsPages/UploadSettingsPage";
|
||||
import { WebhooksSettingsPage } from "./settings/components/SettingsPages/WebhooksSettingsPage";
|
||||
|
||||
export const getSettingsRoutes = () => (
|
||||
<Route
|
||||
@@ -36,7 +37,8 @@ export const getSettingsRoutes = () => (
|
||||
<Route path="general" component={GeneralSettingsPage} />
|
||||
<Route path="updates" component={UpdatesSettingsPage} />
|
||||
<Route path="email" component={EmailSettingsPage} />
|
||||
<Route path="notifications" component={NotificationSettingsPage} />
|
||||
<Route path="slack" component={SlackSettingsPage} />
|
||||
<Route path="webhooks" component={WebhooksSettingsPage} />
|
||||
<Route
|
||||
path="authentication"
|
||||
component={() => <AuthenticationSettingsPage tab="authentication" />}
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import type { EnterpriseSettings } from "metabase-types/api";
|
||||
import type { SlackAppInfo, SlackSettings } from "metabase-types/api";
|
||||
|
||||
import { Api } from "./api";
|
||||
|
||||
type SlackSettings = Pick<
|
||||
EnterpriseSettings,
|
||||
"slack-app-token" | "slack-bug-report-channel" | "slack-token"
|
||||
>;
|
||||
|
||||
export const slackApi = Api.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getSlackManifest: builder.query<string, void>({
|
||||
query: (params) => ({
|
||||
getSlackManifest: builder.query<Record<string, unknown>, void>({
|
||||
query: () => ({
|
||||
method: "GET",
|
||||
url: "/api/slack/manifest",
|
||||
params,
|
||||
}),
|
||||
}),
|
||||
getSlackAppInfo: builder.query<SlackAppInfo, void>({
|
||||
query: () => ({
|
||||
method: "GET",
|
||||
url: "/api/slack/app-info",
|
||||
}),
|
||||
providesTags: ["slack-app-info"],
|
||||
}),
|
||||
updateSlackSettings: builder.mutation<void, Partial<SlackSettings>>({
|
||||
query: (settings) => ({
|
||||
method: "PUT",
|
||||
url: `/api/slack/settings`,
|
||||
body: settings,
|
||||
}),
|
||||
invalidatesTags: ["session-properties"],
|
||||
invalidatesTags: ["session-properties", "slack-app-info"],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetSlackManifestQuery, useUpdateSlackSettingsMutation } =
|
||||
slackApi;
|
||||
export const {
|
||||
useGetSlackManifestQuery,
|
||||
useGetSlackAppInfoQuery,
|
||||
useUpdateSlackSettingsMutation,
|
||||
} = slackApi;
|
||||
|
||||
@@ -38,6 +38,7 @@ export const TAG_TYPES = [
|
||||
"schema",
|
||||
"segment",
|
||||
"session-properties",
|
||||
"slack-app-info",
|
||||
"snippet",
|
||||
"subscription",
|
||||
"subscription-channel",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useToast } from "metabase/common/hooks";
|
||||
import type {
|
||||
@@ -131,55 +132,36 @@ export const useAdminSettings = <
|
||||
async ({
|
||||
toast = true,
|
||||
...settings
|
||||
}: {
|
||||
toast?: boolean;
|
||||
} & Partial<EnterpriseSettings>) => {
|
||||
}: { toast?: boolean } & Partial<EnterpriseSettings>) => {
|
||||
const response = await updateSettings(settings);
|
||||
|
||||
if (!toast) {
|
||||
return response;
|
||||
if (toast) {
|
||||
if (response.error) {
|
||||
const message =
|
||||
(response.error as { data?: { message: string } })?.data?.message ||
|
||||
t`Error saving settings`;
|
||||
|
||||
sendToast({ message, icon: "warning", toastColor: "danger" });
|
||||
} else {
|
||||
sendToast({ message: t`Changes saved`, icon: "check_filled" });
|
||||
}
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
const message =
|
||||
(response.error as { data?: { message: string } })?.data?.message ||
|
||||
t`Error saving settings`;
|
||||
|
||||
sendToast({ message, icon: "warning", toastColor: "danger" });
|
||||
} else {
|
||||
sendToast({ message: t`Changes saved`, icon: "check_filled" });
|
||||
}
|
||||
return response;
|
||||
},
|
||||
[updateSettings, sendToast],
|
||||
);
|
||||
|
||||
type Values = { [K in SettingNames[number]]: EnterpriseSettings[K] };
|
||||
const values = useMemo(() => {
|
||||
if (!settings) {
|
||||
return {} as Record<SettingNames[number], boolean>;
|
||||
}
|
||||
|
||||
return settingNames.reduce(
|
||||
(acc, key) => {
|
||||
acc[key as SettingNames[number]] = !!settings[key];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<SettingNames[number], boolean>,
|
||||
);
|
||||
return (settings ? _.pick(settings, ...settingNames) : {}) as Values;
|
||||
}, [settings, settingNames]);
|
||||
|
||||
type Details = { [K in SettingNames[number]]: SettingDefinition<K> };
|
||||
const details = useMemo(() => {
|
||||
if (!settingsDetails) {
|
||||
return {} as Record<SettingNames[number], SettingDefinition>;
|
||||
}
|
||||
|
||||
return settingNames.reduce(
|
||||
(acc, key) => {
|
||||
acc[key as SettingNames[number]] = settingsDetails[key];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<SettingNames[number], SettingDefinition>,
|
||||
);
|
||||
return (
|
||||
settingsDetails ? _.pick(settingsDetails, ...settingNames) : {}
|
||||
) as Details;
|
||||
}, [settingsDetails, settingNames]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -161,7 +161,10 @@ describe("useAdminSettings", () => {
|
||||
const valuesElement = await screen.findByTestId("values");
|
||||
|
||||
expect(valuesElement).toHaveTextContent(
|
||||
JSON.stringify({ "site-name": true, "site-url": true }),
|
||||
JSON.stringify({
|
||||
"site-name": "Metabased",
|
||||
"site-url": "https://metabase.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user