mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +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" | ||||
|             /> | ||||
|           </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> | ||||
|   | ||||
| @@ -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' | ||||
|       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 | ||||
|   | ||||
| @@ -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
	 Sojan Jose
					Sojan Jose