diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb new file mode 100644 index 000000000..c5f795d34 --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -0,0 +1,48 @@ +class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseController + before_action :fetch_conversation, only: [:create_a_meeting] + before_action :fetch_message, only: [:add_participant_to_meeting] + before_action :authorize_request + + def create_a_meeting + render_response(dyte_processor_service.create_a_meeting(Current.user)) + end + + def add_participant_to_meeting + if @message.content_type != 'integrations' + return render json: { + error: I18n.t('errors.dyte.invalid_message_type') + }, status: :unprocessable_entity + end + + render_response( + dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user) + ) + end + + private + + def authorize_request + authorize @conversation.inbox, :show? + end + + def render_response(response) + render json: response, status: response[:error].blank? ? :ok : :unprocessable_entity + end + + def dyte_processor_service + Integrations::Dyte::ProcessorService.new(account: Current.account, conversation: @conversation) + end + + def permitted_params + params.permit(:conversation_id, :message_id) + end + + def fetch_conversation + @conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id]) + end + + def fetch_message + @message = Current.account.messages.find(permitted_params[:message_id]) + @conversation = @message.conversation + end +end diff --git a/app/models/message.rb b/app/models/message.rb index 5ff749f6b..50b8661f7 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -57,7 +57,8 @@ class Message < ApplicationRecord form: 6, article: 7, incoming_email: 8, - input_csat: 9 + input_csat: 9, + integrations: 10 } enum status: { sent: 0, delivered: 1, read: 2, failed: 3 } # [:submitted_email, :items, :submitted_values] : Used for bot message types diff --git a/config/integration/apps.yml b/config/integration/apps.yml index ec462367e..97145e1c1 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -60,20 +60,34 @@ dialogflow: } ] visible_properties: ['project_id'] -fullcontact: - id: fullcontact - logo: fullcontact.png - i18n_key: fullcontact - action: /fullcontact +dyte: + id: dyte + logo: dyte.png + i18n_key: dyte + action: /dyte hook_type: account allow_multiple_hooks: false - settings_json_schema: + settings_json_schema: { + "type": "object", + "properties": { + "api_key": { "type": "string" }, + "organization_id": { "type": "string" }, + }, + "required": ["api_key", "organization_id"], + "additionalProperties": false, + } + settings_form_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'] + "label": "Organization ID", + "type": "text", + "name": "organization_id", + "validation": "required", + }, + { + "label": "API Key", + "type": "text", + "name": "api_key", + "validation": "required", + }, + ] + visible_properties: ["organization_id"] diff --git a/config/locales/en.yml b/config/locales/en.yml index 982a480a2..ea7c24c06 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -51,13 +51,15 @@ en: contacts: import: failed: File is blank - email: + email: invalid: Invalid email phone_number: invalid: should be in e164 format categories: locale: unique: should be unique in the category and portal + dyte: + invalid_message_type: "Invalid message type. Action not permitted" inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -153,6 +155,10 @@ en: online: delete: "%{contact_name} is Online, please try again later" integration_apps: + dyte: + name: "Dyte" + description: "Dyte is tool that helps you to add live audio & video to your application with just a few lines of code. This integration allows you to give an option to your agents to have a video or voice call with your customers from without leaving Chatwoot." + meeting_name: "%{agent_name} has started a meeting" slack: name: "Slack" description: "Slack is a chat tool that brings all your communication together in one place. By integrating Slack, you can get notified of all the new conversations in your account right inside your Slack." diff --git a/config/routes.rb b/config/routes.rb index bd4a57108..06fb29bb3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -158,6 +158,12 @@ Rails.application.routes.draw do resources :apps, only: [:index, :show] resources :hooks, only: [:create, :update, :destroy] resource :slack, only: [:create, :update, :destroy], controller: 'slack' + resource :dyte, controller: 'dyte', only: [] do + collection do + post :create_a_meeting + post :add_participant_to_meeting + end + end end resources :working_hours, only: [:update] diff --git a/lib/dyte.rb b/lib/dyte.rb new file mode 100644 index 000000000..e090dbc5e --- /dev/null +++ b/lib/dyte.rb @@ -0,0 +1,58 @@ +class Dyte + BASE_URL = 'https://api.cluster.dyte.in/v1'.freeze + API_KEY_HEADER = 'Authorization'.freeze + + def initialize(organization_id, api_key) + @api_key = api_key + @organization_id = organization_id + + raise ArgumentError, 'Missing Credentials' if @api_key.blank? || @organization_id.blank? + end + + def create_a_meeting(title) + payload = { + 'title': title, + 'authorization': { + 'waitingRoom': false, + 'closed': false + }, + 'recordOnStart': false, + 'liveStreamOnStart': false + } + path = "organizations/#{@organization_id}/meeting" + response = post(path, payload) + process_response(response) + end + + def add_participant_to_meeting(meeting_id, client_id, name, avatar_url) + raise ArgumentError, 'Missing information' if meeting_id.blank? || client_id.blank? || name.blank? || avatar_url.blank? + + payload = { + 'clientSpecificId': client_id.to_s, + 'userDetails': { + 'name': name, + 'picture': avatar_url + } + } + path = "organizations/#{@organization_id}/meetings/#{meeting_id}/participant" + response = post(path, payload) + process_response(response) + end + + private + + def process_response(response) + return response.parsed_response['data'].with_indifferent_access if response.success? + + { error: response.parsed_response, error_code: response.code } + end + + def post(path, payload) + HTTParty.post( + "#{BASE_URL}/#{path}", { + headers: { API_KEY_HEADER => @api_key, 'Content-Type' => 'application/json' }, + body: payload.to_json + } + ) + end +end diff --git a/lib/integrations/dyte/processor_service.rb b/lib/integrations/dyte/processor_service.rb new file mode 100644 index 000000000..e33731738 --- /dev/null +++ b/lib/integrations/dyte/processor_service.rb @@ -0,0 +1,55 @@ +class Integrations::Dyte::ProcessorService + pattr_initialize [:account!, :conversation!] + + def create_a_meeting(agent) + title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name) + response = dyte_client.create_a_meeting(title) + + return response if response[:error].present? + + meeting = response['meeting'] + message = create_a_dyte_integration_message(meeting, title, agent) + message.push_event_data + end + + def add_participant_to_meeting(meeting_id, user) + dyte_client.add_participant_to_meeting(meeting_id, user.id, user.name, avatar_url(user)) + end + + private + + def create_a_dyte_integration_message(meeting, title, agent) + @conversation.messages.create!( + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :outgoing, + content_type: :integrations, + content: title, + content_attributes: { + type: 'dyte', + data: { + meeting_id: meeting['id'], + room_name: meeting['roomName'] + } + }, + sender: agent + } + ) + end + + def avatar_url(user) + return user.avatar_url if user.avatar_url.present? + + "#{ENV.fetch('FRONTEND_URL', nil)}/integrations/slack/user.png" + end + + def dyte_hook + @dyte_hook ||= account.hooks.find_by!(app_id: 'dyte') + end + + def dyte_client + credentials = dyte_hook.settings + @dyte_client ||= Dyte.new(credentials['organization_id'], credentials['api_key']) + end +end diff --git a/public/dashboard/images/integrations/dyte.png b/public/dashboard/images/integrations/dyte.png new file mode 100644 index 000000000..8a4efab2b Binary files /dev/null and b/public/dashboard/images/integrations/dyte.png differ diff --git a/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb new file mode 100644 index 000000000..092e467b8 --- /dev/null +++ b/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb @@ -0,0 +1,138 @@ +require 'rails_helper' + +RSpec.describe 'Dyte Integration API', type: :request do + let(:headers) { { 'Content-Type' => 'application/json' } } + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, status: :pending) } + let(:message) { create(:message, conversation: conversation, account: account, inbox: conversation.inbox) } + let(:integration_message) do + create(:message, content_type: 'integrations', + content_attributes: { type: 'dyte', data: { meeting_id: 'm_id' } }, + conversation: conversation, account: account, inbox: conversation.inbox) + end + let(:agent) { create(:user, account: account, role: :agent) } + let(:unauthorized_agent) { create(:user, account: account, role: :agent) } + + before do + create(:integrations_hook, :dyte, account: account) + create(:inbox_member, user: agent, inbox: conversation.inbox) + end + + describe 'POST /api/v1/accounts/:account_id/integrations/dyte/create_a_meeting' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post create_a_meeting_api_v1_account_integrations_dyte_url(account) + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when the agent does not have access to the inbox' do + it 'returns unauthorized' do + post create_a_meeting_api_v1_account_integrations_dyte_url(account), + params: { conversation_id: conversation.display_id }, + headers: unauthorized_agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent with inbox access and the Dyte API is a success' do + before do + stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting') + .to_return( + status: 200, + body: { success: true, data: { meeting: { id: 'meeting_id', roomName: 'room_name' } } }.to_json, + headers: headers + ) + end + + it 'returns valid message payload' do + post create_a_meeting_api_v1_account_integrations_dyte_url(account), + params: { conversation_id: conversation.display_id }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:success) + response_body = JSON.parse(response.body) + last_message = conversation.reload.messages.last + expect(conversation.display_id).to eq(response_body['conversation_id']) + expect(last_message.id).to eq(response_body['id']) + end + end + + context 'when it is an agent with inbox access and the Dyte API is errored' do + before do + stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting') + .to_return( + status: 422, + body: { success: false, data: { message: 'Title is required' } }.to_json, + headers: headers + ) + end + + it 'returns error payload' do + post create_a_meeting_api_v1_account_integrations_dyte_url(account), + params: { conversation_id: conversation.display_id }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + response_body = JSON.parse(response.body) + expect(response_body['error']).to eq({ 'data' => { 'message' => 'Title is required' }, 'success' => false }) + end + end + end + + describe 'POST /api/v1/accounts/:account_id/integrations/dyte/add_participant_to_meeting' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post add_participant_to_meeting_api_v1_account_integrations_dyte_url(account) + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when the agent does not have access to the inbox' do + it 'returns unauthorized' do + post add_participant_to_meeting_api_v1_account_integrations_dyte_url(account), + params: { message_id: message.id }, + headers: unauthorized_agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent with inbox access and message_type is not integrations' do + it 'returns error' do + post add_participant_to_meeting_api_v1_account_integrations_dyte_url(account), + params: { message_id: message.id }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when it is an agent with inbox access and message_type is integrations' do + before do + stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meetings/m_id/participant') + .to_return( + status: 200, + body: { success: true, data: { authResponse: { userAdded: true, id: 'random_uuid', auth_token: 'json-web-token' } } }.to_json, + headers: headers + ) + end + + it 'returns authResponse' do + post add_participant_to_meeting_api_v1_account_integrations_dyte_url(account), + params: { message_id: integration_message.id }, + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:success) + response_body = JSON.parse(response.body) + expect(response_body['authResponse']).to eq( + { + 'userAdded' => true, 'id' => 'random_uuid', 'auth_token' => 'json-web-token' + } + ) + end + end + end +end diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb index 2d635015a..21d8a84be 100644 --- a/spec/factories/integrations/hooks.rb +++ b/spec/factories/integrations/hooks.rb @@ -11,5 +11,10 @@ FactoryBot.define do app_id { 'dialogflow' } settings { { project_id: 'test', credentials: {} } } end + + trait :dyte do + app_id { 'dyte' } + settings { { api_key: 'api_key', organization_id: 'org_id' } } + end end end diff --git a/spec/lib/dyte_spec.rb b/spec/lib/dyte_spec.rb new file mode 100644 index 000000000..56b29b133 --- /dev/null +++ b/spec/lib/dyte_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +describe Dyte do + let(:dyte_client) { described_class.new('org_id', 'api_key') } + let(:headers) { { 'Content-Type' => 'application/json' } } + + it 'raises an exception if api_key or organization ID is absent' do + expect { described_class.new }.to raise_error(StandardError) + end + + context 'when create_a_meeting is called' do + context 'when API response is success' do + before do + stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting') + .to_return( + status: 200, + body: { success: true, data: { meeting: { id: 'meeting_id' } } }.to_json, + headers: headers + ) + end + + it 'returns api response' do + response = dyte_client.create_a_meeting('title_of_the_meeting') + expect(response).to eq({ 'meeting' => { 'id' => 'meeting_id' } }) + end + end + + context 'when API response is invalid' do + before do + stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting') + .to_return(status: 422, body: { message: 'Title is required' }.to_json, headers: headers) + end + + it 'returns error code with data' do + response = dyte_client.create_a_meeting('') + expect(response).to eq({ error: { 'message' => 'Title is required' }, error_code: 422 }) + end + end + end + + context 'when add_participant_to_meeting is called' do + context 'when API parameters are missing' do + it 'raises an exception' do + expect { dyte_client.add_participant_to_meeting }.to raise_error(StandardError) + end + end + + context 'when API response is success' do + before do + stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meetings/m_id/participant') + .to_return( + status: 200, + body: { success: true, data: { authResponse: { userAdded: true, id: 'random_uuid', auth_token: 'json-web-token' } } }.to_json, + headers: headers + ) + end + + it 'returns api response' do + response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url') + expect(response).to eq({ 'authResponse' => { 'userAdded' => true, 'id' => 'random_uuid', 'auth_token' => 'json-web-token' } }) + end + end + + context 'when API response is invalid' do + before do + stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meetings/m_id/participant') + .to_return(status: 422, body: { message: 'Meeting ID is invalid' }.to_json, headers: headers) + end + + it 'returns error code with data' do + response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url') + expect(response).to eq({ error: { 'message' => 'Meeting ID is invalid' }, error_code: 422 }) + end + end + end +end diff --git a/spec/lib/integrations/dyte/processor_service_spec.rb b/spec/lib/integrations/dyte/processor_service_spec.rb new file mode 100644 index 000000000..bdfe615c1 --- /dev/null +++ b/spec/lib/integrations/dyte/processor_service_spec.rb @@ -0,0 +1,68 @@ +require 'rails_helper' + +describe Integrations::Dyte::ProcessorService do + let(:headers) { { 'Content-Type' => 'application/json' } } + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, status: :pending) } + let(:processor) { described_class.new(account: account, conversation: conversation) } + let(:agent) { create(:user, account: account, role: :agent) } + + before do + create(:integrations_hook, :dyte, account: account) + end + + describe '#create_a_meeting' do + context 'when the API response is success' do + before do + stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting') + .to_return( + status: 200, + body: { success: true, data: { meeting: { id: 'meeting_id', roomName: 'room_name' } } }.to_json, + headers: headers + ) + end + + it 'creates an integration message in the conversation' do + response = processor.create_a_meeting(agent) + expect(response['content']).to eq("#{agent.available_name} has started a meeting") + expect(conversation.reload.messages.last.content_type).to eq('integrations') + end + end + + context 'when the API response is errored' do + before do + stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting') + .to_return( + status: 422, + body: { success: false, data: { message: 'Title is required' } }.to_json, + headers: headers + ) + end + + it 'does not create an integration message in the conversation' do + response = processor.create_a_meeting(agent) + expect(response).to eq({ error: { 'data' => { 'message' => 'Title is required' }, 'success' => false }, error_code: 422 }) + expect(conversation.reload.messages.count).to eq(0) + end + end + end + + describe '#add_participant_to_meeting' do + context 'when the API response is success' do + before do + stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meetings/m_id/participant') + .to_return( + status: 200, + body: { success: true, data: { authResponse: { userAdded: true, id: 'random_uuid', auth_token: 'json-web-token' } } }.to_json, + headers: headers + ) + end + + it 'return the authResponse' do + response = processor.add_participant_to_meeting('m_id', agent) + expect(response[:authResponse]).not_to be_nil + end + end + end +end