mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
feat: Implement UI for Agent Bots in settings and remove CSML support (#11276)
- Add agent bots management UI in settings with avatar upload - Enable agent bot configuration for all inbox types - Implement proper CRUD operations with webhook URL support - Fix agent bots menu item visibility in settings sidebar - Remove all CSML-related code and features - Add migration to convert existing CSML bots to webhook bots - Simplify agent bot model and services to focus on webhook bots - Improve UI to differentiate between system bots and account bots ## Video https://github.com/user-attachments/assets/3f4edbb7-b758-468c-8dd6-a9537b983f7d --------- Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
|
||||
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {})
|
||||
end
|
||||
|
||||
def process_avatar_from_url
|
||||
|
||||
@@ -36,7 +36,7 @@ class DashboardController < ActionController::Base
|
||||
'LOGOUT_REDIRECT_LINK',
|
||||
'DISABLE_USER_PROFILE_UPDATE',
|
||||
'DEPLOYMENT_ENV',
|
||||
'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
|
||||
'INSTALLATION_PRICING_PLAN'
|
||||
).merge(app_config)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AgentBotsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('agent_bots', { accountScoped: true });
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
update(id, data) {
|
||||
return axios.patch(`${this.url}/${id}`, data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
deleteAgentBotAvatar(botId) {
|
||||
return axios.delete(`${this.url}/${botId}/avatar`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AgentBotsAPI();
|
||||
|
||||
@@ -125,7 +125,10 @@ defineExpose({ open, close });
|
||||
<slot />
|
||||
<!-- Dialog content will be injected here -->
|
||||
<slot name="footer">
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<div
|
||||
v-if="showCancelButton || showConfirmButton"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
v-if="showCancelButton"
|
||||
variant="faded"
|
||||
|
||||
@@ -127,7 +127,6 @@ const settings = accountId => ({
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
globalConfigFlag: 'csmlEditorHost',
|
||||
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
|
||||
toStateName: 'agent_bots',
|
||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
||||
|
||||
@@ -49,13 +49,6 @@ export default {
|
||||
return !!this.menuItem.children;
|
||||
},
|
||||
isMenuItemVisible() {
|
||||
if (this.menuItem.globalConfigFlag) {
|
||||
// this checks for the `csmlEditorHost` flag in the global config
|
||||
// if this is present, we toggle the CSML editor menu item
|
||||
// TODO: This is very specific, and can be handled better, fix it
|
||||
return !!this.globalConfig[this.menuItem.globalConfigFlag];
|
||||
}
|
||||
|
||||
let isFeatureEnabled = true;
|
||||
if (this.menuItem.featureFlag) {
|
||||
isFeatureEnabled = this.isFeatureEnabledonAccount(
|
||||
|
||||
@@ -2,23 +2,13 @@
|
||||
"AGENT_BOTS": {
|
||||
"HEADER": "Bots",
|
||||
"LOADING_EDITOR": "Loading editor...",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try.You can manage your bots from this page or create new ones using the 'Configure new bot' button.",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"CSML_BOT_EDITOR": {
|
||||
"NAME": {
|
||||
"LABEL": "Bot name",
|
||||
"PLACEHOLDER": "Name your bot.",
|
||||
"ERROR": "Bot name is required."
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Bot description",
|
||||
"PLACEHOLDER": "What does this bot do?"
|
||||
},
|
||||
"BOT_CONFIG": {
|
||||
"ERROR": "Please enter your CSML bot configuration above.",
|
||||
"API_ERROR": "Your CSML configuration is invalid. Please fix it and try again."
|
||||
},
|
||||
"SUBMIT": "Validate and save"
|
||||
"GLOBAL_BOT": "System bot",
|
||||
"GLOBAL_BOT_BADGE": "System",
|
||||
"AVATAR": {
|
||||
"SUCCESS_DELETE": "Bot avatar deleted successfully",
|
||||
"ERROR_DELETE": "Error deleting bot avatar, please try again"
|
||||
},
|
||||
"BOT_CONFIGURATION": {
|
||||
"TITLE": "Select an agent bot",
|
||||
@@ -32,7 +22,7 @@
|
||||
"SELECT_PLACEHOLDER": "Select bot"
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Configure new bot",
|
||||
"TITLE": "Add Bot",
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Bot added successfully.",
|
||||
@@ -40,16 +30,22 @@
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"404": "No bots found. You can create a bot by clicking the 'Configure new bot' button ↗",
|
||||
"404": "No bots found. You can create a bot by clicking the 'Add Bot' button.",
|
||||
"LOADING": "Fetching bots...",
|
||||
"TYPE": "Bot type"
|
||||
"TABLE_HEADER": {
|
||||
"DETAILS": "Bot Details",
|
||||
"URL": "Webhook URL"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "Delete",
|
||||
"TITLE": "Delete bot",
|
||||
"SUBMIT": "Delete",
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"DESCRIPTION": "Are you sure you want to delete this bot? This action is irreversible.",
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm Deletion",
|
||||
"MESSAGE": "Are you sure you want to delete {name}?",
|
||||
"YES": "Yes, Delete",
|
||||
"NO": "No, Keep"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Bot deleted successfully.",
|
||||
"ERROR_MESSAGE": "Could not delete bot. Please try again."
|
||||
@@ -57,17 +53,44 @@
|
||||
},
|
||||
"EDIT": {
|
||||
"BUTTON_TEXT": "Edit",
|
||||
"LOADING": "Fetching bots...",
|
||||
"TITLE": "Edit bot",
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Bot updated successfully.",
|
||||
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
"AVATAR": {
|
||||
"LABEL": "Bot avatar"
|
||||
},
|
||||
"NAME": {
|
||||
"LABEL": "Bot name",
|
||||
"PLACEHOLDER": "Enter bot name",
|
||||
"REQUIRED": "Bot name is required"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "What does this bot do?"
|
||||
},
|
||||
"WEBHOOK_URL": {
|
||||
"LABEL": "Webhook URL",
|
||||
"PLACEHOLDER": "https://example.com/webhook",
|
||||
"REQUIRED": "Webhook URL is required"
|
||||
},
|
||||
"ERRORS": {
|
||||
"NAME": "Bot name is required",
|
||||
"URL": "Webhook URL is required",
|
||||
"VALID_URL": "Please enter a valid URL starting with http:// or https://"
|
||||
},
|
||||
"CANCEL": "Cancel",
|
||||
"CREATE": "Create Bot",
|
||||
"UPDATE": "Update Bot"
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"DESCRIPTION": "Configure a webhook bot to integrate with your custom services. The bot will receive and process events from conversations and can respond to them."
|
||||
},
|
||||
"TYPES": {
|
||||
"WEBHOOK": "Webhook bot",
|
||||
"CSML": "CSML bot"
|
||||
"WEBHOOK": "Webhook bot"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +1,191 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import AgentBotRow from './components/AgentBotRow.vue';
|
||||
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import AgentBotModal from './components/AgentBotModal.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const MODAL_TYPES = {
|
||||
CREATE: 'create',
|
||||
EDIT: 'edit',
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const agentBots = useMapGetter('agentBots/getBots');
|
||||
const uiFlags = useMapGetter('agentBots/getUIFlags');
|
||||
|
||||
const confirmDialog = ref(null);
|
||||
const selectedBot = ref({});
|
||||
const loading = ref({});
|
||||
const modalType = ref(MODAL_TYPES.CREATE);
|
||||
const agentBotModalRef = ref(null);
|
||||
const agentBotDeleteDialogRef = ref(null);
|
||||
|
||||
const onConfigureNewBot = () => {
|
||||
router.push({
|
||||
name: 'agent_bots_csml_new',
|
||||
});
|
||||
const tableHeaders = computed(() => {
|
||||
return [
|
||||
t('AGENT_BOTS.LIST.TABLE_HEADER.DETAILS'),
|
||||
t('AGENT_BOTS.LIST.TABLE_HEADER.URL'),
|
||||
];
|
||||
});
|
||||
|
||||
const selectedBotName = computed(() => selectedBot.value?.name || '');
|
||||
|
||||
const openAddModal = () => {
|
||||
modalType.value = MODAL_TYPES.CREATE;
|
||||
selectedBot.value = {};
|
||||
agentBotModalRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const openEditModal = bot => {
|
||||
modalType.value = MODAL_TYPES.EDIT;
|
||||
selectedBot.value = bot;
|
||||
agentBotModalRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const openDeletePopup = bot => {
|
||||
selectedBot.value = bot;
|
||||
agentBotDeleteDialogRef.value.open();
|
||||
};
|
||||
|
||||
const deleteAgentBot = async id => {
|
||||
try {
|
||||
await store.dispatch('agentBots/delete', id);
|
||||
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
loading.value[id] = false;
|
||||
selectedBot.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeletion = () => {
|
||||
loading.value[selectedBot.value.id] = true;
|
||||
deleteAgentBot(selectedBot.value.id);
|
||||
agentBotDeleteDialogRef.value.close();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('agentBots/get');
|
||||
});
|
||||
|
||||
const onDeleteAgentBot = async bot => {
|
||||
const ok = await confirmDialog.value.showConfirmation();
|
||||
|
||||
if (ok) {
|
||||
try {
|
||||
await store.dispatch('agentBots/delete', bot.id);
|
||||
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onEditAgentBot = bot => {
|
||||
router.push(
|
||||
frontendURL(
|
||||
`accounts/${accountId.value}/settings/agent-bots/csml/${bot.id}`
|
||||
)
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:loading-message="$t('AGENT_BOTS.LIST.LOADING')"
|
||||
:loading-message="t('AGENT_BOTS.LIST.LOADING')"
|
||||
:no-records-found="!agentBots.length"
|
||||
:no-records-message="$t('AGENT_BOTS.LIST.404')"
|
||||
:no-records-message="t('AGENT_BOTS.LIST.404')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('AGENT_BOTS.HEADER')"
|
||||
:description="$t('AGENT_BOTS.DESCRIPTION')"
|
||||
:link-text="$t('AGENT_BOTS.LEARN_MORE')"
|
||||
:title="t('AGENT_BOTS.HEADER')"
|
||||
:description="t('AGENT_BOTS.DESCRIPTION')"
|
||||
:link-text="t('AGENT_BOTS.LEARN_MORE')"
|
||||
feature-name="agent_bots"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('AGENT_BOTS.ADD.TITLE')"
|
||||
@click="onConfigureNewBot"
|
||||
@click="openAddModal"
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<table class="divide-y divide-slate-75 dark:divide-slate-700">
|
||||
<tbody class="divide-y divide-n-weak text-n-slate-11">
|
||||
<AgentBotRow
|
||||
v-for="(agentBot, index) in agentBots"
|
||||
:key="agentBot.id"
|
||||
:agent-bot="agentBot"
|
||||
:index="index"
|
||||
@delete="onDeleteAgentBot"
|
||||
@edit="onEditAgentBot"
|
||||
<table class="min-w-full overflow-x-auto divide-y divide-n-strong">
|
||||
<thead>
|
||||
<th
|
||||
v-for="thHeader in tableHeaders"
|
||||
:key="thHeader"
|
||||
class="py-4 font-semibold text-left ltr:pr-4 rtl:pl-4 text-n-slate-11"
|
||||
>
|
||||
{{ thHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody class="flex-1 divide-y divide-n-weak text-n-slate-12">
|
||||
<tr v-for="bot in agentBots" :key="bot.id">
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4">
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<Avatar
|
||||
:name="bot.name"
|
||||
:src="bot.thumbnail"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('AGENT_BOTS.DELETE.TITLE')"
|
||||
:description="$t('AGENT_BOTS.DELETE.DESCRIPTION')"
|
||||
<div>
|
||||
<span class="block font-medium break-words">
|
||||
{{ bot.name }}
|
||||
<span
|
||||
v-if="bot.system_bot"
|
||||
class="text-xs text-n-slate-12 bg-n-blue-5 inline-block rounded-md py-0.5 px-1 ltr:ml-1 rtl:mr-1"
|
||||
>
|
||||
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ bot.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4 text-sm">
|
||||
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
|
||||
</td>
|
||||
<td class="py-4 min-w-xs">
|
||||
<div class="flex gap-1 justify-end">
|
||||
<Button
|
||||
v-if="!bot.system_bot"
|
||||
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
|
||||
icon="i-lucide-pen"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
:is-loading="loading[bot.id]"
|
||||
@click="openEditModal(bot)"
|
||||
/>
|
||||
<Button
|
||||
v-if="!bot.system_bot"
|
||||
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
|
||||
icon="i-lucide-trash-2"
|
||||
xs
|
||||
ruby
|
||||
faded
|
||||
:is-loading="loading[bot.id]"
|
||||
@click="openDeletePopup(bot)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<AgentBotModal
|
||||
ref="agentBotModalRef"
|
||||
:type="modalType"
|
||||
:selected-bot="selectedBot"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref="agentBotDeleteDialogRef"
|
||||
type="alert"
|
||||
:title="t('AGENT_BOTS.DELETE.CONFIRM.TITLE')"
|
||||
:description="
|
||||
t('AGENT_BOTS.DELETE.CONFIRM.MESSAGE', { name: selectedBotName })
|
||||
"
|
||||
:is-loading="uiFlags.isDeleting"
|
||||
:confirm-button-label="t('AGENT_BOTS.DELETE.CONFIRM.YES')"
|
||||
:cancel-button-label="t('AGENT_BOTS.DELETE.CONFIRM.NO')"
|
||||
@confirm="confirmDeletion"
|
||||
/>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import Bot from './Index.vue';
|
||||
import CsmlEditBot from './csml/Edit.vue';
|
||||
import CsmlNewBot from './csml/New.vue';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsContent from '../Wrapper.vue';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
|
||||
export default {
|
||||
@@ -26,36 +23,5 @@ export default {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/agent-bots'),
|
||||
component: SettingsContent,
|
||||
props: () => {
|
||||
return {
|
||||
headerTitle: 'AGENT_BOTS.HEADER',
|
||||
icon: 'bot',
|
||||
showBackButton: true,
|
||||
};
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'csml/new',
|
||||
name: 'agent_bots_csml_new',
|
||||
component: CsmlNewBot,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'csml/:botId',
|
||||
name: 'agent_bots_csml_edit',
|
||||
component: CsmlEditBot,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<script setup>
|
||||
import { ref, computed, reactive, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { required, helpers, url } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
selectedBot: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const MODAL_TYPES = {
|
||||
CREATE: 'create',
|
||||
EDIT: 'edit',
|
||||
};
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const uiFlags = useMapGetter('agentBots/getUIFlags');
|
||||
|
||||
const formState = reactive({
|
||||
botName: '',
|
||||
botDescription: '',
|
||||
botUrl: '',
|
||||
botAvatar: null,
|
||||
botAvatarUrl: '',
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
botName: {
|
||||
required: helpers.withMessage(
|
||||
() => t('AGENT_BOTS.FORM.ERRORS.NAME'),
|
||||
required
|
||||
),
|
||||
},
|
||||
botUrl: {
|
||||
required: helpers.withMessage(
|
||||
() => t('AGENT_BOTS.FORM.ERRORS.URL'),
|
||||
required
|
||||
),
|
||||
url: helpers.withMessage(
|
||||
() => t('AGENT_BOTS.FORM.ERRORS.VALID_URL'),
|
||||
url
|
||||
),
|
||||
},
|
||||
},
|
||||
formState
|
||||
);
|
||||
|
||||
const isLoading = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
? uiFlags.value.isCreating
|
||||
: uiFlags.value.isUpdating
|
||||
);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
? t('AGENT_BOTS.ADD.TITLE')
|
||||
: t('AGENT_BOTS.EDIT.TITLE')
|
||||
);
|
||||
|
||||
const confirmButtonLabel = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
? t('AGENT_BOTS.FORM.CREATE')
|
||||
: t('AGENT_BOTS.FORM.UPDATE')
|
||||
);
|
||||
|
||||
const botNameError = computed(() =>
|
||||
v$.value.botName.$error ? v$.value.botName.$errors[0]?.$message : ''
|
||||
);
|
||||
|
||||
const botUrlError = computed(() =>
|
||||
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formState, {
|
||||
botName: '',
|
||||
botDescription: '',
|
||||
botUrl: '',
|
||||
botAvatar: null,
|
||||
botAvatarUrl: '',
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleImageUpload = ({ file, url: avatarUrl }) => {
|
||||
formState.botAvatar = file;
|
||||
formState.botAvatarUrl = avatarUrl;
|
||||
};
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
if (props.selectedBot?.id) {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'agentBots/deleteAgentBotAvatar',
|
||||
props.selectedBot.id
|
||||
);
|
||||
formState.botAvatar = null;
|
||||
formState.botAvatarUrl = '';
|
||||
useAlert(t('AGENT_BOTS.AVATAR.SUCCESS_DELETE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_BOTS.AVATAR.ERROR_DELETE'));
|
||||
}
|
||||
} else {
|
||||
formState.botAvatar = null;
|
||||
formState.botAvatarUrl = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
const botData = {
|
||||
name: formState.botName,
|
||||
description: formState.botDescription,
|
||||
outgoing_url: formState.botUrl,
|
||||
bot_type: 'webhook',
|
||||
avatar: formState.botAvatar,
|
||||
};
|
||||
|
||||
const isCreate = props.type === MODAL_TYPES.CREATE;
|
||||
|
||||
try {
|
||||
const actionPayload = isCreate
|
||||
? botData
|
||||
: { id: props.selectedBot.id, data: botData };
|
||||
|
||||
await store.dispatch(
|
||||
`agentBots/${isCreate ? 'create' : 'update'}`,
|
||||
actionPayload
|
||||
);
|
||||
|
||||
const alertKey = isCreate
|
||||
? t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE')
|
||||
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
|
||||
useAlert(alertKey);
|
||||
|
||||
dialogRef.value.close();
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
const errorKey = isCreate
|
||||
? t('AGENT_BOTS.ADD.API.ERROR_MESSAGE')
|
||||
: t('AGENT_BOTS.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(errorKey);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeForm = () => {
|
||||
if (props.selectedBot && Object.keys(props.selectedBot).length) {
|
||||
const { name, description, outgoing_url, thumbnail, bot_config } =
|
||||
props.selectedBot;
|
||||
formState.botName = name || '';
|
||||
formState.botDescription = description || '';
|
||||
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
|
||||
formState.botAvatarUrl = thumbnail || '';
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="dialogTitle"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="v$.$reset()"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div class="mb-2 flex flex-col items-start">
|
||||
<span class="mb-2 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
|
||||
</span>
|
||||
<Avatar
|
||||
:src="formState.botAvatarUrl"
|
||||
:name="formState.botName"
|
||||
:size="68"
|
||||
allow-upload
|
||||
@upload="handleImageUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="formState.botName"
|
||||
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
|
||||
:message="botNameError"
|
||||
:message-type="botNameError ? 'error' : 'info'"
|
||||
@blur="v$.botName.$touch()"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="formState.botDescription"
|
||||
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="formState.botUrl"
|
||||
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
|
||||
:message="botUrlError"
|
||||
:message-type="botUrlError ? 'error' : 'info'"
|
||||
@blur="v$.botUrl.$touch()"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AGENT_BOTS.FORM.CANCEL')"
|
||||
@click="dialogRef.close()"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
data-testid="label-submit"
|
||||
:label="confirmButtonLabel"
|
||||
:is-loading="isLoading"
|
||||
:disabled="v$.$invalid"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
|
||||
import AgentBotType from './AgentBotType.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
agentBot: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
|
||||
const isACSMLTypeBot = computed(() => {
|
||||
const { bot_type: botType } = props.agentBot;
|
||||
return botType === 'csml';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr class="space-x-2">
|
||||
<td class="py-4 ltr:pl-0 ltr:pr-4 rtl:pl-4 rtl:pr-0">
|
||||
<div class="flex items-center break-words font-medium">
|
||||
{{ agentBot.name }}
|
||||
(<AgentBotType :bot-type="agentBot.bot_type" />)
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<ShowMore :text="agentBot.description || ''" :limit="120" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="flex justify-end gap-1 h-full items-center">
|
||||
<Button
|
||||
v-if="isACSMLTypeBot"
|
||||
v-tooltip.top="$t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
|
||||
icon="i-lucide-pen"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="emit('edit', agentBot)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="$t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
|
||||
icon="i-lucide-trash-2"
|
||||
xs
|
||||
ruby
|
||||
faded
|
||||
@click="emit('delete', agentBot, index)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
botType: {
|
||||
type: String,
|
||||
default: 'webhook',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const botTypeConfig = computed(() => ({
|
||||
csml: {
|
||||
label: t('AGENT_BOTS.TYPES.CSML'),
|
||||
thumbnail: '/dashboard/images/agent-bots/csml.png',
|
||||
},
|
||||
webhook: {
|
||||
label: t('AGENT_BOTS.TYPES.WEBHOOK'),
|
||||
thumbnail: '/dashboard/images/agent-bots/webhook.svg',
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<img
|
||||
v-tooltip="botTypeConfig[botType].label"
|
||||
class="h-3 w-auto"
|
||||
:src="botTypeConfig[botType].thumbnail"
|
||||
:alt="botTypeConfig[botType].label"
|
||||
/>
|
||||
<span>{{ botTypeConfig[botType].label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,99 +0,0 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import CsmlMonacoEditor from './CSMLMonacoEditor.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { CsmlMonacoEditor, NextButton },
|
||||
props: {
|
||||
agentBot: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['submit'],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
validations: {
|
||||
bot: {
|
||||
name: { required },
|
||||
csmlContent: { required },
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bot: {
|
||||
name: this.agentBot.name || '',
|
||||
description: this.agentBot.description || '',
|
||||
csmlContent: this.agentBot.bot_config.csml_content || '',
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
return;
|
||||
}
|
||||
this.$emit('submit', {
|
||||
id: this.agentBot.id || '',
|
||||
...this.bot,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<div class="flex flex-row">
|
||||
<div class="w-[68%]">
|
||||
<div class="h-[calc(100vh-56px)] relative">
|
||||
<CsmlMonacoEditor v-model="bot.csmlContent" class="w-full h-full" />
|
||||
<div
|
||||
v-if="v$.bot.csmlContent.$error"
|
||||
class="bg-red-100 dark:bg-red-200 text-white dark:text-white absolute bottom-0 w-full p-2.5 flex items-center text-xs justify-center flex-shrink-0"
|
||||
>
|
||||
<span>{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.ERROR') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-[32%] overflow-auto p-4 h-[calc(100vh-56px)]">
|
||||
<form
|
||||
class="flex flex-col justify-between h-full"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div>
|
||||
<label :class="{ error: v$.bot.name.$error }">
|
||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.LABEL') }}
|
||||
<input
|
||||
v-model="bot.name"
|
||||
type="text"
|
||||
:placeholder="$t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<span v-if="v$.bot.name.$error" class="message">
|
||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.LABEL') }}
|
||||
<textarea
|
||||
v-model="bot.description"
|
||||
rows="4"
|
||||
:placeholder="
|
||||
$t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('AGENT_BOTS.CSML_BOT_EDITOR.SUBMIT')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,71 +0,0 @@
|
||||
<script>
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
components: { LoadingState },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
iframeLoading: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
window.onmessage = e => {
|
||||
if (
|
||||
typeof e.data !== 'string' ||
|
||||
!e.data.startsWith('chatwoot-csml-editor:update')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const csmlContent = e.data.replace('chatwoot-csml-editor:update', '');
|
||||
this.$emit('update:modelValue', csmlContent);
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onEditorLoad() {
|
||||
const frameElement = document.getElementById(`csml-editor--frame`);
|
||||
const eventData = {
|
||||
event: 'editorContext',
|
||||
data: this.modelValue || '',
|
||||
};
|
||||
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
|
||||
this.iframeLoading = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="csml-editor--container">
|
||||
<LoadingState
|
||||
v-if="iframeLoading"
|
||||
:message="$t('AGENT_BOTS.LOADING_EDITOR')"
|
||||
class="dashboard-app_loading-container"
|
||||
/>
|
||||
<iframe
|
||||
id="csml-editor--frame"
|
||||
:src="globalConfig.csmlEditorHost"
|
||||
@load="onEditorLoad"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#csml-editor--frame {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
|
||||
|
||||
export default {
|
||||
components: { Spinner, CsmlBotEditor },
|
||||
computed: {
|
||||
agentBot() {
|
||||
return this.$store.getters['agentBots/getBot'](this.$route.params.botId);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agentBots/show', this.$route.params.botId);
|
||||
},
|
||||
methods: {
|
||||
async updateBot(bot) {
|
||||
try {
|
||||
await this.$store.dispatch('agentBots/update', {
|
||||
id: bot.id,
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
bot_type: 'csml',
|
||||
bot_config: { csml_content: bot.csmlContent },
|
||||
});
|
||||
useAlert(this.$t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.API_ERROR'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CsmlBotEditor v-if="agentBot.id" :agent-bot="agentBot" @submit="updateBot" />
|
||||
<div v-else class="flex flex-col h-auto overflow-auto no-padding">
|
||||
<Spinner />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL } from '../../../../../helper/URLHelper';
|
||||
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
|
||||
|
||||
export default {
|
||||
components: { CsmlBotEditor },
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
async saveBot(bot) {
|
||||
try {
|
||||
const agentBot = await this.$store.dispatch('agentBots/create', {
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
bot_type: 'csml',
|
||||
bot_config: { csml_content: bot.csmlContent },
|
||||
});
|
||||
if (agentBot) {
|
||||
this.$router.replace(
|
||||
frontendURL(
|
||||
`accounts/${this.accountId}/settings/agent-bots/csml/${agentBot.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
useAlert(this.$t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_BOTS.ADD.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CsmlBotEditor :agent-bot="{ bot_config: {} }" @submit="saveBot" />
|
||||
</template>
|
||||
@@ -141,11 +141,7 @@ export default {
|
||||
}
|
||||
|
||||
if (
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.AGENT_BOTS
|
||||
) &&
|
||||
!(this.isAnEmailChannel || this.isATwitterInbox)
|
||||
this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.AGENT_BOTS)
|
||||
) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
|
||||
@@ -12,6 +12,7 @@ export const state = {
|
||||
isCreating: false,
|
||||
isDeleting: false,
|
||||
isUpdating: false,
|
||||
isUpdatingAvatar: false,
|
||||
isFetchingAgentBot: false,
|
||||
isSettingAgentBot: false,
|
||||
isDisconnecting: false,
|
||||
@@ -48,10 +49,23 @@ export const actions = {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
create: async ({ commit }, agentBotObj) => {
|
||||
|
||||
create: async ({ commit }, botData) => {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isCreating: true });
|
||||
try {
|
||||
const response = await AgentBotsAPI.create(agentBotObj);
|
||||
// Create FormData for file upload
|
||||
const formData = new FormData();
|
||||
formData.append('name', botData.name);
|
||||
formData.append('description', botData.description);
|
||||
formData.append('bot_type', botData.bot_type || 'webhook');
|
||||
formData.append('outgoing_url', botData.outgoing_url);
|
||||
|
||||
// Add avatar file if available
|
||||
if (botData.avatar) {
|
||||
formData.append('avatar', botData.avatar);
|
||||
}
|
||||
|
||||
const response = await AgentBotsAPI.create(formData);
|
||||
commit(types.ADD_AGENT_BOT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -61,10 +75,22 @@ export const actions = {
|
||||
}
|
||||
return null;
|
||||
},
|
||||
update: async ({ commit }, { id, ...agentBotObj }) => {
|
||||
|
||||
update: async ({ commit }, { id, data }) => {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
const response = await AgentBotsAPI.update(id, agentBotObj);
|
||||
// Create FormData for file upload
|
||||
const formData = new FormData();
|
||||
formData.append('name', data.name);
|
||||
formData.append('description', data.description);
|
||||
formData.append('bot_type', data.bot_type || 'webhook');
|
||||
formData.append('outgoing_url', data.outgoing_url);
|
||||
|
||||
if (data.avatar) {
|
||||
formData.append('avatar', data.avatar);
|
||||
}
|
||||
|
||||
const response = await AgentBotsAPI.update(id, formData);
|
||||
commit(types.EDIT_AGENT_BOT, response.data);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
@@ -72,6 +98,7 @@ export const actions = {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ commit }, id) => {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true });
|
||||
try {
|
||||
@@ -83,6 +110,20 @@ export const actions = {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteAgentBotAvatar: async ({ commit }, id) => {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: true });
|
||||
try {
|
||||
await AgentBotsAPI.deleteAgentBotAvatar(id);
|
||||
// Update the thumbnail to empty string after deletion
|
||||
commit(types.UPDATE_AGENT_BOT_AVATAR, { id, thumbnail: '' });
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: false });
|
||||
}
|
||||
},
|
||||
|
||||
show: async ({ commit }, id) => {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingItem: true });
|
||||
try {
|
||||
@@ -150,6 +191,12 @@ export const mutations = {
|
||||
[inboxId]: agentBotId,
|
||||
};
|
||||
},
|
||||
[types.UPDATE_AGENT_BOT_AVATAR]($state, { id, thumbnail }) {
|
||||
const botIndex = $state.records.findIndex(bot => bot.id === id);
|
||||
if (botIndex !== -1) {
|
||||
$state.records[botIndex].thumbnail = thumbnail || '';
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../agentBots';
|
||||
import types from '../../../mutation-types';
|
||||
import { agentBotRecords } from './fixtures';
|
||||
import { agentBotRecords, agentBotData } from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
@@ -30,16 +30,22 @@ describe('#actions', () => {
|
||||
describe('#create', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.post.mockResolvedValue({ data: agentBotRecords[0] });
|
||||
await actions.create({ commit }, agentBotRecords[0]);
|
||||
await actions.create({ commit }, agentBotData);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
|
||||
[types.ADD_AGENT_BOT, agentBotRecords[0]],
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
|
||||
]);
|
||||
|
||||
expect(axios.post.mock.calls.length).toBe(1);
|
||||
const formDataArg = axios.post.mock.calls[0][1];
|
||||
expect(formDataArg instanceof FormData).toBe(true);
|
||||
});
|
||||
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await expect(actions.create({ commit })).rejects.toThrow(Error);
|
||||
await expect(actions.create({ commit }, {})).rejects.toThrow(Error);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
|
||||
@@ -50,17 +56,29 @@ describe('#actions', () => {
|
||||
describe('#update', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.patch.mockResolvedValue({ data: agentBotRecords[0] });
|
||||
await actions.update({ commit }, agentBotRecords[0]);
|
||||
await actions.update(
|
||||
{ commit },
|
||||
{
|
||||
id: agentBotRecords[0].id,
|
||||
data: agentBotData,
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
|
||||
[types.EDIT_AGENT_BOT, agentBotRecords[0]],
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false }],
|
||||
]);
|
||||
|
||||
expect(axios.patch.mock.calls.length).toBe(1);
|
||||
const formDataArg = axios.patch.mock.calls[0][1];
|
||||
expect(formDataArg instanceof FormData).toBe(true);
|
||||
});
|
||||
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await expect(
|
||||
actions.update({ commit }, agentBotRecords[0])
|
||||
actions.update({ commit }, { id: 1, data: {} })
|
||||
).rejects.toThrow(Error);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
|
||||
@@ -68,7 +86,6 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#delete', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.delete.mockResolvedValue({ data: agentBotRecords[0] });
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
export const agentBotRecords = [
|
||||
{
|
||||
account_id: 1,
|
||||
id: 11,
|
||||
name: 'Agent Bot 11',
|
||||
description: 'Agent Bot Description',
|
||||
type: 'csml',
|
||||
bot_type: 'webhook',
|
||||
thumbnail: 'https://example.com/thumbnail.jpg',
|
||||
bot_config: {},
|
||||
outgoing_url: 'https://example.com/outgoing',
|
||||
access_token: 'hN8QwG769RqBXmme',
|
||||
system_bot: false,
|
||||
},
|
||||
|
||||
{
|
||||
account_id: 1,
|
||||
id: 12,
|
||||
name: 'Agent Bot 12',
|
||||
description: 'Agent Bot Description 12',
|
||||
type: 'csml',
|
||||
bot_type: 'webhook',
|
||||
thumbnail: 'https://example.com/thumbnail.jpg',
|
||||
bot_config: {},
|
||||
outgoing_url: 'https://example.com/outgoing',
|
||||
access_token: 'hN8QwG769RqBXmme',
|
||||
system_bot: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const agentBotData = {
|
||||
name: 'Test Bot',
|
||||
description: 'Test Description',
|
||||
outgoing_url: 'https://test.com',
|
||||
bot_type: 'webhook',
|
||||
avatar: new File([''], 'filename'),
|
||||
};
|
||||
|
||||
@@ -51,4 +51,16 @@ describe('#mutations', () => {
|
||||
expect(state.agentBotInbox).toEqual({ 3: 2 });
|
||||
});
|
||||
});
|
||||
describe('#UPDATE_AGENT_BOT_AVATAR', () => {
|
||||
it('update agent bot avatar', () => {
|
||||
const state = { records: [agentBotRecords[0]] };
|
||||
mutations[types.UPDATE_AGENT_BOT_AVATAR](state, {
|
||||
id: 11,
|
||||
thumbnail: 'https://example.com/thumbnail.jpg',
|
||||
});
|
||||
expect(state.records[0].thumbnail).toEqual(
|
||||
'https://example.com/thumbnail.jpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,6 +301,7 @@ export default {
|
||||
EDIT_AGENT_BOT: 'EDIT_AGENT_BOT',
|
||||
DELETE_AGENT_BOT: 'DELETE_AGENT_BOT',
|
||||
SET_AGENT_BOT_INBOX: 'SET_AGENT_BOT_INBOX',
|
||||
UPDATE_AGENT_BOT_AVATAR: 'UPDATE_AGENT_BOT_AVATAR',
|
||||
|
||||
// MACROS
|
||||
SET_MACROS_UI_FLAG: 'SET_MACROS_UI_FLAG',
|
||||
|
||||
@@ -5,7 +5,6 @@ const {
|
||||
AZURE_APP_ID: azureAppId,
|
||||
BRAND_NAME: brandName,
|
||||
CHATWOOT_INBOX_TOKEN: chatwootInboxToken,
|
||||
CSML_EDITOR_HOST: csmlEditorHost,
|
||||
CREATE_NEW_ACCOUNT_FROM_DASHBOARD: createNewAccountFromDashboard,
|
||||
DIRECT_UPLOADS_ENABLED: directUploadsEnabled,
|
||||
DISPLAY_MANIFEST: displayManifest,
|
||||
@@ -29,7 +28,6 @@ const state = {
|
||||
azureAppId,
|
||||
brandName,
|
||||
chatwootInboxToken,
|
||||
csmlEditorHost,
|
||||
deploymentEnv,
|
||||
createNewAccountFromDashboard,
|
||||
directUploadsEnabled: directUploadsEnabled === 'true',
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
class AgentBots::CsmlJob < ApplicationJob
|
||||
queue_as :high
|
||||
|
||||
def perform(event, agent_bot, message)
|
||||
event_data = { message: message }
|
||||
Integrations::Csml::ProcessorService.new(
|
||||
event_name: event, agent_bot: agent_bot, event_data: event_data
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -59,14 +59,10 @@ class AgentBotListener < BaseListener
|
||||
true
|
||||
end
|
||||
|
||||
def process_message_event(method_name, agent_bot, message, event)
|
||||
case agent_bot.bot_type
|
||||
when 'webhook'
|
||||
def process_message_event(method_name, agent_bot, message, _event)
|
||||
# Only webhook bots are supported
|
||||
payload = message.webhook_data.merge(event: method_name)
|
||||
process_webhook_bot_event(agent_bot, payload)
|
||||
when 'csml'
|
||||
process_csml_bot_event(event.name, agent_bot, message)
|
||||
end
|
||||
end
|
||||
|
||||
def process_webhook_bot_event(agent_bot, payload)
|
||||
@@ -74,8 +70,4 @@ class AgentBotListener < BaseListener
|
||||
|
||||
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload)
|
||||
end
|
||||
|
||||
def process_csml_bot_event(event, agent_bot, message)
|
||||
AgentBots::CsmlJob.perform_later(event, agent_bot, message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,9 +25,8 @@ class AgentBot < ApplicationRecord
|
||||
has_many :inboxes, through: :agent_bot_inboxes
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
belongs_to :account, optional: true
|
||||
enum bot_type: { webhook: 0, csml: 1 }
|
||||
enum bot_type: { webhook: 0 }
|
||||
|
||||
validate :validate_agent_bot_config
|
||||
validates :outgoing_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
||||
|
||||
def available_name
|
||||
@@ -51,9 +50,7 @@ class AgentBot < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_agent_bot_config
|
||||
errors.add(:bot_config, 'Invalid Bot Configuration') unless AgentBots::ValidateBotService.new(agent_bot: self).perform
|
||||
def system_bot?
|
||||
account.nil?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
class AgentBots::ValidateBotService
|
||||
pattr_initialize [:agent_bot]
|
||||
def perform
|
||||
return true unless agent_bot.bot_type == 'csml'
|
||||
|
||||
validate_csml_bot
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def csml_client
|
||||
@csml_client ||= CsmlEngine.new
|
||||
end
|
||||
|
||||
def csml_bot_payload
|
||||
{
|
||||
id: agent_bot[:name],
|
||||
name: agent_bot[:name],
|
||||
default_flow: 'Default',
|
||||
flows: [
|
||||
{
|
||||
id: SecureRandom.uuid,
|
||||
name: 'Default',
|
||||
content: agent_bot.bot_config['csml_content'],
|
||||
commands: []
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def validate_csml_bot
|
||||
response = csml_client.validate(csml_bot_payload)
|
||||
response.blank? || response['valid']
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: agent_bot&.account).capture_exception
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,10 @@
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.description resource.description
|
||||
json.outgoing_url resource.outgoing_url
|
||||
json.thumbnail resource.avatar_url
|
||||
json.outgoing_url resource.outgoing_url unless resource.system_bot?
|
||||
json.bot_type resource.bot_type
|
||||
json.bot_config resource.bot_config
|
||||
json.account_id resource.account_id
|
||||
json.access_token resource.access_token if resource.access_token.present?
|
||||
json.system_bot resource.system_bot?
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
help_url: https://chwt.app/hc/help-center
|
||||
- name: agent_bots
|
||||
display_name: Agent Bots
|
||||
enabled: false
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/agent-bots
|
||||
- name: macros
|
||||
display_name: Macros
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class ConvertCsmlBotsToWebhookBots < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
# Find all CSML bots (bot_type = 1) and convert them to webhook (bot_type = 0)
|
||||
AgentBot.where(bot_type: 1).find_each do |bot|
|
||||
bot.update(bot_type: 0, bot_config: {})
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
# This migration is not reversible - we've removed CSML support
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_04_02_233933) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_04_10_061725) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
class CsmlEngine
|
||||
API_KEY_HEADER = 'X-Api-Key'.freeze
|
||||
|
||||
def initialize
|
||||
@host_url = GlobalConfigService.load('CSML_BOT_HOST', '')
|
||||
@api_key = GlobalConfigService.load('CSML_BOT_API_KEY', '')
|
||||
|
||||
raise ArgumentError, 'Missing Credentials' if @host_url.blank? || @api_key.blank?
|
||||
end
|
||||
|
||||
def status
|
||||
response = HTTParty.get("#{@host_url}/status")
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
def run(bot, params)
|
||||
payload = {
|
||||
bot: bot,
|
||||
event: {
|
||||
request_id: SecureRandom.uuid,
|
||||
client: params[:client],
|
||||
payload: params[:payload],
|
||||
metadata: params[:metadata],
|
||||
ttl_duration: 4000
|
||||
}
|
||||
}
|
||||
response = post('run', payload)
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
def validate(bot)
|
||||
response = post('validate', bot)
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_response(response)
|
||||
return response.parsed_response if response.success?
|
||||
|
||||
{ error: response.parsed_response, status: response.code }
|
||||
end
|
||||
|
||||
def post(path, payload)
|
||||
HTTParty.post(
|
||||
"#{@host_url}/#{path}", {
|
||||
headers: { API_KEY_HEADER => @api_key, 'Content-Type' => 'application/json' },
|
||||
body: payload.to_json
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,4 @@
|
||||
class Integrations::BotProcessorService
|
||||
# TODO: In CSML processor service, the argument is agent bot, update initializers accordingly.
|
||||
pattr_initialize [:event_name!, :hook!, :event_data!]
|
||||
|
||||
def perform
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
class Integrations::Csml::ProcessorService < Integrations::BotProcessorService
|
||||
pattr_initialize [:event_name!, :event_data!, :agent_bot!]
|
||||
|
||||
private
|
||||
|
||||
def csml_client
|
||||
@csml_client ||= CsmlEngine.new
|
||||
end
|
||||
|
||||
def get_response(session_id, content)
|
||||
csml_client.run(
|
||||
bot_payload,
|
||||
{
|
||||
client: client_params(session_id),
|
||||
payload: message_payload(content),
|
||||
metadata: metadata_params
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def client_params(session_id)
|
||||
{
|
||||
bot_id: "chatwoot-bot-#{conversation.inbox.id}",
|
||||
channel_id: "chatwoot-bot-inbox-#{conversation.inbox.id}",
|
||||
user_id: session_id
|
||||
}
|
||||
end
|
||||
|
||||
def message_payload(content)
|
||||
{
|
||||
content_type: 'text',
|
||||
content: { text: content }
|
||||
}
|
||||
end
|
||||
|
||||
def metadata_params
|
||||
{
|
||||
conversation: conversation,
|
||||
contact: conversation.contact
|
||||
}
|
||||
end
|
||||
|
||||
def bot_payload
|
||||
{
|
||||
id: "chatwoot-csml-bot-#{agent_bot.id}",
|
||||
name: "chatwoot-csml-bot-#{agent_bot.id}",
|
||||
default_flow: 'chatwoot_bot_flow',
|
||||
flows: [
|
||||
{
|
||||
id: "chatwoot-csml-bot-flow-#{agent_bot.id}-inbox-#{conversation.inbox.id}",
|
||||
name: 'chatwoot_bot_flow',
|
||||
content: agent_bot.bot_config['csml_content'],
|
||||
commands: []
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def process_response(message, response)
|
||||
csml_messages = response['messages']
|
||||
has_conversation_ended = response['conversation_end']
|
||||
|
||||
process_action(message, 'handoff') if has_conversation_ended.present?
|
||||
|
||||
return if csml_messages.blank?
|
||||
|
||||
# We do not support wait, typing now.
|
||||
csml_messages.each do |csml_message|
|
||||
create_messages(csml_message, conversation)
|
||||
end
|
||||
end
|
||||
|
||||
def create_messages(message, conversation)
|
||||
message_payload = message['payload']
|
||||
|
||||
case message_payload['content_type']
|
||||
when 'text'
|
||||
process_text_messages(message_payload, conversation)
|
||||
when 'question'
|
||||
process_question_messages(message_payload, conversation)
|
||||
when 'image'
|
||||
process_image_messages(message_payload, conversation)
|
||||
end
|
||||
end
|
||||
|
||||
def process_text_messages(message_payload, conversation)
|
||||
conversation.messages.create!(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: message_payload['content']['text'],
|
||||
sender: agent_bot
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def process_question_messages(message_payload, conversation)
|
||||
buttons = message_payload['content']['buttons'].map do |button|
|
||||
{ title: button['content']['title'], value: button['content']['payload'] }
|
||||
end
|
||||
conversation.messages.create!(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: message_payload['content']['title'],
|
||||
content_type: 'input_select',
|
||||
content_attributes: { items: buttons },
|
||||
sender: agent_bot
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def prepare_attachment(message_payload, message, account_id)
|
||||
attachment_params = { file_type: :image, account_id: account_id }
|
||||
attachment_url = message_payload['content']['url']
|
||||
attachment = message.attachments.new(attachment_params)
|
||||
attachment_file = Down.download(attachment_url)
|
||||
attachment.file.attach(
|
||||
io: attachment_file,
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
)
|
||||
end
|
||||
|
||||
def process_image_messages(message_payload, conversation)
|
||||
message = conversation.messages.new(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: '',
|
||||
content_type: 'text',
|
||||
sender: agent_bot
|
||||
}
|
||||
)
|
||||
|
||||
prepare_attachment(message_payload, message, conversation.account_id)
|
||||
message.save!
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,31 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
end
|
||||
|
||||
it 'properly differentiates between system bots and account bots' do
|
||||
global_bot = create(:agent_bot)
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
response_data = response.parsed_body
|
||||
# Find the global bot in the response
|
||||
global_bot_response = response_data.find { |bot| bot['id'] == global_bot.id }
|
||||
# Find the account bot in the response
|
||||
account_bot_response = response_data.find { |bot| bot['id'] == agent_bot.id }
|
||||
|
||||
# Verify system_bot attribute and outgoing_url for global bot
|
||||
expect(global_bot_response['system_bot']).to be(true)
|
||||
expect(global_bot_response).not_to include('outgoing_url')
|
||||
|
||||
# Verify account bot has system_bot attribute false and includes outgoing_url
|
||||
expect(account_bot_response['system_bot']).to be(false)
|
||||
expect(account_bot_response).to include('outgoing_url')
|
||||
|
||||
# Verify both bots have thumbnail field
|
||||
expect(global_bot_response).to include('thumbnail')
|
||||
expect(account_bot_response).to include('thumbnail')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -60,6 +85,10 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(global_bot.name)
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
|
||||
# Test for system_bot attribute and webhook URL not being exposed
|
||||
expect(response.parsed_body['system_bot']).to be(true)
|
||||
expect(response.parsed_body).not_to include('outgoing_url')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -142,7 +171,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
end
|
||||
|
||||
it 'updates avatar' do
|
||||
it 'updates avatar and includes thumbnail in response' do
|
||||
# no avatar before upload
|
||||
expect(agent_bot.avatar.attached?).to be(false)
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
@@ -153,6 +182,9 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
agent_bot.reload
|
||||
expect(agent_bot.avatar.attached?).to be(true)
|
||||
|
||||
# Verify thumbnail is included in the response
|
||||
expect(response.parsed_body).to include('thumbnail')
|
||||
end
|
||||
|
||||
it 'updated avatar with avatar_url' do
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AgentBots::CsmlJob do
|
||||
it 'runs csml processor service' do
|
||||
event = 'message.created'
|
||||
message = create(:message)
|
||||
agent_bot = create(:agent_bot)
|
||||
processor = double
|
||||
|
||||
allow(Integrations::Csml::ProcessorService).to receive(:new).and_return(processor)
|
||||
allow(processor).to receive(:perform)
|
||||
|
||||
described_class.perform_now(event, agent_bot, message)
|
||||
|
||||
expect(Integrations::Csml::ProcessorService)
|
||||
.to have_received(:new)
|
||||
.with(event_name: event, agent_bot: agent_bot, event_data: { message: message })
|
||||
end
|
||||
end
|
||||
@@ -1,99 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe CsmlEngine do
|
||||
it 'raises an exception if host and api is absent' do
|
||||
expect { described_class.new }.to raise_error(StandardError)
|
||||
end
|
||||
|
||||
context 'when CSML_BOT_HOST & CSML_BOT_API_KEY is present' do
|
||||
before do
|
||||
create(:installation_config, { name: 'CSML_BOT_HOST', value: 'https://csml.chatwoot.dev' })
|
||||
create(:installation_config, { name: 'CSML_BOT_API_KEY', value: 'random_api_key' })
|
||||
end
|
||||
|
||||
let(:csml_request) { double }
|
||||
|
||||
context 'when status is called' do
|
||||
it 'returns api response if client response is valid' do
|
||||
allow(HTTParty).to receive(:get).and_return(csml_request)
|
||||
allow(csml_request).to receive(:success?).and_return(true)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'engine_version': '1.11.1' })
|
||||
|
||||
response = described_class.new.status
|
||||
|
||||
expect(HTTParty).to have_received(:get).with('https://csml.chatwoot.dev/status')
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(csml_request).to have_received(:parsed_response)
|
||||
expect(response).to eq({ 'engine_version': '1.11.1' })
|
||||
end
|
||||
|
||||
it 'returns error if client response is invalid' do
|
||||
allow(HTTParty).to receive(:get).and_return(csml_request)
|
||||
allow(csml_request).to receive(:success?).and_return(false)
|
||||
allow(csml_request).to receive(:code).and_return(401)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'error': true })
|
||||
|
||||
response = described_class.new.status
|
||||
|
||||
expect(HTTParty).to have_received(:get).with('https://csml.chatwoot.dev/status')
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(response).to eq({ error: { 'error': true }, status: 401 })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when run is called' do
|
||||
it 'returns api response if client response is valid' do
|
||||
allow(HTTParty).to receive(:post).and_return(csml_request)
|
||||
allow(SecureRandom).to receive(:uuid).and_return('xxxx-yyyy-wwww-cccc')
|
||||
allow(csml_request).to receive(:success?).and_return(true)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'success': true })
|
||||
|
||||
response = described_class.new.run({ flow: 'default' }, { client: 'client', payload: { id: 1 }, metadata: {} })
|
||||
|
||||
payload = {
|
||||
bot: { flow: 'default' },
|
||||
event: {
|
||||
request_id: 'xxxx-yyyy-wwww-cccc',
|
||||
client: 'client',
|
||||
payload: { id: 1 },
|
||||
metadata: {},
|
||||
ttl_duration: 4000
|
||||
}
|
||||
}
|
||||
expect(HTTParty).to have_received(:post)
|
||||
.with(
|
||||
'https://csml.chatwoot.dev/run', {
|
||||
body: payload.to_json,
|
||||
headers: { 'X-Api-Key' => 'random_api_key', 'Content-Type' => 'application/json' }
|
||||
}
|
||||
)
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(csml_request).to have_received(:parsed_response)
|
||||
expect(response).to eq({ 'success': true })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when validate is called' do
|
||||
it 'returns api response if client response is valid' do
|
||||
allow(HTTParty).to receive(:post).and_return(csml_request)
|
||||
allow(SecureRandom).to receive(:uuid).and_return('xxxx-yyyy-wwww-cccc')
|
||||
allow(csml_request).to receive(:success?).and_return(true)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'success': true })
|
||||
|
||||
payload = { flow: 'default' }
|
||||
response = described_class.new.validate(payload)
|
||||
|
||||
expect(HTTParty).to have_received(:post)
|
||||
.with(
|
||||
'https://csml.chatwoot.dev/validate', {
|
||||
body: payload.to_json,
|
||||
headers: { 'X-Api-Key' => 'random_api_key', 'Content-Type' => 'application/json' }
|
||||
}
|
||||
)
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(csml_request).to have_received(:parsed_response)
|
||||
expect(response).to eq({ 'success': true })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,108 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Csml::ProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:agent_bot) { create(:agent_bot, :skip_validate, bot_type: 'csml', account: account) }
|
||||
let(:agent_bot_inbox) { create(:agent_bot_inbox, agent_bot: agent_bot, inbox: inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:message) { create(:message, account: account, conversation: conversation) }
|
||||
let(:event_name) { 'message.created' }
|
||||
let(:event_data) { { message: message } }
|
||||
|
||||
describe '#perform' do
|
||||
let(:csml_client) { double }
|
||||
let(:processor) { described_class.new(event_name: event_name, agent_bot: agent_bot, event_data: event_data) }
|
||||
|
||||
before do
|
||||
allow(CsmlEngine).to receive(:new).and_return(csml_client)
|
||||
end
|
||||
|
||||
context 'when a conversation is completed from CSML' do
|
||||
it 'open the conversation and handsoff it to an agent' do
|
||||
csml_response = ActiveSupport::HashWithIndifferentAccess.new(conversation_end: true)
|
||||
allow(csml_client).to receive(:run).and_return(csml_response)
|
||||
|
||||
processor.perform
|
||||
expect(conversation.reload.status).to eql('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a new message is returned from CSML' do
|
||||
it 'creates a text message' do
|
||||
csml_response = ActiveSupport::HashWithIndifferentAccess.new(
|
||||
messages: [
|
||||
{ payload: { content_type: 'text', content: { text: 'hello payload' } } }
|
||||
]
|
||||
)
|
||||
allow(csml_client).to receive(:run).and_return(csml_response)
|
||||
processor.perform
|
||||
expect(conversation.messages.last.content).to eql('hello payload')
|
||||
end
|
||||
|
||||
it 'creates a question message' do
|
||||
csml_response = ActiveSupport::HashWithIndifferentAccess.new(
|
||||
messages: [{
|
||||
payload: {
|
||||
content_type: 'question',
|
||||
content: { title: 'Question Payload', buttons: [{ content: { title: 'Q1', payload: 'q1' } }] }
|
||||
}
|
||||
}]
|
||||
)
|
||||
allow(csml_client).to receive(:run).and_return(csml_response)
|
||||
processor.perform
|
||||
expect(conversation.messages.last.content).to eql('Question Payload')
|
||||
expect(conversation.messages.last.content_type).to eql('input_select')
|
||||
expect(conversation.messages.last.content_attributes).to eql({ items: [{ title: 'Q1', value: 'q1' }] }.with_indifferent_access)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation status is not pending' do
|
||||
let(:conversation) { create(:conversation, account: account, status: :open) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is private' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, private: true) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message type is template (not outgoing or incoming)' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, message_type: :template) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message updated' do
|
||||
let(:event_name) { 'message.updated' }
|
||||
|
||||
context 'when content_type is input_select' do
|
||||
let(:message) do
|
||||
create(:message, account: account, conversation: conversation, private: true,
|
||||
submitted_values: [{ 'title' => 'Support', 'value' => 'selected_gas' }])
|
||||
end
|
||||
|
||||
it 'returns submitted value for message content' do
|
||||
expect(processor.send(:message_content, message)).to eql('selected_gas')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when content_type is not input_select' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, message_type: :outgoing, content_type: :text) }
|
||||
let(:event_name) { 'message.updated' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -37,15 +37,6 @@ describe AgentBotListener do
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent bot csml type is configured' do
|
||||
it 'sends message to agent bot' do
|
||||
agent_bot_csml = create(:agent_bot, :skip_validate, bot_type: 'csml')
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot_csml)
|
||||
expect(AgentBots::CsmlJob).to receive(:perform_later).with('message.created', agent_bot_csml, message).once
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#webwidget_triggered' do
|
||||
|
||||
@@ -39,4 +39,23 @@ RSpec.describe AgentBot do
|
||||
expect(message.reload.sender).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#system_bot?' do
|
||||
context 'when account_id is nil' do
|
||||
let(:agent_bot) { create(:agent_bot, account_id: nil) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(agent_bot.system_bot?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account_id is present' do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent_bot) { create(:agent_bot, account: account) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(agent_bot.system_bot?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe AgentBots::ValidateBotService do
|
||||
describe '#perform' do
|
||||
it 'returns true if bot_type is not csml' do
|
||||
agent_bot = create(:agent_bot)
|
||||
valid = described_class.new(agent_bot: agent_bot).perform
|
||||
expect(valid).to be true
|
||||
end
|
||||
|
||||
it 'returns true if validate csml returns true' do
|
||||
agent_bot = create(:agent_bot, :skip_validate, bot_type: 'csml', bot_config: {})
|
||||
csml_client = double
|
||||
csml_response = double
|
||||
allow(CsmlEngine).to receive(:new).and_return(csml_client)
|
||||
allow(csml_client).to receive(:validate).and_return(csml_response)
|
||||
allow(csml_response).to receive(:blank?).and_return(false)
|
||||
allow(csml_response).to receive(:[]).with('valid').and_return(true)
|
||||
|
||||
valid = described_class.new(agent_bot: agent_bot).perform
|
||||
expect(valid).to be true
|
||||
expect(CsmlEngine).to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user