diff --git a/packages/backend/src/apps/mattermost/actions/index.ts b/packages/backend/src/apps/mattermost/actions/index.ts new file mode 100644 index 00000000..d28a6432 --- /dev/null +++ b/packages/backend/src/apps/mattermost/actions/index.ts @@ -0,0 +1,3 @@ +import sendMessageToChannel from './send-a-message-to-channel'; + +export default [sendMessageToChannel]; diff --git a/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/index.ts b/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/index.ts new file mode 100644 index 00000000..39734a99 --- /dev/null +++ b/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/index.ts @@ -0,0 +1,42 @@ +import defineAction from '../../../../helpers/define-action'; +import postMessage from './post-message'; + +export default defineAction({ + name: 'Send a message to channel', + key: 'sendMessageToChannel', + description: 'Sends a message to a channel you specify.', + arguments: [ + { + label: 'Channel', + key: 'channel', + type: 'dropdown' as const, + required: true, + description: 'Pick a channel to send the message to.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listChannels', + }, + ], + }, + }, + { + label: 'Message text', + key: 'message', + type: 'string' as const, + required: true, + description: 'The content of your new message.', + variables: true, + }, + ], + + async run($) { + const message = await postMessage($); + + return message; + }, +}); diff --git a/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/post-message.ts b/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/post-message.ts new file mode 100644 index 00000000..86bb7586 --- /dev/null +++ b/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/post-message.ts @@ -0,0 +1,27 @@ +import { IGlobalVariable } from '@automatisch/types'; + +type TData = { + channel_id: string; + message: string; +}; + +const postMessage = async ($: IGlobalVariable) => { + const { parameters } = $.step; + const channel_id = parameters.channel as string; + const message = parameters.message as string; + + const data: TData = { + channel_id, + message, + }; + + const response = await $.http.post('/api/v4/posts', data); + + const actionData = { + raw: response?.data, + }; + + $.setActionItem(actionData); +}; + +export default postMessage; diff --git a/packages/backend/src/apps/mattermost/assets/favicon.svg b/packages/backend/src/apps/mattermost/assets/favicon.svg new file mode 100644 index 00000000..1d5bf91f --- /dev/null +++ b/packages/backend/src/apps/mattermost/assets/favicon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/mattermost/auth/generate-auth-url.ts b/packages/backend/src/apps/mattermost/auth/generate-auth-url.ts new file mode 100644 index 00000000..a497e54a --- /dev/null +++ b/packages/backend/src/apps/mattermost/auth/generate-auth-url.ts @@ -0,0 +1,18 @@ +import { IGlobalVariable } from '@automatisch/types'; +import { URL, URLSearchParams } from 'url'; +import getBaseUrl from '../common/get-base-url'; + +export default async function generateAuthUrl($: IGlobalVariable) { + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId as string, + redirect_uri: $.auth.data.oAuthRedirectUrl as string, + response_type: 'code', + }); + + const baseUrl = getBaseUrl($); + const path = `/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url: new URL(path, baseUrl).toString(), + }); +} diff --git a/packages/backend/src/apps/mattermost/auth/index.ts b/packages/backend/src/apps/mattermost/auth/index.ts new file mode 100644 index 00000000..853cf1eb --- /dev/null +++ b/packages/backend/src/apps/mattermost/auth/index.ts @@ -0,0 +1,57 @@ +import generateAuthUrl from './generate-auth-url'; +import verifyCredentials from './verify-credentials'; +import isStillVerified from './is-still-verified'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string' as const, + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/mattermost/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Mattermost OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'instanceUrl', + label: 'Mattermost instance URL', + type: 'string' as const, + required: false, + readOnly: false, + value: null, + placeholder: null, + description: 'Your Mattermost instance URL', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client id', + type: 'string' as const, + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client secret', + type: 'string' as const, + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/mattermost/auth/is-still-verified.ts b/packages/backend/src/apps/mattermost/auth/is-still-verified.ts new file mode 100644 index 00000000..befb7694 --- /dev/null +++ b/packages/backend/src/apps/mattermost/auth/is-still-verified.ts @@ -0,0 +1,9 @@ +import { IGlobalVariable } from '@automatisch/types'; +import getCurrentUser from '../common/get-current-user'; + +const isStillVerified = async ($: IGlobalVariable) => { + const user = await getCurrentUser($); + return !!user.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/mattermost/auth/verify-credentials.ts b/packages/backend/src/apps/mattermost/auth/verify-credentials.ts new file mode 100644 index 00000000..da0538ea --- /dev/null +++ b/packages/backend/src/apps/mattermost/auth/verify-credentials.ts @@ -0,0 +1,44 @@ +import { IGlobalVariable } from '@automatisch/types'; +import getCurrentUser from '../common/get-current-user'; + +const verifyCredentials = async ($: IGlobalVariable) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value as string; + const params = { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }; + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', // This is not documented yet required + }; + const response = await $.http.post('/oauth/access_token', null, { + params, + headers, + }); + + const { + data: { access_token, refresh_token, scope, token_type }, + } = response; + + $.auth.data.accessToken = response.data.access_token; + + const currentUser = await getCurrentUser($); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + accessToken: access_token, + refreshToken: refresh_token, + scope: scope, + tokenType: token_type, + userId: currentUser.id, + screenName: currentUser.username, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/mattermost/common/add-auth-header.ts b/packages/backend/src/apps/mattermost/common/add-auth-header.ts new file mode 100644 index 00000000..edbff231 --- /dev/null +++ b/packages/backend/src/apps/mattermost/common/add-auth-header.ts @@ -0,0 +1,12 @@ +import { TBeforeRequest } from '@automatisch/types'; + +const addAuthHeader: TBeforeRequest = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers = requestConfig.headers || {}; + requestConfig.headers.Authorization = `Bearer ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/mattermost/common/add-x-requested-with-header.ts b/packages/backend/src/apps/mattermost/common/add-x-requested-with-header.ts new file mode 100644 index 00000000..65d89643 --- /dev/null +++ b/packages/backend/src/apps/mattermost/common/add-x-requested-with-header.ts @@ -0,0 +1,11 @@ +import { TBeforeRequest } from '@automatisch/types'; + +const addXRequestedWithHeader: TBeforeRequest = ($, requestConfig) => { + // This is not documented yet required + // ref. https://forum.mattermost.com/t/solved-invalid-or-expired-session-please-login-again/6772 + requestConfig.headers = requestConfig.headers || {}; + requestConfig.headers['X-Requested-With'] = `XMLHttpRequest`; + return requestConfig; +}; + +export default addXRequestedWithHeader; diff --git a/packages/backend/src/apps/mattermost/common/get-base-url.ts b/packages/backend/src/apps/mattermost/common/get-base-url.ts new file mode 100644 index 00000000..77538eca --- /dev/null +++ b/packages/backend/src/apps/mattermost/common/get-base-url.ts @@ -0,0 +1,7 @@ +import { IGlobalVariable } from '@automatisch/types'; + +const getBaseUrl = ($: IGlobalVariable): string => { + return $.auth.data.instanceUrl as string; +}; + +export default getBaseUrl; diff --git a/packages/backend/src/apps/mattermost/common/get-current-user.ts b/packages/backend/src/apps/mattermost/common/get-current-user.ts new file mode 100644 index 00000000..c6f42624 --- /dev/null +++ b/packages/backend/src/apps/mattermost/common/get-current-user.ts @@ -0,0 +1,9 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; + +const getCurrentUser = async ($: IGlobalVariable): Promise => { + const response = await $.http.get('/api/v4/users/me'); + const currentUser = response.data; + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/mattermost/common/set-base-url.ts b/packages/backend/src/apps/mattermost/common/set-base-url.ts new file mode 100644 index 00000000..8f3aab56 --- /dev/null +++ b/packages/backend/src/apps/mattermost/common/set-base-url.ts @@ -0,0 +1,9 @@ +import { TBeforeRequest } from '@automatisch/types'; + +const setBaseUrl: TBeforeRequest = ($, requestConfig) => { + requestConfig.baseURL = $.auth.data.instanceUrl as string; + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/mattermost/dynamic-data/index.ts b/packages/backend/src/apps/mattermost/dynamic-data/index.ts new file mode 100644 index 00000000..fae496fc --- /dev/null +++ b/packages/backend/src/apps/mattermost/dynamic-data/index.ts @@ -0,0 +1,3 @@ +import listChannels from './list-channels'; + +export default [listChannels]; diff --git a/packages/backend/src/apps/mattermost/dynamic-data/list-channels/index.ts b/packages/backend/src/apps/mattermost/dynamic-data/list-channels/index.ts new file mode 100644 index 00000000..35cde864 --- /dev/null +++ b/packages/backend/src/apps/mattermost/dynamic-data/list-channels/index.ts @@ -0,0 +1,36 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; + +type TChannel = { + id: string; + display_name: string; +}; + +type TResponse = { + data: TChannel[]; +}; + +export default { + name: 'List channels', + key: 'listChannels', + + async run($: IGlobalVariable) { + const channels: { + data: IJSONObject[]; + error: IJSONObject | null; + } = { + data: [], + error: null, + }; + + const response: TResponse = await $.http.get('/api/v4/users/me/channels'); // this endpoint will return only channels user joined, there is no endpoint to list all channels available for user + + for (const channel of response.data) { + channels.data.push({ + value: channel.id as string, + name: (channel.display_name as string) || (channel.id as string), // it's possible for channel to not have any name thus falling back to using id + }); + } + + return channels; + }, +}; diff --git a/packages/backend/src/apps/mattermost/index.d.ts b/packages/backend/src/apps/mattermost/index.d.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/backend/src/apps/mattermost/index.ts b/packages/backend/src/apps/mattermost/index.ts new file mode 100644 index 00000000..8d049421 --- /dev/null +++ b/packages/backend/src/apps/mattermost/index.ts @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app'; +import addAuthHeader from './common/add-auth-header'; +import addXRequestedWithHeader from './common/add-x-requested-with-header'; +import setBaseUrl from './common/set-base-url'; +import auth from './auth'; +import actions from './actions'; +import dynamicData from './dynamic-data'; + +export default defineApp({ + name: 'Mattermost', + key: 'mattermost', + iconUrl: '{BASE_URL}/apps/mattermost/assets/favicon.svg', + authDocUrl: 'https://automatisch.io/docs/apps/slack/connection', + baseUrl: 'https://mattermost.com', + apiBaseUrl: '', // there is no cloud version of this app, user always need to provide address of own instance when creating connection + primaryColor: '4a154b', + supportsConnections: true, + beforeRequest: [setBaseUrl, addXRequestedWithHeader, addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/docs/pages/.vitepress/config.js b/packages/docs/pages/.vitepress/config.js index 7f0fbbaa..66550b42 100644 --- a/packages/docs/pages/.vitepress/config.js +++ b/packages/docs/pages/.vitepress/config.js @@ -142,6 +142,15 @@ export default defineConfig({ { text: 'Connection', link: '/apps/http-request/connection' }, ], }, + { + text: 'Mattermost', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/mattermost/actions' }, + { text: 'Connection', link: '/apps/notion/connection' }, + ], + }, { text: 'Notion', collapsible: true, diff --git a/packages/docs/pages/apps/mattermost/actions.md b/packages/docs/pages/apps/mattermost/actions.md new file mode 100644 index 00000000..b4ed063e --- /dev/null +++ b/packages/docs/pages/apps/mattermost/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/mattermost.svg +items: + - name: Send a message to channel + desc: Sends a message to a channel you specify. +--- + + + + diff --git a/packages/docs/pages/apps/mattermost/connection.md b/packages/docs/pages/apps/mattermost/connection.md new file mode 100644 index 00000000..9fa6187c --- /dev/null +++ b/packages/docs/pages/apps/mattermost/connection.md @@ -0,0 +1,19 @@ +# Mattermost + +:::info +This page explains the steps you need to follow to set up the Mattermost +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the `/integrations/oauth2-apps/add` page of your Mattermost server to register a **new OAuth application**. + - You can find details about registering new Mattermost oAuth application at https://docs.mattermost.com/integrations/cloud-oauth-2-0-applications.html#register-your-application-in-mattermost. +2. Fill in the **Display Name** field. +3. Fill in the **Description** field. +4. Fill in the **Homepage** field. +5. Copy **OAuth Redirect URL** from Automatisch to the **Callback URLs** field on Mattermost page. +6. Click on the **Save** button at the end of the form on Mattermost page. +7. Copy the **Client ID** value from the following page to the `Client ID` field on Automatisch. +8. Copy the **Client Secret** value from the same page to the `Client Secret` field on Automatisch. +9. Click **Done** button on MAttermost page. +10. Click **Submit** button on Automatisch. +11. Congrats! Start using your new Mattermost connection within the flows. diff --git a/packages/docs/pages/guide/available-apps.md b/packages/docs/pages/guide/available-apps.md index b3df524b..58b4370e 100644 --- a/packages/docs/pages/guide/available-apps.md +++ b/packages/docs/pages/guide/available-apps.md @@ -18,6 +18,7 @@ Following integrations are currently supported by Automatisch. - [Google Forms](/apps/google-forms/triggers) - [Google Sheets](/apps/google-sheets/triggers) - [HTTP Request](/apps/http-request/actions) +- [Mattermost](/apps/mattermost/actions) - [Notion](/apps/notion/triggers) - [Ntfy](/apps/ntfy/actions) - [Odoo](/apps/odoo/actions)