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:
Sojan Jose
2025-04-16 05:32:49 -07:00
committed by GitHub
parent 72509f9e38
commit 630826baed
41 changed files with 657 additions and 1019 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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"

View File

@@ -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,

View File

@@ -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(

View File

@@ -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"
}
}
}

View File

@@ -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"
/>
</tbody>
</table>
<woot-confirm-modal
ref="confirmDialog"
:title="$t('AGENT_BOTS.DELETE.TITLE')"
:description="$t('AGENT_BOTS.DELETE.DESCRIPTION')"
/>
</div>
<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
/>
<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>

View File

@@ -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'],
},
},
],
},
],
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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] });

View File

@@ -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'),
};

View File

@@ -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'
);
});
});
});

View File

@@ -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',

View File

@@ -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',

View File

@@ -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

View File

@@ -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'
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
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)
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

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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