Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,47 @@
class Api::V1::Accounts::AgentCapacityPolicies::InboxLimitsController < Api::V1::Accounts::EnterpriseAccountsController
before_action -> { check_authorization(AgentCapacityPolicy) }
before_action :fetch_policy
before_action :fetch_inbox, only: [:create]
before_action :fetch_inbox_limit, only: [:update, :destroy]
before_action :validate_no_duplicate, only: [:create]
def create
@inbox_limit = @agent_capacity_policy.inbox_capacity_limits.create!(
inbox: @inbox,
conversation_limit: permitted_params[:conversation_limit]
)
end
def update
@inbox_limit.update!(conversation_limit: permitted_params[:conversation_limit])
end
def destroy
@inbox_limit.destroy!
head :no_content
end
private
def fetch_policy
@agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:agent_capacity_policy_id])
end
def fetch_inbox
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
end
def fetch_inbox_limit
@inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find(params[:id])
end
def validate_no_duplicate
return unless @agent_capacity_policy.inbox_capacity_limits.exists?(inbox: @inbox)
render_could_not_create_error(I18n.t('agent_capacity_policy.inbox_already_assigned'))
end
def permitted_params
params.permit(:inbox_id, :conversation_limit)
end
end

View File

@@ -0,0 +1,35 @@
class Api::V1::Accounts::AgentCapacityPolicies::UsersController < Api::V1::Accounts::EnterpriseAccountsController
before_action -> { check_authorization(AgentCapacityPolicy) }
before_action :fetch_policy
before_action :fetch_user, only: [:destroy]
def index
@users = User.joins(:account_users)
.where(account_users: { account_id: Current.account.id, agent_capacity_policy_id: @agent_capacity_policy.id })
end
def create
@account_user = Current.account.account_users.find_by!(user_id: permitted_params[:user_id])
@account_user.update!(agent_capacity_policy: @agent_capacity_policy)
@user = @account_user.user
end
def destroy
@account_user.update!(agent_capacity_policy: nil)
head :ok
end
private
def fetch_policy
@agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:agent_capacity_policy_id])
end
def fetch_user
@account_user = Current.account.account_users.find_by!(user_id: params[:id])
end
def permitted_params
params.permit(:user_id)
end
end

View File

@@ -0,0 +1,37 @@
class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :check_authorization
before_action :fetch_policy, only: [:show, :update, :destroy]
def index
@agent_capacity_policies = Current.account.agent_capacity_policies
end
def show; end
def create
@agent_capacity_policy = Current.account.agent_capacity_policies.create!(permitted_params)
end
def update
@agent_capacity_policy.update!(permitted_params)
end
def destroy
@agent_capacity_policy.destroy!
head :ok
end
private
def permitted_params
params.require(:agent_capacity_policy).permit(
:name,
:description,
exclusion_rules: [:exclude_older_than_hours, { excluded_labels: [] }]
)
end
def fetch_policy
@agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:id])
end
end

View File

@@ -0,0 +1,70 @@
class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAccountsController
include Sift
include DateRangeHelper
RESULTS_PER_PAGE = 25
before_action :set_applied_slas, only: [:index, :metrics, :download]
before_action :set_current_page, only: [:index]
before_action :check_admin_authorization?
sort_on :created_at, type: :datetime
def index
@count = number_of_sla_misses
@applied_slas = @missed_applied_slas.page(@current_page).per(RESULTS_PER_PAGE)
end
def metrics
@total_applied_slas = total_applied_slas
@number_of_sla_misses = number_of_sla_misses
@hit_rate = hit_rate
end
def download
@missed_applied_slas = missed_applied_slas
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=breached_conversation.csv'
render layout: false, formats: [:csv]
end
private
def total_applied_slas
@total_applied_slas ||= @applied_slas.count
end
def number_of_sla_misses
@number_of_sla_misses ||= missed_applied_slas.count
end
def hit_rate
number_of_sla_misses.zero? ? '100%' : "#{hit_rate_percentage}%"
end
def hit_rate_percentage
((total_applied_slas - number_of_sla_misses) / total_applied_slas.to_f * 100).round(2)
end
def set_applied_slas
initial_query = Current.account.applied_slas.includes(:conversation)
@applied_slas = apply_filters(initial_query)
end
def apply_filters(query)
query.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
.filter_by_team_id(params[:team_id])
.filter_by_sla_policy_id(params[:sla_policy_id])
.filter_by_label_list(params[:label_list])
.filter_by_assigned_agent_id(params[:assigned_agent_id])
end
def missed_applied_slas
@missed_applied_slas ||= @applied_slas.missed
end
def set_current_page
@current_page = params[:page] || 1
end
end

View File

@@ -0,0 +1,30 @@
class Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::EnterpriseAccountsController
before_action :check_admin_authorization?
before_action :fetch_audit
RESULTS_PER_PAGE = 15
def show
@audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE)
@current_page = @audit_logs.current_page
@total_entries = @audit_logs.total_count
@per_page = RESULTS_PER_PAGE
end
private
def fetch_audit
@audit_logs = if audit_logs_enabled?
Current.account.associated_audits.order(created_at: :desc)
else
Current.account.associated_audits.none
end
return if audit_logs_enabled?
Rails.logger.warn("Audit logs are disabled for account #{Current.account.id}")
end
def audit_logs_enabled?
Current.account.feature_enabled?(:audit_logs)
end
end

View File

@@ -0,0 +1,88 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
before_action :set_assistant, only: [:create]
before_action :set_responses, except: [:create]
before_action :set_response, only: [:show, :update, :destroy]
RESULTS_PER_PAGE = 25
def index
filtered_query = apply_filters(@responses)
@responses_count = filtered_query.count
@responses = filtered_query.page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
def create
@response = Current.account.captain_assistant_responses.new(response_params)
@response.documentable = Current.user
@response.save!
end
def update
@response.update!(response_params)
end
def destroy
@response.destroy
head :no_content
end
private
def apply_filters(base_query)
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
if permitted_params[:document_id].present?
base_query = base_query.where(
documentable_id: permitted_params[:document_id],
documentable_type: 'Captain::Document'
)
end
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
if permitted_params[:search].present?
search_term = "%#{permitted_params[:search]}%"
base_query = base_query.where(
'question ILIKE :search OR answer ILIKE :search',
search: search_term
)
end
base_query
end
def set_assistant
@assistant = Current.account.captain_assistants.find_by(id: params[:assistant_id])
end
def set_responses
@responses = Current.account.captain_assistant_responses.includes(:assistant, :documentable).ordered
end
def set_response
@response = @responses.find(permitted_params[:id])
end
def set_current_page
@current_page = permitted_params[:page] || 1
end
def permitted_params
params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status, :search)
end
def response_params
params.require(:assistant_response).permit(
:question,
:answer,
:assistant_id,
:status
)
end
end

View File

@@ -0,0 +1,73 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground]
def index
@assistants = account_assistants.ordered
end
def show; end
def create
@assistant = account_assistants.create!(assistant_params)
end
def update
@assistant.update!(assistant_params)
end
def destroy
@assistant.destroy
head :no_content
end
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
additional_message: params[:message_content],
message_history: message_history
)
render json: response
end
def tools
assistant = Captain::Assistant.new(account: Current.account)
@tools = assistant.available_agent_tools
end
private
def set_assistant
@assistant = account_assistants.find(params[:id])
end
def account_assistants
@account_assistants ||= Captain::Assistant.for_account(Current.account.id)
end
def assistant_params
permitted = params.require(:assistant).permit(:name, :description,
config: [
:product_name, :feature_faq, :feature_memory, :feature_citation,
:welcome_message, :handoff_message, :resolution_message,
:instructions, :temperature
])
# Handle array parameters separately to allow partial updates
permitted[:response_guidelines] = params[:assistant][:response_guidelines] if params[:assistant].key?(:response_guidelines)
permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant].key?(:guardrails)
permitted
end
def playground_params
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
end
def message_history
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
end
end

View File

@@ -0,0 +1,51 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params
before_action :type_matches?
MODEL_TYPE = ['AssistantResponse'].freeze
def create
@responses = process_bulk_action
end
private
def validate_params
return if params[:type].present? && params[:ids].present? && params[:fields].present?
render json: { success: false }, status: :unprocessable_entity
end
def type_matches?
return if MODEL_TYPE.include?(params[:type])
render json: { success: false }, status: :unprocessable_entity
end
def process_bulk_action
case params[:type]
when 'AssistantResponse'
handle_assistant_responses
end
end
def handle_assistant_responses
responses = Current.account.captain_assistant_responses.where(id: params[:ids])
return unless responses.exists?
case params[:fields][:status]
when 'approve'
responses.pending.update(status: 'approved')
responses
when 'delete'
responses.destroy_all
[]
end
end
def permitted_params
params.permit(:type, ids: [], fields: [:status])
end
end

View File

@@ -0,0 +1,33 @@
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
before_action :set_copilot_thread
def index
@copilot_messages = @copilot_thread
.copilot_messages
.includes(:copilot_thread)
.order(created_at: :asc)
.page(permitted_params[:page] || 1)
.per(1000)
end
def create
@copilot_message = @copilot_thread.copilot_messages.create!(
message: { content: params[:message] },
message_type: :user
)
@copilot_message.enqueue_response_job(params[:conversation_id], Current.user.id)
end
private
def set_copilot_thread
@copilot_thread = Current.account.copilot_threads.find_by!(
id: params[:copilot_thread_id],
user: Current.user
)
end
def permitted_params
params.permit(:page)
end
end

View File

@@ -0,0 +1,58 @@
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
before_action :ensure_message, only: :create
def index
@copilot_threads = Current.account.copilot_threads
.where(user_id: Current.user.id)
.includes(:user, :assistant)
.order(created_at: :desc)
.page(permitted_params[:page] || 1)
.per(5)
end
def create
ActiveRecord::Base.transaction do
@copilot_thread = Current.account.copilot_threads.create!(
title: copilot_thread_params[:message],
user: Current.user,
assistant: assistant
)
copilot_message = @copilot_thread.copilot_messages.create!(
message_type: :user,
message: { content: copilot_thread_params[:message] }
)
build_copilot_response(copilot_message)
end
end
private
def build_copilot_response(copilot_message)
if Current.account.usage_limits[:captain][:responses][:current_available].positive?
copilot_message.enqueue_response_job(copilot_thread_params[:conversation_id], Current.user.id)
else
copilot_message.copilot_thread.copilot_messages.create!(
message_type: :assistant,
message: { content: I18n.t('captain.copilot_limit') }
)
end
end
def ensure_message
return render_could_not_create_error(I18n.t('captain.copilot_message_required')) if copilot_thread_params[:message].blank?
end
def assistant
Current.account.captain_assistants.find(copilot_thread_params[:assistant_id])
end
def copilot_thread_params
params.permit(:message, :assistant_id, :conversation_id)
end
def permitted_params
params.permit(:page)
end
end

View File

@@ -0,0 +1,49 @@
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::CustomTool) }
before_action :set_custom_tool, only: [:show, :update, :destroy]
def index
@custom_tools = account_custom_tools.enabled
end
def show; end
def create
@custom_tool = account_custom_tools.create!(custom_tool_params)
end
def update
@custom_tool.update!(custom_tool_params)
end
def destroy
@custom_tool.destroy
head :no_content
end
private
def set_custom_tool
@custom_tool = account_custom_tools.find(params[:id])
end
def account_custom_tools
@account_custom_tools ||= Current.account.captain_custom_tools
end
def custom_tool_params
params.require(:custom_tool).permit(
:title,
:description,
:endpoint_url,
:http_method,
:request_template,
:response_template,
:auth_type,
:enabled,
auth_config: {},
param_schema: [:name, :type, :description, :required]
)
end
end

View File

@@ -0,0 +1,62 @@
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy]
before_action :set_assistant, only: [:create]
RESULTS_PER_PAGE = 25
def index
base_query = @documents
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
@documents_count = base_query.count
@documents = base_query.page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
def create
return render_could_not_create_error('Missing Assistant') if @assistant.nil?
@document = @assistant.documents.build(document_params)
@document.save!
rescue Captain::Document::LimitExceededError => e
render_could_not_create_error(e.message)
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
def destroy
@document.destroy
head :no_content
end
private
def set_documents
@documents = Current.account.captain_documents.includes(:assistant).ordered
end
def set_document
@document = @documents.find(permitted_params[:id])
end
def set_assistant
@assistant = Current.account.captain_assistants.find_by(id: document_params[:assistant_id])
end
def set_current_page
@current_page = permitted_params[:page] || 1
end
def permitted_params
params.permit(:assistant_id, :page, :id, :account_id)
end
def document_params
params.require(:document).permit(:name, :external_link, :assistant_id, :pdf_file)
end
end

View File

@@ -0,0 +1,39 @@
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant
def index
@inboxes = @assistant.inboxes
end
def create
inbox = Current.account.inboxes.find(assistant_params[:inbox_id])
@captain_inbox = @assistant.captain_inboxes.build(inbox: inbox)
@captain_inbox.save!
end
def destroy
@captain_inbox = @assistant.captain_inboxes.find_by!(inbox_id: permitted_params[:inbox_id])
@captain_inbox.destroy!
head :no_content
end
private
def set_assistant
@assistant = account_assistants.find(permitted_params[:assistant_id])
end
def account_assistants
@account_assistants ||= Current.account.captain_assistants
end
def permitted_params
params.permit(:assistant_id, :id, :account_id, :inbox_id)
end
def assistant_params
params.require(:inbox).permit(:inbox_id)
end
end

View File

@@ -0,0 +1,47 @@
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Scenario) }
before_action :set_assistant
before_action :set_scenario, only: [:show, :update, :destroy]
def index
@scenarios = assistant_scenarios.enabled
end
def show; end
def create
@scenario = assistant_scenarios.create!(scenario_params.merge(account: Current.account))
end
def update
@scenario.update!(scenario_params)
end
def destroy
@scenario.destroy
head :no_content
end
private
def set_assistant
@assistant = account_assistants.find(params[:assistant_id])
end
def account_assistants
@account_assistants ||= Current.account.captain_assistants
end
def set_scenario
@scenario = assistant_scenarios.find(params[:id])
end
def assistant_scenarios
@assistant.scenarios
end
def scenario_params
params.require(:scenario).permit(:title, :description, :instruction, :enabled, tools: [])
end
end

View File

@@ -0,0 +1,71 @@
class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseController
before_action :check_authorization
def rewrite
result = Captain::RewriteService.new(
account: Current.account,
content: params[:content],
operation: params[:operation],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def summarize
result = Captain::SummaryService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def reply_suggestion
result = Captain::ReplySuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id],
user: Current.user
).perform
render_result(result)
end
def label_suggestion
result = Captain::LabelSuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def follow_up
result = Captain::FollowUpService.new(
account: Current.account,
follow_up_context: params[:follow_up_context]&.to_unsafe_h,
user_message: params[:message],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
private
def render_result(result)
if result.nil?
render json: { message: nil }
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity
else
response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
render json: response_data
end
end
def check_authorization
authorize(:'captain/tasks')
end
end

View File

@@ -0,0 +1,75 @@
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
include Sift
sort_on :name, type: :string
sort_on :domain, type: :string
sort_on :created_at, type: :datetime
sort_on :contacts_count, internal_name: :order_on_contacts_count, type: :scope, scope_params: [:direction]
RESULTS_PER_PAGE = 25
before_action :check_authorization
before_action :set_current_page, only: [:index, :search]
before_action :fetch_company, only: [:show, :update, :destroy]
def index
@companies = fetch_companies(resolved_companies)
@companies_count = @companies.total_count
end
def search
if params[:q].blank?
return render json: { error: I18n.t('errors.companies.search.query_missing') },
status: :unprocessable_entity
end
companies = resolved_companies.search_by_name_or_domain(params[:q])
@companies = fetch_companies(companies)
@companies_count = @companies.total_count
end
def show; end
def create
@company = Current.account.companies.build(company_params)
@company.save!
end
def update
@company.update!(company_params)
end
def destroy
@company.destroy!
head :ok
end
private
def resolved_companies
@resolved_companies ||= Current.account.companies
end
def set_current_page
@current_page = params[:page] || 1
end
def fetch_companies(companies)
filtrate(companies)
.page(@current_page)
.per(RESULTS_PER_PAGE)
end
def check_authorization
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
authorize(Company)
end
def fetch_company
@company = Current.account.companies.find(params[:id])
end
def company_params
params.require(:company).permit(:name, :domain, :description, :avatar)
end
end

View File

@@ -0,0 +1,58 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference
def token
render json: Voice::Provider::Twilio::TokenService.new(
inbox: @voice_inbox,
user: Current.user,
account: Current.account
).generate
end
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
end
def set_voice_inbox_for_conference
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @voice_inbox, :show?
end
def fetch_conversation_by_display_id
cid = params[:conversation_id]
raise ActiveRecord::RecordNotFound, 'conversation_id required' if cid.blank?
conversation = @voice_inbox.conversations.find_by!(display_id: cid)
authorize conversation, :show?
conversation
end
end

View File

@@ -0,0 +1,38 @@
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
before_action :contact
before_action :voice_inbox
def create
authorize contact, :show?
authorize voice_inbox, :show?
result = Voice::OutboundCallBuilder.perform!(
account: Current.account,
inbox: voice_inbox,
user: Current.user,
contact: contact
)
conversation = result[:conversation]
render json: {
conversation_id: conversation.display_id,
inbox_id: voice_inbox.id,
call_sid: result[:call_sid],
conference_sid: conversation.additional_attributes['conference_sid']
}
end
private
def contact
@contact ||= Current.account.contacts.find(params[:id])
end
def voice_inbox
@voice_inbox ||= Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::Voice'
).find(params.require(:inbox_id))
end
end

View File

@@ -0,0 +1,31 @@
class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :fetch_custom_role, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@custom_roles = Current.account.custom_roles
end
def show; end
def create
@custom_role = Current.account.custom_roles.create!(permitted_params)
end
def update
@custom_role.update!(permitted_params)
end
def destroy
@custom_role.destroy!
head :ok
end
def permitted_params
params.require(:custom_role).permit(:name, :description, permissions: [])
end
def fetch_custom_role
@custom_role = Current.account.custom_roles.find_by(id: params[:id])
end
end

View File

@@ -0,0 +1,2 @@
class Api::V1::Accounts::EnterpriseAccountsController < Api::V1::Accounts::BaseController
end

View File

@@ -0,0 +1,30 @@
class Api::V1::Accounts::ReportingEventsController < Api::V1::Accounts::EnterpriseAccountsController
include DateRangeHelper
RESULTS_PER_PAGE = 25
before_action :check_admin_authorization?
before_action :set_reporting_events, only: [:index]
before_action :set_current_page, only: [:index]
def index
@reporting_events = @reporting_events.page(@current_page).per(RESULTS_PER_PAGE)
@total_count = @reporting_events.total_count
end
private
def set_reporting_events
@reporting_events = Current.account.reporting_events
.includes(:conversation, :user, :inbox)
.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
.filter_by_user_id(params[:user_id])
.filter_by_name(params[:name])
.order(created_at: :desc)
end
def set_current_page
@current_page = (params[:page] || 1).to_i
end
end

View File

@@ -0,0 +1,63 @@
class Api::V1::Accounts::SamlSettingsController < Api::V1::Accounts::BaseController
before_action :check_saml_sso_enabled
before_action :check_saml_feature_enabled
before_action :check_authorization
before_action :set_saml_settings
def show; end
def create
@saml_settings = Current.account.build_saml_settings(saml_settings_params)
if @saml_settings.save
render :show
else
render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @saml_settings.update(saml_settings_params)
render :show
else
render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@saml_settings.destroy!
head :no_content
end
private
def set_saml_settings
@saml_settings = Current.account.saml_settings ||
Current.account.build_saml_settings
end
def saml_settings_params
params.require(:saml_settings).permit(
:sso_url,
:certificate,
:idp_entity_id,
:sp_entity_id,
role_mappings: {}
)
end
def check_authorization
authorize(AccountSamlSettings)
end
def check_saml_feature_enabled
return if Current.account.feature_enabled?('saml')
render json: { error: I18n.t('errors.saml.feature_not_enabled') }, status: :forbidden
end
def check_saml_sso_enabled
return if GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s == 'true'
render json: { error: I18n.t('errors.saml.sso_not_enabled') }, status: :forbidden
end
end

View File

@@ -0,0 +1,32 @@
class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :fetch_sla, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@sla_policies = Current.account.sla_policies
end
def show; end
def create
@sla_policy = Current.account.sla_policies.create!(permitted_params)
end
def update
@sla_policy.update!(permitted_params)
end
def destroy
::DeleteObjectJob.perform_later(@sla_policy, Current.user, request.ip) if @sla_policy.present?
head :ok
end
def permitted_params
params.require(:sla_policy).permit(:name, :description, :first_response_time_threshold, :next_response_time_threshold,
:resolution_time_threshold, :only_during_business_hours)
end
def fetch_sla
@sla_policy = Current.account.sla_policies.find_by(id: params[:id])
end
end

View File

@@ -0,0 +1,81 @@
class Api::V1::AuthController < Api::BaseController
skip_before_action :authenticate_user!, only: [:saml_login]
before_action :find_user_and_account, only: [:saml_login]
def saml_login
unless saml_sso_enabled?
render json: { error: 'SAML SSO login is not enabled' }, status: :forbidden
return
end
return if @account.nil?
relay_state = params[:target] || 'web'
saml_initiation_url = "/auth/saml?account_id=#{@account.id}&RelayState=#{relay_state}"
redirect_to saml_initiation_url, status: :temporary_redirect
end
private
def find_user_and_account
return unless validate_email_presence
find_saml_enabled_account
end
def validate_email_presence
@email = params[:email]&.downcase&.strip
return true if @email.present?
render json: { error: I18n.t('auth.saml.invalid_email') }, status: :bad_request
false
end
def find_saml_enabled_account
user = User.from_email(@email)
return render_saml_error unless user
account_user = find_account_with_saml(user)
return render_saml_error unless account_user
@account = account_user.account
end
def find_account_with_saml(user)
user.account_users
.joins(account: :saml_settings)
.where.not(saml_settings: { sso_url: [nil, ''] })
.where.not(saml_settings: { certificate: [nil, ''] })
.find { |account_user| account_user.account.feature_enabled?('saml') }
end
def render_saml_error
error = 'saml-authentication-failed'
if mobile_target?
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
else
redirect_to sso_login_page_url(error: error)
end
end
def mobile_target?
params[:target]&.casecmp('mobile')&.zero?
end
def sso_login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
params = { error: error }.compact
query = params.to_query
query_fragment = query.present? ? "?#{query}" : ''
"#{frontend_url}/app/login/sso#{query_fragment}"
end
def saml_sso_enabled?
GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s == 'true'
end
end

View File

@@ -0,0 +1,22 @@
class CustomDomainsController < ApplicationController
def verify
challenge_id = permitted_params[:id]
domain = request.host
portal = Portal.find_by(custom_domain: domain)
return render plain: 'Domain not found', status: :not_found unless portal
ssl_settings = portal.ssl_settings || {}
return render plain: 'Challenge ID not found', status: :not_found unless ssl_settings['cf_verification_id'] == challenge_id
render plain: ssl_settings['cf_verification_body'], status: :ok
end
private
def permitted_params
params.permit(:id)
end
end

View File

@@ -0,0 +1,17 @@
module Enterprise::Api::V1::Accounts::AgentsController
def create
super
associate_agent_with_custom_role
end
def update
super
associate_agent_with_custom_role
end
private
def associate_agent_with_custom_role
@agent.current_account_user.update!(custom_role_id: params[:custom_role_id])
end
end

View File

@@ -0,0 +1,27 @@
module Enterprise::Api::V1::Accounts::ConversationsController
extend ActiveSupport::Concern
def inbox_assistant
assistant = @conversation.inbox.captain_assistant
if assistant
render json: { assistant: { id: assistant.id, name: assistant.name } }
else
render json: { assistant: nil }
end
end
def reporting_events
@reporting_events = @conversation.reporting_events.order(created_at: :asc)
end
def permitted_update_params
super.merge(params.permit(:sla_policy_id))
end
private
def copilot_params
params.permit(:previous_history, :message, :assistant_id)
end
end

View File

@@ -0,0 +1,12 @@
module Enterprise::Api::V1::Accounts::CsatSurveyResponsesController
def update
@csat_survey_response = Current.account.csat_survey_responses.find(params[:id])
authorize @csat_survey_response
@csat_survey_response.update!(
csat_review_notes: params[:csat_review_notes],
review_notes_updated_by: Current.user,
review_notes_updated_at: Time.current
)
end
end

View File

@@ -0,0 +1,33 @@
module Enterprise::Api::V1::Accounts::InboxesController
def inbox_attributes
super + ee_inbox_attributes
end
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
private
def allowed_channel_types
super + ['voice']
end
def channel_type_from_params
case permitted_params[:channel][:type]
when 'voice'
Channel::Voice
else
super
end
end
def account_channels_method
case permitted_params[:channel][:type]
when 'voice'
Current.account.voice_channels
else
super
end
end
end

View File

@@ -0,0 +1,15 @@
module Enterprise::Api::V1::Accounts::PortalsController
def ssl_status
return render_could_not_create_error(I18n.t('portals.ssl_status.custom_domain_not_configured')) if @portal.custom_domain.blank?
result = Cloudflare::CheckCustomHostnameService.new(portal: @portal).perform
return render_could_not_create_error(result[:errors]) if result[:errors].present?
ssl_settings = @portal.ssl_settings || {}
render json: {
status: ssl_settings['cf_status'],
verification_errors: ssl_settings['cf_verification_errors']
}
end
end

View File

@@ -0,0 +1,147 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
def subscription
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
@account.update(custom_attributes: { is_creating_customer: true })
Enterprise::CreateStripeCustomerJob.perform_later(@account)
end
head :no_content
end
def limits
limits = if default_plan?(@account)
{
'conversation' => {
'allowed' => 500,
'consumed' => conversations_this_month(@account)
},
'non_web_inboxes' => {
'allowed' => 0,
'consumed' => non_web_inboxes(@account)
},
'agents' => {
'allowed' => 2,
'consumed' => agents(@account)
}
}
else
default_limits
end
# include id in response to ensure that the store can be updated on the frontend
render json: { id: @account.id, limits: limits }, status: :ok
end
def checkout
return create_stripe_billing_session(stripe_customer_id) if stripe_customer_id.present?
render_invalid_billing_details
end
def toggle_deletion
action_type = params[:action_type]
case action_type
when 'delete'
mark_for_deletion
when 'undelete'
unmark_for_deletion
else
render json: { error: 'Invalid action_type. Must be either "delete" or "undelete"' }, status: :unprocessable_entity
end
end
def topup_checkout
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
result = service.create_checkout_session(credits: params[:credits].to_i)
@account.reload
render json: result.merge(
id: @account.id,
limits: @account.limits,
custom_attributes: @account.custom_attributes
)
rescue Enterprise::Billing::TopupCheckoutService::Error, Stripe::StripeError => e
render_could_not_create_error(e.message)
end
private
def check_cloud_env
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
end
def default_limits
{
'conversation' => {},
'non_web_inboxes' => {},
'agents' => {
'allowed' => @account.usage_limits[:agents],
'consumed' => agents(@account)
},
'captain' => @account.usage_limits[:captain]
}
end
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def stripe_customer_id
@account.custom_attributes['stripe_customer_id']
end
def mark_for_deletion
reason = 'manual_deletion'
if @account.mark_for_deletion(reason)
cancel_cloud_subscriptions_for_deletion
render json: { message: 'Account marked for deletion' }, status: :ok
else
render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def unmark_for_deletion
if @account.unmark_for_deletion
render json: { message: 'Account unmarked for deletion' }, status: :ok
else
render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def render_invalid_billing_details
render_could_not_create_error('Please subscribe to a plan before viewing the billing details')
end
def create_stripe_billing_session(customer_id)
session = Enterprise::Billing::CreateSessionService.new.create_session(customer_id)
render_redirect_url(session.url)
end
def cancel_cloud_subscriptions_for_deletion
Enterprise::Billing::CancelCloudSubscriptionsService.new(account: @account).perform
rescue Stripe::StripeError => e
Rails.logger.warn("Failed to cancel cloud subscriptions for account #{@account.id}: #{e.class} - #{e.message}")
end
def render_redirect_url(redirect_url)
render json: { redirect_url: redirect_url }
end
def pundit_user
{
user: current_user,
account: @account,
account_user: @current_account_user
}
end
end

View File

@@ -0,0 +1,7 @@
module Enterprise::Api::V1::AccountsSettings
private
def permitted_settings_attributes
super + [{ conversation_required_attributes: [] }]
end
end

View File

@@ -0,0 +1,40 @@
module Enterprise::Api::V2::AccountsController
private
def fetch_account_and_user_info
@data = fetch_from_clearbit
return if @data.blank?
update_user_info
end
def fetch_from_clearbit
Enterprise::ClearbitLookupService.lookup(@user.email)
rescue StandardError => e
Rails.logger.error "Error fetching data from clearbit: #{e}"
nil
end
def update_user_info
@user.update!(name: @data[:name]) if @data[:name].present?
end
def data_from_clearbit
return {} if @data.blank?
{ name: @data[:company_name],
custom_attributes: {
'industry' => @data[:industry],
'company_size' => @data[:company_size],
'timezone' => @data[:timezone],
'logo' => @data[:logo]
} }
end
def account_attributes
super.deep_merge(
data_from_clearbit
)
end
end

View File

@@ -0,0 +1,3 @@
module Enterprise::Concerns::ApplicationControllerConcern
extend ActiveSupport::Concern
end

View File

@@ -0,0 +1,100 @@
module Enterprise::DeviseOverrides::OmniauthCallbacksController
def saml
# Call parent's omniauth_success which handles the auth
omniauth_success
end
def redirect_callbacks
# derive target redirect route from 'resource_class' param, which was set
# before authentication.
devise_mapping = get_devise_mapping
redirect_route = get_redirect_route(devise_mapping)
# preserve omniauth info for success route. ignore 'extra' in twitter
# auth response to avoid CookieOverflow.
session['dta.omniauth.auth'] = request.env['omniauth.auth'].except('extra')
session['dta.omniauth.params'] = request.env['omniauth.params']
# For SAML, use 303 See Other to convert POST to GET and preserve session
if params[:provider] == 'saml'
redirect_to redirect_route, { status: 303 }.merge(redirect_options)
else
super
end
end
def omniauth_success
case auth_hash&.dig('provider')
when 'saml'
handle_saml_auth
else
super
end
end
def omniauth_failure
return super unless params[:provider] == 'saml'
relay_state = saml_relay_state
error = params[:message] || 'authentication-failed'
if for_mobile?(relay_state)
redirect_to_mobile_error(error, relay_state)
else
redirect_to login_page_url(error: "saml-#{error}")
end
end
private
def handle_saml_auth
account_id = extract_saml_account_id
relay_state = saml_relay_state
unless saml_enabled_for_account?(account_id)
return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
return redirect_to login_page_url(error: 'saml-not-enabled')
end
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
if @resource.persisted?
return sign_in_user_on_mobile if for_mobile?(relay_state)
sign_in_user
else
return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
redirect_to login_page_url(error: 'saml-authentication-failed')
end
end
def extract_saml_account_id
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
end
def saml_relay_state
session[:saml_relay_state] || request.env['omniauth.params']&.dig('RelayState')
end
def for_mobile?(relay_state)
relay_state.to_s.casecmp('mobile').zero?
end
def redirect_to_mobile_error(error)
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
end
def saml_enabled_for_account?(account_id)
return false if account_id.blank?
account = Account.find_by(id: account_id)
return false if account.nil?
return false unless account.feature_enabled?('saml')
AccountSamlSettings.find_by(account_id: account_id).present?
end
end

View File

@@ -0,0 +1,16 @@
module Enterprise::DeviseOverrides::PasswordsController
include SamlAuthenticationHelper
def create
if saml_user_attempting_password_auth?(params[:email])
render json: {
success: false,
message: I18n.t('messages.reset_password_saml_user'),
errors: [I18n.t('messages.reset_password_saml_user')]
}, status: :forbidden
return
end
super
end
end

View File

@@ -0,0 +1,40 @@
module Enterprise::DeviseOverrides::SessionsController
include SamlAuthenticationHelper
def create
if saml_user_attempting_password_auth?(params[:email], sso_auth_token: params[:sso_auth_token])
render json: {
success: false,
message: I18n.t('messages.login_saml_user'),
errors: [I18n.t('messages.login_saml_user')]
}, status: :unauthorized
return
end
super
end
def render_create_success
create_audit_event('sign_in')
super
end
def destroy
create_audit_event('sign_out')
super
end
def create_audit_event(action)
return unless @resource
associated_type = 'Account'
@resource.accounts.each do |account|
@resource.audits.create(
action: action,
user_id: @resource.id,
associated_id: account.id,
associated_type: associated_type
)
end
end
end

View File

@@ -0,0 +1,11 @@
module Enterprise::Public::Api::V1::Portals::ArticlesController
private
def search_articles
if @portal.account.feature_enabled?('help_center_embedding_search')
@articles = @articles.vector_search(list_params.merge(account_id: @portal.account_id)) if list_params[:query].present?
else
super
end
end
end

View File

@@ -0,0 +1,15 @@
module Enterprise::SuperAdmin::AccountsController
def update
# Handle manually managed features from form submission
if params[:account] && params[:account][:manually_managed_features].present?
# Update using the service - it will handle array conversion and validation
service = ::Internal::Accounts::InternalAttributesService.new(requested_resource)
service.manually_managed_features = params[:account][:manually_managed_features]
# Remove the manually_managed_features from params to prevent ActiveModel::UnknownAttributeError
params[:account].delete(:manually_managed_features)
end
super
end
end

View File

@@ -0,0 +1,56 @@
module Enterprise::SuperAdmin::AppConfigsController
private
def allowed_configs
return super if ChatwootHub.pricing_plan == 'community'
case @config
when 'custom_branding'
@allowed_configs = custom_branding_options
when 'internal'
@allowed_configs = internal_config_options
when 'captain'
@allowed_configs = captain_config_options
when 'saml'
@allowed_configs = saml_config_options
else
super
end
end
def custom_branding_options
%w[
LOGO_THUMBNAIL
LOGO
LOGO_DARK
BRAND_NAME
INSTALLATION_NAME
BRAND_URL
WIDGET_BRAND_URL
TERMS_URL
PRIVACY_URL
DISPLAY_MANIFEST
]
end
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY CLOUD_ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS
SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS
OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
end
def captain_config_options
%w[
CAPTAIN_OPEN_AI_API_KEY
CAPTAIN_OPEN_AI_MODEL
CAPTAIN_OPEN_AI_ENDPOINT
CAPTAIN_EMBEDDING_MODEL
CAPTAIN_FIRECRAWL_API_KEY
]
end
def saml_config_options
%w[ENABLE_SAML_SSO_LOGIN]
end
end

View File

@@ -0,0 +1,47 @@
class Enterprise::Webhooks::FirecrawlController < ActionController::API
before_action :validate_token
def process_payload
Captain::Tools::FirecrawlParserJob.perform_later(assistant_id: assistant.id, payload: payload) if crawl_page_event?
head :ok
end
private
include Captain::FirecrawlHelper
def payload
permitted_params[:data]&.first&.to_h
end
def validate_token
render json: { error: 'Invalid access_token' }, status: :unauthorized if assistant_token != permitted_params[:token]
end
def assistant
@assistant ||= Captain::Assistant.find(permitted_params[:assistant_id])
end
def assistant_token
generate_firecrawl_token(assistant.id, assistant.account_id)
end
def crawl_page_event?
permitted_params[:type] == 'crawl.page'
end
def permitted_params
params.permit(
:type,
:assistant_id,
:token,
:success,
:id,
:metadata,
:format,
:firecrawl,
data: [:markdown, { metadata: {} }]
)
end
end

View File

@@ -0,0 +1,21 @@
class Enterprise::Webhooks::StripeController < ActionController::API
def process_payload
# Get the event payload and signature
payload = request.body.read
sig_header = request.headers['Stripe-Signature']
# Attempt to verify the signature. If successful, we'll handle the event
begin
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
# If we fail to verify the signature, then something was wrong with the request
rescue JSON::ParserError, Stripe::SignatureVerificationError
# Invalid payload
head :bad_request
return
end
# We've successfully processed the event without blowing up
head :ok
end
end

View File

@@ -0,0 +1,14 @@
module Enterprise::WidgetsController
private
def ensure_location_is_supported
countries = @web_widget.inbox.account.custom_attributes['allowed_countries']
return if countries.blank?
geocoder_result = IpLookupService.new.perform(request.remote_ip)
return unless geocoder_result
country_enabled = countries.include?(geocoder_result.country_code)
render json: { error: 'Location is not supported' }, status: :unauthorized unless country_enabled
end
end

View File

@@ -0,0 +1,2 @@
class SuperAdmin::EnterpriseBaseController < SuperAdmin::ApplicationController
end

View File

@@ -0,0 +1,44 @@
class SuperAdmin::ResponseDocumentsController < SuperAdmin::EnterpriseBaseController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
end

View File

@@ -0,0 +1,44 @@
class SuperAdmin::ResponsesController < SuperAdmin::EnterpriseBaseController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
end

View File

@@ -0,0 +1,188 @@
class Twilio::VoiceController < ApplicationController
CONFERENCE_EVENT_PATTERNS = {
/conference-start/i => 'start',
/participant-join/i => 'join',
/participant-leave/i => 'leave',
/conference-end/i => 'end'
}.freeze
before_action :set_inbox!
def status
Voice::StatusUpdateService.new(
account: current_account,
call_sid: twilio_call_sid,
call_status: params[:CallStatus],
payload: params.to_unsafe_h
).perform
head :no_content
end
def call_twiml
account = current_account
Rails.logger.info(
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
conversation = resolve_conversation
conference_sid = ensure_conference_sid!(conversation)
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
end
def conference_status
event = mapped_conference_event
return head :no_content unless event
conversation = find_conversation_for_conference!(
friendly_name: params[:FriendlyName],
call_sid: twilio_call_sid
)
Voice::Conference::Manager.new(
conversation: conversation,
event: event,
call_sid: twilio_call_sid,
participant_label: participant_label
).process
head :no_content
end
private
def twilio_call_sid
params[:CallSid]
end
def twilio_from
params[:From].to_s
end
def twilio_to
params[:To]
end
def twilio_direction
@twilio_direction ||= (params['Direction'] || params['CallDirection']).to_s
end
def mapped_conference_event
event = params[:StatusCallbackEvent].to_s
CONFERENCE_EVENT_PATTERNS.each do |pattern, mapped|
return mapped if event.match?(pattern)
end
nil
end
def agent_leg?(from_number)
from_number.start_with?('client:')
end
def resolve_conversation
return find_conversation_for_agent if agent_leg?(twilio_from)
case twilio_direction
when 'inbound'
Voice::InboundCallBuilder.perform!(
account: current_account,
inbox: inbox,
from_number: twilio_from,
call_sid: twilio_call_sid
)
when 'outbound-api', 'outbound-dial'
sync_outbound_leg(
call_sid: twilio_call_sid,
from_number: twilio_from,
direction: twilio_direction
)
else
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
end
end
def find_conversation_for_agent
if params[:conversation_id].present?
current_account.conversations.find_by!(display_id: params[:conversation_id])
else
current_account.conversations.find_by!(identifier: twilio_call_sid)
end
end
def sync_outbound_leg(call_sid:, from_number:, direction:)
parent_sid = params['ParentCallSid'].presence
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
Voice::CallSessionSyncService.new(
conversation: conversation,
call_sid: call_sid,
message_call_sid: conversation.identifier,
leg: {
from_number: from_number,
to_number: twilio_to,
direction: 'outbound'
}
).perform
end
def ensure_conference_sid!(conversation)
attrs = conversation.additional_attributes || {}
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
attrs['conference_sid']
end
def conference_twiml(conference_sid, agent_leg)
Twilio::TwiML::VoiceResponse.new.tap do |response|
response.dial do |dial|
dial.conference(
conference_sid,
start_conference_on_enter: agent_leg,
end_conference_on_exit: false,
status_callback: conference_status_callback_url,
status_callback_event: 'start end join leave',
status_callback_method: 'POST',
participant_label: agent_leg ? 'agent' : 'contact'
)
end
end.to_s
end
def conference_status_callback_url
phone_digits = inbox_channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
name = friendly_name.to_s
scope = current_account.conversations
if name.present?
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
return conversation if conversation
end
scope.find_by!(identifier: call_sid)
end
def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '')
e164 = "+#{digits}"
channel = Channel::Voice.find_by!(phone_number: e164)
@inbox = channel.inbox
end
def current_account
@current_account ||= inbox_account
end
def participant_label
params[:ParticipantLabel].to_s
end
attr_reader :inbox
delegate :account, :channel, to: :inbox, prefix: true
end