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:
Muhsin Keloth
2026-06-24 11:04:08 +04:00
committed by GitHub
co-authored by Muhsin Sony Mathew
parent e8edc9ebf5
commit 74db16158d
20 changed files with 833 additions and 58 deletions
@@ -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()}`;
};
+46 -1
View File
@@ -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
+15 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
+83 -4
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+128
View File
@@ -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