From 74db16158d2ee4f503bd32838e574f20e32a45f5 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 24 Jun 2026 11:04:08 +0400 Subject: [PATCH] feat: migrate dyte integration to cloudflare realtimekit (#14752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../accounts/integrations/dyte_controller.rb | 2 +- .../v1/widget/integrations/dyte_controller.rb | 3 +- .../components/widgets/VideoCallButton.vue | 12 +- .../shared/helpers/IntegrationHelper.js | 10 +- app/models/integrations/hook.rb | 47 +++++- config/integration/apps.yml | 23 ++- config/locales/en.yml | 13 +- lib/dyte.rb | 71 +++++++-- .../realtime_kit_credentials_validator.rb | 104 +++++++++++++ lib/integrations/dyte/processor_service.rb | 87 ++++++++++- .../images/integrations/dyte-dark.png | Bin 8069 -> 18392 bytes public/dashboard/images/integrations/dyte.png | Bin 8069 -> 18392 bytes .../integrations/dyte_controller_spec.rb | 14 +- .../integrations/hooks_controller_spec.rb | 13 ++ .../integrations/dyte_controller_spec.rb | 10 +- spec/factories/integrations/hooks.rb | 2 +- spec/lib/dyte_spec.rb | 111 +++++++++++++- ...realtime_kit_credentials_validator_spec.rb | 96 ++++++++++++ .../dyte/processor_service_spec.rb | 145 +++++++++++++++++- spec/models/integrations/hook_spec.rb | 128 ++++++++++++++++ 20 files changed, 833 insertions(+), 58 deletions(-) create mode 100644 lib/integrations/cloudflare/realtime_kit_credentials_validator.rb create mode 100644 spec/lib/integrations/cloudflare/realtime_kit_credentials_validator_spec.rb diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index 845caab5e..7bda1c802 100644 --- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -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 diff --git a/app/controllers/api/v1/widget/integrations/dyte_controller.rb b/app/controllers/api/v1/widget/integrations/dyte_controller.rb index 0661b4a3c..fde425b26 100644 --- a/app/controllers/api/v1/widget/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/widget/integrations/dyte_controller.rb @@ -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 diff --git a/app/javascript/dashboard/components/widgets/VideoCallButton.vue b/app/javascript/dashboard/components/widgets/VideoCallButton.vue index 30984ab4b..0080c1498 100644 --- a/app/javascript/dashboard/components/widgets/VideoCallButton.vue +++ b/app/javascript/dashboard/components/widgets/VideoCallButton.vue @@ -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; } diff --git a/app/javascript/shared/helpers/IntegrationHelper.js b/app/javascript/shared/helpers/IntegrationHelper.js index 8d8be822b..d1dc23d01 100644 --- a/app/javascript/shared/helpers/IntegrationHelper.js +++ b/app/javascript/shared/helpers/IntegrationHelper.js @@ -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()}`; }; diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index a3396951f..36515eb66 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -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 diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 9ef01ed30..5550e8ecb 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 826894466..c3672ef7b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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' diff --git a/lib/dyte.rb b/lib/dyte.rb index 96750da08..7e448d18a 100644 --- a/lib/dyte.rb +++ b/lib/dyte.rb @@ -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 diff --git a/lib/integrations/cloudflare/realtime_kit_credentials_validator.rb b/lib/integrations/cloudflare/realtime_kit_credentials_validator.rb new file mode 100644 index 000000000..da4266ab1 --- /dev/null +++ b/lib/integrations/cloudflare/realtime_kit_credentials_validator.rb @@ -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 diff --git a/lib/integrations/dyte/processor_service.rb b/lib/integrations/dyte/processor_service.rb index dbe429776..f74332b32 100644 --- a/lib/integrations/dyte/processor_service.rb +++ b/lib/integrations/dyte/processor_service.rb @@ -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 diff --git a/public/dashboard/images/integrations/dyte-dark.png b/public/dashboard/images/integrations/dyte-dark.png index 42162b58a0d94492a4c42892b470621b754c3770..f45987cb269d70eb2e8a652eda6739493a809940 100644 GIT binary patch literal 18392 zcmeIaXIPWX*DrblB8m#AC@NAOX#xTY(yNNnG++=AkRk|3@4X}zEGR8BfdoV;(gh@R zf*?Hv1*xG*@10OWI1_yS`#t+y*FO6?U(WvW&KK^Bd+wQ4X4b5==C|@z2c~v{@d6_R zK_@iSRpAgs3m$19mc!sr>hzH^@Q2${9exjj{P-aV`4WP5z`u~w5af9kf@aMjNbUm! zvALkj?<;^8hb^?!RQC_s(TAh~@E-;bjeEBlW)HJ4N$^^yq9Y*aPN|0KjR!vc3xhU3 z54!prR{XU!c-Dt1SnnS?qspT5{dovyR><{ZUa1dtBCQP)-c;%2Al+E&538vPJ`Z73 zzi;%k!}#XS{&`ZJCetdXswPTOVt0Rc^^w-qfhd;~{)0vD4uN_9yZs-MKt#z!A`LXM zuLa|j$}y7M7cCM}_=!hdvyR>Ez%wSt8)l}*$IQhWx*`Zm{;ha%?+=ERW`rIsaS5k) z@zu6iGcl+pUB^IzS?{_;SysG%V?x;&y+nzpw7b?d3F+Lc?rTO8C8kMhyP}fr-ggK+ z?0TikUmvqeJ&7ya)1*PtLMqj*eLrz_`f0jtIVN@ZBDD{`HzXv8lMcPddWtdZ5VA6g zoETqZDz#Rz)JC@b5|0>XJt}|Njq!yN_B7oaB~}Q6#rKV{DG_sK7bMqe)2+QSpIDFC zI7}UaxZSS!{={dpx1o!E;$!&k-iRPaExd#-G1Egx#1pBlYdmdPlm`;|0gddk39xqE_lu)i3!k;m@cO$|zAAD3x{b;{sn-}rcu=uI@q$4h|kK#exhM=02 zgh==B=W+J&vP&gs35E+q*x?#ZMyTcm{*H3WYr|r{HvI0Ac+o~Sc8vx?l2`8$s_2u> zJ5LT6-@O(g*XJb;ZHLoBZ!D}oFQKjVPrOU$%l{m&$n_P&3_&m7X=umGdX>lH7NzTv zyA=yhpj9S%h~>5$Y_wgrIDgDRVz{*H?(hRDB`T>$Jf+ss(GqmZ4y|nr&zjU|Al0iy zqxsS9p~Fbwws~B)mna0W~#rhq|VN5U1w&4G*payZ2fx5aS9j~ zQ^`Fi2`bQ?3?&Frf-;I5HvDfjTeM=79@gevQA=8 z)5yH1g^uum!8~&eLn(E5rm=&;WMn&c6J$Te3LZi&7GRDniv#&wDjF5*?#y|Gmn_=% zBT|jmYU76s)?rU=8J`74IS9~JzNCdNt60}9?RMn&q3tJM{=vG_Kv3^5|NQtU#MNMx zX*lsM^64EKh!rYYQ5LqTQ?}2JPm>}N=mD3UUV7Jy9B=C$BCoz!P*u7935@@&Z&|=Y zI=gW)v3U##3<}d;Orw1v28Nn_LLEwl1{(jTXvNvIK4+4zk3a{8;V^n2al0lUEFY;0 z-lA!XM#G!0W(yFmx{nAQ5dEq1i=Va9x8JO^=dS?)L;|8^j#`gVr73bWKK^P0RpyYh z`htzMB~Fl5KOBoW^5zgLRCDwy`#yV1LYFboVG6K?W5Mi`0B^aTRbNE-v zo3J9kW*h4z*8|Q+p7O6ttZiP=J(7wump{n@3&7GrqfHKE;n-{0sYcd?D+~~H#JQ?5 zA^yQ!)0ixD$_9eeuI}qx&vep%*L}bcvhwG*SF$|jD}F=`C{czE(phZ4b;elNH*kT| zqx_0doO`8r%mOd+9-0Qyme^-mSAL@Z+0v0pqRNeP5swK+&eg4ODFo9(NfuJ%mh0J( zHXmeN(iR>8F26p0U?Q<(;>1Wp?gmaD)4sw$0hof-wcd3X5I={s6KRwogVsB z8!1g{t!lv4$sA~nrCIL1^jaIA8mBXDq@cMx6ybeG>T1!H5ReMc`aQR$lV7uw;z|8X zhtRBxG{`sB3B8mVYe~5VFHX?G=R4BF-5Gh!6&nQ!JlBF)BJn_qdVi$|(c5}B5LTvR z4qi$?Ecfy3j#0DL#ZNH{3ecxy-P-t1PAGl`#AZ(%XokGx4 z7NaDwKGn-`nwiAbO;qKSWg~{mR9cc^J(`E?(|(qYCwCfXY4-FOS;})js@V&cZ8_x) ztTZ!Xy|6>%GukvNhkOL5mIT3CXly@!?#rmX;C83*ps7bFYh~4aTu6|{Sl}Ec6qQ;Y zQX7iMQ^}Ugh*2_Q{FYUevVKQH`4z!q4?d}-TtbB3XbBr6|DhP6tyx3dC)(y1}@d9(~0^ z9@7?~&Bon|yyyX8iKG@z3;8pU>bu1Wd_3grepI-YmL94N>D%f#Z?2zx2kcqy09iUZ z$v}}b6^n(uG$YMtHnaJny9EmmBE{w?XEGddMlG7#mMwYCvbTVVyEFJK{m4Vbey>wL zVA(O5eFnQ|5l>y$r$s7i-^@bd|ph zMcioJ3z`br4@^4^e^Js8qlY5-AHR>+Mn9H6S{W(<%`?j5a%V#8 zU0&RC`vhKnTW9$b6dc~@9kbJpYKswe=3QWyLk0~}z4KF+>}hk#9iqmF2(U!LV7-9D zv|xpSTQ+WY*mW_|qFRBptLa?0!tDKB@*bmJwPwin>cH)oAqDap9dt3=S3|b3ZqQNS zS&0r*^Qp1A2m>JlVem&?HM#JCXRTtHyD{Uq^p>Lf#$H(P*aUc#sBgST*_PFC)uVlM z34$7D3bwJQYm7?|LG&%xF*X=`ePuB$h5@Nqy~{Yv?jOu5PeE;DxY9oPt$~}%w!;}j z-fZU&*G#sRKLNe0xN{HBM}s4q%OCy3ZUcQ(`4Ea*(ke`e&%ULAk#^ z4ELnYmYsROk_KuNx1Wn_Y*nFrgj8r|BiEV{f>b|(QnBLf`YWS&mI!Y(FJ!}84(u&%SiJ4w|*>!W?xWiD* zu;5VZqf=jejSgum#6sz?DuLg z(A})Nbr*tayBJ}MVlpVQIW+h;$w*W5%3VaXjR#OeO0hZXeqyi zx0`Sw`a-Pen)5MY!q^*(dA9H`t9sZ@?v5wjM(9GM^u(uZ{1wUK%} z4gxoxj2(x@Uli0k2tYb60SD`_aHTQxXr3+VVOrI=a7@I7c}uEV5({oS2&i}f0ViEe zCil!Z>&I=DU>HOFCo&B!Q!wq)dTRMG>tir`~Xs9KK|6^U=sD5!D3c+aaI;e zH4RWqX$O!Pe-bS+$|9k$)+Vv*85dA`ZKQ6UZ3;85$UA=#9lcdgSS9bKV)J>k^|I1j z)Y8T!<;Hpq0`d~ZvOO+9^7N}le8jrm{QDYSjod&3*<9Yh(>p&f!ZWD#O+&@BpZ&id zpI_kfUVmmgb1e0z2*U*q&A0)15=rraDdRn@ZJ|9m4V#PQe8L)TJ!NaZ1({9TGp@ZL z^Q4v%4EF9L5*C(3D<^+`)3(c>zEWmjtq`L){u`In+28Z4Ox?|^QTalEu3I@D(>ue< z+jrO&E|jRa-C>(yl{Ry8x>CvQRWdFdHF9VPwSNC@NsWpI+ZMrt5WQ~lG_&GChvfOC zC!wgD^6)|Y3ToGSb$f=7&%5RsGm~W8O}b5OS&!=R>L~FmpX0}4@A~tu-D%$AUr*UB z1_U9S^u`_5JdopjK!@Tw&`9bB+K)Y)MF~0sUYs@j0)lyNAH&C&?pyo*VQfa26`!aV ziweMKg{z}2cJ(wCQ=^k+JX7Z@x@{H(#jLz{*V@LH_()aJY@I~SMX8dkq_3D$DqFKv zJ*?1atzOvkCKVdwX$vU|!PYv)Gd*5O>DWsF8D@sX)s0B~mrFt7=8^a3GriEW}o9o53Jkp9~ZHnMWuUC-uYpXA6~#1KYzQ;hUE1 z-3x&?821Fy3>uCTGohE^I&2bjxP(hJdxKt=UX~yipf{=@$Z2li`qd_T4ky0+f;Gq2 zD71r1ow8Rd$YO7jQYvMW>Rep+d}AmG5i!B#R!OmOvD6 z9C;ij0lD17pNCE-U(Zf@0UY*Kl!W}I{Qd($nZh8=K3NpPDYQq6P=paWKCh!o^3|~V z)>KCHI_Hy={UlAx?ZLwWY~9OeOE+<)IL-4#Y}bdb-frP64AgC*Ly50fU0T8OLISoO zos-9rrWcdPK3s-e9{oM>IM7eznIqWHA+T3T(d2x6XZ=gu^zxEf`mTiw4m<=={^G#;Zi*`g+ri#3W@r2|5=!N!k?5LTC_NnYeaFATY?N`oA8AzlD77#mf%SW4_s3*qS9TBte*yayT6_0-<3-hmeX_KsNV#TB(>%_{;` zAtnx%i!R({rA%5+$sC-ZK5AF9ZC?n<^P26~8Z<~1U=G);2ov|4#{7Laz+9h(H~~{Q zHzB$6e(;dJ^ocad$v$vc51k?Awzc#RYY~F0!NXIKfJ1Ws?HV*_QBr zc2{D|5%rcg-Y+x&QOMUOBl4}bm+4>qUNW8x0uIAP+_A)dpi5x%K2O)~1)V&L?kgDH z&d~f03(e9)I{@;y4L+J+T&)&k0<_s|S$3U)bjU@bG2qPi`3NCX2kiIv@)PvN1i+ce z`|sAp>Ov3&c6Y-%+#pa*L6qf)e`vDost=dxZ5kx*GR97xhFgR1eJkb}m)qiMyW~E^ zf<-62jj}Y*qMWqK=2dc(KY>IvtW@{hhepKgdcI+xh*a~oPP=GIjTdm=RzhsuDJVM= zzKqnhXXU_g8CmMe1D?VBC*UKX6$iX%eG=DPMnuiHnsK=9jWneq%wfo7Ky+Es2 zAuU;x{L||kgjkvf`IQ<`Mu=QbKh+0=oY&g3mwh77P|1hsFaNo7OZd@ad3q$yn?`$g zRR;9CO(Z%9AWP^Gwm&N#-6sk{kU;B9q|7z1nDGi3SBty;RN>@fy?bF$#broAbjs;t z1wLze2D?_1h)ykSd8ULC|AQI11G+&OEy|@v8-Kj}VKgI%GkN~wE6SCxXdx6MGvIe< zl6rDp%AnAx{66w6TOto%h0-Nxbld`3+%mhk?|+6K;*EJu-V zj;FUp72WT`(jHnp$JY=v&bGfb`|K3rfI_{IXRVyKX&{XlyM7?5e;p#K2)65I;$>vL zipL*K&rLr9O9ojdLm>$1HwGAZk%j+Jj+yZqriRgtTS66NX{rQ6_HCI`>)pRtChQ~ow6kf4onpt34G)lhrP z(whjBaW!neDuk*C0k9^*?G`FJY^~%$?i07&!YlK=f=bl6#(l7sAq`h!upHEYulE~c zPwg@s$m)K}9J#;(F^&Bdf3tKwbt1l*g(AOxCcv6E)&IHx9!IVYUnd?lX8@Ts`7CG(h*&uKDnMy461;0n@vxe6l2VyAs z83xjil?xzq3vM#HOT8CyyE^m-s%Z<9C^PgsN__@zbB-Q3n?`?Mak!ZGr%Y01bQz=o z-5XMj3w;lxnZ!?AS!VKo{I{esGFT$Od2zGRD*ykT}nOP{?u9?ByedV zq-S5d)0c9R``@)760@m3dGFs*AI2&AHpUB{0Q!c;CB5OA;p=iJc>&-;a#6Pcl_d9U z&i)S@A*lyW)WUU;o<;@s?Bpm;9Iysbj4V>(?M9RxY}J7_HN8rd1$Yt8U~X%!H3fy+-1I60G5CI-F0YRVWICg(0zSf-9U zxo6KFrBYD_*nf$^7vu5=`+iaFOLI$*L)}gl$a~O0Tl>_v93?tNRXOfy^ZIj6LxInz zWR6Y1G-u*X)&Ap`fr0k9s079ME`|J9$fc5hF(03g;4IjXpo#|Zno5xyG1V+$@OBG% zWNO10*sd4P|L`v!O>>)MU@@#dCgO>Rp$b}a^pq%c#2Cn@*3>I`V|b}KmNjb9S|6MZ zV7Hxnq*GYM+S}>YVlc>Ora3`c-0Sobf*N@&H=o~OPgnof-#QE}dsB&5^nS90m(3itMKcF}ey0kvj9c9%@=h0T}{j;*wJ z`@r0sLi^xO#S7Puu$A?YZJ7J2uT9WU<+cyUrm)`L|d1_fLtw8VqWHWv}k$t zMp@&U@|W~G8nCZT)n~mmD8(BR8KQ9k;jxNyJ7rk2d+W=JM%d8}cP0eF9XDJyj8MF= z`!ptz&uqBkJf^1~cFY*K+JoMW>VoSv&n9N}3#q3?C6JtI4t^Nn8!%T7f7-d2!mxzJ z9Bypg{-)Ej)5x)C@WqVc>D|3Y~eO1CAGZL4WBTjkld==!_e>}W}g`cjVe zUM8?m?^79%tsr1BVDN^j;d>8<@3^sXkwwOk3lFyB3AxuQTGG_3Uv&l4F=z>;k^+|| zM!A004wo$ZHJ>-MzEhGWn{2+%zuj$c!AJYkIIB17n_qj)xNn^O=R|*>=)L1Ds(Zk7lkl zPwCqRDu*N98O1-l-6|CudD9Z5e9w&PnT$M<#nRQ}jgLRB%hGc}7c!l!?6gbv$$KDSbsWLW0ZC;*=v6b>3PZ3E}6Z>G!W<2oq^mtJPS zvOK%(GpKYM zFJkB24(+})_yzSvQv<`y*V^?xeH4?ot$cgJTV8MJQBL72aZB8?%ncMn+t;aT7VTdy z3d@q}2{{jq;o&2pR%g}i{)l0Gl2gx_ObtBVO>}4d$MLr=$~ds~%M_D(il@Kxc6;W) z9F6`HT(9LuY$3&`Py}}y++!o)2ws=TQS&(>CrC@t;##7!Wh-}@ z2PgHrPE+F#W>#qEha$K#xU)D$*D1EWn%yO>F68}GzdIMJds{Ta(j}-_n0J4x^K8{B z13QAO?CMw3cX?Y}C}YS>d$sE_Wwzea@pbKRbCAf)u)k&R7M;qs&)|kccF}O=-d?gs zb*p^?Zart*_+iS=g+Ygmkc-v9XHGfcV5?OIHl6GSTIB+h>8rwy88>@d1ZmM5XO^lm zUXJ^x;!*aAy-dN6%6hhJ2DU7t+T_8_%ip2T6XL7eC`@p2&Z6`^Cw|$ecf*ubS$r5K+t_5U$ zx`A3#jpyuPCivQ^*bq}Hg-Lmo{i-sqo-H2cEpL4jVroJ32N{p)ozuQKmyxumzj6S_ zKoj5JI?T0MIqohbkR0V2J^Zs}-y-rcYR$xVkiS{EYF*NVQDgC?$-8vvsGD3136ebD zH?x}%)%Kbu9ZGs`esOjkk5f{YXj?V$Uonio8jiB|b$zNBM^t)g77wTWY?5>he66`C z>aC~GK%DvX(>oVXP?t5ghUh+q}o5SkHRgbAoKM^b>T=e(yia_usxit!b1gJ12>3Bl2+pEoF$Z zK9j}~GRmMo$Wx&G;6Mx{%$;}J+K^3-Ci<<-*X46H88A2U>-#-e-7`h}`t8%)Q{r)F zO4;Fc2q*9Ws%;w8XMR?3C2t$E3ts2iz?lEjcV27Wgn^d-8dV_m#L)O7n4L zBX^pI^Y!!f1O70p9$MU)c2%HXyXT z1wZpm86FIUH{{TJN>^U70zQY}1dZ!GW!PJ1r{;n#)5t$(y{6X(&NY)B2KNxiIToiz_B>K?SXXfws`& zumfVQ)r&a{>>mS8=R;S6yL)c_d{e=Np%wYjghuV*ILf{BKFMmffAz5-;B^=#&#w!*LhYi+T*Jz_t47nTo zTd}tq#J}!{`R5@e>V8)iC$fror&f9mvIBT#hI-$gLBx7^jufESG=vteGe4OeY}IsB z;$N)f61Q?pRrSr^B;yq zS8!i@xIp<4_Hk1ge7QFLv>(V49fszc$$t0qk782S`OO}1twb$czx)&yID7^1oYiON zif8%VkgCNjU+vJsxYxAYmG>c^({T{=<{^Ptuy$G+00@wezQ0R=&F|eH?2hphZFO|W zxg_ek-1>;O zzGum;M{8JpMSAeJ=Z+#O>3GRDOh%6}5#i7*WHZQGrQAkmNNSa6M{TC4XL4r<>q~syWZxFZq zI*22VRt`OV9Or$&JeyC|uTP=_ypj&GbuW4vNs$)Wrdzl6x8b!sq(ittfo<{ZFgw5O z1rO-cNf2_q8ASuzw8XtFtVbMjhy+WIg+h+W+Us-pK1$Mlcs3)<55|bk-A%?GOTF2y z^uWLV1>D8EReM+eD+d#j4TRHg&cU{SlxS{HBgaeOx=Jg#a8fU9)TH`b$1!`wD|}Ce zRyGZ8rIiYo2kqMVf<&y(-+7I7EFNzh2OEuhb?a>GRuZZG`568!(ez0aYsJFqsk(lx zqiIW97Ts+qvFdf*Vgwe$gp6AOj{V6xgUtCeo^~22Y(Ie(iu_?v;7}na$!A-atH7_c zBpZWHP%>bA6mX|4TMT8POg1R`-lvAOKbE_MB+?fDpl|DfIE>qE%Pz(@Kd)~a zVJWY5eP!72@xXcGw9?!j=j5w~Zl@UOHNA>4j1YyBx-8FC52iWi7UMyj`$pm5c_Vv) zHM0G!ShRj&F{^Y%-R!kwt(7RJK1Ci(s4}wYJ5{Hz)_e+3F$vThQ)HuW<-@m{AYXRU z7d=`1>{)hd4XUW;QIBcJMv1(=D!WEcFYGWRK#g{PxG?d36FLdh4R2QTII9>kP)ax7i~<()~IxaKHpx7l0KLEfmy7jGGB)vHuMx=7{W*5 zRksk?PV`}TO)f&CViNA)l`%M0F;2FK)7|NSSYmt*=(!Yvd)%k#X%=y6hs3e!s1nk) zc-@c84OSI5*_8@Bg=!Ou1gi}ScGG$*h(C_ciqUsVi4GT=5X7+ZX<{d!q*|a8080}7 z*9wtSS90<=Qg3c7=I^X6z|FnJR!x}uU<^>?I|*t>mb3p_oRFn%OOpJOM!BerXizjY zJ+BCYItVfkUYf7viY-$QxmOWDSpElvu&hGG>BaDRb}3>zX;juzsJ&F4x#X>wog|ww z#yYItZim6~vs$rAI|eTB%J1Q($prs!jbA8DeQTV9DiCfNdIE zx8K8%HE)=|>zDD0PwOSr4aoR6wuc~>2LyEK!fh#yYa60D(jzdzhuO_+iq3@X`b6y+ zEjsARM=H}u-sa5l-|J=Ykv|f4rh$>HyQ8>-&>(8QMaA@hm}QxL%$WvEfnEsFZ*TKx z6rVtf&c`PPK%J0pC@mPG#uB}C)0WRzt04JNx$80=+`Qv_MSi9|V3`|tpE`+;>Q%}> zb_I^Veus#W{1lpM53qU=D{t}w_3hIDwZ%O$v4qU=znG8i<+EnWhVRoP?1<~E$+`u@! z2)OWjKU^~%+^-QKcG2ZC*g(iifVl)s169#y!tR!e*U-Z4kS@-Q2xt%gy2@>pXGkWe z`a5LML1>Uz2kl~v|7c1fgOJwDgwn8t!>j1>wfffnYSG{hFQ%Q(k>F@Vo(36eK;77> zkl{XuwlTQfay`4~c~Oo8*)*$UWk+y3c#-OM0ASyTTRS==b;DeJ6NR~enUO$k^8I}c z!J_dJgD|3<{6%PF=|B`C(WSQ}hVS|7X=*39FMgPW&wg&V!d~GIey%~sR%HP!E;S_! z+UkQZ`_%$cXX4-_*cHD8iH?*6XG~yS3{_?al87*S6r7r4)>E9kpXl+m%~ClDYtk~k z(`6ZMW-fmL@&V9b&=_Ct-rvOI*HYh0pS8wS8*1iC?BLU-HTv}Q7b%yTf$#Gs=)mrL zy|k|D*2rO|WhhN~)$-fUpkl~WLy`Hru)1t%2)^a~tEpgsPlVs27TIXs4`dhIt# z0+qH-QnJ1F^y7b-LKgjjGbz}o3JBd}flwbIUb*Nus^ZwX>J*KHe=|8R9?;=UENC%D`IO*MMnti;*WGb38gdf-j0 zA-sJ51&z0&71;kTmH+nO^-d=CnTT2~?&nV94N_mUE9(`0e;~L(WB{P!rM zFY)#pe!;9hbIV!}QTQ@i&OKn|7!GywMafX%x*Kr@s!{qoVwz6&;GWcgP@2-ZQB<~N z>MieT<9reeuu4=UHI=H82&`RZ^u{_@PtaPd$5-Om+HAF`Rlv?haXaTX4kn0^=|Bkz z^?`hI$}{0w=W}t{Bnvvh;<4V5ZD-9s%bvSv<3XT=CS{<6OO4;=86;wRnP{MsPmMN5 z-dkaBz0Ciay7yu~;DKhvHYU{+)zPgz|Erj~+#^U}8K@qY7WG`@94dxJUanAlEef>+ zx*k=k%JnY)itZ0gTc2+aIxkKHhE~RttP62llrk?7i?;m_ye;Z;m2V30TpBT47h*Qv zM#pc~H98Mo;=pstPNRq5-D<6Q`%h!QK5+R%o$I|$GbTUB_9?hGP)#~`maX#&X_`vs zct5-s>hYy*U2ab-uEVcdzc4UoJe?EYuUHRw+p?kZcIRnZ*qlDb=HTO>2^=N{k_DJc zJ5O+5iXx4DFWf8YvZuPVO{e=S11D!CvAU{a z-bEEsOAoJ+CxCm3^C{+)8$KVgMp z=rVAOIYv>(P*aSwo%C2D^*2V$w0qN2FdFOrlPeS$hY>3(wXb$|1b+;)?C;C+_ux`mO{=|ECn#OcQO`_$~%c6It-Qz{xX{4BU)ZESo40B(g5+Ku; z-Cd=G%K*1nER;fnT>rozW}=`=<;^{kVnJqUbh)?1FZ<+3rmldwdGR0l?v+rp~yY6ZU2{UGv)TB)5E*9ML> z2%Y|S#)pbaSNYLWZtXcc{|sDCscbbLJkwxxxp6OpG#5+9>;)a`f3K93#C8n%SZcnW z;#K_6C%vEm_9+#Q4xMbR`lrfQ(@Jn{?sgTw2le7%z7<{ch4fNXb}3xXA>+o4=EZW~ zu6E0`7fTAo7D<9kdyucV(wi(S!pyPzxZ<&H2Md^g=n$L!=RVpXnu){jWlHf!ZwQsYhM-PE6P z42K<^0pPMW*jU!_Pqj_zEv~@T*^jGmqqex=QnEyImGkiLHgHSAgggPp`gEsZNLH)i z5%o5ZRbgn~MWGNeSnb<|fzN($n4vHq9@wCIn~bNxD)}Go2MF0!o>j0AG~yTfUxK$=)UiK+ot*wM*t-gbs}9eo+LKCVRs+MZd?G! z?!G|wiuT43hOaD{x1@J75k!3vqycSI`|#$w=&r4d!%GBkZ4pUWzzmT_=-@jMhF!^5 z{z)TTG$YsMr~VwtExA%P0Cu=9aH~{)yY`gw+^kBw?)?Wpr}P!*VbwH2e&yYqwff=( zZ=Wx53)kOBrRZ1eI+p5b22L0A8#JoFUJNKPUHS){X-&tda?qdtq-ZI9H@ZV?m(;Zf zAh2{$hUi`)Vn2IdM$qF|(lvH|0NMP))}b#FQvtYPF?sn1m=5-&z1PN7Z}z>w z*3rnEr;cSh$)LrKxE_{q2zv7l>p?k+DQkC37=EX%J)gs?WZ`;Eawu(+8Ij^ZHtg5Q*8xL8$YTD(I(SC+aOAY+{R5U6N6kPtHK{BkV{1XIHD|E z;GVL~8bvM3B~%&uXnx3n0YbXO?3U)vpHE~a7ix!i`C02BIuonSWzXQJCsRj;Mz%KwpJreS5#!bv^3w zV&c9a>)c4t9=J@eloj>U`WvgeO(F;LH$vHKm0MuXp^uQF#@9;rF8ymQ({k+fGYFhb zV>^&)UTyH}6U49IA1hB^BiAT~ff^&9>celnV@)q@&vloUZ9frQ_WK-cG86-{GLFvJ zCNH@fVxhG2W!!Rr;M`<#B5dK3RlX5vhnxg|>f3Tmo}CDeLEqQ^;|7UE2`v$rxW~bp z5%9-2ZAY`3p6{%FdqhA?i|PH0*zs&VYt6xWl(YAYvAswXy7661&hNW!2dM|t@4o$q z`14pZdh@;m2SwfK(d>YtQgfisa8lc_Rtieq%&=33F}KfkFl#OKXY4|9uBmWv!h)=G z;QMh(d`Do9yFwT<(pv|l^$xD>sJ>Grm9)~M+|*}?6BIE~8laVBlye_!2kdL?_Y>dKm7N6nAx$9Z+Qt8X_{Keg9<-T5r`raBOCXZL18bsKIdOHBFwC-9R3A~ z1(f{!OZE0d#gd}{(u5V-dYAkPz_vi+#*wcBrh2JhwJl4`-|1Fcx$OVb5p(n}l}%z7 zm;3=lk9Ut`3kBlsn)d*}540v>El!1h;clNll=uCe%GYZ4&VS%}?q>nCh&ok1e$>`1 z0w5_r8yN!d74?6vjEy|Wod(#u0|aBsBjIS*e8aY_wu9;`sL{S5 zgeQE82I#7BbfOcW{y}r8G|dB()Pkyou)4~Lzg)|huF1S$Qv=emZ1Htaw=CL85C2OZ zg&-XmrVzvs2KYI>jQ9DgUor<3v&ss|(6~DTyc(6b;ZYxs#ybE$Y60WZ#5m|jC4nLk z@Ioa|Lz~05lIbE4Dy+7b`R`hbu%Gt;tegk@oeCOsw*K;NffsZ}dI!rT#`^~_T}-r; zhc4;5t$uynm9^1tsed{IR0V(@KH5uFlfU=h@=?nfMvg=1=gh>vlJMzy?dT|=@+1YS z(P;Lqo<3D{LAUcj5H<1*587op_nW;=se%)5{MoN$0C%Gf);Qe`Fpp0F#!&ED+t}oe zHK^tUl@)-HY014so^af=)3-p?#k$V>gB7E)?sxg=-)e=`m$sJ-!lkms)DG$eA=PlP z#_@~J;RTpOksw2~{c)J)Kvc--U(U%3hu$!;LQT5q@sA{HbJBDNJuA2}U}Nxr4Q{vhnUU@af#KA;6jl&)^BDbIajIb_ zMF8g>pTH;LZ4=cR)?NPj2kN7O;7LtmHr&qlp^wl#%6Gg80C`gPacyH>i1j3=7~M8l z9rU%=<5RTSvK;CEGQa!_`{zFuHqXSJ9dP5<6D{okkQaQ|8|7bwGus;HqJK^coju{? zbg*E}+eeO3QIs+Oyg@`oy*`nxpX+<3C?O8OvcX3Y`=CBFncP0L#YP_jN}cow?8A48 zF6j0AWo-X_R$~K~6et>buka0z|4jiUfuQ*c6eq_sixguH4S|LA)jZ+n`*THh_+c&Z zPBpTD!V~UIBVWn@)uPIzYff0T&E`7(-K+w6{<1|Y2>%LDp!ow7p6RMp--k`xACTfg z=3loQ3wHtQ`G|{u6Mn(c@jxIH)5Vq91w$~iS{)y#@tZ^;L#*(268D6_Y_&RRg`jv% zL>>q)>=me0JQ)_i<7iZOK>oQP4<*iO*wZO{|ZtJREbJaEM+LFs-wpt)o*C&AsHZ&_Jc81i|%{&ZSkOhNJ0iG z@OTaFJ{Yhh$fqd(0N*7yq6Sj;1`YCydBQ{bw%LXTYZ$KeZ^dWfOki7O!AfLfqRNv$ zoX{K1$svehVlAmSRq$+K?x5xuseaWmIG)3eb1v?`szN&=oquz*B*YU(s0K?d%KtDZ z3RDG7xoR=baH+hREkEf9cu`|E!O1nL)EyqG{0Ur%)#&2WXsE@lC-4_bTMj7bjo4+) z^pA55Rjve?%Oq>YXaBc?P}Y|;Gipk(3(JQ^X^1?uO8HcQ)`o*tu2%Lw(>b0K~?e5%Meo6 zJOugSc#+?qyAli_dV4&VwSit2T$xo_U6C z{T9}lA_q0jFJGI);2n&=J8U7gzk|wNYPe0`#7a{w)|RCP`{!y(=|QEj*ZPqC$>FM?OvB^T7dwr0`*es(HyQxh%GqFvjI{63Y2ZZ2QlDZ zA-ax$f_SP%R9SS7{B9P6lA5j;)vX;&nV%3;63<-!;l%#zFHjD?<~ z$$R-9wjZ+>jcS`($e%|>sezfV=^l~Eh}Wz8WW70OZry(4ptH-h8C!|Qpr@1hyKS$A zLBWkU)iH&Zxpj~8u3yd8&yV*-5KR1xadz^c+Pg_?cpQCx3-2Q_HDn>RlNseGKo>zp zkdh4ue1~K+Hj8Bj@kBRHwcaFqGBwL15hSrTftH||5^0wW#ub1cBbZMC0Z#bcYb>B3 zVpjXwT8npErG0w5)lvZlcy&v?N)7lGgZOP*X$b?5YwUaP{T&4ku>^rB(1kqCN)MgG zRQq~?j`iDi6KnR1Ex31z;y@y0~(wN~2zCUR}# z&V<9XR$ovWuq*%F{trk%r-s7Ju6&ERxPFWp-SBzbe(3S!v4@qMrJEJ_1BqW1mlC-u zC2~#b!L{phlGo%Uq=c_tmAiWN{Lb8q|LFrq=O?z-zW?hFo@VGOgAXqMj~P5{9j)9w a9y_}HufI_)bQ=NRKpMAUs(ClfpZ{OV-B4D~=o|G5>Y z!R2<-^dHOaUE#8hTmaM&7=GWXrrM>(6T$lMJ#iCcJ`kOz! zcD6nxg5^@4rs-bO`ODc{N=9fcYbEQ5h3d=Scy#=W*k1+g(Ei5n`VgZ3O`jZ7KJ24Z z>-1K!#in2bdOzmn(WpovyQgBguT_X8Pb3xeE!UY|H5FDRo>XPMu-C9G@Z@3Hv+S%2 zS5{djz4}z{N`}3bmvl~i#jvfm_K^Cs#d>mqM17lRf9;iXR42`XE6aU`4NRk2;_j8H zod`RvaFyrA#zp+7TO6S3?PWz5ww*p^BP&y`EmL9liuSqo^tJScfR^_~wW|K(#~X&< z+if}-ms56=y;~|Pdo$(h=E%XFjmxWDGKY0P4J2;VlsZ(lY8vmY`V5sK$P@XjL(iFm z$lBq|lSK;Fn84I*vG?T;o~W8GWohpH)6YneES(I>rbdYa!$_az#`?g-x6*hf*}CY{ z`<&=!D4U)oH)(3)vO|R2;o@LNGf87f*!-DfhFL~j*r98FwBJXe)ju91E@peGXH;{0* z#|&A)jz3DnJD$IXOy1weWqcdC;Bo4U`R3~*krAT;t0vC%l7kHM^Iu5iSz^6j4D-l3 zKzJtvu(1~M&U9DrKIA>avF`k}+J;SsQt?Krb`20$m=Iq?A zn9l-xQ6TCWv(dpmYTik$d9bHMw(*=u`u0$@F@V%BLoVl21AD-HhFg7#yo85fAYRlV&;MAG!| z@o=6T(S)7dVv9IT`S(f6W!7l?;Y)`@8(7)TFc8B3{<7k&O`d`FbW+{^BGIZIX|+UE zI=*ltV|tw^V|G2Wi+NXe8$}=&p!VCcM;<~G)@ShdlIPq>?4P+{565Z0Efp(8PR@b2Kn#mSZIYK z^2h?d%Fo)~z#(Ddeq^=1X`)OURoT$j{%jB9+wwIVZ3Qc>ML0dVTcfE4&Zg(ZNPO?d(zR2QS_<$ zy|RBZaU(#1`39Hzk9AL=ny6Q7L4FC(mOL*|-hO|MZO~g^+5QRX6Vqix&b?d|j7e`U zPu%h;?Z8;I_{3A6Hs}E?UtMi#-|~t4mYOTHsc6k%`RO*&Rd~>Y>!?Yl#xUt}BluAe26@kZ?s76}85Pe0FsBJj8J!+<|IL|qw2m`W4>C$HHpJvji8WXa* zlH=hY&M?G1a%N}mq05@z62gd=4e>($tf)25nDs1_;wEu0_hp*!f#YURcQ|m81%$A- z(d_yzr^&R+c7?zw_H^6B+{cB@r9m_}*=*xoWu^f3o)jQt6a99DtEDBAYkMc~G1jpe(tdV%6v4hYIm_37IDNu=3Uoxy6m%sYhzas&p=ZazQFE zF*_z}a$;RIO~qon&S_xCs{Hv#tEeSWr{urEWo$xL6k;GHx}*f+32w>%0x z%?qlGHjeZZn+L^P{Tn4lNl}|mwZ0-wACVSxI}oT~4iImiM^x%E$kW};hseFVkDj4R5lGgM9+%x4pS?D++9Qv3xRspmw-IY94QoT`UgQ{1|ezu&F7LN#i2? zDGcVao#{#^Yih<@l_n2tQneP87PTPYxD#Mbq1&%b#A(1QHiZmw9A(#gL>G6YU>HO5oH7jjJt@g|mY{3(&ABm7uMiHvEbR58i8xV)RDGN8 z)0+zA*_zTbmPTtOb zquJ^?hhXRiK(9_gd&qVCIm0HeO(ood!@~SoUcoo!Nab5xpt#3f121A9Yx&?f!rJC*V9G;U9u(Q-vta0h60Vz3| z-h_%DWJay;A}Y!w{dl+Yoi_@k$}XF>fBztXoHZt_!8IIb)^;#A%ig zd}M+B+GZDN$HUYfj>4950ri3+uS6Z;2K8};AamX1qTF}FF!CEB14J1>p{Lt#R+xm@ zSozc<6IQS)g-{d=9yRlV11RXAEm7}qM?N|ts!GjO5uU0;n9Cmz)?d8G*E8KXu?|w8 zYCcaVr^QD{BAol#e0fV0+cSB4{BqrvC!MqTXBbxCe*)2D9m3qXM%9%vlM6 zF~a-Y1CJ@4FB>>g>TF`K?P`hv3;6s22+wOjq_WUSnegE^j|F$3ciGT8 z6`M8MU4=a}wm0LGMVJEwEjUexM+FKCAw!Ur@gzq)GE`RZcud=5Avxk`gnpE_Z@I*T z%}7B|9tQlmS)kN4BRd&mTp3ZXAE8;PNUgyk32G<6*a`dGxCe_y*Heo^Ra9q1&M;uj z3LHRq^B`j<9a_`Q5)@2fJ=2T}_~ZbBzBY9v!Z2eRUxLVg+t4`(W0l@hPl21uj1wcqaDk?7eLINQG`zz8n=J<+{PE3VMkZhDG{|_fj~5w}houF2Jjv z2LO~R&Y1NWbG`n-#=`63Z&pT2xVnRZW5DwcK=(}@hF_Ts+;$gUCmW~7lv8<7NDkoX z81UA?Zdr1`;;6ecW>QqtzCoO)wdn& z17regz39V*FnW{B&fQehi`qFjx&^ZM)#U~q$w_XpV2zfIYOPg#gYLCA2b?C{mZ0n< zDY=#SAh&-_*A@{~JhRkR6a|E#6~A^S!|!OJLoCfJU5uX17wkR&?KJ4YqhW@_J}6Vz8#a zfjKw9f<6Vh(UgT02CsWv2zc2P1Jsp?)nJO=xkr;5LxD5wRwz0O1JrgGMmQ$jnD$9% zdqry#e*ugQbAg4!3x1TesHq2q&5*hz@CxG~q}o1W!57?9rhP^l**p|WTL`}y$n={H z)1uasB0tn0A-dxuVT8LUK(Rd|=Olgm@KY>6d+-9wwe+st3dJ0y;YPOh>hkRV5wL96 z1(wTe<7=z}dM4&ArPaokO*8DQI1Jze#m|9q^ijlo^twz?9>nSJM|}Am*fJN)M9hb2 z=~_FoY0~t>_R++^$j|I`$RD|Yr6I_a!Wkd!INFh^VR7SWX3p&;2c9fWlb>Peml2f4 z%x*6;mv1qYwqId{VIff5-Jxl1M0bwe$%pn(K47`fPTqa~kgPN45^;WL!3_K80x)?B zZP-c;AAFe)#pi-$iI#08k=U})j9>jxU5ov#Ne23<_a;~6a27C#r(blKLdYi6w>aL+;V zE&MwthL+U{1wXNH^Qad zjA}P~6-6k`>}r73I1jv}L}IG(u{-XmJ2Qt>B4CS~3)|-?m$T@OSW%hH!=pqWn#h+DhTyB)fwwk#OG>xWw9F=kJLEm>Y5J~rM}*oboU$)h2>=}p9RJZQG& zAaGMNY#psu>fBbpu%%(Z2Qu^NcQXLJqB$=xe!GdV?5!ZP(-|2`alACCIY+GXv#?!gIa!a)5$xVUSx``Pd$ zL1t|K6cUi{fUz5gG57BY(yQ;-C%+(a!~%O$}ZND-jp193K`_bnkyZqVa(E zr-Fn(OL6S=8YsJ|XCr?CqQWrr=d`%$%7E!`qu5_zz)1?2RQ)sirBQuyaOk5JXpiNeSsSk|mp{mbLkAo!tg${>`cU`e=E4EdN2cRiFEQ zdej{dp9T%sL-hSy|JwRj1i1g~M|E_yWkW|SG@}Cp%D7=@j=T^lYEUvQ!{Tw!G8~lN zgmQ?sPg?=yhqyir^%yAQfDryjF`db|iC%)kG5#=6dIMxCSL#dbYb>^$yf#5|U?8yf zeg-C)M51r=%=CR@{EyDhk~7RQEj}iVHvO6vr4Gs@Z@KElQ(2f5i?yDIj3)^&TMlqP zSq{tROHV_MX`P#~jAMK6&zk_kB_SYt({&|n%(^htEwWYz{5Y)QU!InKo`Irdw&uoI-|pymhPPv%LMu+D&OKp0b)WUmvih1Uzp8l$+}M z-1CWD@%5tyZGcYT1r&E*Im#&$|8UnvoNg{0prknnT477$DEGptT$v;;03D{FCDPO% z#|vGMGNb^2UWb(ACT&%B-N#sPtePw+OGz=lCT_h^iu~i(hu@K4G&2&6cF#dRPpmye zU;|%sR^6$vi=A9;VBeOjhSp31AD}!8ICKcQ;4zCaTk4rdf~sOJY~T}CN_A2HVL{@n z@Xt&npxgi`r?|Lr2gn5{xHYW}*%J`2{4jJv6qnZ{S+>~e#n-iVK3p~r{O_`g8 z&8jLv\AK0y5n)%iZIiuGw08wsU23=Cice+Ff*H_ydKrEC6PA<6RGhXf5E!J5i= zLG~k`{&*4-4*=m~weZ~8kAoe0>xTvJ64>j>pG5%0@T85Z^@N@M7ZMf!64EpI*+!bC zbJ8}9HBM3O>4rLoh)6~VI9X}CC#A+i?n3E3i&ax0$4v+BVKke@PJ*ib05Gbb{^?1` zUqsFPld6!22|#gP7#J;_-G|>=B304hKo~Nb(@L;2LU{U<4Wp0E301pTwXdy0O*OIN zt1;wbzy!e10$1DS=Vf=l^l;w*$}lc$-B)?COS`T(fGNwJ`ukqcj#q!ZcVl(xdnJI@ z;{~N_pIcGx*-OuR-$S#mH^%Fprk3I8rXTzub}StRpi?iD3(Ga6gy?s^us4PaR3_J{ z1uf-Vp{2wnb}j9%kDpnOcEhpaFcNJSJDQe7`9NuzCvkuLYWS*lo&=azdq2f?-YdYX zL@4LHZ)&X@FgTe)zb3Z0i0Ih8GQ=qDQ1#t-Tkb@K;U=@YaoDSOiOtNlC|t?j$uOMv65;74JB7dLso5FkT$|q5olmO%4=7gxgOM-d!@JEMscfc;j4< zc~J|LmYiYRu1@O#!k>^0x6&E;U$QRuk#6s@fr%sxt#$1*S>t+9jWF3Qv!eB=Pw zZfQ{VNA&tGYvK@@X(a*6&oo_Ec;$lwIBsX*D)Y-ID1c6{`gJCM^l@&PAmi-?mfH_pG0nEzju{#_C! z`#G$Fb?!9sEg5X8CR7soC0c@H8b z6Y4J1CA2jaRbmNRD*?E z4*~P|7=`~^9)QdU4uWV}S}8ay0U^wXq#o3&7S-0nxX}xI;Gb94;-DqVd1DITgp-s-18OS}@8&bpqFDQS@vfX`+G)?~IF+?H3D*E5{&FPqIBgVst#3J0*w;CRUXZ)*q!Qs<3;%t^rC9F2Skz$7 zfWUnoiejgPplSb!527`i3qq|2UFZGCR^wH0T>p-}7d(1cKpKHJK|Dc&{l;$mvaIGE zJID=8G$Eu*Q6Y7l|2YY%37Y}Y6#8^>k>}(^sF?yaaf6f*IRL@z{sGhBI)3m7F)Uhj z?tiHAr3VEB{)EmP7yLmZ&a&Gx6Dbl_r=5&$%Ta47lG>mGH3>dwY{?F`-@lVmzUDKu47yInOaNoZV zbBv_MBlL%u*-T7aM>7dim*wQ2y=K9sLSD^dJ8Rk5i+V diff --git a/public/dashboard/images/integrations/dyte.png b/public/dashboard/images/integrations/dyte.png index 42162b58a0d94492a4c42892b470621b754c3770..f45987cb269d70eb2e8a652eda6739493a809940 100644 GIT binary patch literal 18392 zcmeIaXIPWX*DrblB8m#AC@NAOX#xTY(yNNnG++=AkRk|3@4X}zEGR8BfdoV;(gh@R zf*?Hv1*xG*@10OWI1_yS`#t+y*FO6?U(WvW&KK^Bd+wQ4X4b5==C|@z2c~v{@d6_R zK_@iSRpAgs3m$19mc!sr>hzH^@Q2${9exjj{P-aV`4WP5z`u~w5af9kf@aMjNbUm! zvALkj?<;^8hb^?!RQC_s(TAh~@E-;bjeEBlW)HJ4N$^^yq9Y*aPN|0KjR!vc3xhU3 z54!prR{XU!c-Dt1SnnS?qspT5{dovyR><{ZUa1dtBCQP)-c;%2Al+E&538vPJ`Z73 zzi;%k!}#XS{&`ZJCetdXswPTOVt0Rc^^w-qfhd;~{)0vD4uN_9yZs-MKt#z!A`LXM zuLa|j$}y7M7cCM}_=!hdvyR>Ez%wSt8)l}*$IQhWx*`Zm{;ha%?+=ERW`rIsaS5k) z@zu6iGcl+pUB^IzS?{_;SysG%V?x;&y+nzpw7b?d3F+Lc?rTO8C8kMhyP}fr-ggK+ z?0TikUmvqeJ&7ya)1*PtLMqj*eLrz_`f0jtIVN@ZBDD{`HzXv8lMcPddWtdZ5VA6g zoETqZDz#Rz)JC@b5|0>XJt}|Njq!yN_B7oaB~}Q6#rKV{DG_sK7bMqe)2+QSpIDFC zI7}UaxZSS!{={dpx1o!E;$!&k-iRPaExd#-G1Egx#1pBlYdmdPlm`;|0gddk39xqE_lu)i3!k;m@cO$|zAAD3x{b;{sn-}rcu=uI@q$4h|kK#exhM=02 zgh==B=W+J&vP&gs35E+q*x?#ZMyTcm{*H3WYr|r{HvI0Ac+o~Sc8vx?l2`8$s_2u> zJ5LT6-@O(g*XJb;ZHLoBZ!D}oFQKjVPrOU$%l{m&$n_P&3_&m7X=umGdX>lH7NzTv zyA=yhpj9S%h~>5$Y_wgrIDgDRVz{*H?(hRDB`T>$Jf+ss(GqmZ4y|nr&zjU|Al0iy zqxsS9p~Fbwws~B)mna0W~#rhq|VN5U1w&4G*payZ2fx5aS9j~ zQ^`Fi2`bQ?3?&Frf-;I5HvDfjTeM=79@gevQA=8 z)5yH1g^uum!8~&eLn(E5rm=&;WMn&c6J$Te3LZi&7GRDniv#&wDjF5*?#y|Gmn_=% zBT|jmYU76s)?rU=8J`74IS9~JzNCdNt60}9?RMn&q3tJM{=vG_Kv3^5|NQtU#MNMx zX*lsM^64EKh!rYYQ5LqTQ?}2JPm>}N=mD3UUV7Jy9B=C$BCoz!P*u7935@@&Z&|=Y zI=gW)v3U##3<}d;Orw1v28Nn_LLEwl1{(jTXvNvIK4+4zk3a{8;V^n2al0lUEFY;0 z-lA!XM#G!0W(yFmx{nAQ5dEq1i=Va9x8JO^=dS?)L;|8^j#`gVr73bWKK^P0RpyYh z`htzMB~Fl5KOBoW^5zgLRCDwy`#yV1LYFboVG6K?W5Mi`0B^aTRbNE-v zo3J9kW*h4z*8|Q+p7O6ttZiP=J(7wump{n@3&7GrqfHKE;n-{0sYcd?D+~~H#JQ?5 zA^yQ!)0ixD$_9eeuI}qx&vep%*L}bcvhwG*SF$|jD}F=`C{czE(phZ4b;elNH*kT| zqx_0doO`8r%mOd+9-0Qyme^-mSAL@Z+0v0pqRNeP5swK+&eg4ODFo9(NfuJ%mh0J( zHXmeN(iR>8F26p0U?Q<(;>1Wp?gmaD)4sw$0hof-wcd3X5I={s6KRwogVsB z8!1g{t!lv4$sA~nrCIL1^jaIA8mBXDq@cMx6ybeG>T1!H5ReMc`aQR$lV7uw;z|8X zhtRBxG{`sB3B8mVYe~5VFHX?G=R4BF-5Gh!6&nQ!JlBF)BJn_qdVi$|(c5}B5LTvR z4qi$?Ecfy3j#0DL#ZNH{3ecxy-P-t1PAGl`#AZ(%XokGx4 z7NaDwKGn-`nwiAbO;qKSWg~{mR9cc^J(`E?(|(qYCwCfXY4-FOS;})js@V&cZ8_x) ztTZ!Xy|6>%GukvNhkOL5mIT3CXly@!?#rmX;C83*ps7bFYh~4aTu6|{Sl}Ec6qQ;Y zQX7iMQ^}Ugh*2_Q{FYUevVKQH`4z!q4?d}-TtbB3XbBr6|DhP6tyx3dC)(y1}@d9(~0^ z9@7?~&Bon|yyyX8iKG@z3;8pU>bu1Wd_3grepI-YmL94N>D%f#Z?2zx2kcqy09iUZ z$v}}b6^n(uG$YMtHnaJny9EmmBE{w?XEGddMlG7#mMwYCvbTVVyEFJK{m4Vbey>wL zVA(O5eFnQ|5l>y$r$s7i-^@bd|ph zMcioJ3z`br4@^4^e^Js8qlY5-AHR>+Mn9H6S{W(<%`?j5a%V#8 zU0&RC`vhKnTW9$b6dc~@9kbJpYKswe=3QWyLk0~}z4KF+>}hk#9iqmF2(U!LV7-9D zv|xpSTQ+WY*mW_|qFRBptLa?0!tDKB@*bmJwPwin>cH)oAqDap9dt3=S3|b3ZqQNS zS&0r*^Qp1A2m>JlVem&?HM#JCXRTtHyD{Uq^p>Lf#$H(P*aUc#sBgST*_PFC)uVlM z34$7D3bwJQYm7?|LG&%xF*X=`ePuB$h5@Nqy~{Yv?jOu5PeE;DxY9oPt$~}%w!;}j z-fZU&*G#sRKLNe0xN{HBM}s4q%OCy3ZUcQ(`4Ea*(ke`e&%ULAk#^ z4ELnYmYsROk_KuNx1Wn_Y*nFrgj8r|BiEV{f>b|(QnBLf`YWS&mI!Y(FJ!}84(u&%SiJ4w|*>!W?xWiD* zu;5VZqf=jejSgum#6sz?DuLg z(A})Nbr*tayBJ}MVlpVQIW+h;$w*W5%3VaXjR#OeO0hZXeqyi zx0`Sw`a-Pen)5MY!q^*(dA9H`t9sZ@?v5wjM(9GM^u(uZ{1wUK%} z4gxoxj2(x@Uli0k2tYb60SD`_aHTQxXr3+VVOrI=a7@I7c}uEV5({oS2&i}f0ViEe zCil!Z>&I=DU>HOFCo&B!Q!wq)dTRMG>tir`~Xs9KK|6^U=sD5!D3c+aaI;e zH4RWqX$O!Pe-bS+$|9k$)+Vv*85dA`ZKQ6UZ3;85$UA=#9lcdgSS9bKV)J>k^|I1j z)Y8T!<;Hpq0`d~ZvOO+9^7N}le8jrm{QDYSjod&3*<9Yh(>p&f!ZWD#O+&@BpZ&id zpI_kfUVmmgb1e0z2*U*q&A0)15=rraDdRn@ZJ|9m4V#PQe8L)TJ!NaZ1({9TGp@ZL z^Q4v%4EF9L5*C(3D<^+`)3(c>zEWmjtq`L){u`In+28Z4Ox?|^QTalEu3I@D(>ue< z+jrO&E|jRa-C>(yl{Ry8x>CvQRWdFdHF9VPwSNC@NsWpI+ZMrt5WQ~lG_&GChvfOC zC!wgD^6)|Y3ToGSb$f=7&%5RsGm~W8O}b5OS&!=R>L~FmpX0}4@A~tu-D%$AUr*UB z1_U9S^u`_5JdopjK!@Tw&`9bB+K)Y)MF~0sUYs@j0)lyNAH&C&?pyo*VQfa26`!aV ziweMKg{z}2cJ(wCQ=^k+JX7Z@x@{H(#jLz{*V@LH_()aJY@I~SMX8dkq_3D$DqFKv zJ*?1atzOvkCKVdwX$vU|!PYv)Gd*5O>DWsF8D@sX)s0B~mrFt7=8^a3GriEW}o9o53Jkp9~ZHnMWuUC-uYpXA6~#1KYzQ;hUE1 z-3x&?821Fy3>uCTGohE^I&2bjxP(hJdxKt=UX~yipf{=@$Z2li`qd_T4ky0+f;Gq2 zD71r1ow8Rd$YO7jQYvMW>Rep+d}AmG5i!B#R!OmOvD6 z9C;ij0lD17pNCE-U(Zf@0UY*Kl!W}I{Qd($nZh8=K3NpPDYQq6P=paWKCh!o^3|~V z)>KCHI_Hy={UlAx?ZLwWY~9OeOE+<)IL-4#Y}bdb-frP64AgC*Ly50fU0T8OLISoO zos-9rrWcdPK3s-e9{oM>IM7eznIqWHA+T3T(d2x6XZ=gu^zxEf`mTiw4m<=={^G#;Zi*`g+ri#3W@r2|5=!N!k?5LTC_NnYeaFATY?N`oA8AzlD77#mf%SW4_s3*qS9TBte*yayT6_0-<3-hmeX_KsNV#TB(>%_{;` zAtnx%i!R({rA%5+$sC-ZK5AF9ZC?n<^P26~8Z<~1U=G);2ov|4#{7Laz+9h(H~~{Q zHzB$6e(;dJ^ocad$v$vc51k?Awzc#RYY~F0!NXIKfJ1Ws?HV*_QBr zc2{D|5%rcg-Y+x&QOMUOBl4}bm+4>qUNW8x0uIAP+_A)dpi5x%K2O)~1)V&L?kgDH z&d~f03(e9)I{@;y4L+J+T&)&k0<_s|S$3U)bjU@bG2qPi`3NCX2kiIv@)PvN1i+ce z`|sAp>Ov3&c6Y-%+#pa*L6qf)e`vDost=dxZ5kx*GR97xhFgR1eJkb}m)qiMyW~E^ zf<-62jj}Y*qMWqK=2dc(KY>IvtW@{hhepKgdcI+xh*a~oPP=GIjTdm=RzhsuDJVM= zzKqnhXXU_g8CmMe1D?VBC*UKX6$iX%eG=DPMnuiHnsK=9jWneq%wfo7Ky+Es2 zAuU;x{L||kgjkvf`IQ<`Mu=QbKh+0=oY&g3mwh77P|1hsFaNo7OZd@ad3q$yn?`$g zRR;9CO(Z%9AWP^Gwm&N#-6sk{kU;B9q|7z1nDGi3SBty;RN>@fy?bF$#broAbjs;t z1wLze2D?_1h)ykSd8ULC|AQI11G+&OEy|@v8-Kj}VKgI%GkN~wE6SCxXdx6MGvIe< zl6rDp%AnAx{66w6TOto%h0-Nxbld`3+%mhk?|+6K;*EJu-V zj;FUp72WT`(jHnp$JY=v&bGfb`|K3rfI_{IXRVyKX&{XlyM7?5e;p#K2)65I;$>vL zipL*K&rLr9O9ojdLm>$1HwGAZk%j+Jj+yZqriRgtTS66NX{rQ6_HCI`>)pRtChQ~ow6kf4onpt34G)lhrP z(whjBaW!neDuk*C0k9^*?G`FJY^~%$?i07&!YlK=f=bl6#(l7sAq`h!upHEYulE~c zPwg@s$m)K}9J#;(F^&Bdf3tKwbt1l*g(AOxCcv6E)&IHx9!IVYUnd?lX8@Ts`7CG(h*&uKDnMy461;0n@vxe6l2VyAs z83xjil?xzq3vM#HOT8CyyE^m-s%Z<9C^PgsN__@zbB-Q3n?`?Mak!ZGr%Y01bQz=o z-5XMj3w;lxnZ!?AS!VKo{I{esGFT$Od2zGRD*ykT}nOP{?u9?ByedV zq-S5d)0c9R``@)760@m3dGFs*AI2&AHpUB{0Q!c;CB5OA;p=iJc>&-;a#6Pcl_d9U z&i)S@A*lyW)WUU;o<;@s?Bpm;9Iysbj4V>(?M9RxY}J7_HN8rd1$Yt8U~X%!H3fy+-1I60G5CI-F0YRVWICg(0zSf-9U zxo6KFrBYD_*nf$^7vu5=`+iaFOLI$*L)}gl$a~O0Tl>_v93?tNRXOfy^ZIj6LxInz zWR6Y1G-u*X)&Ap`fr0k9s079ME`|J9$fc5hF(03g;4IjXpo#|Zno5xyG1V+$@OBG% zWNO10*sd4P|L`v!O>>)MU@@#dCgO>Rp$b}a^pq%c#2Cn@*3>I`V|b}KmNjb9S|6MZ zV7Hxnq*GYM+S}>YVlc>Ora3`c-0Sobf*N@&H=o~OPgnof-#QE}dsB&5^nS90m(3itMKcF}ey0kvj9c9%@=h0T}{j;*wJ z`@r0sLi^xO#S7Puu$A?YZJ7J2uT9WU<+cyUrm)`L|d1_fLtw8VqWHWv}k$t zMp@&U@|W~G8nCZT)n~mmD8(BR8KQ9k;jxNyJ7rk2d+W=JM%d8}cP0eF9XDJyj8MF= z`!ptz&uqBkJf^1~cFY*K+JoMW>VoSv&n9N}3#q3?C6JtI4t^Nn8!%T7f7-d2!mxzJ z9Bypg{-)Ej)5x)C@WqVc>D|3Y~eO1CAGZL4WBTjkld==!_e>}W}g`cjVe zUM8?m?^79%tsr1BVDN^j;d>8<@3^sXkwwOk3lFyB3AxuQTGG_3Uv&l4F=z>;k^+|| zM!A004wo$ZHJ>-MzEhGWn{2+%zuj$c!AJYkIIB17n_qj)xNn^O=R|*>=)L1Ds(Zk7lkl zPwCqRDu*N98O1-l-6|CudD9Z5e9w&PnT$M<#nRQ}jgLRB%hGc}7c!l!?6gbv$$KDSbsWLW0ZC;*=v6b>3PZ3E}6Z>G!W<2oq^mtJPS zvOK%(GpKYM zFJkB24(+})_yzSvQv<`y*V^?xeH4?ot$cgJTV8MJQBL72aZB8?%ncMn+t;aT7VTdy z3d@q}2{{jq;o&2pR%g}i{)l0Gl2gx_ObtBVO>}4d$MLr=$~ds~%M_D(il@Kxc6;W) z9F6`HT(9LuY$3&`Py}}y++!o)2ws=TQS&(>CrC@t;##7!Wh-}@ z2PgHrPE+F#W>#qEha$K#xU)D$*D1EWn%yO>F68}GzdIMJds{Ta(j}-_n0J4x^K8{B z13QAO?CMw3cX?Y}C}YS>d$sE_Wwzea@pbKRbCAf)u)k&R7M;qs&)|kccF}O=-d?gs zb*p^?Zart*_+iS=g+Ygmkc-v9XHGfcV5?OIHl6GSTIB+h>8rwy88>@d1ZmM5XO^lm zUXJ^x;!*aAy-dN6%6hhJ2DU7t+T_8_%ip2T6XL7eC`@p2&Z6`^Cw|$ecf*ubS$r5K+t_5U$ zx`A3#jpyuPCivQ^*bq}Hg-Lmo{i-sqo-H2cEpL4jVroJ32N{p)ozuQKmyxumzj6S_ zKoj5JI?T0MIqohbkR0V2J^Zs}-y-rcYR$xVkiS{EYF*NVQDgC?$-8vvsGD3136ebD zH?x}%)%Kbu9ZGs`esOjkk5f{YXj?V$Uonio8jiB|b$zNBM^t)g77wTWY?5>he66`C z>aC~GK%DvX(>oVXP?t5ghUh+q}o5SkHRgbAoKM^b>T=e(yia_usxit!b1gJ12>3Bl2+pEoF$Z zK9j}~GRmMo$Wx&G;6Mx{%$;}J+K^3-Ci<<-*X46H88A2U>-#-e-7`h}`t8%)Q{r)F zO4;Fc2q*9Ws%;w8XMR?3C2t$E3ts2iz?lEjcV27Wgn^d-8dV_m#L)O7n4L zBX^pI^Y!!f1O70p9$MU)c2%HXyXT z1wZpm86FIUH{{TJN>^U70zQY}1dZ!GW!PJ1r{;n#)5t$(y{6X(&NY)B2KNxiIToiz_B>K?SXXfws`& zumfVQ)r&a{>>mS8=R;S6yL)c_d{e=Np%wYjghuV*ILf{BKFMmffAz5-;B^=#&#w!*LhYi+T*Jz_t47nTo zTd}tq#J}!{`R5@e>V8)iC$fror&f9mvIBT#hI-$gLBx7^jufESG=vteGe4OeY}IsB z;$N)f61Q?pRrSr^B;yq zS8!i@xIp<4_Hk1ge7QFLv>(V49fszc$$t0qk782S`OO}1twb$czx)&yID7^1oYiON zif8%VkgCNjU+vJsxYxAYmG>c^({T{=<{^Ptuy$G+00@wezQ0R=&F|eH?2hphZFO|W zxg_ek-1>;O zzGum;M{8JpMSAeJ=Z+#O>3GRDOh%6}5#i7*WHZQGrQAkmNNSa6M{TC4XL4r<>q~syWZxFZq zI*22VRt`OV9Or$&JeyC|uTP=_ypj&GbuW4vNs$)Wrdzl6x8b!sq(ittfo<{ZFgw5O z1rO-cNf2_q8ASuzw8XtFtVbMjhy+WIg+h+W+Us-pK1$Mlcs3)<55|bk-A%?GOTF2y z^uWLV1>D8EReM+eD+d#j4TRHg&cU{SlxS{HBgaeOx=Jg#a8fU9)TH`b$1!`wD|}Ce zRyGZ8rIiYo2kqMVf<&y(-+7I7EFNzh2OEuhb?a>GRuZZG`568!(ez0aYsJFqsk(lx zqiIW97Ts+qvFdf*Vgwe$gp6AOj{V6xgUtCeo^~22Y(Ie(iu_?v;7}na$!A-atH7_c zBpZWHP%>bA6mX|4TMT8POg1R`-lvAOKbE_MB+?fDpl|DfIE>qE%Pz(@Kd)~a zVJWY5eP!72@xXcGw9?!j=j5w~Zl@UOHNA>4j1YyBx-8FC52iWi7UMyj`$pm5c_Vv) zHM0G!ShRj&F{^Y%-R!kwt(7RJK1Ci(s4}wYJ5{Hz)_e+3F$vThQ)HuW<-@m{AYXRU z7d=`1>{)hd4XUW;QIBcJMv1(=D!WEcFYGWRK#g{PxG?d36FLdh4R2QTII9>kP)ax7i~<()~IxaKHpx7l0KLEfmy7jGGB)vHuMx=7{W*5 zRksk?PV`}TO)f&CViNA)l`%M0F;2FK)7|NSSYmt*=(!Yvd)%k#X%=y6hs3e!s1nk) zc-@c84OSI5*_8@Bg=!Ou1gi}ScGG$*h(C_ciqUsVi4GT=5X7+ZX<{d!q*|a8080}7 z*9wtSS90<=Qg3c7=I^X6z|FnJR!x}uU<^>?I|*t>mb3p_oRFn%OOpJOM!BerXizjY zJ+BCYItVfkUYf7viY-$QxmOWDSpElvu&hGG>BaDRb}3>zX;juzsJ&F4x#X>wog|ww z#yYItZim6~vs$rAI|eTB%J1Q($prs!jbA8DeQTV9DiCfNdIE zx8K8%HE)=|>zDD0PwOSr4aoR6wuc~>2LyEK!fh#yYa60D(jzdzhuO_+iq3@X`b6y+ zEjsARM=H}u-sa5l-|J=Ykv|f4rh$>HyQ8>-&>(8QMaA@hm}QxL%$WvEfnEsFZ*TKx z6rVtf&c`PPK%J0pC@mPG#uB}C)0WRzt04JNx$80=+`Qv_MSi9|V3`|tpE`+;>Q%}> zb_I^Veus#W{1lpM53qU=D{t}w_3hIDwZ%O$v4qU=znG8i<+EnWhVRoP?1<~E$+`u@! z2)OWjKU^~%+^-QKcG2ZC*g(iifVl)s169#y!tR!e*U-Z4kS@-Q2xt%gy2@>pXGkWe z`a5LML1>Uz2kl~v|7c1fgOJwDgwn8t!>j1>wfffnYSG{hFQ%Q(k>F@Vo(36eK;77> zkl{XuwlTQfay`4~c~Oo8*)*$UWk+y3c#-OM0ASyTTRS==b;DeJ6NR~enUO$k^8I}c z!J_dJgD|3<{6%PF=|B`C(WSQ}hVS|7X=*39FMgPW&wg&V!d~GIey%~sR%HP!E;S_! z+UkQZ`_%$cXX4-_*cHD8iH?*6XG~yS3{_?al87*S6r7r4)>E9kpXl+m%~ClDYtk~k z(`6ZMW-fmL@&V9b&=_Ct-rvOI*HYh0pS8wS8*1iC?BLU-HTv}Q7b%yTf$#Gs=)mrL zy|k|D*2rO|WhhN~)$-fUpkl~WLy`Hru)1t%2)^a~tEpgsPlVs27TIXs4`dhIt# z0+qH-QnJ1F^y7b-LKgjjGbz}o3JBd}flwbIUb*Nus^ZwX>J*KHe=|8R9?;=UENC%D`IO*MMnti;*WGb38gdf-j0 zA-sJ51&z0&71;kTmH+nO^-d=CnTT2~?&nV94N_mUE9(`0e;~L(WB{P!rM zFY)#pe!;9hbIV!}QTQ@i&OKn|7!GywMafX%x*Kr@s!{qoVwz6&;GWcgP@2-ZQB<~N z>MieT<9reeuu4=UHI=H82&`RZ^u{_@PtaPd$5-Om+HAF`Rlv?haXaTX4kn0^=|Bkz z^?`hI$}{0w=W}t{Bnvvh;<4V5ZD-9s%bvSv<3XT=CS{<6OO4;=86;wRnP{MsPmMN5 z-dkaBz0Ciay7yu~;DKhvHYU{+)zPgz|Erj~+#^U}8K@qY7WG`@94dxJUanAlEef>+ zx*k=k%JnY)itZ0gTc2+aIxkKHhE~RttP62llrk?7i?;m_ye;Z;m2V30TpBT47h*Qv zM#pc~H98Mo;=pstPNRq5-D<6Q`%h!QK5+R%o$I|$GbTUB_9?hGP)#~`maX#&X_`vs zct5-s>hYy*U2ab-uEVcdzc4UoJe?EYuUHRw+p?kZcIRnZ*qlDb=HTO>2^=N{k_DJc zJ5O+5iXx4DFWf8YvZuPVO{e=S11D!CvAU{a z-bEEsOAoJ+CxCm3^C{+)8$KVgMp z=rVAOIYv>(P*aSwo%C2D^*2V$w0qN2FdFOrlPeS$hY>3(wXb$|1b+;)?C;C+_ux`mO{=|ECn#OcQO`_$~%c6It-Qz{xX{4BU)ZESo40B(g5+Ku; z-Cd=G%K*1nER;fnT>rozW}=`=<;^{kVnJqUbh)?1FZ<+3rmldwdGR0l?v+rp~yY6ZU2{UGv)TB)5E*9ML> z2%Y|S#)pbaSNYLWZtXcc{|sDCscbbLJkwxxxp6OpG#5+9>;)a`f3K93#C8n%SZcnW z;#K_6C%vEm_9+#Q4xMbR`lrfQ(@Jn{?sgTw2le7%z7<{ch4fNXb}3xXA>+o4=EZW~ zu6E0`7fTAo7D<9kdyucV(wi(S!pyPzxZ<&H2Md^g=n$L!=RVpXnu){jWlHf!ZwQsYhM-PE6P z42K<^0pPMW*jU!_Pqj_zEv~@T*^jGmqqex=QnEyImGkiLHgHSAgggPp`gEsZNLH)i z5%o5ZRbgn~MWGNeSnb<|fzN($n4vHq9@wCIn~bNxD)}Go2MF0!o>j0AG~yTfUxK$=)UiK+ot*wM*t-gbs}9eo+LKCVRs+MZd?G! z?!G|wiuT43hOaD{x1@J75k!3vqycSI`|#$w=&r4d!%GBkZ4pUWzzmT_=-@jMhF!^5 z{z)TTG$YsMr~VwtExA%P0Cu=9aH~{)yY`gw+^kBw?)?Wpr}P!*VbwH2e&yYqwff=( zZ=Wx53)kOBrRZ1eI+p5b22L0A8#JoFUJNKPUHS){X-&tda?qdtq-ZI9H@ZV?m(;Zf zAh2{$hUi`)Vn2IdM$qF|(lvH|0NMP))}b#FQvtYPF?sn1m=5-&z1PN7Z}z>w z*3rnEr;cSh$)LrKxE_{q2zv7l>p?k+DQkC37=EX%J)gs?WZ`;Eawu(+8Ij^ZHtg5Q*8xL8$YTD(I(SC+aOAY+{R5U6N6kPtHK{BkV{1XIHD|E z;GVL~8bvM3B~%&uXnx3n0YbXO?3U)vpHE~a7ix!i`C02BIuonSWzXQJCsRj;Mz%KwpJreS5#!bv^3w zV&c9a>)c4t9=J@eloj>U`WvgeO(F;LH$vHKm0MuXp^uQF#@9;rF8ymQ({k+fGYFhb zV>^&)UTyH}6U49IA1hB^BiAT~ff^&9>celnV@)q@&vloUZ9frQ_WK-cG86-{GLFvJ zCNH@fVxhG2W!!Rr;M`<#B5dK3RlX5vhnxg|>f3Tmo}CDeLEqQ^;|7UE2`v$rxW~bp z5%9-2ZAY`3p6{%FdqhA?i|PH0*zs&VYt6xWl(YAYvAswXy7661&hNW!2dM|t@4o$q z`14pZdh@;m2SwfK(d>YtQgfisa8lc_Rtieq%&=33F}KfkFl#OKXY4|9uBmWv!h)=G z;QMh(d`Do9yFwT<(pv|l^$xD>sJ>Grm9)~M+|*}?6BIE~8laVBlye_!2kdL?_Y>dKm7N6nAx$9Z+Qt8X_{Keg9<-T5r`raBOCXZL18bsKIdOHBFwC-9R3A~ z1(f{!OZE0d#gd}{(u5V-dYAkPz_vi+#*wcBrh2JhwJl4`-|1Fcx$OVb5p(n}l}%z7 zm;3=lk9Ut`3kBlsn)d*}540v>El!1h;clNll=uCe%GYZ4&VS%}?q>nCh&ok1e$>`1 z0w5_r8yN!d74?6vjEy|Wod(#u0|aBsBjIS*e8aY_wu9;`sL{S5 zgeQE82I#7BbfOcW{y}r8G|dB()Pkyou)4~Lzg)|huF1S$Qv=emZ1Htaw=CL85C2OZ zg&-XmrVzvs2KYI>jQ9DgUor<3v&ss|(6~DTyc(6b;ZYxs#ybE$Y60WZ#5m|jC4nLk z@Ioa|Lz~05lIbE4Dy+7b`R`hbu%Gt;tegk@oeCOsw*K;NffsZ}dI!rT#`^~_T}-r; zhc4;5t$uynm9^1tsed{IR0V(@KH5uFlfU=h@=?nfMvg=1=gh>vlJMzy?dT|=@+1YS z(P;Lqo<3D{LAUcj5H<1*587op_nW;=se%)5{MoN$0C%Gf);Qe`Fpp0F#!&ED+t}oe zHK^tUl@)-HY014so^af=)3-p?#k$V>gB7E)?sxg=-)e=`m$sJ-!lkms)DG$eA=PlP z#_@~J;RTpOksw2~{c)J)Kvc--U(U%3hu$!;LQT5q@sA{HbJBDNJuA2}U}Nxr4Q{vhnUU@af#KA;6jl&)^BDbIajIb_ zMF8g>pTH;LZ4=cR)?NPj2kN7O;7LtmHr&qlp^wl#%6Gg80C`gPacyH>i1j3=7~M8l z9rU%=<5RTSvK;CEGQa!_`{zFuHqXSJ9dP5<6D{okkQaQ|8|7bwGus;HqJK^coju{? zbg*E}+eeO3QIs+Oyg@`oy*`nxpX+<3C?O8OvcX3Y`=CBFncP0L#YP_jN}cow?8A48 zF6j0AWo-X_R$~K~6et>buka0z|4jiUfuQ*c6eq_sixguH4S|LA)jZ+n`*THh_+c&Z zPBpTD!V~UIBVWn@)uPIzYff0T&E`7(-K+w6{<1|Y2>%LDp!ow7p6RMp--k`xACTfg z=3loQ3wHtQ`G|{u6Mn(c@jxIH)5Vq91w$~iS{)y#@tZ^;L#*(268D6_Y_&RRg`jv% zL>>q)>=me0JQ)_i<7iZOK>oQP4<*iO*wZO{|ZtJREbJaEM+LFs-wpt)o*C&AsHZ&_Jc81i|%{&ZSkOhNJ0iG z@OTaFJ{Yhh$fqd(0N*7yq6Sj;1`YCydBQ{bw%LXTYZ$KeZ^dWfOki7O!AfLfqRNv$ zoX{K1$svehVlAmSRq$+K?x5xuseaWmIG)3eb1v?`szN&=oquz*B*YU(s0K?d%KtDZ z3RDG7xoR=baH+hREkEf9cu`|E!O1nL)EyqG{0Ur%)#&2WXsE@lC-4_bTMj7bjo4+) z^pA55Rjve?%Oq>YXaBc?P}Y|;Gipk(3(JQ^X^1?uO8HcQ)`o*tu2%Lw(>b0K~?e5%Meo6 zJOugSc#+?qyAli_dV4&VwSit2T$xo_U6C z{T9}lA_q0jFJGI);2n&=J8U7gzk|wNYPe0`#7a{w)|RCP`{!y(=|QEj*ZPqC$>FM?OvB^T7dwr0`*es(HyQxh%GqFvjI{63Y2ZZ2QlDZ zA-ax$f_SP%R9SS7{B9P6lA5j;)vX;&nV%3;63<-!;l%#zFHjD?<~ z$$R-9wjZ+>jcS`($e%|>sezfV=^l~Eh}Wz8WW70OZry(4ptH-h8C!|Qpr@1hyKS$A zLBWkU)iH&Zxpj~8u3yd8&yV*-5KR1xadz^c+Pg_?cpQCx3-2Q_HDn>RlNseGKo>zp zkdh4ue1~K+Hj8Bj@kBRHwcaFqGBwL15hSrTftH||5^0wW#ub1cBbZMC0Z#bcYb>B3 zVpjXwT8npErG0w5)lvZlcy&v?N)7lGgZOP*X$b?5YwUaP{T&4ku>^rB(1kqCN)MgG zRQq~?j`iDi6KnR1Ex31z;y@y0~(wN~2zCUR}# z&V<9XR$ovWuq*%F{trk%r-s7Ju6&ERxPFWp-SBzbe(3S!v4@qMrJEJ_1BqW1mlC-u zC2~#b!L{phlGo%Uq=c_tmAiWN{Lb8q|LFrq=O?z-zW?hFo@VGOgAXqMj~P5{9j)9w a9y_}HufI_)bQ=NRKpMAUs(ClfpZ{OV-B4D~=o|G5>Y z!R2<-^dHOaUE#8hTmaM&7=GWXrrM>(6T$lMJ#iCcJ`kOz! zcD6nxg5^@4rs-bO`ODc{N=9fcYbEQ5h3d=Scy#=W*k1+g(Ei5n`VgZ3O`jZ7KJ24Z z>-1K!#in2bdOzmn(WpovyQgBguT_X8Pb3xeE!UY|H5FDRo>XPMu-C9G@Z@3Hv+S%2 zS5{djz4}z{N`}3bmvl~i#jvfm_K^Cs#d>mqM17lRf9;iXR42`XE6aU`4NRk2;_j8H zod`RvaFyrA#zp+7TO6S3?PWz5ww*p^BP&y`EmL9liuSqo^tJScfR^_~wW|K(#~X&< z+if}-ms56=y;~|Pdo$(h=E%XFjmxWDGKY0P4J2;VlsZ(lY8vmY`V5sK$P@XjL(iFm z$lBq|lSK;Fn84I*vG?T;o~W8GWohpH)6YneES(I>rbdYa!$_az#`?g-x6*hf*}CY{ z`<&=!D4U)oH)(3)vO|R2;o@LNGf87f*!-DfhFL~j*r98FwBJXe)ju91E@peGXH;{0* z#|&A)jz3DnJD$IXOy1weWqcdC;Bo4U`R3~*krAT;t0vC%l7kHM^Iu5iSz^6j4D-l3 zKzJtvu(1~M&U9DrKIA>avF`k}+J;SsQt?Krb`20$m=Iq?A zn9l-xQ6TCWv(dpmYTik$d9bHMw(*=u`u0$@F@V%BLoVl21AD-HhFg7#yo85fAYRlV&;MAG!| z@o=6T(S)7dVv9IT`S(f6W!7l?;Y)`@8(7)TFc8B3{<7k&O`d`FbW+{^BGIZIX|+UE zI=*ltV|tw^V|G2Wi+NXe8$}=&p!VCcM;<~G)@ShdlIPq>?4P+{565Z0Efp(8PR@b2Kn#mSZIYK z^2h?d%Fo)~z#(Ddeq^=1X`)OURoT$j{%jB9+wwIVZ3Qc>ML0dVTcfE4&Zg(ZNPO?d(zR2QS_<$ zy|RBZaU(#1`39Hzk9AL=ny6Q7L4FC(mOL*|-hO|MZO~g^+5QRX6Vqix&b?d|j7e`U zPu%h;?Z8;I_{3A6Hs}E?UtMi#-|~t4mYOTHsc6k%`RO*&Rd~>Y>!?Yl#xUt}BluAe26@kZ?s76}85Pe0FsBJj8J!+<|IL|qw2m`W4>C$HHpJvji8WXa* zlH=hY&M?G1a%N}mq05@z62gd=4e>($tf)25nDs1_;wEu0_hp*!f#YURcQ|m81%$A- z(d_yzr^&R+c7?zw_H^6B+{cB@r9m_}*=*xoWu^f3o)jQt6a99DtEDBAYkMc~G1jpe(tdV%6v4hYIm_37IDNu=3Uoxy6m%sYhzas&p=ZazQFE zF*_z}a$;RIO~qon&S_xCs{Hv#tEeSWr{urEWo$xL6k;GHx}*f+32w>%0x z%?qlGHjeZZn+L^P{Tn4lNl}|mwZ0-wACVSxI}oT~4iImiM^x%E$kW};hseFVkDj4R5lGgM9+%x4pS?D++9Qv3xRspmw-IY94QoT`UgQ{1|ezu&F7LN#i2? zDGcVao#{#^Yih<@l_n2tQneP87PTPYxD#Mbq1&%b#A(1QHiZmw9A(#gL>G6YU>HO5oH7jjJt@g|mY{3(&ABm7uMiHvEbR58i8xV)RDGN8 z)0+zA*_zTbmPTtOb zquJ^?hhXRiK(9_gd&qVCIm0HeO(ood!@~SoUcoo!Nab5xpt#3f121A9Yx&?f!rJC*V9G;U9u(Q-vta0h60Vz3| z-h_%DWJay;A}Y!w{dl+Yoi_@k$}XF>fBztXoHZt_!8IIb)^;#A%ig zd}M+B+GZDN$HUYfj>4950ri3+uS6Z;2K8};AamX1qTF}FF!CEB14J1>p{Lt#R+xm@ zSozc<6IQS)g-{d=9yRlV11RXAEm7}qM?N|ts!GjO5uU0;n9Cmz)?d8G*E8KXu?|w8 zYCcaVr^QD{BAol#e0fV0+cSB4{BqrvC!MqTXBbxCe*)2D9m3qXM%9%vlM6 zF~a-Y1CJ@4FB>>g>TF`K?P`hv3;6s22+wOjq_WUSnegE^j|F$3ciGT8 z6`M8MU4=a}wm0LGMVJEwEjUexM+FKCAw!Ur@gzq)GE`RZcud=5Avxk`gnpE_Z@I*T z%}7B|9tQlmS)kN4BRd&mTp3ZXAE8;PNUgyk32G<6*a`dGxCe_y*Heo^Ra9q1&M;uj z3LHRq^B`j<9a_`Q5)@2fJ=2T}_~ZbBzBY9v!Z2eRUxLVg+t4`(W0l@hPl21uj1wcqaDk?7eLINQG`zz8n=J<+{PE3VMkZhDG{|_fj~5w}houF2Jjv z2LO~R&Y1NWbG`n-#=`63Z&pT2xVnRZW5DwcK=(}@hF_Ts+;$gUCmW~7lv8<7NDkoX z81UA?Zdr1`;;6ecW>QqtzCoO)wdn& z17regz39V*FnW{B&fQehi`qFjx&^ZM)#U~q$w_XpV2zfIYOPg#gYLCA2b?C{mZ0n< zDY=#SAh&-_*A@{~JhRkR6a|E#6~A^S!|!OJLoCfJU5uX17wkR&?KJ4YqhW@_J}6Vz8#a zfjKw9f<6Vh(UgT02CsWv2zc2P1Jsp?)nJO=xkr;5LxD5wRwz0O1JrgGMmQ$jnD$9% zdqry#e*ugQbAg4!3x1TesHq2q&5*hz@CxG~q}o1W!57?9rhP^l**p|WTL`}y$n={H z)1uasB0tn0A-dxuVT8LUK(Rd|=Olgm@KY>6d+-9wwe+st3dJ0y;YPOh>hkRV5wL96 z1(wTe<7=z}dM4&ArPaokO*8DQI1Jze#m|9q^ijlo^twz?9>nSJM|}Am*fJN)M9hb2 z=~_FoY0~t>_R++^$j|I`$RD|Yr6I_a!Wkd!INFh^VR7SWX3p&;2c9fWlb>Peml2f4 z%x*6;mv1qYwqId{VIff5-Jxl1M0bwe$%pn(K47`fPTqa~kgPN45^;WL!3_K80x)?B zZP-c;AAFe)#pi-$iI#08k=U})j9>jxU5ov#Ne23<_a;~6a27C#r(blKLdYi6w>aL+;V zE&MwthL+U{1wXNH^Qad zjA}P~6-6k`>}r73I1jv}L}IG(u{-XmJ2Qt>B4CS~3)|-?m$T@OSW%hH!=pqWn#h+DhTyB)fwwk#OG>xWw9F=kJLEm>Y5J~rM}*oboU$)h2>=}p9RJZQG& zAaGMNY#psu>fBbpu%%(Z2Qu^NcQXLJqB$=xe!GdV?5!ZP(-|2`alACCIY+GXv#?!gIa!a)5$xVUSx``Pd$ zL1t|K6cUi{fUz5gG57BY(yQ;-C%+(a!~%O$}ZND-jp193K`_bnkyZqVa(E zr-Fn(OL6S=8YsJ|XCr?CqQWrr=d`%$%7E!`qu5_zz)1?2RQ)sirBQuyaOk5JXpiNeSsSk|mp{mbLkAo!tg${>`cU`e=E4EdN2cRiFEQ zdej{dp9T%sL-hSy|JwRj1i1g~M|E_yWkW|SG@}Cp%D7=@j=T^lYEUvQ!{Tw!G8~lN zgmQ?sPg?=yhqyir^%yAQfDryjF`db|iC%)kG5#=6dIMxCSL#dbYb>^$yf#5|U?8yf zeg-C)M51r=%=CR@{EyDhk~7RQEj}iVHvO6vr4Gs@Z@KElQ(2f5i?yDIj3)^&TMlqP zSq{tROHV_MX`P#~jAMK6&zk_kB_SYt({&|n%(^htEwWYz{5Y)QU!InKo`Irdw&uoI-|pymhPPv%LMu+D&OKp0b)WUmvih1Uzp8l$+}M z-1CWD@%5tyZGcYT1r&E*Im#&$|8UnvoNg{0prknnT477$DEGptT$v;;03D{FCDPO% z#|vGMGNb^2UWb(ACT&%B-N#sPtePw+OGz=lCT_h^iu~i(hu@K4G&2&6cF#dRPpmye zU;|%sR^6$vi=A9;VBeOjhSp31AD}!8ICKcQ;4zCaTk4rdf~sOJY~T}CN_A2HVL{@n z@Xt&npxgi`r?|Lr2gn5{xHYW}*%J`2{4jJv6qnZ{S+>~e#n-iVK3p~r{O_`g8 z&8jLv\AK0y5n)%iZIiuGw08wsU23=Cice+Ff*H_ydKrEC6PA<6RGhXf5E!J5i= zLG~k`{&*4-4*=m~weZ~8kAoe0>xTvJ64>j>pG5%0@T85Z^@N@M7ZMf!64EpI*+!bC zbJ8}9HBM3O>4rLoh)6~VI9X}CC#A+i?n3E3i&ax0$4v+BVKke@PJ*ib05Gbb{^?1` zUqsFPld6!22|#gP7#J;_-G|>=B304hKo~Nb(@L;2LU{U<4Wp0E301pTwXdy0O*OIN zt1;wbzy!e10$1DS=Vf=l^l;w*$}lc$-B)?COS`T(fGNwJ`ukqcj#q!ZcVl(xdnJI@ z;{~N_pIcGx*-OuR-$S#mH^%Fprk3I8rXTzub}StRpi?iD3(Ga6gy?s^us4PaR3_J{ z1uf-Vp{2wnb}j9%kDpnOcEhpaFcNJSJDQe7`9NuzCvkuLYWS*lo&=azdq2f?-YdYX zL@4LHZ)&X@FgTe)zb3Z0i0Ih8GQ=qDQ1#t-Tkb@K;U=@YaoDSOiOtNlC|t?j$uOMv65;74JB7dLso5FkT$|q5olmO%4=7gxgOM-d!@JEMscfc;j4< zc~J|LmYiYRu1@O#!k>^0x6&E;U$QRuk#6s@fr%sxt#$1*S>t+9jWF3Qv!eB=Pw zZfQ{VNA&tGYvK@@X(a*6&oo_Ec;$lwIBsX*D)Y-ID1c6{`gJCM^l@&PAmi-?mfH_pG0nEzju{#_C! z`#G$Fb?!9sEg5X8CR7soC0c@H8b z6Y4J1CA2jaRbmNRD*?E z4*~P|7=`~^9)QdU4uWV}S}8ay0U^wXq#o3&7S-0nxX}xI;Gb94;-DqVd1DITgp-s-18OS}@8&bpqFDQS@vfX`+G)?~IF+?H3D*E5{&FPqIBgVst#3J0*w;CRUXZ)*q!Qs<3;%t^rC9F2Skz$7 zfWUnoiejgPplSb!527`i3qq|2UFZGCR^wH0T>p-}7d(1cKpKHJK|Dc&{l;$mvaIGE zJID=8G$Eu*Q6Y7l|2YY%37Y}Y6#8^>k>}(^sF?yaaf6f*IRL@z{sGhBI)3m7F)Uhj z?tiHAr3VEB{)EmP7yLmZ&a&Gx6Dbl_r=5&$%Ta47lG>mGH3>dwY{?F`-@lVmzUDKu47yInOaNoZV zbBv_MBlL%u*-T7aM>7dim*wQ2y=K9sLSD^dJ8Rk5i+V diff --git a/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb index 3182402f3..4f401d48f 100644 --- a/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb index 5ca2633fc..c49f37611 100644 --- a/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v1/widget/integrations/dyte_controller_spec.rb b/spec/controllers/api/v1/widget/integrations/dyte_controller_spec.rb index 01585cee3..c5a4e1bdc 100644 --- a/spec/controllers/api/v1/widget/integrations/dyte_controller_spec.rb +++ b/spec/controllers/api/v1/widget/integrations/dyte_controller_spec.rb @@ -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 diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb index e02bc2409..02fcc51e9 100644 --- a/spec/factories/integrations/hooks.rb +++ b/spec/factories/integrations/hooks.rb @@ -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 diff --git a/spec/lib/dyte_spec.rb b/spec/lib/dyte_spec.rb index 0963bfffe..2dc16ba49 100644 --- a/spec/lib/dyte_spec.rb +++ b/spec/lib/dyte_spec.rb @@ -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 diff --git a/spec/lib/integrations/cloudflare/realtime_kit_credentials_validator_spec.rb b/spec/lib/integrations/cloudflare/realtime_kit_credentials_validator_spec.rb new file mode 100644 index 000000000..3eeee4665 --- /dev/null +++ b/spec/lib/integrations/cloudflare/realtime_kit_credentials_validator_spec.rb @@ -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 diff --git a/spec/lib/integrations/dyte/processor_service_spec.rb b/spec/lib/integrations/dyte/processor_service_spec.rb index e914ce4cf..5294c4c0d 100644 --- a/spec/lib/integrations/dyte/processor_service_spec.rb +++ b/spec/lib/integrations/dyte/processor_service_spec.rb @@ -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 diff --git a/spec/models/integrations/hook_spec.rb b/spec/models/integrations/hook_spec.rb index 369ea8ca8..aa09dee68 100644 --- a/spec/models/integrations/hook_spec.rb +++ b/spec/models/integrations/hook_spec.rb @@ -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