feat: Add backend APIs for Dyte integration (#6197)
- The backend changes required for Dyte Integration.
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
58
lib/dyte.rb
Normal file
58
lib/dyte.rb
Normal file
@@ -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
|
||||
55
lib/integrations/dyte/processor_service.rb
Normal file
55
lib/integrations/dyte/processor_service.rb
Normal file
@@ -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
|
||||
BIN
public/dashboard/images/integrations/dyte.png
Normal file
BIN
public/dashboard/images/integrations/dyte.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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
|
||||
@@ -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
|
||||
|
||||
76
spec/lib/dyte_spec.rb
Normal file
76
spec/lib/dyte_spec.rb
Normal file
@@ -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
|
||||
68
spec/lib/integrations/dyte/processor_service_spec.rb
Normal file
68
spec/lib/integrations/dyte/processor_service_spec.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user