diff --git a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts index 7983f4c7..0b793961 100644 --- a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts +++ b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts @@ -32,13 +32,46 @@ export default defineAction({ description: 'The content of your new message.', variables: true, }, + { + label: 'Send as a bot?', + key: 'sendAsBot', + type: 'dropdown' as const, + required: false, + value: false, + description: 'If you choose no, this message will appear to come from you. Direct messages are always sent by bots.', + variables: false, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + } + ] + }, + { + label: 'Bot name', + key: 'botName', + type: 'string' as const, + required: true, + value: 'Automatisch', + description: 'Specify the bot name which appears as a bold username above the message inside Slack. Defaults to Automatisch.', + variables: true, + }, + { + label: 'Bot icon', + key: 'botIcon', + type: 'string' as const, + required: false, + description: 'Either an image url or an emoji available to your team (surrounded by :). For example, https://example.com/icon_256.png or :robot_face:', + variables: true, + }, ], async run($) { - const channelId = $.step.parameters.channel as string; - const text = $.step.parameters.message as string; - - const message = await postMessage($, channelId, text); + const message = await postMessage($); return message; }, diff --git a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.ts b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.ts index c19e61d0..fd1f35cc 100644 --- a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.ts +++ b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.ts @@ -1,23 +1,54 @@ import { IGlobalVariable } from '@automatisch/types'; +import { URL } from 'url'; -const postMessage = async ( - $: IGlobalVariable, - channelId: string, - text: string -) => { - const params = { +type TData = { + channel: string; + text: string; + username?: string; + icon_url?: string; + icon_emoji?: string; +} + +const postMessage = async ($: IGlobalVariable) => { + const { parameters } = $.step; + const channelId = parameters.channel as string; + const text = parameters.message as string; + const sendAsBot = parameters.sendAsBot as boolean; + const botName = parameters.botName as string; + const botIcon = parameters.botIcon as string; + + const data: TData = { channel: channelId, text, }; - const response = await $.http.post('/chat.postMessage', params); + if (sendAsBot) { + data.username = botName; + try { + // challenging the input to check if it is a URL! + new URL(botIcon); + data.icon_url = botIcon; + } catch { + data.icon_emoji = botIcon; + } + } + + const customConfig = { + sendAsBot, + }; + + const response = await $.http.post( + '/chat.postMessage', + data, + { additionalProperties: customConfig }, + ); if (response.data.ok === false) { throw new Error(JSON.stringify(response.data)); } const message = { - raw: response?.data?.message, + raw: response?.data, }; $.setActionItem(message); diff --git a/packages/backend/src/apps/slack/auth/create-auth-data.ts b/packages/backend/src/apps/slack/auth/create-auth-data.ts new file mode 100644 index 00000000..64006a74 --- /dev/null +++ b/packages/backend/src/apps/slack/auth/create-auth-data.ts @@ -0,0 +1,62 @@ +import { IField, IGlobalVariable } from '@automatisch/types'; +import qs from 'qs'; + +const scopes = [ + 'channels:manage', + 'channels:read', + 'channels:join', + 'chat:write', + 'chat:write.customize', + 'chat:write.public', + 'files:write', + 'im:write', + 'mpim:write', + 'team:read', + 'users.profile:read', + 'users:read', + 'workflow.steps:execute', + 'users:read.email', + 'commands', +]; +const userScopes = [ + 'channels:history', + 'channels:read', + 'channels:write', + 'chat:write', + 'emoji:read', + 'files:read', + 'files:write', + 'groups:history', + 'groups:read', + 'groups:write', + 'im:write', + 'mpim:write', + 'reactions:read', + 'reminders:write', + 'search:read', + 'stars:read', + 'team:read', + 'users.profile:read', + 'users.profile:write', + 'users:read', + 'users:read.email', +]; + +export default async function createAuthData($: IGlobalVariable) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field: IField) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value as string; + const searchParams = qs.stringify({ + client_id: $.auth.data.consumerKey as string, + redirect_uri: redirectUri, + scope: scopes.join(','), + user_scope: userScopes.join(','), + }); + + const url = `${$.app.baseUrl}/oauth/v2/authorize?${searchParams}`; + + await $.auth.set({ + url, + }); +}; diff --git a/packages/backend/src/apps/slack/auth/index.ts b/packages/backend/src/apps/slack/auth/index.ts index 0f36afcc..88569ba0 100644 --- a/packages/backend/src/apps/slack/auth/index.ts +++ b/packages/backend/src/apps/slack/auth/index.ts @@ -1,17 +1,41 @@ +import createAuthData from './create-auth-data'; import verifyCredentials from './verify-credentials'; import isStillVerified from './is-still-verified'; export default { fields: [ { - key: 'accessToken', - label: 'Access Token', + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string' as const, + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/slack/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Slack OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'consumerKey', + label: 'API Key', type: 'string' as const, required: true, readOnly: false, value: null, placeholder: null, - description: 'Access token of slack that Automatisch will connect to.', + description: null, + clickToCopy: false, + }, + { + key: 'consumerSecret', + label: 'API Secret', + type: 'string' as const, + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, clickToCopy: false, }, ], @@ -30,8 +54,12 @@ export default { value: null, properties: [ { - name: 'accessToken', - value: '{fields.accessToken}', + name: 'consumerKey', + value: '{fields.consumerKey}', + }, + { + name: 'consumerSecret', + value: '{fields.consumerSecret}', }, ], }, @@ -40,6 +68,53 @@ export default { { step: 2, type: 'mutation' as const, + name: 'createAuthData', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + ], + }, + { + step: 3, + type: 'openWithPopup' as const, + name: 'openAuthPopup', + arguments: [ + { + name: 'url', + value: '{createAuthData.url}', + }, + ], + }, + { + step: 4, + type: 'mutation' as const, + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'code', + value: '{openAuthPopup.code}', + }, + { + name: 'state', + value: '{openAuthPopup.state}', + }, + ], + }, + ], + }, + { + step: 5, + type: 'mutation' as const, name: 'verifyConnection', arguments: [ { @@ -75,8 +150,12 @@ export default { value: null, properties: [ { - name: 'accessToken', - value: '{fields.accessToken}', + name: 'consumerKey', + value: '{fields.consumerKey}', + }, + { + name: 'consumerSecret', + value: '{fields.consumerSecret}', }, ], }, @@ -85,6 +164,53 @@ export default { { step: 3, type: 'mutation' as const, + name: 'createAuthData', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + { + step: 4, + type: 'openWithPopup' as const, + name: 'openAuthPopup', + arguments: [ + { + name: 'url', + value: '{createAuthData.url}', + }, + ], + }, + { + step: 5, + type: 'mutation' as const, + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'code', + value: '{openAuthPopup.code}', + }, + { + name: 'state', + value: '{openAuthPopup.state}', + }, + ], + }, + ], + }, + { + step: 6, + type: 'mutation' as const, name: 'verifyConnection', arguments: [ { @@ -95,6 +221,7 @@ export default { }, ], + createAuthData, verifyCredentials, isStillVerified, }; diff --git a/packages/backend/src/apps/slack/auth/is-still-verified.ts b/packages/backend/src/apps/slack/auth/is-still-verified.ts index 95a1ef29..060ad5cd 100644 --- a/packages/backend/src/apps/slack/auth/is-still-verified.ts +++ b/packages/backend/src/apps/slack/auth/is-still-verified.ts @@ -1,10 +1,10 @@ import { IGlobalVariable } from '@automatisch/types'; -import verifyCredentials from './verify-credentials'; +import getCurrentUser from '../common/get-current-user'; const isStillVerified = async ($: IGlobalVariable) => { try { - await verifyCredentials($); - return true; + const user = await getCurrentUser($); + return !!user.id; } catch (error) { return false; } diff --git a/packages/backend/src/apps/slack/auth/verify-credentials.ts b/packages/backend/src/apps/slack/auth/verify-credentials.ts index 0152348a..3f3feefc 100644 --- a/packages/backend/src/apps/slack/auth/verify-credentials.ts +++ b/packages/backend/src/apps/slack/auth/verify-credentials.ts @@ -1,34 +1,51 @@ -import qs from 'qs'; import { IGlobalVariable } from '@automatisch/types'; +import getCurrentUser from '../common/get-current-user'; const verifyCredentials = async ($: IGlobalVariable) => { - const headers = { - 'Content-Type': 'application/x-www-form-urlencoded', + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value as string; + const params = { + code: $.auth.data.code, + client_id: $.auth.data.consumerKey, + client_secret: $.auth.data.consumerSecret, + redirect_uri: redirectUri, }; - - const stringifiedBody = qs.stringify({ - token: $.auth.data.accessToken, - }); - - const response = await $.http.post('/auth.test', stringifiedBody, { - headers, - }); + const response = await $.http.post('/oauth.v2.access', null, { params }); if (response.data.ok === false) { throw new Error( - `Error occured while verifying credentials: ${response.data.error}.(More info: https://api.slack.com/methods/auth.test#errors)` + `Error occured while verifying credentials: ${response.data.error}. (More info: https://api.slack.com/methods/oauth.v2.access#errors)` ); } - const { bot_id: botId, user: screenName } = response.data; + const { + bot_user_id: botId, + authed_user: { + id: userId, + access_token: userAccessToken, + }, + access_token: botAccessToken, + team: { + name: teamName, + } + } = response.data; - $.auth.set({ + await $.auth.set({ botId, - screenName, + userId, + userAccessToken, + botAccessToken, + screenName: teamName, token: $.auth.data.accessToken, }); - return response.data; + const currentUser = await getCurrentUser($); + + await $.auth.set({ + screenName: `${currentUser.real_name} @ ${teamName}` + }); }; export default verifyCredentials; diff --git a/packages/backend/src/apps/slack/common/add-auth-header.ts b/packages/backend/src/apps/slack/common/add-auth-header.ts index f4f2076f..89ab2eec 100644 --- a/packages/backend/src/apps/slack/common/add-auth-header.ts +++ b/packages/backend/src/apps/slack/common/add-auth-header.ts @@ -1,10 +1,21 @@ import { TBeforeRequest } from '@automatisch/types'; const addAuthHeader: TBeforeRequest = ($, requestConfig) => { - if (requestConfig.headers && $.auth.data?.accessToken) { - requestConfig.headers.Authorization = `Bearer ${$.auth.data.accessToken}`; + const authData = $.auth.data; + if ( + requestConfig.headers + && authData?.userAccessToken + && authData?.botAccessToken + ) { + if (requestConfig.additionalProperties?.sendAsBot) { + requestConfig.headers.Authorization = `Bearer ${authData.botAccessToken}`; + } else { + requestConfig.headers.Authorization = `Bearer ${authData.userAccessToken}`; + } } + requestConfig.headers['Content-Type'] = requestConfig.headers['Content-Type'] || 'application/json; charset=utf-8'; + return requestConfig; }; diff --git a/packages/backend/src/apps/slack/common/get-current-user.ts b/packages/backend/src/apps/slack/common/get-current-user.ts new file mode 100644 index 00000000..e0500f8f --- /dev/null +++ b/packages/backend/src/apps/slack/common/get-current-user.ts @@ -0,0 +1,13 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; + +const getCurrentUser = async ($: IGlobalVariable): Promise => { + const params = { + user: $.auth.data.userId as string, + } + const response = await $.http.get('/users.info', { params }); + const currentUser = response.data.user; + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/helpers/compute-parameters.ts b/packages/backend/src/helpers/compute-parameters.ts index 62a9925d..4e845db8 100644 --- a/packages/backend/src/helpers/compute-parameters.ts +++ b/packages/backend/src/helpers/compute-parameters.ts @@ -38,6 +38,9 @@ export default function computeParameters( }; } - return result; + return { + ...result, + [key]: value, + }; }, {}); } diff --git a/packages/docs/pages/apps/slack/connection.md b/packages/docs/pages/apps/slack/connection.md index 92db6c18..d0049944 100644 --- a/packages/docs/pages/apps/slack/connection.md +++ b/packages/docs/pages/apps/slack/connection.md @@ -6,18 +6,20 @@ This page explains the steps you need to follow to set up the Slack connection i 1. Go to the [link](https://api.slack.com/apps?new_app=1) to **create an app** on Slack API. -2. Select **From scratch**. -3. Enter **App name**. -4. Pick the workspace you would like to use with the Slack connection. -5. Click on **Create App** button. -6. Click the **Permissions** card on the **Add features and functionality** - section. -7. Go to **User Token Scopes** and add the necessary scopes. - ([more info](https://api.slack.com/scopes?filter=user)) -8. Go to **OAuth Tokens for Your Workspace** and click **Install to Workspace**. -9. In **Where should Sample post?** section, select the channel you would like - to use with Automatisch, and click **Allow**. -10. Copy **User OAuth Token** and paste it to **Access Token** on the - Automatisch page. -11. Click **Submit** button on Automatisch. -12. Now, you can start using the Slack connection with Automatisch. +1. Select **From scratch**. +1. Enter **App name**. +1. Pick the workspace you would like to use with the Slack connection. +1. Click on **Create App** button. +1. Copy **Client ID** and **Client Secret** values and save them to use later. +1. Go to **OAuth & Permissions** page. +1. Copy **OAuth Redirect URL** from Automatisch and add it in Redirect URLs. Don't forget to save it after adding it by clicking **Save URLs** button! +1. Go to **Bot Token Scopes** and add `chat:write.customize` along with `chat:write` scope to enable the bot functionality. + +:::warning HTTPS required! + +Slack does **not** allow non-secure URLs in redirect URLs. Therefore, you will need to serve Automatisch via HTTPS protocol. +::: + +10. Paste **Client ID** and **Client Secret** values you have saved earlier and paste them into Automatisch as **Consumer Key** and **Consumer Secret**, respectively. +1. Click **Submit** button on Automatisch. +1. Now, you can start using the Slack connection with Automatisch. diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 08446cf9..639d33a7 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -301,4 +301,8 @@ declare module 'axios' { interface AxiosResponse { httpError?: IJSONObject; } + + interface AxiosRequestConfig { + additionalProperties?: Record; + } }