Merge pull request #673 from automatisch/issue-495
feat(slack): add bot capability in send message action
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
@@ -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);
|
||||
|
62
packages/backend/src/apps/slack/auth/create-auth-data.ts
Normal file
62
packages/backend/src/apps/slack/auth/create-auth-data.ts
Normal file
@@ -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,
|
||||
});
|
||||
};
|
@@ -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,
|
||||
};
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
};
|
||||
|
||||
|
13
packages/backend/src/apps/slack/common/get-current-user.ts
Normal file
13
packages/backend/src/apps/slack/common/get-current-user.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
|
||||
|
||||
const getCurrentUser = async ($: IGlobalVariable): Promise<IJSONObject> => {
|
||||
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;
|
@@ -38,6 +38,9 @@ export default function computeParameters(
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
return {
|
||||
...result,
|
||||
[key]: value,
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
|
@@ -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.
|
||||
|
4
packages/types/index.d.ts
vendored
4
packages/types/index.d.ts
vendored
@@ -301,4 +301,8 @@ declare module 'axios' {
|
||||
interface AxiosResponse {
|
||||
httpError?: IJSONObject;
|
||||
}
|
||||
|
||||
interface AxiosRequestConfig {
|
||||
additionalProperties?: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user