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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 815 additions and 188 deletions

View file

@ -16,7 +16,7 @@ defaults: &defaults
- image: circleci/redis:alpine
environment:
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
- RAILS_LOG_TO_STDOUT: false
jobs:
build:
<<: *defaults
@ -69,11 +69,11 @@ jobs:
- run:
name: Download cc-test-reporter
command: |
mkdir -p tmp/
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./tmp/cc-test-reporter
chmod +x ./tmp/cc-test-reporter
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: tmp
root: ~/tmp
paths:
- cc-test-reporter
@ -99,9 +99,9 @@ jobs:
name: Run backend tests
command: |
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:
root: tmp
root: ~/tmp
paths:
- codeclimate.backend.json
@ -109,21 +109,23 @@ jobs:
name: Run frontend tests
command: |
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:
root: tmp
root: ~/tmp
paths:
- codeclimate.frontend.json
# collect reports
- store_test_results:
path: /tmp/test-results
path: ~/tmp/test-results
- store_artifacts:
path: /tmp/test-results
path: ~/tmp/test-results
destination: test-results
- store_artifacts:
path: log
- run:
name: Upload coverage results to Code Climate
command: |
./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 sum-coverage ~/tmp/codeclimate.*.json -p 2 -o ~/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
# Amazon S3
# documentation: https://www.chatwoot.com/docs/configuring-s3-bucket-as-cloud-storage
S3_BUCKET_NAME=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
@ -74,20 +75,23 @@ LOG_LEVEL=info
LOG_SIZE=500
### 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_APP_SECRET=
FB_APP_ID=
# Twitter
# documentation: https://www.chatwoot.com/docs/twitter-app-setup
TWITTER_APP_ID=
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=
TWITTER_ENVIRONMENT=
#slack
#slack integration
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET
SLACK_CLIENT_SECRET=
### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables

View file

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

View file

@ -7,18 +7,12 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
code: params[:code],
inbox_id: params[:inbox_id]
)
@hook = builder.perform
render json: @hook
create_chatwoot_slack_channel
end
def update
builder = Integrations::Slack::ChannelBuilder.new(
hook: @hook, channel: params[:channel]
)
builder.perform
create_chatwoot_slack_channel
render json: @hook
end
@ -31,6 +25,14 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
private
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

View file

@ -10,6 +10,10 @@ class ApiClient {
}
get url() {
return `${this.baseUrl()}/${this.resource}`;
}
baseUrl() {
let url = this.apiVersion;
if (this.options.accountScoped) {
const isInsideAccountScopedURLs = window.location.pathname.includes(
@ -21,7 +25,8 @@ class ApiClient {
url = `${url}/accounts/${accountId}`;
}
}
return `${url}/${this.resource}`;
return url;
}
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;
border: 1px solid $color-border;
border-radius: $space-smaller;
margin-bottom: $space-normal;
padding: $space-normal;
.integration--image {
display: flex;
margin-right: $space-normal;
width: 8rem;
width: 10rem;
img {
max-width: 8rem;
padding: $space-small;
max-width: 100%;
padding: $space-medium;
}
}

View file

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

View file

@ -1,14 +1,26 @@
<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>
<script>
import router from '../../routes/index';
export default {
props: {
backUrl: {
type: [String, Object],
default: '',
},
},
methods: {
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_integrations',
'settings_integrations_webhook',
'settings_integrations_integration',
'general_settings',
'general_settings_index',
],

View file

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

View file

@ -49,6 +49,15 @@
"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">
<h1 class="page-title">
<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>
<span>{{ headerTitle }}</span>
</h1>
@ -45,6 +45,10 @@ export default {
},
showBackButton: { type: Boolean, default: false },
showNewButton: { type: Boolean, default: false },
backUrl: {
type: [String, Object],
default: '',
},
},
computed: {
...mapGetters({

View file

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

View file

@ -3,38 +3,19 @@
<div class="row">
<div class="small-8 columns integrations-wrap">
<div class="row integrations">
<div class="small-12 columns integration">
<div class="row">
<div class="integration--image">
<img src="~dashboard/assets/images/integrations/cable.svg" />
</div>
<div class="column">
<h3 class="integration--title">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.TITLE') }}
</h3>
<p class="integration--description">
{{
useInstallationName(
$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
v-for="item in integrationsList"
:key="item.id"
class="small-12 columns integration"
>
<integration
:integration-id="item.id"
:integration-logo="item.logo"
:integration-name="item.name"
:integration-description="item.description"
:integration-enabled="item.enabled"
:integration-action="item.action"
/>
</div>
</div>
</div>
@ -43,20 +24,19 @@
</template>
<script>
import { mapGetters } from 'vuex';
import { frontendURL } from '../../../../helper/URLHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration';
export default {
mixins: [globalConfigMixin],
components: {
Integration,
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
integrationsList: 'integrations/getIntegrations',
}),
},
methods: {
frontendURL,
mounted() {
this.$store.dispatch('integrations/get');
},
};
</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>
</template>
<script>
/* global bus */
import { mapGetters } from 'vuex';
import NewWebhook from './New';
import alertMixin from 'shared/mixins/alertMixin';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
components: {
NewWebhook,
},
mixins: [globalConfigMixin],
mixins: [alertMixin, globalConfigMixin],
data() {
return {
loading: {},
@ -111,9 +111,6 @@ export default {
this.$store.dispatch('webhooks/get');
},
methods: {
showAlert(message) {
bus.$emit('newToastMessage', message);
},
openAddPopup() {
this.showAddPopup = true;
},

View file

@ -1,6 +1,7 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import Webhook from './Webhook';
import ShowIntegration from './ShowIntegration';
import { frontendURL } from '../../../../helper/URLHelper';
export default {
@ -10,10 +11,15 @@ export default {
component: SettingsContent,
props: params => {
const showBackButton = params.name !== 'settings_integrations';
const backUrl =
params.name === 'settings_integrations_integration'
? { name: 'settings_integrations' }
: '';
return {
headerTitle: 'INTEGRATION_SETTINGS.HEADER',
icon: 'ion-flash',
showBackButton,
backUrl,
};
},
children: [
@ -29,6 +35,18 @@ export default {
name: 'settings_integrations_webhook',
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 inboxes from './modules/inboxes';
import inboxMembers from './modules/inboxMembers';
import integrations from './modules/integrations';
import reports from './modules/reports';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
@ -40,6 +41,7 @@ export default new Vuex.Store({
globalConfig,
inboxes,
inboxMembers,
integrations,
reports,
userNotificationSettings,
webhooks,

View file

@ -1,6 +1,5 @@
/* eslint no-param-reassign: 0 */
import axios from 'axios';
import moment from 'moment';
import Vue from 'vue';
import * as types from '../mutation-types';
import authAPI from '../../api/auth';
@ -50,21 +49,6 @@ export const getters = {
getCurrentUser(_state) {
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

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',
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
SET_WEBHOOK_UI_FLAG: 'SET_WEBHOOK_UI_FLAG',
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) => {
state.records = state.records.filter(record => record.id !== id);
};

View file

@ -25,12 +25,39 @@ class Integrations::App
params[:fields]
end
def button
params[:button]
def action
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
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
class << self

View file

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

View file

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

View file

@ -4,4 +4,4 @@ json.logo @app.logo
json.description @app.description
json.fields @app.fields
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.
# config.action_view.raise_on_missing_translations = true
config.log_level = ENV.fetch('LOG_LEVEL', 'debug').to_sym
end

View file

@ -1,6 +1,13 @@
slack:
id: cw_slack
id: slack
name: Slack
logo: https://a.slack-edge.com/80588/marketing/img/media-kit/img-logos@2x.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"
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>
logo: slack.png
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."
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]
namespace :integrations do
resources :apps, only: [:index, :show]
resources :slack, only: [:create, :update, :destroy]
resource :slack, only: [:create, :update, :destroy], controller: 'slack'
end
end
end

View file

@ -1,4 +1,5 @@
# loading installation configs
GlobalConfig.clear_cache
ConfigLoader.new.process
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
def perform
create_channel
find_or_create_channel
update_reference_id
end
@ -23,11 +23,12 @@ class Integrations::Slack::ChannelBuilder
Slack::Web::Client.new
end
def create_channel
@channel = slack_client.conversations_create(name: params[:channel])
def find_or_create_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
def update_reference_id
@hook.reference_id = channel['channel']['id']
@hook.update(reference_id: channel['id'])
end
end

View file

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

View file

@ -34,7 +34,7 @@ class Integrations::Slack::IncomingMessageBuilder
end
def supported_message?
SUPPORTED_MESSAGE_TYPES.include?(message[:type])
SUPPORTED_MESSAGE_TYPES.include?(message[:type]) if message.present?
end
def hook_verification?
@ -46,7 +46,7 @@ class Integrations::Slack::IncomingMessageBuilder
end
def message
params[:event][:blocks].first
params[:event][:blocks]&.first
end
def verify_hook
@ -56,23 +56,42 @@ class Integrations::Slack::IncomingMessageBuilder
end
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
def conversation
@conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first
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
return unless conversation
conversation.messages.create(
message_type: 0,
message_type: :outgoing,
account_id: conversation.account_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' }
end
def slack_client
Slack.configure do |config|
config.token = integration_hook.access_token
end
Slack::Web::Client.new
end
end

View file

@ -11,6 +11,8 @@ class Integrations::Slack::OutgoingMessageBuilder
end
def perform
return if message.source_id.present?
send_message
update_reference_id
end
@ -25,12 +27,32 @@ class Integrations::Slack::OutgoingMessageBuilder
@contact ||= conversation.contact
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
sender = message.outgoing? ? agent : contact
sender_type = sender.class == Contact ? 'Contact' : 'Agent'
@slack_message = slack_client.chat_postMessage(
channel: hook.reference_id,
text: message.content,
username: contact.try(:name),
thread_ts: conversation.identifier
text: message_content,
username: "#{sender_type}: #{sender.try(:name)}",
thread_ts: conversation.identifier,
icon_url: avatar_url(sender)
)
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 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
expect(response).to have_http_status(:success)
app = JSON.parse(response.body).first
expect(app['id']).to eql('cw_slack')
expect(app['name']).to eql('Slack')
app = JSON.parse(response.body)['payload'].first
expect(app['id']).to eql('webhook')
expect(app['name']).to eql('Webhooks')
end
end
end
@ -30,7 +30,7 @@ RSpec.describe 'Integration Apps API', type: :request do
describe 'GET /api/v1/integrations/apps/:id' do
context 'when it is an unauthenticated user' 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)
end
end
@ -39,13 +39,13 @@ RSpec.describe 'Integration Apps API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
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,
as: :json
expect(response).to have_http_status(:success)
app = JSON.parse(response.body)
expect(app['id']).to eql('cw_slack')
expect(app['id']).to eql('slack')
expect(app['name']).to eql('Slack')
end
end

View file

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

View file

@ -10,7 +10,7 @@ describe Integrations::Slack::HookBuilder do
hooks_count = account.hooks.count
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
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(: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]) }
describe '#perform' do
@ -19,8 +19,10 @@ describe Integrations::Slack::IncomingMessageBuilder do
context 'when message creation' do
it 'creates message' do
expect(hook).not_to eq nil
messages_count = conversation.messages.count
builder = described_class.new(message_params)
allow(builder).to receive(:sender).and_return(nil)
builder.perform
expect(conversation.messages.count).to eql(messages_count + 1)
end

View file

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

View file

@ -3,7 +3,7 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Integrations::Slacks', type: :request do
let(:account) { create(:account) }
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
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
it 'creates hook' do
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)
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",
params: { code: SecureRandom.hex },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['app_id']).to eql('cw_slack')
expect(json_response['id']).to eql('slack')
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
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)
end
end
@ -41,32 +44,32 @@ RSpec.describe 'Api::V1::Accounts::Integrations::Slacks', type: :request do
context 'when it is an authenticated user' do
it 'updates hook' do
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)
put "/api/v1/accounts/#{account.id}/integrations/slack/#{hook.id}",
put "/api/v1/accounts/#{account.id}/integrations/slack",
params: { channel: SecureRandom.hex },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:success)
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
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
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)
end
end
context 'when it is an authenticated user' 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
expect(response).to have_http_status(:success)
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
it 'consumes webhook' do
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)

View file

@ -7,41 +7,12 @@ module SlackStubs
}
end
# rubocop:disable Metrics/MethodLength
def slack_message_stub
{
"token": '[FILTERED]',
"team_id": 'TLST3048H',
"api_app_id": 'A012S5UETV4',
"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'
},
"event": message_event,
"type": 'event_callback',
"event_id": 'Ev013QUX3WV6',
"event_time": 1_588_623_033,
@ -49,5 +20,38 @@ module SlackStubs
"webhook": {}
}
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