Feature: Slack - receive messages, create threads, send replies (#974)

Co-authored-by: Pranav Raj S <pranav@thoughtwoot.com>
This commit is contained in:
Sojan Jose
2020-06-22 13:19:26 +05:30
committed by GitHub
parent aa8a85b8bd
commit 1ef8d03e18
53 changed files with 815 additions and 188 deletions

View File

@@ -16,7 +16,7 @@ defaults: &defaults
- image: circleci/redis:alpine - image: circleci/redis:alpine
environment: environment:
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
- RAILS_LOG_TO_STDOUT: false
jobs: jobs:
build: build:
<<: *defaults <<: *defaults
@@ -69,11 +69,11 @@ jobs:
- run: - run:
name: Download cc-test-reporter name: Download cc-test-reporter
command: | command: |
mkdir -p tmp/ mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./tmp/cc-test-reporter curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ./tmp/cc-test-reporter chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace: - persist_to_workspace:
root: tmp root: ~/tmp
paths: paths:
- cc-test-reporter - cc-test-reporter
@@ -99,9 +99,9 @@ jobs:
name: Run backend tests name: Run backend tests
command: | command: |
bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
./tmp/cc-test-reporter format-coverage -t simplecov -o tmp/codeclimate.backend.json coverage/backend/.resultset.json ~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json
- persist_to_workspace: - persist_to_workspace:
root: tmp root: ~/tmp
paths: paths:
- codeclimate.backend.json - codeclimate.backend.json
@@ -109,21 +109,23 @@ jobs:
name: Run frontend tests name: Run frontend tests
command: | command: |
yarn test:coverage yarn test:coverage
./tmp/cc-test-reporter format-coverage -t lcov -o tmp/codeclimate.frontend.json buildreports/lcov.info ~/tmp/cc-test-reporter format-coverage -t lcov -o ~/tmp/codeclimate.frontend.json buildreports/lcov.info
- persist_to_workspace: - persist_to_workspace:
root: tmp root: ~/tmp
paths: paths:
- codeclimate.frontend.json - codeclimate.frontend.json
# collect reports # collect reports
- store_test_results: - store_test_results:
path: /tmp/test-results path: ~/tmp/test-results
- store_artifacts: - store_artifacts:
path: /tmp/test-results path: ~/tmp/test-results
destination: test-results destination: test-results
- store_artifacts:
path: log
- run: - run:
name: Upload coverage results to Code Climate name: Upload coverage results to Code Climate
command: | command: |
./tmp/cc-test-reporter sum-coverage tmp/codeclimate.*.json -p 2 -o tmp/codeclimate.total.json ~/tmp/cc-test-reporter sum-coverage ~/tmp/codeclimate.*.json -p 2 -o ~/tmp/codeclimate.total.json
./tmp/cc-test-reporter upload-coverage -i tmp/codeclimate.total.json ~/tmp/cc-test-reporter upload-coverage -i ~/tmp/codeclimate.total.json

View File

@@ -59,6 +59,7 @@ MANDRILL_INGRESS_API_KEY=
ACTIVE_STORAGE_SERVICE=local ACTIVE_STORAGE_SERVICE=local
# Amazon S3 # Amazon S3
# documentation: https://www.chatwoot.com/docs/configuring-s3-bucket-as-cloud-storage
S3_BUCKET_NAME= S3_BUCKET_NAME=
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
@@ -74,20 +75,23 @@ LOG_LEVEL=info
LOG_SIZE=500 LOG_SIZE=500
### This environment variables are only required if you are setting up social media channels ### This environment variables are only required if you are setting up social media channels
#facebook
# Facebook
# documentation: https://www.chatwoot.com/docs/facebook-setup
FB_VERIFY_TOKEN= FB_VERIFY_TOKEN=
FB_APP_SECRET= FB_APP_SECRET=
FB_APP_ID= FB_APP_ID=
# Twitter # Twitter
# documentation: https://www.chatwoot.com/docs/twitter-app-setup
TWITTER_APP_ID= TWITTER_APP_ID=
TWITTER_CONSUMER_KEY= TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET= TWITTER_CONSUMER_SECRET=
TWITTER_ENVIRONMENT= TWITTER_ENVIRONMENT=
#slack #slack integration
SLACK_CLIENT_ID= SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET SLACK_CLIENT_SECRET=
### Change this env variable only if you are using a custom build mobile app ### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables ## Mobile app env variables

View File

@@ -9,7 +9,7 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC
private private
def fetch_apps def fetch_apps
@apps = Integrations::App.all @apps = Integrations::App.all.select(&:active?)
end end
def fetch_app def fetch_app

View File

@@ -7,18 +7,12 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
code: params[:code], code: params[:code],
inbox_id: params[:inbox_id] inbox_id: params[:inbox_id]
) )
@hook = builder.perform @hook = builder.perform
create_chatwoot_slack_channel
render json: @hook
end end
def update def update
builder = Integrations::Slack::ChannelBuilder.new( create_chatwoot_slack_channel
hook: @hook, channel: params[:channel]
)
builder.perform
render json: @hook render json: @hook
end end
@@ -31,6 +25,14 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
private private
def fetch_hook def fetch_hook
@hook = Integrations::Hook.find(params[:id]) @hook = Integrations::Hook.find_by(app_id: 'slack')
end
def create_chatwoot_slack_channel
channel = params[:channel] || 'customer-conversations'
builder = Integrations::Slack::ChannelBuilder.new(
hook: @hook, channel: channel
)
builder.perform
end end
end end

View File

@@ -10,6 +10,10 @@ class ApiClient {
} }
get url() { get url() {
return `${this.baseUrl()}/${this.resource}`;
}
baseUrl() {
let url = this.apiVersion; let url = this.apiVersion;
if (this.options.accountScoped) { if (this.options.accountScoped) {
const isInsideAccountScopedURLs = window.location.pathname.includes( const isInsideAccountScopedURLs = window.location.pathname.includes(
@@ -21,7 +25,8 @@ class ApiClient {
url = `${url}/accounts/${accountId}`; url = `${url}/accounts/${accountId}`;
} }
} }
return `${url}/${this.resource}`;
return url;
} }
get() { get() {

View File

@@ -0,0 +1,21 @@
/* global axios */
import ApiClient from './ApiClient';
class IntegrationsAPI extends ApiClient {
constructor() {
super('integrations/apps', { accountScoped: true });
}
connectSlack(code) {
return axios.post(`${this.baseUrl()}/integrations/slack`, {
code: code,
});
}
delete(integrationId) {
return axios.delete(`${this.baseUrl()}/integrations/${integrationId}`);
}
}
export default new IntegrationsAPI();

View File

@@ -3,16 +3,17 @@
background: $color-white; background: $color-white;
border: 1px solid $color-border; border: 1px solid $color-border;
border-radius: $space-smaller; border-radius: $space-smaller;
margin-bottom: $space-normal;
padding: $space-normal; padding: $space-normal;
.integration--image { .integration--image {
display: flex; display: flex;
margin-right: $space-normal; margin-right: $space-normal;
width: 8rem; width: 10rem;
img { img {
max-width: 8rem; max-width: 100%;
padding: $space-small; padding: $space-medium;
} }
} }

View File

@@ -121,7 +121,6 @@ export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
daysLeft: 'getTrialLeft',
globalConfig: 'globalConfig/get', globalConfig: 'globalConfig/get',
inboxes: 'inboxes/getInboxes', inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',

View File

@@ -1,14 +1,26 @@
<template> <template>
<span class="back-button ion-ios-arrow-left" @click.capture="goBack">Back</span> <span class="back-button ion-ios-arrow-left" @click.capture="goBack">
{{ $t('GENERAL_SETTINGS.BACK') }}
</span>
</template> </template>
<script> <script>
import router from '../../routes/index'; import router from '../../routes/index';
export default { export default {
props: {
backUrl: {
type: [String, Object],
default: '',
},
},
methods: { methods: {
goBack() { goBack() {
router.go(-1); if (this.backUrl !== '') {
router.push(this.backUrl);
} else {
router.go(-1);
}
}, },
}, },
}; };
</script> </script>

View File

@@ -52,6 +52,7 @@ export const getSidebarItems = accountId => ({
'settings_inbox_finish', 'settings_inbox_finish',
'settings_integrations', 'settings_integrations',
'settings_integrations_webhook', 'settings_integrations_webhook',
'settings_integrations_integration',
'general_settings', 'general_settings',
'general_settings_index', 'general_settings_index',
], ],

View File

@@ -2,6 +2,7 @@
"GENERAL_SETTINGS": { "GENERAL_SETTINGS": {
"TITLE": "Account settings", "TITLE": "Account settings",
"SUBMIT": "Update settings", "SUBMIT": "Update settings",
"BACK": "Back",
"UPDATE": { "UPDATE": {
"ERROR": "Could not update settings, try again!", "ERROR": "Could not update settings, try again!",
"SUCCESS": "Successfully updated account settings" "SUCCESS": "Successfully updated account settings"

View File

@@ -49,6 +49,15 @@
"NO": "No, Keep it" "NO": "No, Keep it"
} }
} }
} },
"DELETE": {
"BUTTON_TEXT": "Delete",
"API": {
"SUCCESS_MESSAGE": "Integration deleted successfully"
}
},
"CONNECT": {
"BUTTON_TEXT": "Connect"
}
} }
} }

View File

@@ -2,7 +2,7 @@
<div class="settings-header"> <div class="settings-header">
<h1 class="page-title"> <h1 class="page-title">
<woot-sidemenu-icon></woot-sidemenu-icon> <woot-sidemenu-icon></woot-sidemenu-icon>
<back-button v-if="showBackButton"></back-button> <back-button v-if="showBackButton" :back-url="backUrl"></back-button>
<i :class="iconClass"></i> <i :class="iconClass"></i>
<span>{{ headerTitle }}</span> <span>{{ headerTitle }}</span>
</h1> </h1>
@@ -45,6 +45,10 @@ export default {
}, },
showBackButton: { type: Boolean, default: false }, showBackButton: { type: Boolean, default: false },
showNewButton: { type: Boolean, default: false }, showNewButton: { type: Boolean, default: false },
backUrl: {
type: [String, Object],
default: '',
},
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({

View File

@@ -6,6 +6,7 @@
:header-title="$t(headerTitle)" :header-title="$t(headerTitle)"
:button-text="$t(headerButtonText)" :button-text="$t(headerButtonText)"
:show-back-button="showBackButton" :show-back-button="showBackButton"
:back-url="backUrl"
:show-new-button="showNewButton" :show-new-button="showNewButton"
/> />
<keep-alive> <keep-alive>
@@ -34,6 +35,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
backUrl: {
type: [String, Object],
default: '',
},
}, },
data() { data() {
return {}; return {};

View File

@@ -3,38 +3,19 @@
<div class="row"> <div class="row">
<div class="small-8 columns integrations-wrap"> <div class="small-8 columns integrations-wrap">
<div class="row integrations"> <div class="row integrations">
<div class="small-12 columns integration"> <div
<div class="row"> v-for="item in integrationsList"
<div class="integration--image"> :key="item.id"
<img src="~dashboard/assets/images/integrations/cable.svg" /> class="small-12 columns integration"
</div> >
<div class="column"> <integration
<h3 class="integration--title"> :integration-id="item.id"
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.TITLE') }} :integration-logo="item.logo"
</h3> :integration-name="item.name"
<p class="integration--description"> :integration-description="item.description"
{{ :integration-enabled="item.enabled"
useInstallationName( :integration-action="item.action"
$t('INTEGRATION_SETTINGS.WEBHOOK.INTEGRATION_TXT'), />
globalConfig.installationName
)
}}
</p>
</div>
<div class="small-2 column button-wrap">
<router-link
:to="
frontendURL(
`accounts/${accountId}/settings/integrations/webhook`
)
"
>
<button class="button success nice">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.CONFIGURE') }}
</button>
</router-link>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -43,20 +24,19 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { frontendURL } from '../../../../helper/URLHelper'; import Integration from './Integration';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default { export default {
mixins: [globalConfigMixin], components: {
Integration,
},
computed: { computed: {
...mapGetters({ ...mapGetters({
currentUser: 'getCurrentUser', integrationsList: 'integrations/getIntegrations',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
}), }),
}, },
methods: { mounted() {
frontendURL, this.$store.dispatch('integrations/get');
}, },
}; };
</script> </script>

View File

@@ -0,0 +1,114 @@
<template>
<div class="row">
<div class="integration--image">
<img :src="'/assets/dashboard/integrations/' + integrationLogo" />
</div>
<div class="column">
<h3 class="integration--title">
{{ integrationName }}
</h3>
<p class="integration--description">
{{ integrationDescription }}
</p>
</div>
<div class="small-2 column button-wrap">
<router-link
:to="
frontendURL(
`accounts/${accountId}/settings/integrations/` + integrationId
)
"
>
<div v-if="integrationEnabled">
<div v-if="integrationAction === 'disconnect'">
<div @click="openDeletePopup()">
<woot-submit-button
:button-text="
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')
"
icon-class="ion-close-circled"
button-class="nice alert"
/>
</div>
</div>
<div v-else>
<button class="button nice">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.CONFIGURE') }}
</button>
</div>
</div>
</router-link>
<div v-if="!integrationEnabled">
<a :href="integrationAction" class="button success nice">
{{ $t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT') }}
</a>
</div>
</div>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.TITLE')"
:message="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.MESSAGE')"
:confirm-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')"
:reject-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { frontendURL } from '../../../../helper/URLHelper';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: [
'integrationId',
'integrationLogo',
'integrationName',
'integrationDescription',
'integrationEnabled',
'integrationAction',
],
data() {
return {
showDeleteConfirmationPopup: false,
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
accountId: 'getCurrentAccountId',
}),
},
methods: {
frontendURL,
openDeletePopup() {
this.showDeleteConfirmationPopup = true;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
confirmDeletion() {
this.closeDeletePopup();
this.deleteIntegration(this.deleteIntegration);
this.$router.push({ name: 'settings_integrations' });
},
async deleteIntegration() {
try {
await this.$store.dispatch(
'integrations/deleteIntegration',
this.integrationId
);
this.showAlert(
this.$t('INTEGRATION_SETTINGS.DELETE.API.SUCCESS_MESSAGE')
);
} catch (error) {
this.showAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.API.ERROR_MESSAGE')
);
}
},
},
};
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div class="column content-box">
<div class="row">
<div class="small-8 columns integrations-wrap">
<div class="row integrations">
<div v-if="integrationLoaded" class="small-12 columns integration">
<integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction()"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration';
export default {
components: {
Integration,
},
mixins: [globalConfigMixin],
props: ['integrationId', 'code'],
data() {
return {
integrationLoaded: false,
};
},
computed: {
integration() {
return this.$store.getters['integrations/getIntegration'](
this.integrationId
);
},
...mapGetters({
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
}),
},
mounted() {
this.intializeSlackIntegration();
},
methods: {
integrationAction() {
if (this.integration.enabled) {
return 'disconnect';
}
return this.integration.action;
},
async intializeSlackIntegration() {
await this.$store.dispatch('integrations/get', this.integrationId);
if (this.code) {
await this.$store.dispatch('integrations/connectSlack', this.code);
// we are clearing code from the path as subsequent request would throw error
this.$router.replace(this.$route.path);
}
this.integrationLoaded = true;
},
},
};
</script>

View File

@@ -82,16 +82,16 @@
</div> </div>
</template> </template>
<script> <script>
/* global bus */
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import NewWebhook from './New'; import NewWebhook from './New';
import alertMixin from 'shared/mixins/alertMixin';
import globalConfigMixin from 'shared/mixins/globalConfigMixin'; import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default { export default {
components: { components: {
NewWebhook, NewWebhook,
}, },
mixins: [globalConfigMixin], mixins: [alertMixin, globalConfigMixin],
data() { data() {
return { return {
loading: {}, loading: {},
@@ -111,9 +111,6 @@ export default {
this.$store.dispatch('webhooks/get'); this.$store.dispatch('webhooks/get');
}, },
methods: { methods: {
showAlert(message) {
bus.$emit('newToastMessage', message);
},
openAddPopup() { openAddPopup() {
this.showAddPopup = true; this.showAddPopup = true;
}, },

View File

@@ -1,6 +1,7 @@
import Index from './Index'; import Index from './Index';
import SettingsContent from '../Wrapper'; import SettingsContent from '../Wrapper';
import Webhook from './Webhook'; import Webhook from './Webhook';
import ShowIntegration from './ShowIntegration';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
export default { export default {
@@ -10,10 +11,15 @@ export default {
component: SettingsContent, component: SettingsContent,
props: params => { props: params => {
const showBackButton = params.name !== 'settings_integrations'; const showBackButton = params.name !== 'settings_integrations';
const backUrl =
params.name === 'settings_integrations_integration'
? { name: 'settings_integrations' }
: '';
return { return {
headerTitle: 'INTEGRATION_SETTINGS.HEADER', headerTitle: 'INTEGRATION_SETTINGS.HEADER',
icon: 'ion-flash', icon: 'ion-flash',
showBackButton, showBackButton,
backUrl,
}; };
}, },
children: [ children: [
@@ -29,6 +35,18 @@ export default {
name: 'settings_integrations_webhook', name: 'settings_integrations_webhook',
roles: ['administrator'], roles: ['administrator'],
}, },
{
path: ':integration_id',
name: 'settings_integrations_integration',
component: ShowIntegration,
roles: ['administrator'],
props: route => {
return {
integrationId: route.params.integration_id,
code: route.query.code,
};
},
},
], ],
}, },
], ],

View File

@@ -17,6 +17,7 @@ import conversationTypingStatus from './modules/conversationTypingStatus';
import globalConfig from 'shared/store/globalConfig'; import globalConfig from 'shared/store/globalConfig';
import inboxes from './modules/inboxes'; import inboxes from './modules/inboxes';
import inboxMembers from './modules/inboxMembers'; import inboxMembers from './modules/inboxMembers';
import integrations from './modules/integrations';
import reports from './modules/reports'; import reports from './modules/reports';
import userNotificationSettings from './modules/userNotificationSettings'; import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks'; import webhooks from './modules/webhooks';
@@ -40,6 +41,7 @@ export default new Vuex.Store({
globalConfig, globalConfig,
inboxes, inboxes,
inboxMembers, inboxMembers,
integrations,
reports, reports,
userNotificationSettings, userNotificationSettings,
webhooks, webhooks,

View File

@@ -1,6 +1,5 @@
/* eslint no-param-reassign: 0 */ /* eslint no-param-reassign: 0 */
import axios from 'axios'; import axios from 'axios';
import moment from 'moment';
import Vue from 'vue'; import Vue from 'vue';
import * as types from '../mutation-types'; import * as types from '../mutation-types';
import authAPI from '../../api/auth'; import authAPI from '../../api/auth';
@@ -50,21 +49,6 @@ export const getters = {
getCurrentUser(_state) { getCurrentUser(_state) {
return _state.currentUser; return _state.currentUser;
}, },
getSubscription(_state) {
return _state.currentUser.subscription === undefined
? null
: _state.currentUser.subscription;
},
getTrialLeft(_state) {
const createdAt =
_state.currentUser.subscription === undefined
? moment()
: _state.currentUser.subscription.expiry * 1000;
const daysLeft = moment(createdAt).diff(moment(), 'days');
return daysLeft < 0 ? 0 : daysLeft;
},
}; };
// actions // actions

View File

@@ -0,0 +1,83 @@
/* eslint no-param-reassign: 0 */
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import IntegrationsAPI from '../../api/integrations';
const state = {
records: [],
uiFlags: {
isFetching: false,
isFetchingItem: false,
isUpdating: false,
},
};
export const getters = {
getIntegrations($state) {
return $state.records;
},
getIntegration: $state => integrationId => {
const [integration] = $state.records.filter(
record => record.id === integrationId
);
return integration || {};
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: true });
try {
const response = await IntegrationsAPI.get();
commit(types.default.SET_INTEGRATIONS, response.data.payload);
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: false });
}
},
connectSlack: async ({ commit }, code) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: true });
try {
const response = await IntegrationsAPI.connectSlack(code);
commit(types.default.ADD_INTEGRATION, response.data);
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: false });
}
},
deleteIntegration: async ({ commit }, integrationId) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: true });
try {
await IntegrationsAPI.delete(integrationId);
commit(types.default.DELETE_INTEGRATION, {
id: integrationId,
enabled: false,
});
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.default.SET_INTEGRATIONS_UI_FLAG]($state, uiFlag) {
$state.uiFlags = { ...$state.uiFlags, ...uiFlag };
},
[types.default.SET_INTEGRATIONS]: MutationHelpers.set,
[types.default.ADD_INTEGRATION]: MutationHelpers.updateAttributes,
[types.default.DELETE_INTEGRATION]: MutationHelpers.updateAttributes,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,72 @@
import axios from 'axios';
import { actions } from '../../integrations';
import * as types from '../../../mutation-types';
import integrationsList from './fixtures';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: integrationsList });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: true }],
[types.default.SET_INTEGRATIONS, integrationsList.payload],
[types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: true }],
[types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#connectSlack:', () => {
it('sends correct actions if API is success', async () => {
let data = { id: 'slack', enabled: true };
axios.post.mockResolvedValue({ data: data });
await actions.connectSlack({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: true }],
[types.default.ADD_INTEGRATION, data],
[types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await actions.connectSlack({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: true }],
[types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: false }],
]);
});
});
describe('#deleteIntegration:', () => {
it('sends correct actions if API is success', async () => {
let data = { id: 'slack', enabled: false };
axios.delete.mockResolvedValue({ data: data });
await actions.deleteIntegration({ commit }, data.id);
expect(commit.mock.calls).toEqual([
[types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: true }],
[types.default.DELETE_INTEGRATION, data],
[types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await actions.deleteIntegration({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: true }],
[types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
});

View File

@@ -0,0 +1,16 @@
export default {
payload: [
{
id: 1,
name: 'test1',
logo: 'test',
enabled: true,
},
{
id: 2,
name: 'test2',
logo: 'test',
enabled: true,
},
],
};

View File

@@ -0,0 +1,51 @@
import { getters } from '../../integrations';
describe('#getters', () => {
it('getIntegrations', () => {
const state = {
records: [
{
id: 1,
name: 'test1',
logo: 'test',
enabled: true,
},
{
id: 2,
name: 'test2',
logo: 'test',
enabled: true,
},
],
};
expect(getters.getIntegrations(state)).toEqual([
{
id: 1,
name: 'test1',
logo: 'test',
enabled: true,
},
{
id: 2,
name: 'test2',
logo: 'test',
enabled: true,
},
]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: true,
isFetchingItem: false,
isUpdating: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: true,
isFetchingItem: false,
isUpdating: false,
});
});
});

View File

@@ -0,0 +1,26 @@
import * as types from '../../../mutation-types';
import { mutations } from '../../integrations';
describe('#mutations', () => {
describe('#GET_INTEGRATIONS', () => {
it('set integrations records', () => {
const state = { records: [] };
mutations[types.default.SET_INTEGRATIONS](state, [
{
id: 1,
name: 'test1',
logo: 'test',
enabled: true,
},
]);
expect(state.records).toEqual([
{
id: 1,
name: 'test1',
logo: 'test',
enabled: true,
},
]);
});
});
});

View File

@@ -67,6 +67,12 @@ export default {
EDIT_CANNED: 'EDIT_CANNED', EDIT_CANNED: 'EDIT_CANNED',
DELETE_CANNED: 'DELETE_CANNED', DELETE_CANNED: 'DELETE_CANNED',
// Integrations
SET_INTEGRATIONS_UI_FLAG: 'SET_INTEGRATIONS_UI_FLAG',
SET_INTEGRATIONS: 'SET_INTEGRATIONS',
ADD_INTEGRATION: 'ADD_INTEGRATION',
DELETE_INTEGRATION: 'DELETE_INTEGRATION',
// WebHook // WebHook
SET_WEBHOOK_UI_FLAG: 'SET_WEBHOOK_UI_FLAG', SET_WEBHOOK_UI_FLAG: 'SET_WEBHOOK_UI_FLAG',
SET_WEBHOOK: 'SET_WEBHOOK', SET_WEBHOOK: 'SET_WEBHOOK',

View File

@@ -25,6 +25,15 @@ export const update = (state, data) => {
}); });
}; };
/* when you don't want to overwrite the whole object */
export const updateAttributes = (state, data) => {
state.records.forEach((element, index) => {
if (element.id === data.id) {
Vue.set(state.records, index, { ...state.records[index], ...data });
}
});
};
export const destroy = (state, id) => { export const destroy = (state, id) => {
state.records = state.records.filter(record => record.id !== id); state.records = state.records.filter(record => record.id !== id);
}; };

View File

@@ -25,12 +25,39 @@ class Integrations::App
params[:fields] params[:fields]
end end
def button def action
params[:button] case params[:id]
when 'slack'
"#{params[:action]}&client_id=#{ENV['SLACK_CLIENT_ID']}&redirect_uri=#{self.class.slack_integration_url}"
else
params[:action]
end
end
def active?
case params[:id]
when 'slack'
ENV['SLACK_CLIENT_SECRET'].present?
else
true
end
end end
def enabled?(account) def enabled?(account)
account.hooks.where(app_id: id).exists? case params[:id]
when 'slack'
account.hooks.where(app_id: id).exists?
else
true
end
end
def hooks
Current.account.hooks.where(app_id: id)
end
def self.slack_integration_url
"#{ENV['FRONTEND_URL']}/app/accounts/#{Current.account.id}/settings/integrations/slack"
end end
class << self class << self

View File

@@ -31,6 +31,6 @@ class Integrations::Hook < ApplicationRecord
end end
def slack? def slack?
app_id == 'cw_slack' app_id == 'slack'
end end
end end

View File

@@ -1,7 +1,10 @@
json.array! @apps do |app| json.payload do
json.id app.id json.array! @apps do |app|
json.name app.name json.id app.id
json.logo app.logo json.name app.name
json.enabled app.enabled?(@current_account) json.description app.description
json.button app.button json.logo app.logo
json.enabled app.enabled?(@current_account)
json.action app.action
end
end end

View File

@@ -4,4 +4,4 @@ json.logo @app.logo
json.description @app.description json.description @app.description
json.fields @app.fields json.fields @app.fields
json.enabled @app.enabled?(@current_account) json.enabled @app.enabled?(@current_account)
json.button @app.button json.button @app.action

View File

@@ -0,0 +1,2 @@
json.id @hook.app_id
json.enabled true

View File

@@ -50,4 +50,5 @@ Rails.application.configure do
# Raises error for missing translations. # Raises error for missing translations.
# config.action_view.raise_on_missing_translations = true # config.action_view.raise_on_missing_translations = true
config.log_level = ENV.fetch('LOG_LEVEL', 'debug').to_sym
end end

View File

@@ -1,6 +1,13 @@
slack: slack:
id: cw_slack id: slack
name: Slack name: Slack
logo: https://a.slack-edge.com/80588/marketing/img/media-kit/img-logos@2x.png logo: slack.png
description: "'Be less busy' - Slack is the chat tool that brings all your communication together in one place. By integrating Slack with SupportBee, you can get notified in your Slack channels for important events in your support desk" description: "Slack is a chat tool that brings all your communication together in one place. By integrating Slack, you can get notified of all the new conversations in your account right inside your Slack."
button: <a href="https://slack.com/oauth/v2/authorize?scope=incoming-webhook,commands,chat:write&client_id=706921004289.1094198503990"><img alt=""Add to Slack"" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a> action: https://slack.com/oauth/v2/authorize?scope=commands,chat:write,channels:manage,channels:join,groups:write,im:write,mpim:write,users:read,users:read.email,chat:write.customize
webhooks:
id: webhook
name: Webhooks
logo: cable.svg
description: Webhook events provide you the realtime information about what's happening in your account. You can make use of the webhooks to communicate the events to your favourite apps like Slack or Github. Click on Configure to set up your webhooks.
action: /webhook

View File

@@ -90,7 +90,7 @@ Rails.application.routes.draw do
resources :webhooks, except: [:show] resources :webhooks, except: [:show]
namespace :integrations do namespace :integrations do
resources :apps, only: [:index, :show] resources :apps, only: [:index, :show]
resources :slack, only: [:create, :update, :destroy] resource :slack, only: [:create, :update, :destroy], controller: 'slack'
end end
end end
end end

View File

@@ -1,4 +1,5 @@
# loading installation configs # loading installation configs
GlobalConfig.clear_cache
ConfigLoader.new.process ConfigLoader.new.process
account = Account.create!( account = Account.create!(

View File

@@ -0,0 +1,39 @@
---
path: "/docs/slack-integration-setup"
title: "Setting Up Slack Intergration"
---
### Register A Facebook App
To use Slack Integration, you have to create an Slack app in developer portal. You can find more details about creating Slack apps [here](https://api.slack.com/)
Once you register your Slack App, you will have to obtain the `Client Id` and `Client Secret` . These values will be available in the app settings and will be required while setting up Chatwoot environment variables.
### Configure the Slack App
1) Create a slack app and add it to your development workspace.
2) Obtain the `Client Id` and `Client Secret` for the app and configure it in your Chatwoot environment variables.
3) Head over to the `oauth & permissions` section under `features` tab.
4) In the redirect urls, Add your Chatwoot installation base url.
5) In the scopes section configure the given scopes for bot token scopes.
`commands,chat:write,channels:manage,channels:join,groups:write,im:write,mpim:write,users:read,users:read.email,chat:write.customize`
6) Head over to the `events subscriptions` section under `features` tab.
7) Enable events and configure the the given request url `{chatwoot installation url}/api/v1/integrations/webhooks`
8) Subscribe to the following bot events `message.channels` , `message.groups`, `message.im`, `message.mpim`
9) Connect slack integration on Chatwoot app and get productive.
### Configuring the Environment Variables in Chatwoot
Configure the following Chatwoot environment variables with the values you have obtained during the slack app setup.
```bash
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
```
### Test your Setup
1. Ensure that you are recieving the Chatwoot messages in `customer-conversations` channel
2. Add a message to that thread and ensure its coming back on to Chatwoot
3. Add `note:` or `private:` in front on the slack message see if its coming out as private notes
4. If your slack member email matches their email on Chatwoot, the messages will be associated with their Chatwoot user account.

View File

@@ -6,7 +6,7 @@ class Integrations::Slack::ChannelBuilder
end end
def perform def perform
create_channel find_or_create_channel
update_reference_id update_reference_id
end end
@@ -23,11 +23,12 @@ class Integrations::Slack::ChannelBuilder
Slack::Web::Client.new Slack::Web::Client.new
end end
def create_channel def find_or_create_channel
@channel = slack_client.conversations_create(name: params[:channel]) exisiting_channel = slack_client.conversations_list.channels.find { |channel| channel['name'] == params[:channel] }
@channel = exisiting_channel || slack_client.conversations_create(name: params[:channel])['channel']
end end
def update_reference_id def update_reference_id
@hook.reference_id = channel['channel']['id'] @hook.update(reference_id: channel['id'])
end end
end end

View File

@@ -13,7 +13,7 @@ class Integrations::Slack::HookBuilder
status: 'enabled', status: 'enabled',
inbox_id: params[:inbox_id], inbox_id: params[:inbox_id],
hook_type: hook_type, hook_type: hook_type,
app_id: 'cw_slack' app_id: 'slack'
) )
hook.save! hook.save!
@@ -32,11 +32,12 @@ class Integrations::Slack::HookBuilder
def fetch_access_token def fetch_access_token
client = Slack::Web::Client.new client = Slack::Web::Client.new
slack_access = client.oauth_v2_access(
client.oauth_access(
client_id: ENV.fetch('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'), client_id: ENV.fetch('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
client_secret: ENV.fetch('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'), client_secret: ENV.fetch('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
code: params[:code] code: params[:code],
)['bot']['bot_access_token'] redirect_uri: Integrations::App.slack_integration_url
)
slack_access['access_token']
end end
end end

View File

@@ -34,7 +34,7 @@ class Integrations::Slack::IncomingMessageBuilder
end end
def supported_message? def supported_message?
SUPPORTED_MESSAGE_TYPES.include?(message[:type]) SUPPORTED_MESSAGE_TYPES.include?(message[:type]) if message.present?
end end
def hook_verification? def hook_verification?
@@ -46,7 +46,7 @@ class Integrations::Slack::IncomingMessageBuilder
end end
def message def message
params[:event][:blocks].first params[:event][:blocks]&.first
end end
def verify_hook def verify_hook
@@ -56,23 +56,42 @@ class Integrations::Slack::IncomingMessageBuilder
end end
def integration_hook def integration_hook
@integration_hook ||= Integrations::Hook.where(reference_id: params[:event][:channel]) @integration_hook ||= Integrations::Hook.find_by(reference_id: params[:event][:channel])
end end
def conversation def conversation
@conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first @conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first
end end
def sender
user_email = slack_client.users_info(user: params[:event][:user])[:user][:profile][:email]
conversation.account.users.find_by(email: user_email)
end
def private_note?
params[:event][:text].strip.starts_with?('note:', 'private:')
end
def create_message def create_message
return unless conversation return unless conversation
conversation.messages.create( conversation.messages.create(
message_type: 0, message_type: :outgoing,
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
content: message[:elements].first[:elements].first[:text] content: params[:event][:text],
source_id: "slack_#{params[:event][:ts]}",
private: private_note?,
user: sender
) )
{ status: 'success' } { status: 'success' }
end end
def slack_client
Slack.configure do |config|
config.token = integration_hook.access_token
end
Slack::Web::Client.new
end
end end

View File

@@ -11,6 +11,8 @@ class Integrations::Slack::OutgoingMessageBuilder
end end
def perform def perform
return if message.source_id.present?
send_message send_message
update_reference_id update_reference_id
end end
@@ -25,12 +27,32 @@ class Integrations::Slack::OutgoingMessageBuilder
@contact ||= conversation.contact @contact ||= conversation.contact
end end
def agent
@agent ||= message.user
end
def message_content
if conversation.identifier.present?
message.content
else
"*Inbox: #{message.inbox.name}* \n\n #{message.content}"
end
end
def avatar_url(sender)
sender.try(:avatar_url) || "#{ENV['FRONTEND_URL']}/admin/avatar_square.png"
end
def send_message def send_message
sender = message.outgoing? ? agent : contact
sender_type = sender.class == Contact ? 'Contact' : 'Agent'
@slack_message = slack_client.chat_postMessage( @slack_message = slack_client.chat_postMessage(
channel: hook.reference_id, channel: hook.reference_id,
text: message.content, text: message_content,
username: contact.try(:name), username: "#{sender_type}: #{sender.try(:name)}",
thread_ts: conversation.identifier thread_ts: conversation.identifier,
icon_url: avatar_url(sender)
) )
end end

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -20,9 +20,9 @@ RSpec.describe 'Integration Apps API', type: :request do
as: :json as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
app = JSON.parse(response.body).first app = JSON.parse(response.body)['payload'].first
expect(app['id']).to eql('cw_slack') expect(app['id']).to eql('webhook')
expect(app['name']).to eql('Slack') expect(app['name']).to eql('Webhooks')
end end
end end
end end
@@ -30,7 +30,7 @@ RSpec.describe 'Integration Apps API', type: :request do
describe 'GET /api/v1/integrations/apps/:id' do describe 'GET /api/v1/integrations/apps/:id' do
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do
get api_v1_account_integrations_app_url(account_id: account.id, id: 'cw_slack') get api_v1_account_integrations_app_url(account_id: account.id, id: 'slack')
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end
end end
@@ -39,13 +39,13 @@ RSpec.describe 'Integration Apps API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
it 'returns details of the app' do it 'returns details of the app' do
get api_v1_account_integrations_app_url(account_id: account.id, id: 'cw_slack'), get api_v1_account_integrations_app_url(account_id: account.id, id: 'slack'),
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
app = JSON.parse(response.body) app = JSON.parse(response.body)
expect(app['id']).to eql('cw_slack') expect(app['id']).to eql('slack')
expect(app['name']).to eql('Slack') expect(app['name']).to eql('Slack')
end end
end end

View File

@@ -3,7 +3,7 @@ FactoryBot.define do
status { 1 } status { 1 }
inbox_id { 1 } inbox_id { 1 }
account_id { 1 } account_id { 1 }
app_id { 'cw_slack' } app_id { 'slack' }
settings { 'MyText' } settings { 'MyText' }
hook_type { 1 } hook_type { 1 }
access_token { SecureRandom.hex } access_token { SecureRandom.hex }

View File

@@ -10,7 +10,7 @@ describe Integrations::Slack::HookBuilder do
hooks_count = account.hooks.count hooks_count = account.hooks.count
builder = described_class.new(account: account, code: code) builder = described_class.new(account: account, code: code)
builder.stub(:fetch_access_token) { token } allow(builder).to receive(:fetch_access_token).and_return(token)
builder.perform builder.perform
expect(account.hooks.count).to eql(hooks_count + 1) expect(account.hooks.count).to eql(hooks_count + 1)

View File

@@ -5,7 +5,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
let(:message_params) { slack_message_stub } let(:message_params) { slack_message_stub }
let(:verification_params) { slack_url_verification_stub } let(:verification_params) { slack_url_verification_stub }
let(:hook) { create(:integrations_hook, account: account, reference_id: message_params[:event][:channel]) } let!(:hook) { create(:integrations_hook, account: account, reference_id: message_params[:event][:channel]) }
let!(:conversation) { create(:conversation, identifier: message_params[:event][:thread_ts]) } let!(:conversation) { create(:conversation, identifier: message_params[:event][:thread_ts]) }
describe '#perform' do describe '#perform' do
@@ -19,8 +19,10 @@ describe Integrations::Slack::IncomingMessageBuilder do
context 'when message creation' do context 'when message creation' do
it 'creates message' do it 'creates message' do
expect(hook).not_to eq nil
messages_count = conversation.messages.count messages_count = conversation.messages.count
builder = described_class.new(message_params) builder = described_class.new(message_params)
allow(builder).to receive(:sender).and_return(nil)
builder.perform builder.perform
expect(conversation.messages.count).to eql(messages_count + 1) expect(conversation.messages.count).to eql(messages_count + 1)
end end

View File

@@ -14,15 +14,16 @@ describe Integrations::Slack::OutgoingMessageBuilder do
builder = described_class.new(hook, message) builder = described_class.new(hook, message)
stub_request(:post, 'https://slack.com/api/chat.postMessage') stub_request(:post, 'https://slack.com/api/chat.postMessage')
.to_return(status: 200, body: '', headers: {}) .to_return(status: 200, body: '', headers: {})
slack_client = double
expect(builder).to receive(:slack_client).and_return(slack_client)
# rubocop:disable RSpec/AnyInstance expect(slack_client).to receive(:chat_postMessage).with(
allow_any_instance_of(Slack::Web::Client).to receive(:chat_postMessage).with(
channel: hook.reference_id, channel: hook.reference_id,
text: message.content, text: message.content,
username: contact.name, username: "Contact: #{contact.name}",
thread_ts: conversation.identifier thread_ts: conversation.identifier,
icon_url: anything
) )
# rubocop:enable RSpec/AnyInstance
builder.perform builder.perform
end end

View File

@@ -3,7 +3,7 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Integrations::Slacks', type: :request do RSpec.describe 'Api::V1::Accounts::Integrations::Slacks', type: :request do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
let(:hook) { create(:integrations_hook, account: account) } let!(:hook) { create(:integrations_hook, account: account) }
describe 'POST /api/v1/accounts/{account.id}/integrations/slack' do describe 'POST /api/v1/accounts/{account.id}/integrations/slack' do
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
@@ -16,24 +16,27 @@ RSpec.describe 'Api::V1::Accounts::Integrations::Slacks', type: :request do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
it 'creates hook' do it 'creates hook' do
hook_builder = Integrations::Slack::HookBuilder.new(account: account, code: SecureRandom.hex) hook_builder = Integrations::Slack::HookBuilder.new(account: account, code: SecureRandom.hex)
hook_builder.stub(:fetch_access_token) { SecureRandom.hex } expect(hook_builder).to receive(:fetch_access_token).and_return(SecureRandom.hex)
expect(Integrations::Slack::HookBuilder).to receive(:new).and_return(hook_builder) expect(Integrations::Slack::HookBuilder).to receive(:new).and_return(hook_builder)
channel_builder = Integrations::Slack::ChannelBuilder.new(hook: hook, channel: 'channel')
expect(channel_builder).to receive(:perform)
expect(Integrations::Slack::ChannelBuilder).to receive(:new).and_return(channel_builder)
post "/api/v1/accounts/#{account.id}/integrations/slack", post "/api/v1/accounts/#{account.id}/integrations/slack",
params: { code: SecureRandom.hex }, params: { code: SecureRandom.hex },
headers: agent.create_new_auth_token headers: agent.create_new_auth_token
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body) json_response = JSON.parse(response.body)
expect(json_response['app_id']).to eql('cw_slack') expect(json_response['id']).to eql('slack')
end end
end end
describe 'PUT /api/v1/accounts/{account.id}/integrations/slack/{id}' do describe 'PUT /api/v1/accounts/{account.id}/integrations/slack/' do
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}/integrations/slack/#{hook.id}", params: {} put "/api/v1/accounts/#{account.id}/integrations/slack/", params: {}
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end
end end
@@ -41,32 +44,32 @@ RSpec.describe 'Api::V1::Accounts::Integrations::Slacks', type: :request do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
it 'updates hook' do it 'updates hook' do
channel_builder = Integrations::Slack::ChannelBuilder.new(hook: hook, channel: 'channel') channel_builder = Integrations::Slack::ChannelBuilder.new(hook: hook, channel: 'channel')
channel_builder.stub(:perform) expect(channel_builder).to receive(:perform)
expect(Integrations::Slack::ChannelBuilder).to receive(:new).and_return(channel_builder) expect(Integrations::Slack::ChannelBuilder).to receive(:new).and_return(channel_builder)
put "/api/v1/accounts/#{account.id}/integrations/slack/#{hook.id}", put "/api/v1/accounts/#{account.id}/integrations/slack",
params: { channel: SecureRandom.hex }, params: { channel: SecureRandom.hex },
headers: agent.create_new_auth_token headers: agent.create_new_auth_token
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body) json_response = JSON.parse(response.body)
expect(json_response['app_id']).to eql('cw_slack') expect(json_response['app_id']).to eql('slack')
end end
end end
end end
describe 'DELETE /api/v1/accounts/{account.id}/integrations/slack/{id}' do describe 'DELETE /api/v1/accounts/{account.id}/integrations/slack' do
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/integrations/slack/#{hook.id}", params: {} delete "/api/v1/accounts/#{account.id}/integrations/slack", params: {}
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end
end end
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
it 'deletes hook' do it 'deletes hook' do
delete "/api/v1/accounts/#{account.id}/integrations/slack/#{hook.id}", delete "/api/v1/accounts/#{account.id}/integrations/slack",
headers: agent.create_new_auth_token headers: agent.create_new_auth_token
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(Integrations::Hook.find_by(id: hook.id)).to be nil expect(Integrations::Hook.find_by(id: hook.id)).to be nil

View File

@@ -4,7 +4,7 @@ RSpec.describe 'Api::V1::Integrations::Webhooks', type: :request do
describe 'POST /api/v1/integrations/webhooks' do describe 'POST /api/v1/integrations/webhooks' do
it 'consumes webhook' do it 'consumes webhook' do
builder = Integrations::Slack::IncomingMessageBuilder.new({}) builder = Integrations::Slack::IncomingMessageBuilder.new({})
builder.stub(:perform) { true } expect(builder).to receive(:perform).and_return(true)
expect(Integrations::Slack::IncomingMessageBuilder).to receive(:new).and_return(builder) expect(Integrations::Slack::IncomingMessageBuilder).to receive(:new).and_return(builder)

View File

@@ -7,41 +7,12 @@ module SlackStubs
} }
end end
# rubocop:disable Metrics/MethodLength
def slack_message_stub def slack_message_stub
{ {
"token": '[FILTERED]', "token": '[FILTERED]',
"team_id": 'TLST3048H', "team_id": 'TLST3048H',
"api_app_id": 'A012S5UETV4', "api_app_id": 'A012S5UETV4',
"event": { "event": message_event,
"client_msg_id": 'ffc6e64e-6f0c-4a3d-b594-faa6b44e48ab',
"type": 'message',
"text": 'this is test',
"user": 'ULYPAKE5S',
"ts": '1588623033.006000',
"team": 'TLST3048H',
"blocks": [
{
"type": 'rich_text',
"block_id": 'jaIv3',
"elements": [
{
"type": 'rich_text_section',
"elements": [
{
"type": 'text',
"text": 'this is test'
}
]
}
]
}
],
"thread_ts": '1588623023.005900',
"channel": 'G01354F6A6Q',
"event_ts": '1588623033.006000',
"channel_type": 'group'
},
"type": 'event_callback', "type": 'event_callback',
"event_id": 'Ev013QUX3WV6', "event_id": 'Ev013QUX3WV6',
"event_time": 1_588_623_033, "event_time": 1_588_623_033,
@@ -49,5 +20,38 @@ module SlackStubs
"webhook": {} "webhook": {}
} }
end end
# rubocop:enable Metrics/MethodLength
def message_event
{ "client_msg_id": 'ffc6e64e-6f0c-4a3d-b594-faa6b44e48ab',
"type": 'message',
"text": 'this is test',
"user": 'ULYPAKE5S',
"ts": '1588623033.006000',
"team": 'TLST3048H',
"blocks": message_blocks,
"thread_ts": '1588623023.005900',
"channel": 'G01354F6A6Q',
"event_ts": '1588623033.006000',
"channel_type": 'group' }
end
def message_blocks
[
{
"type": 'rich_text',
"block_id": 'jaIv3',
"elements": [
{
"type": 'rich_text_section',
"elements": [
{
"type": 'text',
"text": 'this is test'
}
]
}
]
}
]
end
end end