Compare commits

...

114 Commits

Author SHA1 Message Date
Noah Moss
12ab5191a2 Share native Slack table implementation with alerts & subscriptions 2026-03-09 10:59:43 -04:00
Noah Moss
c62fbe8783 balence parens 2026-03-09 10:03:30 -04:00
Noah Moss
c88a4aab5a add user_id to metabot_message to drive deletion permissions 2026-03-09 10:03:30 -04:00
Noah Moss
e6678e126e minor cleanup 2026-03-09 10:03:30 -04:00
Noah Moss
ecbe793f4b Fix tests 2026-03-09 10:03:30 -04:00
Noah Moss
3406023905 Tests 2026-03-09 10:03:30 -04:00
Noah Moss
d3d30d0a2c Slackbot message deletion 2026-03-09 10:03:30 -04:00
Noah Moss
590bc552fd condense some code 2026-03-09 10:03:07 -04:00
Noah Moss
df589fc389 Update enterprise/backend/src/metabase_enterprise/metabot_v3/api/slackbot/channel.clj 2026-03-07 01:23:14 -05:00
Noah Moss
8c37f59145 more test porting 2026-03-06 18:13:25 -05:00
Noah Moss
09fb6281c9 more refactor 2026-03-06 16:29:54 -05:00
Noah Moss
da6db51779 simplify 2026-03-06 15:58:12 -05:00
Noah Moss
981ca07308 Viz delivery refactor for slackbot 2026-03-06 15:47:58 -05:00
Noah Moss
51c841b69f Ensure we respect slack api rate limits (#70409)
* try to respect slack streaming rate limits

* tests

* simplify

* lint

* try to fix tests, some tweaks

* bump ci
2026-03-05 12:45:30 -05:00
Sloan Sparger
c78a4f21df Merge branch 'master' into metabot/slackbot 2026-03-04 20:47:10 -06:00
Sloan Sparger
a26eadc223 [Slackbot] Enrich chat history (#70408)
* poc

* move code around

* fix tests, clean up logs

* clean up

* fix

* automated review feedback

* do not store messages on error

* test coverage

* lint fix
2026-03-04 20:22:24 -06:00
Noah Moss
374c2432cf Slackbot feedback buttons (#70280)
* WIP slack feedback buttons

* wire up feedback buttons

* tests

* feedback modal

* revert back to a single hm call

* fix parens

* revert an unexpected change

* small fixes

* fix tests

* tweaks

* fix rebase

* Update enterprise/backend/src/metabase_enterprise/metabot_v3/api/slackbot/streaming.clj

* fix rebase
2026-03-04 19:44:46 -05:00
Noah Moss
4441f64703 Improved titles for slackbot visualizations; misc other fixes (#70396)
* support context text for results

* viz captions with links; hold viz results until stream finishes

* tests; remove separate caption & description fields

* grab bag of fixes

* strip out user mention stuff from this branch

* caption->title

* fix build

* fix test

* polish

* some escaping logic for markdown links

* fix filename
2026-03-04 18:31:59 -05:00
Noah Moss
34fc4c1df4 add some truncation logic for slack blocks + long cell values 2026-03-03 18:27:39 -05:00
Sloan Sparger
05c08ee5d6 make metabot self-aware... of @mentions 2026-03-03 15:40:08 -06:00
Sloan Sparger
d991563e76 [Slackbot] Better static viz sizing (#70288)
* make slack user look up deterministic

* calc size of static viz based on display type

* fix doc string

* fix tests, add sankey support

* fix lint
2026-03-03 15:20:46 -06:00
Noah Moss
17ed32fae8 log actual oidc cookie 2026-03-03 15:46:44 -05:00
Sloan Sparger
2b6ea0cb5b make slack user look up deterministic 2026-03-03 14:40:05 -06:00
Noah Moss
b83b144e0a Slack SSO info logs in initiate & callback endpoints 2026-03-03 15:39:25 -05:00
Edward Paget
70cfb8ad11 fixup! set a proper response code in safari 2026-03-03 13:58:47 -06:00
Sloan Sparger
35fb1db6dd [Slackbot] Stable conversation ids (#70281)
* make convo ids stable for slack conversations

* fix lint

* fix missing paren

* Update enterprise/backend/test/metabase_enterprise/metabot_v3/api/slackbot_test.clj

Co-authored-by: appleby <86076+appleby@users.noreply.github.com>

---------

Co-authored-by: appleby <86076+appleby@users.noreply.github.com>
2026-03-03 09:32:15 -06:00
Noah Moss
01279cad55 Report QP errors to Slack, and run queries off the main thread (#70124)
* WIP - surface QP errors, don't block queries on text stream

* simplifications

* fix up a couple other tests that were corrupting values

* try to fix test

* cleanup

* more cleanup

* more cleanup

* Fix test

* small simplification

* more fixes

* timeouts

* simplify comment

* call shutdown-agents at the end of the core shutdown routine
2026-03-02 16:06:56 -05:00
Sloan Sparger
8100e919e8 fix threading for auth message in dms 2026-02-27 15:13:06 -06:00
Sloan Sparger
ab8eed92c7 kick ci 2026-02-26 16:48:43 -06:00
Sloan Sparger
960055f07c fix oauth flow requiring user page refresh 2026-02-26 16:10:48 -06:00
Edward Paget
0792409437 Migrate slack-connect SSO to dedicated /auth/sso/slack-connect URL pattern
Move slack-connect from the old unified /auth/sso multimethod dispatch to
dedicated GET /auth/sso/slack-connect and GET /auth/sso/slack-connect/callback
endpoints, aligning with the pattern used by custom OIDC providers.
2026-02-26 14:11:18 -05:00
Sloan Sparger
f8517521aa [Slackbot] Prevent channel response (#70136)
* add more tests, refactor

* bit more clean up

* ignore channel messages without @mention

* simplify, fix tests

* add file mock to tests

* move bot id fetch
2026-02-26 12:54:22 -06:00
Sloan Sparger
68fcc6f2ba [Slackbot] Adds check to prevent replay attacks (#70081)
* prevent relay attack

* fix syntax issue

* pr feedback

* lint fix
2026-02-26 12:53:54 -06:00
Sloan Sparger
791539b9d2 [Slackbot] Modularize (#70130)
modularize
2026-02-26 09:27:49 -06:00
Noah Moss
d230cf86ed Slackbot test refactor + fix overwriting encrypted settings (#70113)
* Fix encryption overwriting in tests; small cleanup

* more refactor
2026-02-25 11:03:56 -05:00
Sloan Sparger
daddf34807 [Slackbot] Render unsupported static viz as tables (#70077)
render unsupported static viz as tables
2026-02-25 10:00:54 -06:00
Sloan Sparger
6b71b9a675 rip out overrides, streaming api reverts the defaults so it flashes images if they differ from the one saved for the slack app 2026-02-24 17:10:25 -06:00
Noah Moss
27eec0e565 Add a thinking... placeholder before stream starts + do initial Slack requests in futures 2026-02-24 17:59:32 -05:00
Sloan Sparger
e413380c1d prevent tests from clearing out setting values 2026-02-24 16:45:03 -06:00
Edward Paget
8768bd104d Merge branch 'master' into metabot/slackbot 2026-02-24 16:43:39 -06:00
Noah Moss
f6c9ee2eb7 Rework + simplify streaming using an agent to try to reduce burstiness 2026-02-24 17:31:41 -05:00
Sloan Sparger
e3dcf70bf2 embarrassing 2026-02-24 16:08:44 -06:00
Sloan Sparger
86e41d95f5 fix streaming not having icon_url set and make sure that channel level @mentions has the bot reply with auth request at channel level too 2026-02-24 15:42:00 -06:00
Sloan Sparger
62adf62d18 properly destructure api calls for streaming / auth 2026-02-24 15:13:03 -06:00
Noah Moss
37196eb22f Slackbot streaming (#69947)
* [wip] Slackbot streaming

* simplify, lint

* Fix tests

* Use a volatile

* fix tests

* refactors, cleanup

* more cleanup

* fix tool name

* Better stream-related error handling + tests

* add another test
2026-02-24 13:05:52 -05:00
Sloan Sparger
e60fdf94ec Merge branch 'master' into metabot/slackbot 2026-02-24 10:10:59 -06:00
Sloan Sparger
3eba3ed18b Merge slackbots and settings pages (#69782)
* wip

* more progress

* conslidate manifest endpoints

* remove slack-token

* handle missing scopes case

* respect slackbot feature toggle in event handler

* other-sso-enabled? now takes into account sso mode

* new slack app-info endpoint for getting helpful metadata for the connected slack app, update links on FE to more direct location in slack

* replaced "metabot-slack-bot-enabled" with "slack-connect-enabled"

* misc. small fixes

* move slack scopes endpoint to app-info endpoint, update copy, misc ui improvements

* fix slack events api endpoint to still ack events if metabot later becomes disabled

* first step now configured to show obscured token, remove old modal component that was unused

* improve app icon section

* small design pass and linkage improvement to step 3

* new settings endpoint for metabot slackbot settings, updated FE component and form validation

* improve slack bug channel input, also update api endpoint so that slack setting values can be set seperately

* obfuscate client secret and signing secret

* improve the webhooks page a bit

* inline save / delete btuton for slack oauth token input

* allow removing metabot related slack settings in the ui

* self-review

* fix kondo

* fix some be tests

* test fixes, link fixes, page reorg

* poll for updates

* fix some e2e tests

* show info button w/ bot token info on hover

* updated profile image for slackbot

* fix be tests

* fix unit tests

* fix be test

* add unit test converage for base setup

* remove SlackSettingsPage which had duplicate tests

* add test coverage for settings endpoint

* new settings ui tests

* remove todo comment

* set default icon / name for slackbot

* make slack-get match slack-post response format

* show api errors better in slack metabot form

* impl set-many feedback

* fix requiring resolve code smell

* log error based on feedback

* handle error for app-info
2026-02-24 09:44:27 -06:00
Noah Moss
df3733d6f3 Merge branch 'master' into metabot/slackbot 2026-02-19 13:19:57 -05:00
Noah Moss
18b6071fd1 Slackbot table output format (#69511)
* WIP - table ouptut for Slack metabot

* lint

* WIP support for pre-fetched data

* WIP

* lint

* add slack-connect-enabled to do-with-other-sso-types-disabled

* fix another test

* change slack-connect-enabled default to false

* simplify approach

* Misc fixups

* more cleanup

* fix another test

* small readability improvements

* Move some of the table data preparation stuff to a new namespace to share with  existing slack/email rendering code

* fix modules lint

* extract table-block components and include in thread history

* revert query results changes (for now) and just drive slack table formatting via viz type selection

* truncation message

* truncation message test case

* rollback more execute-query stuff

* fix build

* temporarily comment out extract-table-blocks stuff

* fix test
2026-02-19 11:56:09 -05:00
appleby
8dcf0895f0 BOT-1026 Pass slack-channel-id in the ai-service request context (#69828)
* BOT-1026 Pass slack-channel-id in the ai-service request context

For ai-service requests originating from slackbot, send along the slack channel id. The ai-service tools will also be
updated to use this channel id when creating dashboard subscriptions or alerts.

* Add make-ai-request-args-test
2026-02-18 18:11:24 -08:00
appleby
66c46b0a87 BOT-985 Add create-alert metabot tool for slackbot (#69533)
* Add create-alert metabot tool

To allow the agent to create alerts for a given card on a given schedule, similar to the existing
create-dashboard-subscription tool.

* Add notification.api/create-notification! and call it from metabot_v3.tools/create-alert

This ensures that create-alert does the same perms checks and send the same verification email and notifications as
the main api endpoint.

* Improve ::create-alert-arguments malli schema

Use :multi with :dispatch on :channel_type to make it clear that email is required when :channel_type is "email" and
likewise :slack_channel is required when :channel_type is "slack"

* Destructure email in create-alert args

* handle-agent-error in create-alert

* Drop support for email subscriptions

Since this is only going to be used from the slackbot profile, we'll only support sending subscriptions to slack
channels for now.

* Add api test for create-alert metabot tool endpoint

* Fix bad conflict resolution from rebase
2026-02-18 18:04:32 -08:00
appleby
66940a924a BOT-984 Add slack channel support to create-dashboard-subscription tool (#69371)
* Add slack channel support to create-dashboard-subscription tool

* Fix render-html-to-png

We recently added a new 3-arg arity, but were only passing along the :content of the ::body/RenderedPartCard rather
than the full map, resulting in a malli schema error due to dropping the required :attachments key for any callers of
the 2-arg arity, e.g. when trying to send a slack notification.

    clojure.lang.ExceptionInfo: Invalid input: {:attachments ["missing required key, got: nil"]}
	at metabase.channel.render.png$fn__107479$fn__107505.invoke(png.clj:124)
	at metabase.channel.render.png$fn__107479$render_html_to_png107478__107482.invoke(png.clj:128)
	at metabase.channel.render.png$fn__107479$fn__107505.invoke(png.clj:124)
	at metabase.channel.render.card$fn__108322$png_from_render_info108321__108323.invoke(card.clj:302)
	at metabase.channel.render.card$fn__108322$fn__108325.invoke(card.clj:298)
	at metabase.channel.impl.slack$part__GT_sections_BANG_.invokeStatic(slack.clj:117)
	at metabase.channel.impl.slack$part__GT_sections_BANG_.invoke(slack.clj:87)

* Add find-cached-slack-channel-or-username

* Validate slack-channel in create-dashboard-subscription

Serves the dual purpose of making sure the channel exists and also normalizing the name to the display-name with
leading # which is how channels selected in the UI are stored.

* Drop support for email subscriptions

Since this is only going to be used from the slackbot profile, we'll only support sending subscriptions to slack
channels for now.

* handle-agent-error in create-dashboard-subscription

* Add create-pulse-with-perm-checks! and call that from create-dashboard-subscription tool

To ensure the same permission checks and other logic is used as when creating a pulse from the UI or API.

* Do no export pulse.models.create-pulse! from pulse.core

The only consumer was metabot's create-dashboard-subscription which now calls create-pulse-with-perm-checks! instead.
2026-02-18 17:44:28 -08:00
Tyler Plude
56bfc925a6 metabot/slackbot - Adjust CSV upload support (#69832)
* init

* remove debug def
2026-02-17 16:24:26 -08:00
Mike Appleby
8dd2c3452b Require user to be active in slack-id->user-id 2026-02-16 15:35:36 -08:00
Mike Appleby
ab0914a046 Fix failing metabase.core.modules-test/module-uses-should-be-sorted-test 2026-02-13 16:46:07 -08:00
Sloan Sparger
2a2dea86eb default enable slack sso 2026-02-11 12:07:56 -06:00
Sloan Sparger
6a4f734cd3 skip channel messages 2026-02-11 11:20:28 -06:00
Tyler Plude
ab0975e48c test fix 2026-02-10 16:23:22 -08:00
Sloan Sparger
4018419dd8 [Metabot] Slackbot channel support (#69391)
* channel support

* ignore edited messages

* add tests

* lint fix

* lint fix

* pr feedback
2026-02-10 17:54:20 -06:00
Sloan Sparger
0f4704d98c Merge branch 'master' into metabot/slackbot 2026-02-10 17:26:55 -06:00
Noah Moss
df9dff5114 Ad-hoc querying for slackbot (#69315)
* basic ad-hoc query support

* fix capabilities

* cleanup + another test

* more test fixes

* fix some kondo

* move middleware auth tests to a new EE namespace

* minor fixes

* fix tests

* remove unneeded transforms capability

* remove some debug code
2026-02-10 15:54:03 -05:00
Tyler Plude
b21c844ce7 fixes & cleanups 2026-02-09 16:22:14 -08:00
Tyler Plude
3291c09647 tweaks 2026-02-09 14:55:56 -08:00
Tyler Plude
ccc8dcbdef tweaks 2026-02-09 13:02:35 -08:00
Tyler Plude
ce5064a41b Merge branch 'metabot/slackbot' of https://www.github.com/metabase/metabase into metabot/slackbot 2026-02-09 08:08:36 -08:00
Tyler Plude
6a84eccf17 WIP csv uploads 2026-02-09 08:06:27 -08:00
Sloan Sparger
5eaf8a8fc9 improve settings page 2026-02-09 09:44:19 -06:00
Mike Appleby
cc2b0de9e9 Rename SlackHandledMessageEvent to SlackKnownMessageEvent
And fix the order of types in SlackKnownMessageEvent
2026-02-06 12:19:33 -08:00
Sloan Sparger
76ccdd1e66 oops 2026-02-06 13:30:23 -06:00
Sloan Sparger
f74b6efa24 improve auth flow 2026-02-06 13:19:44 -06:00
Mike Appleby
7e8e39ee45 BOT-852 Add malli schemas for slack event types 2026-02-05 22:20:18 -08:00
Sloan Sparger
a997cb7936 clean up dev comment form 2026-02-05 21:21:45 -06:00
Sloan Sparger
13c7b165a6 make setup instructions clearer 2026-02-05 21:16:15 -06:00
Mike Appleby
c6732a29c8 Fix busted tests due to missing site-url
Site-url needs to be added to with-slackbot-setup and do-with-setup-override since I added site-url to
metabase-enterprise.metabot-v3.api.slackbot/setup-complete?

These tests passed when run from the REPL because I had site-url configured locally, but failed when run from the cli.
2026-02-05 17:22:58 -08:00
Mike Appleby
8bfd913f8c Ensure :test-users are initialized in slackbot tests 2026-02-05 17:04:50 -08:00
Sloan Sparger
1ca48d98fb make image rendering larger 2026-02-05 17:43:27 -06:00
Mike Appleby
03bab6a2c8 Add reminder to set MB_SLACK_CONNECT_ENABLED 2026-02-04 15:29:18 -08:00
Mike Appleby
884c7d1a56 Ensure site-url is configured and fix failing test 2026-02-04 15:29:18 -08:00
Mike Appleby
2a0cdc10f5 BOT-853 Add malli schemas to slackbot defendpoints 2026-02-04 15:29:18 -08:00
Sloan Sparger
61cd2f43d3 temp staging deploy fix? 2026-02-04 16:12:58 -06:00
Mike Appleby
25b668f867 Fix kondo whitespace lint 2026-02-04 14:52:33 -06:00
Mike Appleby
f17218806c Re-enable user-not-linked-sends-auth-message-test
This deftest got accidentally slurped into the preceding one in 07eb42c76f4. Extract it back out into the toplevel.
2026-02-04 14:52:33 -06:00
Sloan Sparger
d462d3fd10 add instructions to add bot image 2026-02-04 14:52:33 -06:00
Sloan Sparger
f08ad6040e ensure slackbot is only enabled if setup is complete 2026-02-04 14:52:33 -06:00
Sloan Sparger
887b8296d5 refine sso flow, update temp admin form to collect info we need 2026-02-04 14:52:33 -06:00
Sloan Sparger
7bf94f99ee fix failing verification step 2026-02-04 14:52:33 -06:00
Sloan Sparger
a8e8dcabb2 validate slack bot token 2026-02-04 14:52:33 -06:00
Sloan Sparger
232fa7fe2a throw down a basic settings ui page 2026-02-04 14:52:33 -06:00
Sloan Sparger
242f875476 fix builds 2026-02-04 14:52:33 -06:00
Mike Appleby
88c5c8fb3b Rename client :bot-token to :token
Currently only used for endpoints that expect a bot token, but in principal these could be used for other endpoints
that might expect, e.g. an app config token to update the app manifest.
2026-02-04 14:52:33 -06:00
Mike Appleby
3850a6a9d9 Make all defs except endpoints and routes private 2026-02-04 14:52:32 -06:00
Mike Appleby
c40221e955 Use :on-complete handler to capture lines in make-ai-request 2026-02-04 14:52:32 -06:00
Mike Appleby
0050daaf5c Fix reflection warning in user-message-with-visualizations-test 2026-02-04 14:52:32 -06:00
Mike Appleby
9c9c3fe8f5 Fix reflection warning for metabase.server.streaming_response/StreamingResponse 2026-02-04 14:52:32 -06:00
Mike Appleby
380dc9ef3a Fix kondo lint warning about undeclared channel module dep 2026-02-04 14:52:32 -06:00
Mike Appleby
099d32a2fc smol refactor to clean up process-user-message 2026-02-04 14:52:32 -06:00
Mike Appleby
6f20e43d42 Use request/with-current-user rather than binding api/*current-user* directly
This is the preferred way to set the user and handles binding things like api/*is-superuser?* and
api/*current-user-permissions-set* as well.
2026-02-04 14:52:32 -06:00
Mike Appleby
a636d42dfa dev comments: fix assert when updating manifest and encourage env vars for dev secrets 2026-02-04 14:52:32 -06:00
Mike Appleby
af762bf464 Add some more repl testing comments 2026-02-04 14:52:32 -06:00
Mike Appleby
debfd7019d Ensure user has linked slack and metabase accounts before sending messages
And send them an ephemeral message with a (currently invalid) link to authorize
2026-02-04 14:52:32 -06:00
Mike Appleby
36745b0599 Re-enable +auth middleware for endpoints in metabase-enterprise.metabot-v3.api 2026-02-04 14:52:32 -06:00
Sloan Sparger
e84f1554f4 allow for rendering adhoc questions and surrounding the static viz with padding 2026-02-04 14:52:32 -06:00
Sloan Sparger
3d72dd59d5 fix convo history 2026-02-04 14:52:32 -06:00
Sloan Sparger
f2707dc830 add test coverage for messages with visualizations 2026-02-04 14:52:31 -06:00
Sloan Sparger
a3a5075eb3 fix image uploads not working 2026-02-04 14:52:31 -06:00
Sloan Sparger
9f8ba5a7af add test coverage for user messages 2026-02-04 14:52:31 -06:00
Sloan Sparger
f6d22ac259 add test coverage for endpoints only being available with metabot token feature 2026-02-04 14:52:31 -06:00
Sloan Sparger
65a2e9d5b6 add test coverage for validation of slack requests 2026-02-04 14:52:31 -06:00
Sloan Sparger
29c7b19a9d image uploading and clean up 2026-02-04 14:52:31 -06:00
Sloan Sparger
d7aeab3acf add loading / thinking state 2026-02-04 14:52:31 -06:00
Sloan Sparger
111c047e49 get ai response working, make slack app an agent, fetch threads 2026-02-04 14:52:31 -06:00
Sloan Sparger
8ca1ef1bf3 validate incoming slack requests and better sending of messages / processing responses 2026-02-04 14:52:30 -06:00
Sloan Sparger
545b35ef37 clean up and move secrets to settings 2026-02-04 14:52:30 -06:00
Sloan Sparger
aba9cced80 wip 2026-02-04 14:52:30 -06:00
156 changed files with 8049 additions and 1592 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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();

View File

@@ -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();

View File

@@ -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");
});
});

View File

@@ -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");
});

View File

@@ -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",
);
});
});

View File

@@ -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.

View File

@@ -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)

View File

@@ -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))))

View File

@@ -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)))

View File

@@ -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))))))))))

View File

@@ -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)))

View File

@@ -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}))

View File

@@ -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))

View File

@@ -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}))))

View File

@@ -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})))

View File

@@ -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 "&" "&amp;")
(str/replace "<" "&lt;")
(str/replace ">" "&gt;")
(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!})))))

View File

@@ -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."})))

View File

@@ -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]

View File

@@ -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."

View File

@@ -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)}))))

View File

@@ -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)))))

View File

@@ -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))

View File

@@ -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

View File

@@ -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}))))))

View File

@@ -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}))))))

View File

@@ -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}))

View File

@@ -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)}))

View File

@@ -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]

View File

@@ -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)]

View File

@@ -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)))

View File

@@ -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-]*"}])

View File

@@ -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.")})

View File

@@ -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)}))

View File

@@ -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))))

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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)))))))))))

View File

@@ -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}

View File

@@ -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")))))))))

View File

@@ -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"))))))))))

View File

@@ -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

View File

@@ -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])))))))))

View File

@@ -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)

View File

@@ -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"

View File

@@ -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)]

View File

@@ -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 --------------------------------------------------

View File

@@ -2,4 +2,3 @@ export {
aiStreamingQuery,
findMatchingInflightAiStreamingRequests,
} from "./requests";
export type { JSONValue } from "./types";

View File

@@ -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",

View File

@@ -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

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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();
});
});
});
});

View File

@@ -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>
);
}

View File

@@ -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;
};

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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";

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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
*/

View File

@@ -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;
}

View File

@@ -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>;

View File

@@ -18,7 +18,6 @@ export const Default: StoryObj<DeprecationNoticeProps> = {
},
args: {
hasSlackBot: true,
hasDeprecatedDatabase: true,
isEnabled: true,
},

View File

@@ -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;

View File

@@ -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),
});

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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>
</>
);
};

View File

@@ -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;
}) => {

View File

@@ -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.`;
}
};

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -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 });
});
});

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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({});
});
});

View File

@@ -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%;
}

View File

@@ -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",

View File

@@ -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" />}

View File

@@ -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;

View File

@@ -38,6 +38,7 @@ export const TAG_TYPES = [
"schema",
"segment",
"session-properties",
"slack-app-info",
"snippet",
"subscription",
"subscription-channel",

View File

@@ -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 {

View File

@@ -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