mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	feat: Ability to improve drafts in the editor using GPT integration (#6957)
ref: https://github.com/chatwoot/chatwoot/issues/6436 fixes: https://linear.app/chatwoot/issue/CW-1552/ability-to-rephrase-text-in-the-editor-using-gpt-integration --------- Co-authored-by: Sojan <sojan@pepalo.com> Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
This commit is contained in:
		| @@ -1,5 +1,5 @@ | |||||||
| class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController | class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController | ||||||
|   before_action :fetch_hook, only: [:update, :destroy] |   before_action :fetch_hook, except: [:create] | ||||||
|   before_action :check_authorization |   before_action :check_authorization | ||||||
|  |  | ||||||
|   def create |   def create | ||||||
| @@ -10,6 +10,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base | |||||||
|     @hook.update!(permitted_params.slice(:status, :settings)) |     @hook.update!(permitted_params.slice(:status, :settings)) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def process_event | ||||||
|  |     render json: { message: @hook.process_event(params[:event]) } | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def destroy |   def destroy | ||||||
|     @hook.destroy! |     @hook.destroy! | ||||||
|     head :ok |     head :ok | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								app/javascript/dashboard/api/integrations/openapi.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/javascript/dashboard/api/integrations/openapi.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | /* global axios */ | ||||||
|  |  | ||||||
|  | import ApiClient from '../ApiClient'; | ||||||
|  |  | ||||||
|  | class OpenAIAPI extends ApiClient { | ||||||
|  |   constructor() { | ||||||
|  |     super('integrations', { accountScoped: true }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   processEvent({ name = 'rephrase', content, tone, hookId }) { | ||||||
|  |     return axios.post(`${this.url}/hooks/${hookId}/process_event`, { | ||||||
|  |       event: { | ||||||
|  |         name: name, | ||||||
|  |         data: { | ||||||
|  |           tone, | ||||||
|  |           content, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default new OpenAIAPI(); | ||||||
| @@ -0,0 +1,171 @@ | |||||||
|  | <template> | ||||||
|  |   <div v-if="isAIIntegrationEnabled" class="position-relative"> | ||||||
|  |     <woot-button | ||||||
|  |       v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')" | ||||||
|  |       icon="wand" | ||||||
|  |       color-scheme="secondary" | ||||||
|  |       variant="smooth" | ||||||
|  |       size="small" | ||||||
|  |       @click="toggleDropdown" | ||||||
|  |     /> | ||||||
|  |     <div | ||||||
|  |       v-if="showDropdown" | ||||||
|  |       v-on-clickaway="closeDropdown" | ||||||
|  |       class="dropdown-pane dropdown-pane--open ai-modal" | ||||||
|  |     > | ||||||
|  |       <h4 class="sub-block-title margin-top-1"> | ||||||
|  |         {{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }} | ||||||
|  |       </h4> | ||||||
|  |       <p> | ||||||
|  |         {{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }} | ||||||
|  |       </p> | ||||||
|  |       <label> | ||||||
|  |         {{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }} | ||||||
|  |       </label> | ||||||
|  |       <div class="tone__item"> | ||||||
|  |         <select v-model="activeTone" class="status--filter small"> | ||||||
|  |           <option v-for="tone in tones" :key="tone.key" :value="tone.key"> | ||||||
|  |             {{ tone.value }} | ||||||
|  |           </option> | ||||||
|  |         </select> | ||||||
|  |       </div> | ||||||
|  |       <div class="modal-footer flex-container align-right"> | ||||||
|  |         <woot-button variant="clear" size="small" @click="closeDropdown"> | ||||||
|  |           {{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }} | ||||||
|  |         </woot-button> | ||||||
|  |         <woot-button | ||||||
|  |           :is-loading="isGenerating" | ||||||
|  |           size="small" | ||||||
|  |           @click="processText" | ||||||
|  |         > | ||||||
|  |           {{ buttonText }} | ||||||
|  |         </woot-button> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | <script> | ||||||
|  | import { mapGetters } from 'vuex'; | ||||||
|  | import { mixin as clickaway } from 'vue-clickaway'; | ||||||
|  | import OpenAPI from 'dashboard/api/integrations/openapi'; | ||||||
|  | import alertMixin from 'shared/mixins/alertMixin'; | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   mixins: [alertMixin, clickaway], | ||||||
|  |   props: { | ||||||
|  |     conversationId: { | ||||||
|  |       type: Number, | ||||||
|  |       default: 0, | ||||||
|  |     }, | ||||||
|  |     message: { | ||||||
|  |       type: String, | ||||||
|  |       default: '', | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       isGenerating: false, | ||||||
|  |       showDropdown: false, | ||||||
|  |       activeTone: 'professional', | ||||||
|  |       tones: [ | ||||||
|  |         { | ||||||
|  |           key: 'professional', | ||||||
|  |           value: this.$t( | ||||||
|  |             'INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.PROFESSIONAL' | ||||||
|  |           ), | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           key: 'friendly', | ||||||
|  |           value: this.$t('INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.FRIENDLY'), | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     ...mapGetters({ appIntegrations: 'integrations/getAppIntegrations' }), | ||||||
|  |     isAIIntegrationEnabled() { | ||||||
|  |       return this.appIntegrations.find( | ||||||
|  |         integration => integration.id === 'openai' && !!integration.hooks.length | ||||||
|  |       ); | ||||||
|  |     }, | ||||||
|  |     hookId() { | ||||||
|  |       return this.appIntegrations.find( | ||||||
|  |         integration => integration.id === 'openai' && !!integration.hooks.length | ||||||
|  |       ).hooks[0].id; | ||||||
|  |     }, | ||||||
|  |     buttonText() { | ||||||
|  |       return this.isGenerating | ||||||
|  |         ? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING') | ||||||
|  |         : this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE'); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   mounted() { | ||||||
|  |     if (!this.appIntegrations.length) { | ||||||
|  |       this.$store.dispatch('integrations/get'); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     toggleDropdown() { | ||||||
|  |       this.showDropdown = !this.showDropdown; | ||||||
|  |     }, | ||||||
|  |     closeDropdown() { | ||||||
|  |       this.showDropdown = false; | ||||||
|  |     }, | ||||||
|  |     async processText() { | ||||||
|  |       this.isGenerating = true; | ||||||
|  |       try { | ||||||
|  |         const result = await OpenAPI.processEvent({ | ||||||
|  |           hookId: this.hookId, | ||||||
|  |           type: 'rephrase', | ||||||
|  |           content: this.message, | ||||||
|  |           tone: this.activeTone, | ||||||
|  |         }); | ||||||
|  |         const { | ||||||
|  |           data: { message: generatedMessage }, | ||||||
|  |         } = result; | ||||||
|  |         this.$emit('replace-text', generatedMessage || this.message); | ||||||
|  |         this.closeDropdown(); | ||||||
|  |       } catch (error) { | ||||||
|  |         this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR')); | ||||||
|  |       } finally { | ||||||
|  |         this.isGenerating = false; | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style lang="scss" scoped> | ||||||
|  | .ai-modal { | ||||||
|  |   width: 400px; | ||||||
|  |   right: 0; | ||||||
|  |   left: 0; | ||||||
|  |   padding: var(--space-normal); | ||||||
|  |   bottom: 34px; | ||||||
|  |   position: absolute; | ||||||
|  |   span { | ||||||
|  |     font-size: var(--font-size-small); | ||||||
|  |     font-weight: var(--font-weight-medium); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   p { | ||||||
|  |     color: var(--s-600); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   label { | ||||||
|  |     margin-bottom: var(--space-smaller); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .status--filter { | ||||||
|  |     background-color: var(--color-background-light); | ||||||
|  |     border: 1px solid var(--color-border); | ||||||
|  |     font-size: var(--font-size-small); | ||||||
|  |     height: var(--space-large); | ||||||
|  |     padding: 0 var(--space-medium) 0 var(--space-small); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .modal-footer { | ||||||
|  |     gap: var(--space-smaller); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -91,6 +91,12 @@ | |||||||
|         v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote" |         v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote" | ||||||
|         :conversation-id="conversationId" |         :conversation-id="conversationId" | ||||||
|       /> |       /> | ||||||
|  |       <AIAssistanceButton | ||||||
|  |         v-if="message" | ||||||
|  |         :conversation-id="conversationId" | ||||||
|  |         :message="message" | ||||||
|  |         @replace-text="replaceText" | ||||||
|  |       /> | ||||||
|       <transition name="modal-fade"> |       <transition name="modal-fade"> | ||||||
|         <div |         <div | ||||||
|           v-show="$refs.upload && $refs.upload.dropActive" |           v-show="$refs.upload && $refs.upload.dropActive" | ||||||
| @@ -129,12 +135,13 @@ import { | |||||||
|   ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP, |   ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP, | ||||||
| } from 'shared/constants/messages'; | } from 'shared/constants/messages'; | ||||||
| import VideoCallButton from '../VideoCallButton'; | import VideoCallButton from '../VideoCallButton'; | ||||||
|  | import AIAssistanceButton from '../AIAssistanceButton.vue'; | ||||||
| import { REPLY_EDITOR_MODES } from './constants'; | import { REPLY_EDITOR_MODES } from './constants'; | ||||||
| import { mapGetters } from 'vuex'; | import { mapGetters } from 'vuex'; | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   name: 'ReplyBottomPanel', |   name: 'ReplyBottomPanel', | ||||||
|   components: { FileUpload, VideoCallButton }, |   components: { FileUpload, VideoCallButton, AIAssistanceButton }, | ||||||
|   mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin], |   mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin], | ||||||
|   props: { |   props: { | ||||||
|     mode: { |     mode: { | ||||||
| @@ -217,6 +224,10 @@ export default { | |||||||
|       type: Number, |       type: Number, | ||||||
|       required: true, |       required: true, | ||||||
|     }, |     }, | ||||||
|  |     message: { | ||||||
|  |       type: String, | ||||||
|  |       default: '', | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     ...mapGetters({ |     ...mapGetters({ | ||||||
| @@ -303,6 +314,9 @@ export default { | |||||||
|         send_with_signature: !this.sendWithSignature, |         send_with_signature: !this.sendWithSignature, | ||||||
|       }); |       }); | ||||||
|     }, |     }, | ||||||
|  |     replaceText(text) { | ||||||
|  |       this.$emit('replace-text', text); | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -120,8 +120,10 @@ | |||||||
|       :toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause" |       :toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause" | ||||||
|       :toggle-audio-recorder="toggleAudioRecorder" |       :toggle-audio-recorder="toggleAudioRecorder" | ||||||
|       :toggle-emoji-picker="toggleEmojiPicker" |       :toggle-emoji-picker="toggleEmojiPicker" | ||||||
|  |       :message="message" | ||||||
|       @selectWhatsappTemplate="openWhatsappTemplateModal" |       @selectWhatsappTemplate="openWhatsappTemplateModal" | ||||||
|       @toggle-editor="toggleRichContentEditor" |       @toggle-editor="toggleRichContentEditor" | ||||||
|  |       @replace-text="replaceText" | ||||||
|     /> |     /> | ||||||
|     <whatsapp-templates |     <whatsapp-templates | ||||||
|       :inbox-id="inbox.id" |       :inbox-id="inbox.id" | ||||||
|   | |||||||
| @@ -111,7 +111,8 @@ | |||||||
|         }, |         }, | ||||||
|         "PLACEHOLDER": { |         "PLACEHOLDER": { | ||||||
|           "AGENT": "Search agents", |           "AGENT": "Search agents", | ||||||
|           "TEAM": "Search teams" |           "TEAM": "Search teams", | ||||||
|  |           "INPUT": "Search for agents" | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -82,6 +82,23 @@ | |||||||
|       "JOIN_ERROR": "There was an error joining the call, please try again", |       "JOIN_ERROR": "There was an error joining the call, please try again", | ||||||
|       "CREATE_ERROR": "There was an error creating a meeting link, please try again" |       "CREATE_ERROR": "There was an error creating a meeting link, please try again" | ||||||
|     }, |     }, | ||||||
|  |     "OPEN_AI": { | ||||||
|  |       "TITLE": "Improve With AI", | ||||||
|  |       "SUBTITLE": "An improved reply will be generated using AI, based on your current draft.", | ||||||
|  |       "TONE": { | ||||||
|  |         "TITLE": "Tone", | ||||||
|  |         "OPTIONS": { | ||||||
|  |           "PROFESSIONAL": "Professional", | ||||||
|  |           "FRIENDLY": "Friendly" | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "BUTTONS": { | ||||||
|  |         "GENERATE": "Generate", | ||||||
|  |         "GENERATING": "Generating...", | ||||||
|  |         "CANCEL": "Cancel" | ||||||
|  |       }, | ||||||
|  |       "GENERATE_ERROR": "There was an error processing the content, please try again" | ||||||
|  |     }, | ||||||
|     "DELETE": { |     "DELETE": { | ||||||
|       "BUTTON_TEXT": "Delete", |       "BUTTON_TEXT": "Delete", | ||||||
|       "API": { |       "API": { | ||||||
|   | |||||||
| @@ -45,7 +45,7 @@ | |||||||
|           $t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM') |           $t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM') | ||||||
|         " |         " | ||||||
|         :input-placeholder=" |         :input-placeholder=" | ||||||
|           $t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.INPUT_PLACEHOLDER') |           $t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.INPUT') | ||||||
|         " |         " | ||||||
|         @click="onClickAssignTeam" |         @click="onClickAssignTeam" | ||||||
|       /> |       /> | ||||||
|   | |||||||
| @@ -17,7 +17,9 @@ const state = { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| const isAValidAppIntegration = integration => { | const isAValidAppIntegration = integration => { | ||||||
|   return ['dialogflow', 'dyte', 'google_translate'].includes(integration.id); |   return ['dialogflow', 'dyte', 'google_translate', 'openai'].includes( | ||||||
|  |     integration.id | ||||||
|  |   ); | ||||||
| }; | }; | ||||||
| export const getters = { | export const getters = { | ||||||
|   getIntegrations($state) { |   getIntegrations($state) { | ||||||
|   | |||||||
| @@ -178,6 +178,7 @@ | |||||||
|   "translate-outline": "M16.953 5.303a1 1 0 0 0-1.906-.606c-.124.389-.236.899-.324 1.344-.565.012-1.12 0-1.652-.038a1 1 0 1 0-.142 1.995c.46.032.934.048 1.416.047a25.649 25.649 0 0 0-.24 1.698c-1.263.716-2.142 1.684-2.636 2.7-.624 1.283-.7 2.857.239 3.883.675.736 1.704.758 2.499.588.322-.068.654-.176.988-.32a1 1 0 0 0 1.746-.93 13.17 13.17 0 0 0-.041-.115 8.404 8.404 0 0 0 2.735-4.06c.286.251.507.55.658.864.284.594.334 1.271.099 1.91-.234.633-.78 1.313-1.84 1.843a1 1 0 0 0 .895 1.789c1.44-.72 2.385-1.758 2.821-2.94a4.436 4.436 0 0 0-.17-3.464 4.752 4.752 0 0 0-2.104-2.165C19.998 9.22 20 9.11 20 9a1 1 0 0 0-1.974-.23 5.984 5.984 0 0 0-1.796.138c.047-.305.102-.626.166-.964a20.142 20.142 0 0 0 2.842-.473 1 1 0 0 0-.476-1.942c-.622.152-1.286.272-1.964.358.048-.208.1-.409.155-.584Zm-3.686 8.015c.166-.34.414-.697.758-1.037.02.348.053.67.098.973.083.56.207 1.048.341 1.477a3.41 3.41 0 0 1-.674.227c-.429.092-.588.019-.614.006l-.004-.001c-.162-.193-.329-.774.095-1.645Zm4.498-2.562a6.362 6.362 0 0 1-1.568 2.73 7.763 7.763 0 0 1-.095-.525 10.294 10.294 0 0 1-.088-1.904c.033-.013.067-.024.1-.036l1.651-.265Zm0 0-1.651.265c.602-.212 1.155-.29 1.651-.265ZM7.536 6.29a6.342 6.342 0 0 0-4.456.331 1 1 0 0 0 .848 1.811 4.342 4.342 0 0 1 3.049-.222c.364.107.568.248.69.37.12.123.203.27.257.454.067.225.087.446.09.69a8.195 8.195 0 0 0-.555-.117c-1.146-.199-2.733-.215-4.262.64-1.271.713-1.796 2.168-1.682 3.448.12 1.326.94 2.679 2.572 3.136 1.48.414 2.913-.045 3.877-.507l.08-.04a1 1 0 0 0 1.96-.281V10.5c0-.053.002-.12.005-.2.012-.417.034-1.16-.168-1.838a3.043 3.043 0 0 0-.755-1.29c-.394-.398-.91-.694-1.547-.881h-.003Zm-.419 5.288c.344.06.647.143.887.222v2.197a7.021 7.021 0 0 1-.905.524c-.792.38-1.682.605-2.473.384-.698-.195-1.06-.742-1.119-1.389-.062-.693.243-1.286.667-1.523.987-.553 2.06-.569 2.943-.415Z", |   "translate-outline": "M16.953 5.303a1 1 0 0 0-1.906-.606c-.124.389-.236.899-.324 1.344-.565.012-1.12 0-1.652-.038a1 1 0 1 0-.142 1.995c.46.032.934.048 1.416.047a25.649 25.649 0 0 0-.24 1.698c-1.263.716-2.142 1.684-2.636 2.7-.624 1.283-.7 2.857.239 3.883.675.736 1.704.758 2.499.588.322-.068.654-.176.988-.32a1 1 0 0 0 1.746-.93 13.17 13.17 0 0 0-.041-.115 8.404 8.404 0 0 0 2.735-4.06c.286.251.507.55.658.864.284.594.334 1.271.099 1.91-.234.633-.78 1.313-1.84 1.843a1 1 0 0 0 .895 1.789c1.44-.72 2.385-1.758 2.821-2.94a4.436 4.436 0 0 0-.17-3.464 4.752 4.752 0 0 0-2.104-2.165C19.998 9.22 20 9.11 20 9a1 1 0 0 0-1.974-.23 5.984 5.984 0 0 0-1.796.138c.047-.305.102-.626.166-.964a20.142 20.142 0 0 0 2.842-.473 1 1 0 0 0-.476-1.942c-.622.152-1.286.272-1.964.358.048-.208.1-.409.155-.584Zm-3.686 8.015c.166-.34.414-.697.758-1.037.02.348.053.67.098.973.083.56.207 1.048.341 1.477a3.41 3.41 0 0 1-.674.227c-.429.092-.588.019-.614.006l-.004-.001c-.162-.193-.329-.774.095-1.645Zm4.498-2.562a6.362 6.362 0 0 1-1.568 2.73 7.763 7.763 0 0 1-.095-.525 10.294 10.294 0 0 1-.088-1.904c.033-.013.067-.024.1-.036l1.651-.265Zm0 0-1.651.265c.602-.212 1.155-.29 1.651-.265ZM7.536 6.29a6.342 6.342 0 0 0-4.456.331 1 1 0 0 0 .848 1.811 4.342 4.342 0 0 1 3.049-.222c.364.107.568.248.69.37.12.123.203.27.257.454.067.225.087.446.09.69a8.195 8.195 0 0 0-.555-.117c-1.146-.199-2.733-.215-4.262.64-1.271.713-1.796 2.168-1.682 3.448.12 1.326.94 2.679 2.572 3.136 1.48.414 2.913-.045 3.877-.507l.08-.04a1 1 0 0 0 1.96-.281V10.5c0-.053.002-.12.005-.2.012-.417.034-1.16-.168-1.838a3.043 3.043 0 0 0-.755-1.29c-.394-.398-.91-.694-1.547-.881h-.003Zm-.419 5.288c.344.06.647.143.887.222v2.197a7.021 7.021 0 0 1-.905.524c-.792.38-1.682.605-2.473.384-.698-.195-1.06-.742-1.119-1.389-.062-.693.243-1.286.667-1.523.987-.553 2.06-.569 2.943-.415Z", | ||||||
|   "eye-show-outline": "M12 9.005a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm0 1.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM12 5.5c4.613 0 8.596 3.15 9.701 7.564a.75.75 0 1 1-1.455.365 8.503 8.503 0 0 0-16.493.004.75.75 0 0 1-1.455-.363A10.003 10.003 0 0 1 12 5.5Z", |   "eye-show-outline": "M12 9.005a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm0 1.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM12 5.5c4.613 0 8.596 3.15 9.701 7.564a.75.75 0 1 1-1.455.365 8.503 8.503 0 0 0-16.493.004.75.75 0 0 1-1.455-.363A10.003 10.003 0 0 1 12 5.5Z", | ||||||
|   "eye-hide-outline": "M2.22 2.22a.75.75 0 0 0-.073.976l.073.084 4.034 4.035a9.986 9.986 0 0 0-3.955 5.75.75.75 0 0 0 1.455.364 8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084-6.113-6.114.001-.002-1.2-1.198-2.87-2.87h.002L8.719 7.658l.001-.002-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0Zm7.984 9.045 3.535 3.536a2.5 2.5 0 0 1-3.535-3.535ZM12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5Zm.195 3.51 3.801 3.8a4.003 4.003 0 0 0-3.801-3.8Z", |   "eye-hide-outline": "M2.22 2.22a.75.75 0 0 0-.073.976l.073.084 4.034 4.035a9.986 9.986 0 0 0-3.955 5.75.75.75 0 0 0 1.455.364 8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084-6.113-6.114.001-.002-1.2-1.198-2.87-2.87h.002L8.719 7.658l.001-.002-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0Zm7.984 9.045 3.535 3.536a2.5 2.5 0 0 1-3.535-3.535ZM12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5Zm.195 3.51 3.801 3.8a4.003 4.003 0 0 0-3.801-3.8Z", | ||||||
|  |   "wand-outline": "m13.314 7.565l-.136.126l-10.48 10.488a2.27 2.27 0 0 0 3.211 3.208L16.388 10.9a2.251 2.251 0 0 0-.001-3.182l-.157-.146a2.25 2.25 0 0 0-2.916-.007Zm-.848 2.961l1.088 1.088l-8.706 8.713a.77.77 0 1 1-1.089-1.088l8.707-8.713Zm4.386 4.48L16.75 15a.75.75 0 0 0-.743.648L16 15.75v.75h-.75a.75.75 0 0 0-.743.648l-.007.102c0 .38.282.694.648.743l.102.007H16v.75c0 .38.282.694.648.743l.102.007a.75.75 0 0 0 .743-.648l.007-.102V18h.75a.75.75 0 0 0 .743-.648L19 17.25a.75.75 0 0 0-.648-.743l-.102-.007h-.75v-.75a.75.75 0 0 0-.648-.743L16.75 15l.102.007Zm-1.553-6.254l.027.027a.751.751 0 0 1 0 1.061l-.711.713l-1.089-1.089l.73-.73a.75.75 0 0 1 1.043.018ZM6.852 5.007L6.75 5a.75.75 0 0 0-.743.648L6 5.75v.75h-.75a.75.75 0 0 0-.743.648L4.5 7.25c0 .38.282.693.648.743L5.25 8H6v.75c0 .38.282.693.648.743l.102.007a.75.75 0 0 0 .743-.648L7.5 8.75V8h.75a.75.75 0 0 0 .743-.648L9 7.25a.75.75 0 0 0-.648-.743L8.25 6.5H7.5v-.75a.75.75 0 0 0-.648-.743L6.75 5l.102.007Zm12-2L18.75 3a.75.75 0 0 0-.743.648L18 3.75v.75h-.75a.75.75 0 0 0-.743.648l-.007.102c0 .38.282.693.648.743L17.25 6H18v.75c0 .38.282.693.648.743l.102.007a.75.75 0 0 0 .743-.648l.007-.102V6h.75a.75.75 0 0 0 .743-.648L21 5.25a.75.75 0 0 0-.648-.743L20.25 4.5h-.75v-.75a.75.75 0 0 0-.648-.743L18.75 3l.102.007Z", | ||||||
|   "grab-handle-outline": [ |   "grab-handle-outline": [ | ||||||
|     "M5 4C5 3.44772 5.44772 3 6 3H10C10.5523 3 11 3.44772 11 4V7C11 7.55228 10.5523 8 10 8H6C5.44772 8 5 7.55228 5 7V4Z", |     "M5 4C5 3.44772 5.44772 3 6 3H10C10.5523 3 11 3.44772 11 4V7C11 7.55228 10.5523 8 10 8H6C5.44772 8 5 7.55228 5 7V4Z", | ||||||
|     "M13 4C13 3.44772 13.4477 3 14 3H18C18.5523 3 19 3.44772 19 4V7C19 7.55228 18.5523 8 18 8H14C13.4477 8 13 7.55228 13 7V4Z", |     "M13 4C13 3.44772 13.4477 3 14 3H18C18.5523 3 19 3.44772 19 4V7C19 7.55228 18.5523 8 18 8H14C13.4477 8 13 7.55228 13 7V4Z", | ||||||
|   | |||||||
| @@ -25,6 +25,8 @@ class Integrations::Hook < ApplicationRecord | |||||||
|   validate :validate_settings_json_schema |   validate :validate_settings_json_schema | ||||||
|   validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } } |   validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } } | ||||||
|  |  | ||||||
|  |   # TODO: This seems to be only used for slack at the moment | ||||||
|  |   # We can add a validator when storing the integration settings and toggle this in future | ||||||
|   enum status: { disabled: 0, enabled: 1 } |   enum status: { disabled: 0, enabled: 1 } | ||||||
|  |  | ||||||
|   belongs_to :account |   belongs_to :account | ||||||
| @@ -45,6 +47,15 @@ class Integrations::Hook < ApplicationRecord | |||||||
|     update(status: 'disabled') |     update(status: 'disabled') | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def process_event(event) | ||||||
|  |     case app_id | ||||||
|  |     when 'openai' | ||||||
|  |       Integrations::Openai::ProcessorService.new(hook: self, event: event).perform if app_id == 'openai' | ||||||
|  |     else | ||||||
|  |       'No processor found' | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|   def ensure_hook_type |   def ensure_hook_type | ||||||
|   | |||||||
| @@ -7,6 +7,10 @@ class HookPolicy < ApplicationPolicy | |||||||
|     @account_user.administrator? |     @account_user.administrator? | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def process_event? | ||||||
|  |     true | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def destroy? |   def destroy? | ||||||
|     @account_user.administrator? |     @account_user.administrator? | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -128,3 +128,27 @@ google_translate: | |||||||
|     }, |     }, | ||||||
|   ] |   ] | ||||||
|   visible_properties: ['project_id'] |   visible_properties: ['project_id'] | ||||||
|  | openai: | ||||||
|  |   id: openai | ||||||
|  |   logo: openai.png | ||||||
|  |   i18n_key: openai | ||||||
|  |   action: /openai | ||||||
|  |   hook_type: account | ||||||
|  |   allow_multiple_hooks: false | ||||||
|  |   settings_json_schema: { | ||||||
|  |     "type": "object", | ||||||
|  |     "properties": { | ||||||
|  |         "api_key": { "type": "string" }, | ||||||
|  |       }, | ||||||
|  |     "required": ["api_key"], | ||||||
|  |     "additionalProperties": false, | ||||||
|  |   } | ||||||
|  |   settings_form_schema: [ | ||||||
|  |     { | ||||||
|  |       "label": "API Key", | ||||||
|  |       "type": "text", | ||||||
|  |       "name": "api_key", | ||||||
|  |       "validation": "required", | ||||||
|  |     }, | ||||||
|  |   ] | ||||||
|  |   visible_properties: ['api_key'] | ||||||
|   | |||||||
| @@ -187,6 +187,9 @@ en: | |||||||
|     google_translate: |     google_translate: | ||||||
|       name: "Google Translate" |       name: "Google Translate" | ||||||
|       description: "Make it easier for agents to translate messages by adding a Google Translate Integration. Google translate helps to identify the language automatically and convert it to the language chosen by the agent/account admin." |       description: "Make it easier for agents to translate messages by adding a Google Translate Integration. Google translate helps to identify the language automatically and convert it to the language chosen by the agent/account admin." | ||||||
|  |     openai: | ||||||
|  |       name: "OpenAI" | ||||||
|  |       description: "Integrate powerful AI features into Chatwoot by leveraging the GPT models from OpenAI." | ||||||
|   public_portal: |   public_portal: | ||||||
|     search: |     search: | ||||||
|       search_placeholder: Search for article by title or body... |       search_placeholder: Search for article by title or body... | ||||||
|   | |||||||
| @@ -177,7 +177,11 @@ Rails.application.routes.draw do | |||||||
|           resources :webhooks, only: [:index, :create, :update, :destroy] |           resources :webhooks, only: [:index, :create, :update, :destroy] | ||||||
|           namespace :integrations do |           namespace :integrations do | ||||||
|             resources :apps, only: [:index, :show] |             resources :apps, only: [:index, :show] | ||||||
|             resources :hooks, only: [:create, :update, :destroy] |             resources :hooks, only: [:create, :update, :destroy] do | ||||||
|  |               member do | ||||||
|  |                 post :process_event | ||||||
|  |               end | ||||||
|  |             end | ||||||
|             resource :slack, only: [:create, :update, :destroy], controller: 'slack' |             resource :slack, only: [:create, :update, :destroy], controller: 'slack' | ||||||
|             resource :dyte, controller: 'dyte', only: [] do |             resource :dyte, controller: 'dyte', only: [] do | ||||||
|               collection do |               collection do | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								lib/integrations/openai/processor_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								lib/integrations/openai/processor_service.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | class Integrations::Openai::ProcessorService | ||||||
|  |   pattr_initialize [:hook!, :event!] | ||||||
|  |  | ||||||
|  |   def perform | ||||||
|  |     rephrase_message if event['name'] == 'rephrase' | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def rephrase_body(tone, message) | ||||||
|  |     { | ||||||
|  |       model: 'gpt-3.5-turbo', | ||||||
|  |       messages: [ | ||||||
|  |         { role: 'system', content: "You are a helpful support agent. Please rephrase the following response to a more #{tone} tone." }, | ||||||
|  |         { role: 'user', content: message } | ||||||
|  |       ] | ||||||
|  |     }.to_json | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def rephrase_message | ||||||
|  |     response = make_api_call(rephrase_body(event['data']['tone'], event['data']['content'])) | ||||||
|  |     JSON.parse(response)['choices'].first['message']['content'] | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def make_api_call(body) | ||||||
|  |     headers = { | ||||||
|  |       'Content-Type' => 'application/json', | ||||||
|  |       'Authorization' => "Bearer #{hook.settings['api_key']}" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     response = HTTParty.post( | ||||||
|  |       'https://api.openai.com/v1/chat/completions', | ||||||
|  |       headers: headers, | ||||||
|  |       body: body | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     response.body | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										
											BIN
										
									
								
								public/dashboard/images/integrations/openai.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/dashboard/images/integrations/openai.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 6.5 KiB | 
| @@ -28,6 +28,18 @@ RSpec.describe 'Integration Apps API', type: :request do | |||||||
|         expect(apps['action']).to be_nil |         expect(apps['action']).to be_nil | ||||||
|       end |       end | ||||||
|  |  | ||||||
|  |       it 'will not return sensitive information for openai app for agents' do | ||||||
|  |         openai = create(:integrations_hook, :openai, account: account) | ||||||
|  |         get api_v1_account_integrations_apps_url(account), | ||||||
|  |             headers: agent.create_new_auth_token, | ||||||
|  |             as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |  | ||||||
|  |         app = JSON.parse(response.body)['payload'].find { |int_app| int_app['id'] == openai.app.id } | ||||||
|  |         expect(app['hooks'].first['settings']).to be_nil | ||||||
|  |       end | ||||||
|  |  | ||||||
|       it 'returns all active apps with sensitive information if user is an admin' do |       it 'returns all active apps with sensitive information if user is an admin' do | ||||||
|         first_app = Integrations::App.all.find(&:active?) |         first_app = Integrations::App.all.find(&:active?) | ||||||
|         get api_v1_account_integrations_apps_url(account), |         get api_v1_account_integrations_apps_url(account), | ||||||
| @@ -53,6 +65,18 @@ RSpec.describe 'Integration Apps API', type: :request do | |||||||
|           expect(slack_app['action']).to include('client_id=client_id') |           expect(slack_app['action']).to include('client_id=client_id') | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|  |  | ||||||
|  |       it 'will return sensitive information for openai app for admins' do | ||||||
|  |         openai = create(:integrations_hook, :openai, account: account) | ||||||
|  |         get api_v1_account_integrations_apps_url(account), | ||||||
|  |             headers: admin.create_new_auth_token, | ||||||
|  |             as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |  | ||||||
|  |         app = JSON.parse(response.body)['payload'].find { |int_app| int_app['id'] == openai.app.id } | ||||||
|  |         expect(app['hooks'].first['settings']).not_to be_nil | ||||||
|  |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -65,7 +89,8 @@ RSpec.describe 'Integration Apps API', type: :request do | |||||||
|     end |     end | ||||||
|  |  | ||||||
|     context 'when it is an authenticated user' do |     context 'when it is an authenticated user' do | ||||||
|       let(:agent) { create(:user, account: account, role: :administrator) } |       let(:agent) { create(:user, account: account, role: :agent) } | ||||||
|  |       let(:admin) { create(:user, account: account, role: :administrator) } | ||||||
|  |  | ||||||
|       it 'returns details of the app' do |       it 'returns details of the app' do | ||||||
|         get api_v1_account_integrations_app_url(account_id: account.id, id: 'slack'), |         get api_v1_account_integrations_app_url(account_id: account.id, id: 'slack'), | ||||||
| @@ -77,6 +102,30 @@ RSpec.describe 'Integration Apps API', type: :request do | |||||||
|         expect(app['id']).to eql('slack') |         expect(app['id']).to eql('slack') | ||||||
|         expect(app['name']).to eql('Slack') |         expect(app['name']).to eql('Slack') | ||||||
|       end |       end | ||||||
|  |  | ||||||
|  |       it 'will not return sensitive information for openai app for agents' do | ||||||
|  |         openai = create(:integrations_hook, :openai, account: account) | ||||||
|  |         get api_v1_account_integrations_app_url(account_id: account.id, id: openai.app.id), | ||||||
|  |             headers: agent.create_new_auth_token, | ||||||
|  |             as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |  | ||||||
|  |         app = JSON.parse(response.body) | ||||||
|  |         expect(app['hooks'].first['settings']).to be_nil | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'will return sensitive information for openai app for admins' do | ||||||
|  |         openai = create(:integrations_hook, :openai, account: account) | ||||||
|  |         get api_v1_account_integrations_app_url(account_id: account.id, id: openai.app.id), | ||||||
|  |             headers: admin.create_new_auth_token, | ||||||
|  |             as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |  | ||||||
|  |         app = JSON.parse(response.body) | ||||||
|  |         expect(app['hooks'].first['settings']).not_to be_nil | ||||||
|  |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -77,6 +77,33 @@ RSpec.describe 'Integration Hooks API', type: :request do | |||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   describe 'POST /api/v1/accounts/{account.id}/integrations/hooks/{hook_id}/process_event' do | ||||||
|  |     let(:hook) { create(:integrations_hook, account: account) } | ||||||
|  |     let(:params) { { event: 'rephrase', payload: { test: 'test' } } } | ||||||
|  |  | ||||||
|  |     context 'when it is an unauthenticated user' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         post process_event_api_v1_account_integrations_hook_url(account_id: account.id, id: hook.id), | ||||||
|  |              params: params, | ||||||
|  |              as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when it is an authenticated user' do | ||||||
|  |       it 'will process the events' do | ||||||
|  |         post process_event_api_v1_account_integrations_hook_url(account_id: account.id, id: hook.id), | ||||||
|  |              params: params, | ||||||
|  |              headers: agent.create_new_auth_token, | ||||||
|  |              as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |         expect(response.parsed_body['message']).to eq('No processor found') | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|   describe 'DELETE /api/v1/accounts/{account.id}/integrations/hooks/{hook_id}' do |   describe 'DELETE /api/v1/accounts/{account.id}/integrations/hooks/{hook_id}' do | ||||||
|     let(:hook) { create(:integrations_hook, account: account) } |     let(:hook) { create(:integrations_hook, account: account) } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -21,5 +21,10 @@ FactoryBot.define do | |||||||
|       app_id { 'google_translate' } |       app_id { 'google_translate' } | ||||||
|       settings { { project_id: 'test', credentials: {} } } |       settings { { project_id: 'test', credentials: {} } } | ||||||
|     end |     end | ||||||
|  |  | ||||||
|  |     trait :openai do | ||||||
|  |       app_id { 'openai' } | ||||||
|  |       settings { { api_key: 'api_key' } } | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								spec/lib/integrations/openai/processor_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								spec/lib/integrations/openai/processor_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe Integrations::Openai::ProcessorService do | ||||||
|  |   subject { described_class.new(hook: hook, event: event) } | ||||||
|  |  | ||||||
|  |   let(:hook) { create(:integrations_hook, :openai) } | ||||||
|  |   let(:expected_headers) { { 'Authorization' => "Bearer #{hook.settings['api_key']}" } } | ||||||
|  |   let(:openai_response) do | ||||||
|  |     { | ||||||
|  |       'choices' => [ | ||||||
|  |         { | ||||||
|  |           'message' => { | ||||||
|  |             'content' => 'This is a rephrased test message.' | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       ] | ||||||
|  |     }.to_json | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#perform' do | ||||||
|  |     context 'when event name is rephrase' do | ||||||
|  |       let(:event) { { 'name' => 'rephrase', 'data' => { 'tone' => 'friendly', 'content' => 'This is a test message' } } } | ||||||
|  |  | ||||||
|  |       it 'returns the rephrased message using the tone in data' do | ||||||
|  |         request_body = { | ||||||
|  |           'model' => 'gpt-3.5-turbo', | ||||||
|  |           'messages' => [ | ||||||
|  |             { 'role' => 'system', | ||||||
|  |               'content' => "You are a helpful support agent. Please rephrase the following response to a more #{event['data']['tone']} tone." }, | ||||||
|  |             { 'role' => 'user', 'content' => event['data']['content'] } | ||||||
|  |           ] | ||||||
|  |         }.to_json | ||||||
|  |  | ||||||
|  |         stub_request(:post, 'https://api.openai.com/v1/chat/completions') | ||||||
|  |           .with(body: request_body, headers: expected_headers) | ||||||
|  |           .to_return(status: 200, body: openai_response, headers: {}) | ||||||
|  |  | ||||||
|  |         result = subject.perform | ||||||
|  |         expect(result).to eq('This is a rephrased test message.') | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when event name is not rephrase' do | ||||||
|  |       let(:event) { { 'name' => 'unknown', 'data' => {} } } | ||||||
|  |  | ||||||
|  |       it 'returns nil' do | ||||||
|  |         expect(subject.perform).to be_nil | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -27,4 +27,25 @@ RSpec.describe Integrations::Hook, type: :model do | |||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   describe 'process_event' do | ||||||
|  |     let(:account) { create(:account) } | ||||||
|  |     let(:params) { { event: 'rephrase', payload: { test: 'test' } } } | ||||||
|  |  | ||||||
|  |     it 'returns no processor found for hooks with out processor defined' do | ||||||
|  |       hook = create(:integrations_hook, account: account) | ||||||
|  |       expect(hook.process_event(params)).to eq('No processor found') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'returns results from procesor for openai hook' do | ||||||
|  |       hook = create(:integrations_hook, :openai, account: account) | ||||||
|  |  | ||||||
|  |       openai_double = double | ||||||
|  |       allow(Integrations::Openai::ProcessorService).to receive(:new).and_return(openai_double) | ||||||
|  |       allow(openai_double).to receive(:perform).and_return('test') | ||||||
|  |       expect(hook.process_event(params)).to eq('test') | ||||||
|  |       expect(Integrations::Openai::ProcessorService).to have_received(:new).with(event: params, hook: hook) | ||||||
|  |       expect(openai_double).to have_received(:perform) | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Muhsin Keloth
					Muhsin Keloth