feat: migrate dyte integration to cloudflare realtimekit (#14752)
Dyte is sunsetting its existing infrastructure after the Cloudflare acquisition, so this migrates Chatwoot’s video call integration to Cloudflare RealtimeKit. The integration now uses Cloudflare Account ID, RealtimeKit App ID, and a Cloudflare API token with Realtime Admin permissions. Meeting creation and participant token generation now call Cloudflare’s RealtimeKit APIs, while the existing Chatwoot call experience remains unchanged for agents and customers. This also adds setup-time credential validation, so admins get clearer errors when the API token is invalid, the Cloudflare account or permissions are incorrect, or the RealtimeKit App ID does not belong to the selected account. Fixes https://linear.app/chatwoot/issue/PLA-176/migrate-dyte-integration-to-cloudflare-realtimekit **How to test** 1. Go to Settings → Integrations → Cloudflare RealtimeKit. 2. Add a Cloudflare Account ID, RealtimeKit App ID, and API token with Realtime Admin permissions. 3. Confirm the integration saves successfully with valid credentials. 4. Try invalid credentials and confirm the error identifies whether the token, account/permissions, or app ID is wrong. 5. Start a video call from a conversation and confirm the RealtimeKit meeting opens. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sony Mathew <sony@chatwoot.com>
This commit is contained in:
co-authored by
Muhsin
Sony Mathew
parent
e8edc9ebf5
commit
74db16158d
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
render_response(
|
||||
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
|
||||
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
|
||||
|
||||
response = dyte_processor_service.add_participant_to_meeting(
|
||||
@message.content_attributes['data']['meeting_id'],
|
||||
@conversation.contact
|
||||
@conversation.contact,
|
||||
@message
|
||||
)
|
||||
render_response(response)
|
||||
end
|
||||
|
||||
@@ -31,12 +31,22 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createErrorMessage(error) {
|
||||
const responseError = error?.response?.data?.error;
|
||||
if (typeof responseError === 'string') return responseError;
|
||||
|
||||
return (
|
||||
responseError?.error?.message ||
|
||||
responseError?.message ||
|
||||
this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')
|
||||
);
|
||||
},
|
||||
async onClick() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
await DyteAPI.createAMeeting(this.conversationId);
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
|
||||
useAlert(this.createErrorMessage(error));
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
const DYTE_MEETING_LINK = 'https://app.dyte.io/v2/meeting';
|
||||
const DYTE_MEETING_LINK = 'https://examples.realtime.cloudflare.com/meeting/';
|
||||
|
||||
export const buildDyteURL = dyteAuthToken => {
|
||||
return `${DYTE_MEETING_LINK}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`;
|
||||
const params = new URLSearchParams({
|
||||
authToken: dyteAuthToken,
|
||||
showSetupScreen: true,
|
||||
disableVideoBackground: true,
|
||||
});
|
||||
|
||||
return `${DYTE_MEETING_LINK}?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ class Integrations::Hook < ApplicationRecord
|
||||
validate :validate_settings_json_schema
|
||||
validate :ensure_feature_enabled
|
||||
validate :validate_openai_api_key, if: :validate_openai_api_key?
|
||||
validate :validate_cloudflare_realtimekit_credentials, if: :validate_cloudflare_realtimekit_credentials?
|
||||
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
|
||||
@@ -61,6 +62,10 @@ class Integrations::Hook < ApplicationRecord
|
||||
app_id == 'openai'
|
||||
end
|
||||
|
||||
def dyte?
|
||||
app_id == 'dyte'
|
||||
end
|
||||
|
||||
def notion?
|
||||
app_id == 'notion'
|
||||
end
|
||||
@@ -96,6 +101,7 @@ class Integrations::Hook < ApplicationRecord
|
||||
|
||||
def validate_settings_json_schema
|
||||
return if app.blank? || app.params[:settings_json_schema].blank?
|
||||
return if legacy_dyte_settings_unchanged?
|
||||
|
||||
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
|
||||
end
|
||||
@@ -106,18 +112,57 @@ class Integrations::Hook < ApplicationRecord
|
||||
openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?)
|
||||
end
|
||||
|
||||
def validate_cloudflare_realtimekit_credentials?
|
||||
dyte? && enabled? && !legacy_dyte_settings_unchanged? &&
|
||||
(new_record? || cloudflare_realtimekit_credentials_changed? || will_save_change_to_status?)
|
||||
end
|
||||
|
||||
def openai_api_key_changed?
|
||||
settings_api_key(settings) != settings_api_key(settings_in_database)
|
||||
end
|
||||
|
||||
def cloudflare_realtimekit_credentials_changed?
|
||||
settings_cloudflare_realtimekit_credentials(settings) != settings_cloudflare_realtimekit_credentials(settings_in_database)
|
||||
end
|
||||
|
||||
def legacy_dyte_settings_unchanged?
|
||||
dyte? && persisted? && !will_save_change_to_settings? && legacy_dyte_settings?(settings_in_database)
|
||||
end
|
||||
|
||||
def legacy_dyte_settings?(value)
|
||||
return false if value.blank?
|
||||
|
||||
%w[organization_id api_key].any? { |key| settings_value(value, key).present? } &&
|
||||
%w[account_id app_id api_token].none? { |key| settings_value(value, key).present? }
|
||||
end
|
||||
|
||||
def validate_openai_api_key
|
||||
return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings))
|
||||
|
||||
errors.add(:base, I18n.t('errors.openai.invalid_api_key'))
|
||||
end
|
||||
|
||||
def validate_cloudflare_realtimekit_credentials
|
||||
result = Integrations::Cloudflare::RealtimeKitCredentialsValidator.validate(*settings_cloudflare_realtimekit_credentials(settings))
|
||||
return if result.success?
|
||||
|
||||
errors.add(:base, I18n.t("errors.cloudflare.realtimekit.#{result.error}"))
|
||||
end
|
||||
|
||||
def settings_api_key(value)
|
||||
value&.dig('api_key') || value&.dig(:api_key)
|
||||
settings_value(value, 'api_key')
|
||||
end
|
||||
|
||||
def settings_cloudflare_realtimekit_credentials(value)
|
||||
[
|
||||
settings_value(value, 'account_id'),
|
||||
settings_value(value, 'app_id'),
|
||||
settings_value(value, 'api_token')
|
||||
]
|
||||
end
|
||||
|
||||
def settings_value(value, key)
|
||||
value&.dig(key) || value&.dig(key.to_sym)
|
||||
end
|
||||
|
||||
def trigger_setup_if_crm
|
||||
|
||||
@@ -215,28 +215,35 @@ dyte:
|
||||
'type': 'object',
|
||||
'properties':
|
||||
{
|
||||
'api_key': { 'type': 'string' },
|
||||
'organization_id': { 'type': 'string' },
|
||||
'account_id': { 'type': 'string' },
|
||||
'app_id': { 'type': 'string' },
|
||||
'api_token': { 'type': 'string' },
|
||||
},
|
||||
'required': ['api_key', 'organization_id'],
|
||||
'required': ['account_id', 'app_id', 'api_token'],
|
||||
'additionalProperties': false,
|
||||
}
|
||||
settings_form_schema:
|
||||
[
|
||||
{
|
||||
'label': 'Organization ID',
|
||||
'label': 'Cloudflare Account ID',
|
||||
'type': 'text',
|
||||
'name': 'organization_id',
|
||||
'name': 'account_id',
|
||||
'validation': 'required',
|
||||
},
|
||||
{
|
||||
'label': 'API Key',
|
||||
'label': 'RealtimeKit App ID',
|
||||
'type': 'text',
|
||||
'name': 'api_key',
|
||||
'name': 'app_id',
|
||||
'validation': 'required',
|
||||
},
|
||||
{
|
||||
'label': 'Cloudflare API Token',
|
||||
'type': 'text',
|
||||
'name': 'api_token',
|
||||
'validation': 'required',
|
||||
},
|
||||
]
|
||||
visible_properties: ['organization_id']
|
||||
visible_properties: ['account_id', 'app_id']
|
||||
|
||||
shopify:
|
||||
id: shopify
|
||||
|
||||
+11
-2
@@ -113,6 +113,15 @@ en:
|
||||
unique: should be unique in the category and portal
|
||||
dyte:
|
||||
invalid_message_type: 'Invalid message type. Action not permitted'
|
||||
realtimekit_credentials_required: 'Cloudflare RealtimeKit credentials are required. Delete the existing Dyte integration and create it again with Cloudflare RealtimeKit credentials.'
|
||||
cloudflare:
|
||||
realtimekit:
|
||||
invalid_credentials: 'Cloudflare RealtimeKit credentials are invalid. Please check the Account ID, RealtimeKit App ID, and API token permissions.'
|
||||
missing_credentials: 'Cloudflare Account ID, RealtimeKit App ID, and API token are required.'
|
||||
invalid_api_token: 'Cloudflare API token is invalid or inactive. Please create a new token with Realtime Admin permissions.'
|
||||
invalid_account_or_permissions: 'Cloudflare Account ID is invalid, or the API token does not have Realtime Admin access to this account.'
|
||||
app_not_found: 'RealtimeKit App ID was not found in this Cloudflare account. Please check the app you selected.'
|
||||
verification_failed: 'Could not verify Cloudflare RealtimeKit credentials right now. Please try again.'
|
||||
slack:
|
||||
invalid_channel_id: 'Invalid slack channel. Please try again'
|
||||
whatsapp:
|
||||
@@ -350,9 +359,9 @@ en:
|
||||
name: 'Dashboard Apps'
|
||||
description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
|
||||
dyte:
|
||||
name: 'Dyte'
|
||||
name: 'Cloudflare RealtimeKit'
|
||||
short_description: 'Start video/voice calls with customers directly from Chatwoot.'
|
||||
description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
|
||||
description: 'Cloudflare RealtimeKit lets your agents start video/voice calls with your customers directly from Chatwoot.'
|
||||
meeting_name: '%{agent_name} has started a meeting'
|
||||
slack:
|
||||
name: 'Slack'
|
||||
|
||||
+57
-14
@@ -1,13 +1,15 @@
|
||||
class Dyte
|
||||
BASE_URL = 'https://api.dyte.io/v2'.freeze
|
||||
BASE_URL = 'https://api.cloudflare.com/client/v4'.freeze
|
||||
API_KEY_HEADER = 'Authorization'.freeze
|
||||
PRESET_NAME = 'group_call_host'.freeze
|
||||
PRESET_NAME = 'group-call-host'.freeze
|
||||
LEGACY_PRESET_NAME = 'group_call_host'.freeze
|
||||
|
||||
def initialize(organization_id, api_key)
|
||||
@api_key = Base64.strict_encode64("#{organization_id}:#{api_key}")
|
||||
@organization_id = organization_id
|
||||
def initialize(account_id = nil, app_id = nil, api_token = nil)
|
||||
@account_id = account_id
|
||||
@app_id = app_id
|
||||
@api_token = api_token
|
||||
|
||||
raise ArgumentError, 'Missing Credentials' if @api_key.blank? || @organization_id.blank?
|
||||
raise ArgumentError, 'Missing Credentials' if @account_id.blank? || @app_id.blank? || @api_token.blank?
|
||||
end
|
||||
|
||||
def create_a_meeting(title)
|
||||
@@ -29,24 +31,65 @@ class Dyte
|
||||
'preset_name': PRESET_NAME
|
||||
}
|
||||
path = "meetings/#{meeting_id}/participants"
|
||||
response = post(path, payload)
|
||||
response = process_response(post(path, payload))
|
||||
return response unless preset_not_found?(response)
|
||||
|
||||
payload[:preset_name] = LEGACY_PRESET_NAME
|
||||
process_response(post(path, payload))
|
||||
end
|
||||
|
||||
def refresh_participant_token(meeting_id, participant_id)
|
||||
raise ArgumentError, 'Missing information' if meeting_id.blank? || participant_id.blank?
|
||||
|
||||
path = "meetings/#{meeting_id}/participants/#{participant_id}/token"
|
||||
response = post(path)
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
def fetch_participants(meeting_id)
|
||||
raise ArgumentError, 'Missing information' if meeting_id.blank?
|
||||
|
||||
response = get("meetings/#{meeting_id}/participants")
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_response(response)
|
||||
return response.parsed_response['data'].with_indifferent_access if response.success?
|
||||
return { error: response.parsed_response, error_code: response.code } unless response.success?
|
||||
|
||||
{ error: response.parsed_response, error_code: response.code }
|
||||
data = parsed_data(response)
|
||||
return data.with_indifferent_access if data.is_a?(Hash)
|
||||
return data.map(&:with_indifferent_access) if data.is_a?(Array)
|
||||
|
||||
{ error: :unexpected_response, error_code: response.code }
|
||||
end
|
||||
|
||||
def post(path, payload)
|
||||
def parsed_data(response)
|
||||
response.parsed_response['data']
|
||||
end
|
||||
|
||||
def preset_not_found?(response)
|
||||
error = response[:error]
|
||||
message = error.dig('error', 'message') if error.is_a?(Hash) && error['error'].is_a?(Hash)
|
||||
message ||= error['message'] if error.is_a?(Hash)
|
||||
message ||= error.to_s
|
||||
message.include?('No preset found')
|
||||
end
|
||||
|
||||
def post(path, payload = nil)
|
||||
HTTParty.post(
|
||||
"#{BASE_URL}/#{path}", {
|
||||
headers: { API_KEY_HEADER => "Basic #{@api_key}", 'Content-Type' => 'application/json' },
|
||||
body: payload.to_json
|
||||
}
|
||||
"#{BASE_URL}/accounts/#{@account_id}/realtime/kit/#{@app_id}/#{path}", {
|
||||
headers: { API_KEY_HEADER => "Bearer #{@api_token}", 'Content-Type' => 'application/json' },
|
||||
body: payload&.to_json
|
||||
}.compact
|
||||
)
|
||||
end
|
||||
|
||||
def get(path)
|
||||
HTTParty.get(
|
||||
"#{BASE_URL}/accounts/#{@account_id}/realtime/kit/#{@app_id}/#{path}",
|
||||
headers: { API_KEY_HEADER => "Bearer #{@api_token}", 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
module Integrations::Cloudflare::RealtimeKitCredentialsValidator
|
||||
Result = Data.define(:success?, :error)
|
||||
|
||||
BASE_URL = 'https://api.cloudflare.com/client/v4'.freeze
|
||||
TIMEOUT_SECONDS = 5
|
||||
APPS_PAGE_SIZE = 50
|
||||
|
||||
def self.valid?(account_id, app_id, api_token)
|
||||
validate(account_id, app_id, api_token).success?
|
||||
end
|
||||
|
||||
def self.validate(account_id, app_id, api_token)
|
||||
return failure(:missing_credentials) if account_id.blank? || app_id.blank? || api_token.blank?
|
||||
|
||||
token_result = validate_token(api_token)
|
||||
return token_result unless token_result.success?
|
||||
|
||||
validate_realtimekit_app(account_id, app_id, api_token)
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.warn("[cloudflare-realtimekit-credentials-validator] #{e.class}: #{e.message}")
|
||||
failure(:verification_failed)
|
||||
end
|
||||
|
||||
def self.validate_token(api_token)
|
||||
response = connection.get("#{BASE_URL}/user/tokens/verify") do |req|
|
||||
req.headers['Authorization'] = "Bearer #{api_token}"
|
||||
end
|
||||
|
||||
return failure(:verification_failed) if transient_error?(response)
|
||||
|
||||
body = parse_response(response)
|
||||
return success if response.status == 200 && body['success'] == true && body.dig('result', 'status') == 'active'
|
||||
|
||||
failure(:invalid_api_token)
|
||||
end
|
||||
private_class_method :validate_token
|
||||
|
||||
def self.validate_realtimekit_app(account_id, app_id, api_token)
|
||||
page_no = 1
|
||||
|
||||
loop do
|
||||
response = fetch_realtimekit_apps(account_id, api_token, page_no)
|
||||
return failure(:verification_failed) if transient_error?(response)
|
||||
return failure(:invalid_account_or_permissions) unless response.status == 200
|
||||
|
||||
body = parse_response(response)
|
||||
apps = body['data'] || []
|
||||
return success if apps.any? { |app| app['id'] == app_id }
|
||||
break unless next_apps_page?(body, page_no, apps)
|
||||
|
||||
page_no += 1
|
||||
end
|
||||
|
||||
failure(:app_not_found)
|
||||
end
|
||||
private_class_method :validate_realtimekit_app
|
||||
|
||||
def self.fetch_realtimekit_apps(account_id, api_token, page_no)
|
||||
connection.get("#{BASE_URL}/accounts/#{account_id}/realtime/kit/apps") do |req|
|
||||
req.headers['Authorization'] = "Bearer #{api_token}"
|
||||
req.params['page_no'] = page_no
|
||||
req.params['per_page'] = APPS_PAGE_SIZE
|
||||
end
|
||||
end
|
||||
private_class_method :fetch_realtimekit_apps
|
||||
|
||||
def self.next_apps_page?(body, page_no, apps)
|
||||
total_count = body.dig('paging', 'total_count') || body.dig('result_info', 'total_count')
|
||||
return page_no * APPS_PAGE_SIZE < total_count.to_i if total_count.present?
|
||||
|
||||
apps.size == APPS_PAGE_SIZE
|
||||
end
|
||||
private_class_method :next_apps_page?
|
||||
|
||||
def self.connection
|
||||
Faraday.new do |f|
|
||||
f.options.timeout = TIMEOUT_SECONDS
|
||||
f.options.open_timeout = TIMEOUT_SECONDS
|
||||
end
|
||||
end
|
||||
private_class_method :connection
|
||||
|
||||
def self.parse_response(response)
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
private_class_method :parse_response
|
||||
|
||||
def self.transient_error?(response)
|
||||
response.status >= 500
|
||||
end
|
||||
private_class_method :transient_error?
|
||||
|
||||
def self.success
|
||||
Result.new(true, nil)
|
||||
end
|
||||
private_class_method :success
|
||||
|
||||
def self.failure(error)
|
||||
Result.new(false, error)
|
||||
end
|
||||
private_class_method :failure
|
||||
end
|
||||
@@ -2,6 +2,8 @@ class Integrations::Dyte::ProcessorService
|
||||
pattr_initialize [:account!, :conversation!]
|
||||
|
||||
def create_a_meeting(agent)
|
||||
return missing_realtimekit_credentials_response if realtimekit_credentials_missing?
|
||||
|
||||
title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name)
|
||||
response = dyte_client.create_a_meeting(title)
|
||||
|
||||
@@ -12,12 +14,31 @@ class Integrations::Dyte::ProcessorService
|
||||
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))
|
||||
def add_participant_to_meeting(meeting_id, user, message = nil)
|
||||
return missing_realtimekit_credentials_response if realtimekit_credentials_missing?
|
||||
|
||||
client_id = realtimekit_client_id(user)
|
||||
participant_id = realtimekit_participant_id(message, client_id)
|
||||
response = participant_token_response(meeting_id, participant_id)
|
||||
return response if response[:error].blank?
|
||||
|
||||
response = dyte_client.add_participant_to_meeting(meeting_id, client_id, user.name, avatar_url(user))
|
||||
return store_participant_id_and_return(message, client_id, response) if response[:error].blank?
|
||||
|
||||
existing_participant_token_response(meeting_id, client_id, message) || response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def realtimekit_client_id(user)
|
||||
"#{user.class.name}:#{user.id}"
|
||||
end
|
||||
|
||||
def store_participant_id_and_return(message, client_id, response)
|
||||
update_realtimekit_participant_id(message, client_id, response['id']) if response['id'].present?
|
||||
response
|
||||
end
|
||||
|
||||
def create_a_dyte_integration_message(meeting, title, agent)
|
||||
@conversation.messages.create!(
|
||||
{
|
||||
@@ -48,7 +69,65 @@ class Integrations::Dyte::ProcessorService
|
||||
end
|
||||
|
||||
def dyte_client
|
||||
credentials = dyte_hook.settings
|
||||
@dyte_client ||= Dyte.new(credentials['organization_id'], credentials['api_key'])
|
||||
@dyte_client ||= Dyte.new(*realtimekit_credentials)
|
||||
end
|
||||
|
||||
def participant_token_response(meeting_id, participant_id)
|
||||
return { error: :participant_id_missing } if participant_id.blank?
|
||||
|
||||
dyte_client.refresh_participant_token(meeting_id, participant_id)
|
||||
end
|
||||
|
||||
def existing_participant_token_response(meeting_id, client_id, message)
|
||||
participant_id = existing_realtimekit_participant_id(meeting_id, client_id)
|
||||
return if participant_id.blank?
|
||||
|
||||
response = dyte_client.refresh_participant_token(meeting_id, participant_id)
|
||||
update_realtimekit_participant_id(message, client_id, participant_id) if response[:error].blank?
|
||||
response
|
||||
end
|
||||
|
||||
def existing_realtimekit_participant_id(meeting_id, client_id)
|
||||
participants = dyte_client.fetch_participants(meeting_id)
|
||||
return if participants.blank? || participants.is_a?(Hash)
|
||||
|
||||
participants.find { |participant| participant['custom_participant_id'].to_s == client_id.to_s }&.dig('id')
|
||||
end
|
||||
|
||||
def realtimekit_participant_id(message, client_id)
|
||||
integration_message_data(message).dig(:participants, client_id.to_s)
|
||||
end
|
||||
|
||||
def update_realtimekit_participant_id(message, client_id, participant_id)
|
||||
return if message.blank?
|
||||
|
||||
attributes = message.content_attributes.with_indifferent_access
|
||||
data = (attributes[:data] || {}).with_indifferent_access
|
||||
participants = (data[:participants] || {}).with_indifferent_access
|
||||
participants[client_id.to_s] = participant_id
|
||||
data[:participants] = participants
|
||||
attributes[:data] = data
|
||||
message.update_columns(content_attributes: attributes.deep_stringify_keys, updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[dyte] Failed to store RealtimeKit participant ID for message #{message.id}: #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
def integration_message_data(message)
|
||||
return {} if message.blank?
|
||||
|
||||
(message.content_attributes.with_indifferent_access[:data] || {}).with_indifferent_access
|
||||
end
|
||||
|
||||
def realtimekit_credentials
|
||||
credentials = dyte_hook.settings.with_indifferent_access
|
||||
[credentials[:account_id], credentials[:app_id], credentials[:api_token]]
|
||||
end
|
||||
|
||||
def realtimekit_credentials_missing?
|
||||
realtimekit_credentials.any?(&:blank?)
|
||||
end
|
||||
|
||||
def missing_realtimekit_credentials_response
|
||||
{ error: I18n.t('errors.dyte.realtimekit_credentials_required') }
|
||||
end
|
||||
end
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 18 KiB |
@@ -15,6 +15,8 @@ RSpec.describe 'Dyte Integration API', type: :request do
|
||||
let(:unauthorized_agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil))
|
||||
create(:integrations_hook, :dyte, account: account)
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
end
|
||||
@@ -39,7 +41,7 @@ RSpec.describe 'Dyte Integration API', type: :request do
|
||||
|
||||
context 'when it is an agent with inbox access and the Dyte API is a success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'meeting_id' } }.to_json,
|
||||
@@ -62,7 +64,7 @@ RSpec.describe 'Dyte Integration API', type: :request do
|
||||
|
||||
context 'when it is an agent with inbox access and the Dyte API is errored' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
|
||||
.to_return(
|
||||
status: 422,
|
||||
body: { success: false, data: { message: 'Title is required' } }.to_json,
|
||||
@@ -112,15 +114,15 @@ RSpec.describe 'Dyte Integration API', type: :request do
|
||||
|
||||
context 'when it is an agent with inbox access and message_type is integrations' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns auth_token' do
|
||||
it 'returns token' 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,
|
||||
@@ -129,7 +131,7 @@ RSpec.describe 'Dyte Integration API', type: :request do
|
||||
response_body = response.parsed_body
|
||||
expect(response_body).to eq(
|
||||
{
|
||||
'id' => 'random_uuid', 'auth_token' => 'json-web-token'
|
||||
'id' => 'random_uuid', 'token' => 'json-web-token'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -38,6 +38,19 @@ RSpec.describe 'Integration Hooks API', type: :request do
|
||||
data = response.parsed_body
|
||||
expect(data['app_id']).to eq params[:app_id]
|
||||
end
|
||||
|
||||
it 'validates Cloudflare RealtimeKit credentials before creating the hook' do
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(false, :invalid_api_token))
|
||||
|
||||
post api_v1_account_integrations_hooks_url(account_id: account.id),
|
||||
params: { app_id: 'dyte', settings: { account_id: 'bad', app_id: 'bad', api_token: 'bad' } },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['message']).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil))
|
||||
create(:integrations_hook, :dyte, account: account)
|
||||
end
|
||||
|
||||
@@ -46,15 +48,15 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
|
||||
|
||||
context 'when message is an integration message' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns auth_token' do
|
||||
it 'returns token' do
|
||||
post add_participant_to_meeting_api_v1_widget_integrations_dyte_url,
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
params: { website_token: web_widget.website_token, message_id: integration_message.id },
|
||||
@@ -64,7 +66,7 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
|
||||
response_body = response.parsed_body
|
||||
expect(response_body).to eq(
|
||||
{
|
||||
'id' => 'random_uuid', 'auth_token' => 'json-web-token'
|
||||
'id' => 'random_uuid', 'token' => 'json-web-token'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ FactoryBot.define do
|
||||
|
||||
trait :dyte do
|
||||
app_id { 'dyte' }
|
||||
settings { { api_key: 'api_key', organization_id: 'org_id' } }
|
||||
settings { { account_id: 'account_id', app_id: 'app_id', api_token: 'api_token' } }
|
||||
end
|
||||
|
||||
trait :google_translate do
|
||||
|
||||
+103
-8
@@ -1,17 +1,17 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Dyte do
|
||||
let(:dyte_client) { described_class.new('org_id', 'api_key') }
|
||||
let(:dyte_client) { described_class.new('account_id', 'app_id', 'api_token') }
|
||||
let(:headers) { { 'Content-Type' => 'application/json' } }
|
||||
|
||||
it 'raises an exception if api_key or organization ID is absent' do
|
||||
it 'raises an exception if account ID, app ID, or API token 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.dyte.io/v2/meetings')
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'meeting_id' } }.to_json,
|
||||
@@ -27,7 +27,7 @@ describe Dyte do
|
||||
|
||||
context 'when API response is invalid' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
|
||||
.to_return(status: 422, body: { message: 'Title is required' }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
@@ -36,9 +36,23 @@ describe Dyte do
|
||||
expect(response).to eq({ error: { 'message' => 'Title is required' }, error_code: 422 })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API response succeeds without data' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
|
||||
.to_return(status: 200, body: { success: true, data: nil }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'returns an explicit unexpected response error' do
|
||||
response = dyte_client.create_a_meeting('title_of_the_meeting')
|
||||
expect(response).to eq({ error: :unexpected_response, error_code: 200 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when add_participant_to_meeting is called' do
|
||||
let(:participants_url) { 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants' }
|
||||
|
||||
context 'when API parameters are missing' do
|
||||
it 'raises an exception' do
|
||||
expect { dyte_client.add_participant_to_meeting }.to raise_error(StandardError)
|
||||
@@ -47,23 +61,26 @@ describe Dyte do
|
||||
|
||||
context 'when API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
stub_request(:post, participants_url)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
body: { success: true, data: { id: 'random_uuid', 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({ 'id' => 'random_uuid', 'auth_token' => 'json-web-token' })
|
||||
expect(response).to eq({ 'id' => 'random_uuid', 'token' => 'json-web-token' })
|
||||
expect(WebMock).to(
|
||||
have_requested(:post, participants_url).with { |request| JSON.parse(request.body)['preset_name'] == 'group-call-host' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API response is invalid' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
stub_request(:post, participants_url)
|
||||
.to_return(status: 422, body: { message: 'Meeting ID is invalid' }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
@@ -72,5 +89,83 @@ describe Dyte do
|
||||
expect(response).to eq({ error: { 'message' => 'Meeting ID is invalid' }, error_code: 422 })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the default preset is not found' do
|
||||
before do
|
||||
stub_request(:post, participants_url)
|
||||
.with { |request| JSON.parse(request.body)['preset_name'] == 'group-call-host' }
|
||||
.to_return(
|
||||
status: 404,
|
||||
body: { success: false, error: { code: 404, message: 'ResourceNotFound: No preset found with name group-call-host' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
|
||||
stub_request(:post, participants_url)
|
||||
.with { |request| JSON.parse(request.body)['preset_name'] == 'group_call_host' }
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'retries with the legacy Dyte preset name' do
|
||||
response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url')
|
||||
|
||||
expect(response).to eq({ 'id' => 'random_uuid', 'token' => 'json-web-token' })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when refresh_participant_token is called' do
|
||||
let(:participant_token_url) do
|
||||
'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token'
|
||||
end
|
||||
|
||||
context 'when API response is success' do
|
||||
before do
|
||||
stub_request(:post, participant_token_url)
|
||||
.to_return(status: 200, body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'returns a refreshed participant token' do
|
||||
response = dyte_client.refresh_participant_token('m_id', 'participant_id')
|
||||
|
||||
expect(response).to eq({ 'token' => 'refreshed-json-web-token' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API parameters are missing' do
|
||||
it 'raises an exception' do
|
||||
expect { dyte_client.refresh_participant_token('m_id', nil) }.to raise_error(StandardError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when fetch_participants is called' do
|
||||
let(:participants_url) { 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants' }
|
||||
|
||||
context 'when API response is success' do
|
||||
before do
|
||||
stub_request(:get, participants_url)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: [{ id: 'participant_id', custom_participant_id: 'c_id' }] }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns participants' do
|
||||
response = dyte_client.fetch_participants('m_id')
|
||||
|
||||
expect(response).to eq([{ 'id' => 'participant_id', 'custom_participant_id' => 'c_id' }])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API parameters are missing' do
|
||||
it 'raises an exception' do
|
||||
expect { dyte_client.fetch_participants(nil) }.to raise_error(StandardError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::Cloudflare::RealtimeKitCredentialsValidator do
|
||||
let(:account_id) { 'account_id' }
|
||||
let(:app_id) { 'app_id' }
|
||||
let(:api_token) { 'api_token' }
|
||||
let(:token_verify_url) { 'https://api.cloudflare.com/client/v4/user/tokens/verify' }
|
||||
let(:apps_url) { "https://api.cloudflare.com/client/v4/accounts/#{account_id}/realtime/kit/apps" }
|
||||
let(:apps_page_size) { described_class::APPS_PAGE_SIZE }
|
||||
|
||||
it 'accepts an active token with access to the requested RealtimeKit app' do
|
||||
stub_token_verify(status: 'active')
|
||||
stub_apps_list([{ id: app_id }])
|
||||
|
||||
expect(described_class.valid?(account_id, app_id, api_token)).to be true
|
||||
expect(described_class.validate(account_id, app_id, api_token).success?).to be true
|
||||
end
|
||||
|
||||
it 'rejects inactive tokens' do
|
||||
stub_token_verify(status: 'disabled')
|
||||
|
||||
expect(described_class.valid?(account_id, app_id, api_token)).to be false
|
||||
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:invalid_api_token)
|
||||
end
|
||||
|
||||
it 'rejects tokens without access to the Cloudflare account' do
|
||||
stub_token_verify(status: 'active')
|
||||
stub_apps_request.to_return(status: 403, body: { success: false }.to_json)
|
||||
|
||||
expect(described_class.valid?(account_id, app_id, api_token)).to be false
|
||||
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:invalid_account_or_permissions)
|
||||
end
|
||||
|
||||
it 'rejects a RealtimeKit App ID that is not present in the account' do
|
||||
stub_token_verify(status: 'active')
|
||||
stub_apps_list([{ id: 'another_app_id' }])
|
||||
|
||||
expect(described_class.valid?(account_id, app_id, api_token)).to be false
|
||||
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:app_not_found)
|
||||
end
|
||||
|
||||
it 'accepts a RealtimeKit App ID from a later apps page' do
|
||||
stub_const("#{described_class}::APPS_PAGE_SIZE", 1)
|
||||
stub_token_verify(status: 'active')
|
||||
stub_apps_list([{ id: 'another_app_id' }], page_no: 1, total_count: 2)
|
||||
stub_apps_list([{ id: app_id }], page_no: 2, total_count: 2)
|
||||
|
||||
expect(described_class.validate(account_id, app_id, api_token).success?).to be true
|
||||
end
|
||||
|
||||
it 'rejects blank credentials without making a network call' do
|
||||
expect(described_class.valid?(nil, app_id, api_token)).to be false
|
||||
expect(described_class.valid?(account_id, nil, api_token)).to be false
|
||||
expect(described_class.valid?(account_id, app_id, nil)).to be false
|
||||
expect(described_class.validate(nil, app_id, api_token).error).to eq(:missing_credentials)
|
||||
end
|
||||
|
||||
it 'rejects transient Cloudflare failures instead of saving unverified credentials' do
|
||||
stub_request(:get, token_verify_url).to_return(status: 500)
|
||||
stub_apps_list([{ id: app_id }])
|
||||
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed)
|
||||
|
||||
stub_token_verify(status: 'active')
|
||||
stub_apps_request.to_return(status: 500)
|
||||
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed)
|
||||
end
|
||||
|
||||
it 'rejects credentials when Cloudflare cannot be reached' do
|
||||
stub_request(:get, token_verify_url).to_raise(Faraday::TimeoutError)
|
||||
|
||||
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed)
|
||||
end
|
||||
|
||||
def stub_token_verify(status:)
|
||||
stub_request(:get, token_verify_url)
|
||||
.with(headers: { 'Authorization' => "Bearer #{api_token}" })
|
||||
.to_return(status: 200, body: { success: true, result: { status: status } }.to_json)
|
||||
end
|
||||
|
||||
def stub_apps_list(apps, page_no: 1, total_count: apps.size)
|
||||
stub_apps_request(page_no: page_no)
|
||||
.to_return(status: 200, body: apps_response_body(apps, total_count: total_count).to_json)
|
||||
end
|
||||
|
||||
def stub_apps_request(page_no: 1)
|
||||
stub_request(:get, apps_url)
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{api_token}" },
|
||||
query: { page_no: page_no.to_s, per_page: apps_page_size.to_s }
|
||||
)
|
||||
end
|
||||
|
||||
def apps_response_body(apps, total_count: apps.size)
|
||||
{ success: true, data: apps.map(&:stringify_keys), paging: { total_count: total_count } }
|
||||
end
|
||||
end
|
||||
@@ -7,15 +7,26 @@ describe Integrations::Dyte::ProcessorService do
|
||||
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) }
|
||||
let(:dyte_settings) { { account_id: 'account_id', app_id: 'app_id', api_token: 'api_token' } }
|
||||
let(:integration_message) do
|
||||
create(:message, content_type: 'integrations',
|
||||
content_attributes: { type: 'dyte', data: { meeting_id: 'm_id' } },
|
||||
conversation: conversation)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :dyte, account: account)
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil))
|
||||
|
||||
hook = build(:integrations_hook, :dyte, account: account, settings: dyte_settings)
|
||||
hook.save!(validate: false) if dyte_settings[:organization_id].present?
|
||||
hook.save! unless hook.persisted?
|
||||
end
|
||||
|
||||
describe '#create_a_meeting' do
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'meeting_id' } }.to_json,
|
||||
@@ -32,7 +43,7 @@ describe Integrations::Dyte::ProcessorService do
|
||||
|
||||
context 'when the API response is errored' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
|
||||
.to_return(
|
||||
status: 422,
|
||||
body: { success: false, data: { message: 'Title is required' } }.to_json,
|
||||
@@ -46,15 +57,28 @@ describe Integrations::Dyte::ProcessorService do
|
||||
expect(conversation.reload.messages.count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the stored hook still has legacy Dyte credentials' do
|
||||
let(:dyte_settings) { { organization_id: 'org_id', api_key: 'dyte_api_key' } }
|
||||
|
||||
it 'returns a normal error response without creating a RealtimeKit client' do
|
||||
expect(Dyte).not_to receive(:new)
|
||||
|
||||
response = processor.create_a_meeting(agent)
|
||||
|
||||
expect(response).to eq({ error: I18n.t('errors.dyte.realtimekit_credentials_required') })
|
||||
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.dyte.io/v2/meetings/m_id/participants')
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
@@ -63,6 +87,117 @@ describe Integrations::Dyte::ProcessorService do
|
||||
response = processor.add_participant_to_meeting('m_id', agent)
|
||||
expect(response).not_to be_nil
|
||||
end
|
||||
|
||||
it 'stores the RealtimeKit participant ID on the integration message' do
|
||||
response = processor.add_participant_to_meeting('m_id', agent, integration_message)
|
||||
|
||||
expect(response).not_to be_nil
|
||||
expect(integration_message.reload.content_attributes.dig('data', 'participants', "User:#{agent.id}")).to eq('random_uuid')
|
||||
end
|
||||
|
||||
it 'sends a namespaced participant ID to RealtimeKit' do
|
||||
processor.add_participant_to_meeting('m_id', agent, integration_message)
|
||||
|
||||
expect(WebMock).to(
|
||||
have_requested(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
|
||||
.with { |request| JSON.parse(request.body)['custom_participant_id'] == "User:#{agent.id}" }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the participant ID is already stored on the integration message' do
|
||||
let(:integration_message) do
|
||||
create(:message, content_type: 'integrations',
|
||||
content_attributes: { type: 'dyte', data: { meeting_id: 'm_id', participants: { "User:#{agent.id}" => 'participant_id' } } },
|
||||
conversation: conversation)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns a refreshed participant token without creating the participant again' do
|
||||
response = processor.add_participant_to_meeting('m_id', agent, integration_message)
|
||||
|
||||
expect(response).to eq({ 'token' => 'refreshed-json-web-token' })
|
||||
expect(WebMock).not_to have_requested(
|
||||
:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the participant exists in RealtimeKit but is not stored on the integration message' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 422,
|
||||
body: { success: false, error: 'Participant already exists' }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
stub_request(:get, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: [{ id: 'participant_id', custom_participant_id: "User:#{agent.id}" }] }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'finds the existing participant and stores the RealtimeKit participant ID' do
|
||||
response = processor.add_participant_to_meeting('m_id', agent, integration_message)
|
||||
|
||||
expect(response).to eq({ 'token' => 'refreshed-json-web-token' })
|
||||
expect(integration_message.reload.content_attributes.dig('data', 'participants', "User:#{agent.id}")).to eq('participant_id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a contact and agent have the same database ID' do
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
|
||||
before do
|
||||
allow(contact).to receive(:id).and_return(agent.id)
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'contact_participant_id', token: 'json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'stores the contact participant separately from the agent participant' do
|
||||
integration_message.update!(
|
||||
content_attributes: { type: 'dyte', data: { meeting_id: 'm_id', participants: { "User:#{agent.id}" => 'agent_participant_id' } } }
|
||||
)
|
||||
|
||||
response = processor.add_participant_to_meeting('m_id', contact, integration_message)
|
||||
|
||||
expect(response).to eq({ 'id' => 'contact_participant_id', 'token' => 'json-web-token' })
|
||||
participants = integration_message.reload.content_attributes.dig('data', 'participants')
|
||||
expect(participants["User:#{agent.id}"]).to eq('agent_participant_id')
|
||||
expect(participants["Contact:#{contact.id}"]).to eq('contact_participant_id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the stored hook still has legacy Dyte credentials' do
|
||||
let(:dyte_settings) { { organization_id: 'org_id', api_key: 'dyte_api_key' } }
|
||||
|
||||
it 'returns a normal error response without creating a RealtimeKit client' do
|
||||
expect(Dyte).not_to receive(:new)
|
||||
|
||||
response = processor.add_participant_to_meeting('m_id', agent)
|
||||
|
||||
expect(response).to eq({ error: I18n.t('errors.dyte.realtimekit_credentials_required') })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -177,4 +177,132 @@ RSpec.describe Integrations::Hook do
|
||||
expect(hook).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe 'cloudflare realtimekit credential validation' do
|
||||
let(:account) { create(:account) }
|
||||
let(:settings) { { 'account_id' => 'account_id', 'app_id' => 'app_id', 'api_token' => 'api_token' } }
|
||||
|
||||
it 'prevents saving a RealtimeKit hook with an invalid API token' do
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(cloudflare_validator_result(false, :invalid_api_token))
|
||||
|
||||
hook = build(:integrations_hook, :dyte, account: account, settings: settings)
|
||||
|
||||
expect(hook).not_to be_valid
|
||||
expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token'))
|
||||
end
|
||||
|
||||
it 'prevents saving a RealtimeKit hook with an invalid account or missing token permissions' do
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(cloudflare_validator_result(false, :invalid_account_or_permissions))
|
||||
|
||||
hook = build(:integrations_hook, :dyte, account: account, settings: settings)
|
||||
|
||||
expect(hook).not_to be_valid
|
||||
expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_account_or_permissions'))
|
||||
end
|
||||
|
||||
it 'prevents saving a RealtimeKit hook when the app is not found' do
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(cloudflare_validator_result(false, :app_not_found))
|
||||
|
||||
hook = build(:integrations_hook, :dyte, account: account, settings: settings)
|
||||
|
||||
expect(hook).not_to be_valid
|
||||
expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.app_not_found'))
|
||||
end
|
||||
|
||||
it 'allows saving a RealtimeKit hook with valid credentials' do
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(cloudflare_validator_result(true))
|
||||
|
||||
hook = build(:integrations_hook, :dyte, account: account, settings: settings)
|
||||
|
||||
expect(hook).to be_valid
|
||||
end
|
||||
|
||||
it 'skips validation when an enabled RealtimeKit hook is saved without changing credentials' do
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(cloudflare_validator_result(true))
|
||||
hook = create(:integrations_hook, :dyte, account: account, settings: settings)
|
||||
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(cloudflare_validator_result(false, :invalid_api_token))
|
||||
hook.settings['account_id'] = 'account_id'
|
||||
|
||||
expect(hook.save).to be true
|
||||
end
|
||||
|
||||
it 'validates when a disabled RealtimeKit hook is re-enabled' do
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(cloudflare_validator_result(true))
|
||||
hook = create(:integrations_hook, :dyte, account: account, settings: settings)
|
||||
hook.update!(status: :disabled)
|
||||
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.with('account_id', 'app_id', 'api_token')
|
||||
.and_return(cloudflare_validator_result(false, :invalid_api_token))
|
||||
|
||||
expect(hook.update(status: :enabled)).to be false
|
||||
expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token'))
|
||||
end
|
||||
|
||||
it 'skips validation for disabled RealtimeKit hooks' do
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(cloudflare_validator_result(true))
|
||||
hook = create(:integrations_hook, :dyte, account: account, settings: settings)
|
||||
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(cloudflare_validator_result(false, :invalid_api_token))
|
||||
hook.disable
|
||||
|
||||
expect(hook.reload).to be_disabled
|
||||
end
|
||||
|
||||
it 'allows disabling a persisted legacy Dyte hook without RealtimeKit credentials' do
|
||||
hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' })
|
||||
hook.save!(validate: false)
|
||||
|
||||
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
|
||||
.and_return(cloudflare_validator_result(false, :invalid_api_token))
|
||||
|
||||
expect(hook.disable).to be true
|
||||
expect(hook.reload).to be_disabled
|
||||
end
|
||||
|
||||
it 'allows re-enabling a persisted legacy Dyte hook without RealtimeKit credential validation' do
|
||||
hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' })
|
||||
hook.save!(validate: false)
|
||||
hook.disable
|
||||
|
||||
expect(Integrations::Cloudflare::RealtimeKitCredentialsValidator).not_to receive(:validate)
|
||||
|
||||
expect(hook.update(status: :enabled)).to be true
|
||||
expect(hook.reload).to be_enabled
|
||||
end
|
||||
|
||||
it 'validates settings when a legacy Dyte hook settings payload is changed' do
|
||||
hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' })
|
||||
hook.save!(validate: false)
|
||||
|
||||
hook.settings = { 'account_id' => 'account_id' }
|
||||
|
||||
expect(hook).not_to be_valid
|
||||
expect(hook.errors[:settings]).to include(': Invalid settings data')
|
||||
end
|
||||
|
||||
it 'rejects new legacy Dyte hooks' do
|
||||
hook = build(:integrations_hook, :dyte,
|
||||
account: account,
|
||||
status: :disabled,
|
||||
settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' })
|
||||
|
||||
expect(hook).not_to be_valid
|
||||
expect(hook.errors[:settings]).to include(': Invalid settings data')
|
||||
end
|
||||
end
|
||||
|
||||
def cloudflare_validator_result(success, error = nil)
|
||||
Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(success, error)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user