Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ def queue_status
head :ok
end

def call_status
::PagerTree::Integrations.deferred_request_class.constantize.perform_later_from_request!(request)

head :ok
end
Comment on lines +30 to +34
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new call_status controller endpoint should have test coverage. Tests should verify that the endpoint properly queues a deferred request and processes the call status callback correctly.

Copilot uses AI. Check for mistakes.

def queue_status_deferred(deferred_request)
params = deferred_request.params

Expand All @@ -42,6 +48,20 @@ def queue_status_deferred(deferred_request)
@integration.adapter_process_queue_status_deferred
end

def call_status_deferred(deferred_request)
params = deferred_request.params

id = params.dig("id")
@integration = find_integration(id)

deferred_request.account_id = @integration.account_id
@integration.adapter_source_log = @integration.logs.create!(level: :info, format: :json, message: deferred_request.request) if @integration.log_incoming_requests?
@integration.adapter_incoming_request_params = params
@integration.adapter_incoming_deferred_request = deferred_request

@integration.adapter_process_call_status_deferred
end

private

def set_integration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class LiveCallRouting::Twilio::V3 < Integration
{key: :api_region, type: :string, default: "ashburn.us1"},
{key: :force_input, type: :boolean, default: false},
{key: :record, type: :boolean, default: false},
{key: :send_straight_to_voicemail, type: :boolean, default: false},
{key: :record_email, type: :string, default: ""},
{key: :banned_phone, type: :string, default: ""},
{key: :dial_pause, type: :integer},
Expand All @@ -30,6 +31,7 @@ class LiveCallRouting::Twilio::V3 < Integration
validates :option_api_region, inclusion: {in: API_REGIONS}
validates :option_force_input, inclusion: {in: [true, false]}
validates :option_record, inclusion: {in: [true, false]}
validates :option_send_straight_to_voicemail, inclusion: {in: [true, false]}
validates :option_max_wait_time, numericality: {greater_than_or_equal_to: 30, less_than_or_equal_to: 3600}, allow_nil: true
validate :validate_record_emails

Comment on lines 36 to 37
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The model should include a validation to ensure that option_send_straight_to_voicemail can only be set to true when option_record is also true. This dependency is enforced in the UI but not at the model level, which could allow invalid configurations if the model is updated programmatically or through other means. Add a custom validation method to check this constraint.

Suggested change
validate :validate_record_emails
validate :validate_record_emails
validate :validate_send_straight_to_voicemail_record_dependency
def validate_send_straight_to_voicemail_record_dependency
return unless option_send_straight_to_voicemail
return if option_record
errors.add(:option_send_straight_to_voicemail, "can only be enabled when recording is enabled")
end

Copilot uses AI. Check for mistakes.
Expand All @@ -40,6 +42,7 @@ class LiveCallRouting::Twilio::V3 < Integration
self.option_api_region ||= "ashburn.us1"
self.option_force_input ||= false
self.option_record ||= false
self.option_send_straight_to_voicemail ||= false
self.option_record_email ||= ""
self.option_banned_phone ||= ""
end
Expand Down Expand Up @@ -189,6 +192,33 @@ def adapter_response_incoming

return adapter_controller&.render(xml: _twiml.to_xml)
end

if adapter_alert.meta["live_call_status_callback_set"] != true
begin
# give us status updates on the call, so we can clean up if they hang up before leaving a message
_call.update(
status_callback: PagerTree::Integrations::Engine.routes.url_helpers.call_status_live_call_routing_twilio_v3_url(id, thirdparty_id: _thirdparty_id),
status_callback_method: "POST",
url: adapter_controller&.url_for || endpoint
)

adapter_alert.meta["live_call_status_callback_set"] = true
adapter_alert.save!
rescue ::Twilio::REST::RestError => e
if e.code == 21220
# 21220 - Unable to update record. Call is not in-progress. Cannot redirect.
adapter_alert.logs.create!(message: "Updating the call for status callbacks failed. The caller has already hung up.")
else
adapter_alert.logs.create!(message: "Updating the call for status callbacks failed. #{e.message}")
end

adapter_alert.logs.create!(message: "Marking alert as resolved due to call update failure.")
adapter_alert.resolve!(self, force: true)
end

return adapter_controller&.render(xml: _twiml.to_xml)
end

# if this was attached to a router
if !adapter_alert.meta["live_call_router_team_prefix_ids"].present? && routers.size > 0 && account.subscription_feature_routers?
adapter_alert.logs.create!(message: "Routed to router. Attempting to get a list of teams...")
Expand Down Expand Up @@ -235,6 +265,12 @@ def adapter_response_incoming
return adapter_controller&.render(xml: _twiml.to_xml)
end

if option_record && option_send_straight_to_voicemail && adapter_alert.meta["live_call_send_straight_to_voicemail"].nil?
# flag this call to send straight to voicemail
adapter_alert.meta["live_call_send_straight_to_voicemail"] = true
adapter_alert.save!
end

if !adapter_alert.meta["live_call_welcome"] && option_welcome_media.present?
adapter_alert.logs.create!(message: "Play welcome media to caller.")
_twiml.play(url: option_welcome_media.url)
Expand All @@ -245,6 +281,17 @@ def adapter_response_incoming
if selected_team
adapter_alert.logs.create!(message: "Caller selected team '#{selected_team.name}'.") if _teams_size > 1 || option_force_input

if adapter_alert.meta["live_call_send_straight_to_voicemail"] == true
adapter_alert.destination_teams = [selected_team]
adapter_alert.save!

adapter_alert.logs.create!(message: "Send caller straight to voicemail (integration option).")

_twiml.redirect(PagerTree::Integrations::Engine.routes.url_helpers.dropped_live_call_routing_twilio_v3_url(id, thirdparty_id: _thirdparty_id), method: "POST")

return adapter_controller&.render(xml: _twiml.to_xml)
end
Comment on lines +284 to +293
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new straight-to-voicemail feature should have test coverage. Tests should verify: (1) the option is properly saved and validated, (2) calls are redirected to voicemail without being queued when the option is enabled, (3) alerts are routed only after a voicemail is recorded, and (4) the feature only works when voicemail recording is enabled.

Copilot uses AI. Check for mistakes.

adapter_alert.logs.create!(message: "Play please wait media to caller.")
_twiml.play(url: option_please_wait_media_url)
friendly_name = adapter_alert.id
Expand Down Expand Up @@ -358,11 +405,20 @@ def adapter_response_dropped
LiveCallRouting::Twilio::V3Mailer.with(email: email, alert: adapter_alert, from: adapter_incoming_request_params.dig("From"), recording_url: recording_url).call_recording.deliver_later
end
end

if adapter_alert.meta["live_call_send_straight_to_voicemail"] == true
adapter_alert.logs.create!(message: "Call was sent straight to voicemail and caller left a message. Routing the alert.")

# kick off the alert workflow
adapter_alert.route_later
adapter_alert.logs.create!(message: "Successfully enqueued alert team workflow.")
end
elsif option_record
adapter_alert.logs.create!(message: "No one is available to answer this call. Requesting voicemail recording.")
_twiml.play(url: option_no_answer_media_url)
_twiml.record(max_length: 60)
else
# A friendly goodbye (when the integration has been configured to not record)
if option_no_answer_no_record_media_url.present?
adapter_alert.logs.create!(message: "No one is available to answer this call. Play media. Hangup on caller.")
_twiml.play(url: option_no_answer_no_record_media_url)
Expand All @@ -383,15 +439,33 @@ def adapter_process_queue_status_deferred
if queue_result == "hangup"
self.adapter_alert = alerts.find_by(thirdparty_id: _thirdparty_id)
adapter_alert.logs.create!(message: "Caller hungup while waiting in queue.")
adapter_alert.resolve!(self)
adapter_alert.resolve!(self, force: true)
queue_destroy
end

adapter_source_log&.save!
end

def adapter_process_call_status_deferred
call_status = adapter_incoming_request_params.dig("CallStatus")
adapter_source_log&.sublog("Processing call status #{call_status}")

if ["completed"].include?(call_status)
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The array wrapper with include? is unnecessary when checking for a single value. Consider using a direct equality check: if call_status == "completed" for better clarity and performance.

Suggested change
if ["completed"].include?(call_status)
if call_status == "completed"

Copilot uses AI. Check for mistakes.
self.adapter_alert = alerts.find_by(thirdparty_id: _thirdparty_id)

if adapter_alert.present? && adapter_alert.meta["live_call_send_straight_to_voicemail"] == true && !adapter_alert.additional_data.any? { |x| x["label"] == "Voicemail" }
adapter_alert.logs.create!(message: "Caller hung up without leaving a message. Marking alert as resolved.")
adapter_alert.resolve!(self, force: true)
elsif adapter_alert.present? && adapter_alert.meta["live_call_send_straight_to_voicemail"] != true && !adapter_alert.meta["live_call_queue_sid"].present?
adapter_alert.logs.create!(message: "Caller hungup before being put in a queue. Marking alert as resolved.")
adapter_alert.resolve!(self, force: true)
end
end
end
Comment on lines +449 to +464
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new call_status callback handling logic should have test coverage. Tests should verify: (1) alerts are properly resolved when a caller hangs up without leaving a voicemail in straight-to-voicemail mode, (2) alerts are properly routed when a voicemail is left, and (3) alerts are resolved when callers hang up before being queued in normal mode.

Copilot uses AI. Check for mistakes.
Comment on lines +449 to +464
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new call_status callback functionality and the adapter_process_call_status_deferred method lack test coverage. Consider adding tests to verify the behavior when a caller hangs up before leaving a voicemail, when they leave a voicemail successfully, and when they hang up before being placed in a queue.

Copilot uses AI. Check for mistakes.

def adapter_process_outgoing
return unless adapter_alert.source_id == id
return if adapter_alert.meta["live_call_send_straight_to_voicemail"] == true

event = adapter_outgoing_event.event_name.to_s
if event == "alert_acknowledged"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<%
x_data = {
option_record: form.object.option_record,
}
%>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4" x-data=<%= x_data.to_json.html_safe %>>
<div class="form-group group">
<%= form.label :option_account_sid %>
<%= form.text_field :option_account_sid, class: "form-control" %>
Expand Down Expand Up @@ -78,12 +83,18 @@
</div>

<div class="form-group group">
<%= form.check_box :option_record, class: "form-checkbox" %>
<%= form.check_box :option_record, class: "form-checkbox", "x-model": "option_record" %>
<%= form.label :option_record, class: "inline-block" %>
<p class="form-hint md:inline-block"><%== t(".option_record_hint_html") %></p>
</div>

<div class="form-group group" data-controller="tagify">
<div class="form-group group" x-show="option_record" x-cloak x-transition>
<%= form.check_box :option_send_straight_to_voicemail, class: "form-checkbox" %>
<%= form.label :option_send_straight_to_voicemail, class: "inline-block" %>
<p class="form-hint md:inline-block"><%== t(".option_send_straight_to_voicemail_hint_html") %></p>
</div>

<div class="form-group group" data-controller="tagify" x-show="option_record" x-cloak x-transition>
<%= form.label :option_record_emails_list %>
<%= form.text_field :option_record_emails_list, class: "form-control", data: { tagify_target: "input" } %>
<p class="form-hint"><%== t(".option_record_emails_list_hint_html") %></p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@
</dd>
</div>

<% if integration.option_record %>
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">
<%= t("activerecord.attributes.pager_tree/integrations/live_call_routing/twilio/v3.option_send_straight_to_voicemail") %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<%= render partial: "shared/components/badge_enabled", locals: { enabled: integration.option_send_straight_to_voicemail } %>
</dd>
</div>
<% end %>

<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">
<%= t("activerecord.attributes.pager_tree/integrations/live_call_routing/twilio/v3.option_no_answer_media") %>
Expand Down Expand Up @@ -189,23 +200,25 @@
</dd>
</div>

<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">
<%= t("activerecord.attributes.pager_tree/integrations/live_call_routing/twilio/v3.option_record_emails_list") %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<div class="flex items-center gap-2">
<% integration.option_record_emails.each do |email| %>
<p class="text-sm truncate">
<%= link_to email, "mailto:#{email}" %>
<% if integration.option_record %>
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">
<%= t("activerecord.attributes.pager_tree/integrations/live_call_routing/twilio/v3.option_record_emails_list") %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<div class="flex items-center gap-2">
<% integration.option_record_emails.each do |email| %>
<p class="text-sm truncate">
<%= link_to email, "mailto:#{email}" %>
</p>
<% end %>
<p class="hidden only:flex text-sm truncate">
(<%= t("pager_tree.integrations.common.none") %>)
</p>
<% end %>
<p class="hidden only:flex text-sm truncate">
(<%= t("pager_tree.integrations.common.none") %>)
</p>
</div>
</dd>
</div>
</div>
</dd>
</div>
<% end %>

<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">
Expand Down
2 changes: 2 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ en:
option_no_answer_no_record_media_hint_html: "An optional <a href='https://www.twilio.com/docs/voice/twiml/play#supported-audio-file-types' target='_blank'>.mp3</a> recording to be played when no one answers if you select to not record voicemails (default: \"No one is available to answer this call. Goodbye.\")"
option_force_input_hint_html: "Force the caller to select a team (even if the integration only has one team)"
option_record_hint_html: "Record a voicemail when no one acknowledges the call"
option_send_straight_to_voicemail_hint_html: "Send the caller straight to voicemail without ringing any team members (only applies if 'Record a voicemail' is enabled). The alert will be routed after the caller leaves a voicemail."
option_record_emails_list_hint_html: "List of email addresses to notify when a voicemail has been recorded"
option_banned_phones_list_hint_html: "List of phone numbers that are banned from calling the integration"
option_max_wait_time_hint_html: "The maximum amount of time (in seconds) that the caller will wait before transferring the call to the voicemail. If set to nil, it will wait indefinitely."
Expand Down Expand Up @@ -263,6 +264,7 @@ en:
option_no_answer_no_record_media: "No Answer (No Voicemail) Recording"
option_force_input: "Force Caller Input"
option_record: "Voicemail"
option_send_straight_to_voicemail: "Send Straight to Voicemail"
option_record_emails_list: "Voicemail Emails"
option_banned_phones_list: "Banned Phones"
option_max_wait_time: "Max Wait Time (seconds)"
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
member do
get :music
post :queue_status
post :call_status
post :dropped
end
end
Expand Down