Add FCM push notifications, queue alerts, caller ID fixes, and auto-revert agent status
All checks were successful
Create Release / build (push) Successful in 6s

Server-side:
- Add push credential auto-creation for FCM incoming call notifications
- Add queue alert FCM notifications (data-only for background delivery)
- Add queue alert cancellation on call accept/disconnect
- Fix caller ID to show caller's number instead of Twilio number
- Fix FCM token storage when refresh_token is null
- Add pre_call_status tracking to revert agent status 30s after call ends
- Add SSE fallback polling for mobile app connectivity

Mobile app:
- Add Android telecom permissions and phone account registration
- Add VoiceFirebaseMessagingService for incoming call push handling
- Add insistent queue alert notifications with custom sound
- Fix caller number display on active call screen
- Add caller ID selection dropdown on dashboard
- Add phone numbers endpoint and provider support
- Add unit tests for CallInfo, QueueState, and CallProvider
- Remove local.properties from tracking, add .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-07 17:11:02 -08:00
parent 78e6c5a4ee
commit 4af4be94a4
26 changed files with 1829 additions and 189 deletions

View File

@@ -479,7 +479,13 @@ class TWP_Activator {
if (empty($auto_busy_at_exists)) {
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN auto_busy_at datetime DEFAULT NULL AFTER logged_in_at");
}
// Add pre_call_status column to store status before a call set agent to busy
$pre_call_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_agent_status LIKE 'pre_call_status'");
if (empty($pre_call_exists)) {
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN pre_call_status varchar(20) DEFAULT NULL AFTER auto_busy_at");
}
$table_schedules = $wpdb->prefix . 'twp_phone_schedules';
// Check if holiday_dates column exists

View File

@@ -619,35 +619,35 @@ class TWP_Agent_Manager {
}
/**
* Check and revert agents from auto-busy to available after 1 minute
* Check and revert agents from auto-busy to their previous status after 30 seconds
*/
public static function revert_auto_busy_agents() {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_agent_status';
// Find agents who have been auto-busy for more than 1 minute and are still logged in
$cutoff_time = date('Y-m-d H:i:s', strtotime('-1 minute'));
// Find agents who have been auto-busy for more than 30 seconds and are still logged in
$cutoff_time = date('Y-m-d H:i:s', strtotime('-30 seconds'));
$auto_busy_agents = $wpdb->get_results($wpdb->prepare(
"SELECT user_id, current_call_sid FROM $table_name
WHERE status = 'busy'
AND auto_busy_at IS NOT NULL
"SELECT user_id, current_call_sid, pre_call_status FROM $table_name
WHERE status = 'busy'
AND auto_busy_at IS NOT NULL
AND auto_busy_at < %s
AND is_logged_in = 1",
$cutoff_time
));
foreach ($auto_busy_agents as $agent) {
// Verify the call is actually finished before reverting
$call_sid = $agent->current_call_sid;
$call_active = false;
if ($call_sid) {
// Check if call is still active using Twilio API
try {
$api = new TWP_Twilio_API();
$call_status = $api->get_call_status($call_sid);
// If call is still in progress, don't revert yet
if (in_array($call_status, ['queued', 'ringing', 'in-progress'])) {
$call_active = true;
@@ -655,17 +655,25 @@ class TWP_Agent_Manager {
}
} catch (Exception $e) {
error_log("TWP Auto-Revert: Could not check call status for {$call_sid}: " . $e->getMessage());
// If we can't check call status, assume it's finished and proceed with revert
}
}
// Only revert if call is not active
if (!$call_active) {
error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from auto-busy to available");
self::set_agent_status($agent->user_id, 'available', null, false);
$revert_to = !empty($agent->pre_call_status) ? $agent->pre_call_status : 'available';
error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from busy to {$revert_to}");
self::set_agent_status($agent->user_id, $revert_to, null, false);
// Clear pre_call_status
$wpdb->update(
$table_name,
array('pre_call_status' => null),
array('user_id' => $agent->user_id),
array('%s'),
array('%d')
);
}
}
return count($auto_busy_agents);
}

View File

@@ -33,8 +33,8 @@ class TWP_Call_Queue {
);
if ($result !== false) {
// Notify agents via SMS when a new call enters the queue
self::notify_agents_for_queue($queue_id, $call_data['from_number']);
// Notify agents via SMS and FCM when a new call enters the queue
self::notify_agents_for_queue($queue_id, $call_data['from_number'], $call_data['call_sid']);
return $position;
}
@@ -580,49 +580,60 @@ class TWP_Call_Queue {
/**
* Notify agents via SMS when a call enters the queue
*/
private static function notify_agents_for_queue($queue_id, $caller_number) {
private static function notify_agents_for_queue($queue_id, $caller_number, $call_sid = '') {
global $wpdb;
error_log("TWP: notify_agents_for_queue called for queue {$queue_id}, caller {$caller_number}");
// Get queue information including assigned agent group and phone number
$queue_table = $wpdb->prefix . 'twp_call_queues';
$queue = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $queue_table WHERE id = %d",
$queue_id
));
if (!$queue) {
error_log("TWP: Queue {$queue_id} not found in database");
return;
}
if (!$queue->agent_group_id) {
error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications");
return;
}
error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}");
// Send Discord/Slack notification for incoming call
require_once dirname(__FILE__) . '/class-twp-notifications.php';
error_log("TWP: Triggering Discord/Slack notification for incoming call");
TWP_Notifications::send_call_notification('incoming_call', array(
'type' => 'incoming_call',
'caller' => $caller_number,
'queue' => $queue->queue_name,
'queue_id' => $queue_id
));
// Get members of the assigned agent group
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
// Send FCM push notifications to agents' mobile devices
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
$notified_users = array();
// Always notify personal queue owner
if (!empty($queue->user_id)) {
$fcm->notify_queue_alert($queue->user_id, $caller_number, $queue->queue_name, $call_sid);
$notified_users[] = $queue->user_id;
error_log("TWP: FCM queue alert sent to queue owner user {$queue->user_id}");
}
if (!$queue->agent_group_id) {
error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications");
return;
}
error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}");
// Get members of the assigned agent group
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
foreach ($members as $member) {
$fcm->notify_incoming_call($member->user_id, $caller_number, $queue->queue_name, '');
if (!in_array($member->user_id, $notified_users)) {
$fcm->notify_queue_alert($member->user_id, $caller_number, $queue->queue_name, $call_sid);
$notified_users[] = $member->user_id;
}
}
if (empty($members)) {

View File

@@ -279,22 +279,68 @@ class TWP_FCM {
}
/**
* Send incoming call notification
* Send queue alert notification (call entered queue).
* Uses data-only message so it works in background/killed state.
*/
public function notify_incoming_call($user_id, $from_number, $queue_name, $call_sid) {
$title = 'Incoming Call';
$body = "Call from $from_number in $queue_name queue";
public function notify_queue_alert($user_id, $from_number, $queue_name, $call_sid) {
$title = 'Call Waiting';
$body = "Call from $from_number in $queue_name";
$data = array(
'type' => 'incoming_call',
'type' => 'queue_alert',
'call_sid' => $call_sid,
'from_number' => $from_number,
'queue_name' => $queue_name
'queue_name' => $queue_name,
);
return $this->send_notification($user_id, $title, $body, $data, true);
}
/**
* Cancel queue alert notification (call answered or caller disconnected).
*/
public function notify_queue_alert_cancel($user_id, $call_sid) {
$data = array(
'type' => 'queue_alert_cancel',
'call_sid' => $call_sid,
);
return $this->send_notification($user_id, '', '', $data, true);
}
/**
* Send queue alert cancel to all agents assigned to a queue.
*/
public function cancel_queue_alert_for_queue($queue_id, $call_sid) {
global $wpdb;
$queue_table = $wpdb->prefix . 'twp_call_queues';
$queue = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $queue_table WHERE id = %d", $queue_id
));
if (!$queue) return;
$notified_users = array();
// Notify personal queue owner
if (!empty($queue->user_id)) {
$this->notify_queue_alert_cancel($queue->user_id, $call_sid);
$notified_users[] = $queue->user_id;
}
// Notify agent group members
if (!empty($queue->agent_group_id)) {
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
foreach ($members as $member) {
if (!in_array($member->user_id, $notified_users)) {
$this->notify_queue_alert_cancel($member->user_id, $call_sid);
$notified_users[] = $member->user_id;
}
}
}
}
/**
* Send queue timeout notification
*/

View File

@@ -113,6 +113,20 @@ class TWP_Mobile_API {
'callback' => array($this, 'get_phone_numbers'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Outbound call (click-to-call via server)
register_rest_route('twilio-mobile/v1', '/calls/outbound', array(
'methods' => 'POST',
'callback' => array($this, 'initiate_outbound_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
// FCM push credential setup (admin only)
register_rest_route('twilio-mobile/v1', '/admin/push-credential', array(
'methods' => 'POST',
'callback' => array($this, 'setup_push_credential'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
@@ -297,12 +311,9 @@ class TWP_Mobile_API {
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
// Get agent phone number
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
if (empty($agent_number)) {
return new WP_Error('no_phone', 'No phone number configured for agent', array('status' => 400));
}
// Check for WebRTC client_identity parameter
$body = $request->get_json_params();
$client_identity = isset($body['client_identity']) ? sanitize_text_field($body['client_identity']) : null;
// Initialize Twilio API
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
@@ -327,46 +338,120 @@ class TWP_Mobile_API {
}
try {
// Connect agent to call
$agent_call = $twilio->create_call(
$agent_number,
$call->to_number,
array(
'url' => site_url('/wp-json/twilio-webhook/v1/connect-agent'),
'statusCallback' => site_url('/wp-json/twilio-webhook/v1/agent-call-status'),
'statusCallbackEvent' => array('completed', 'no-answer', 'busy', 'failed'),
'timeout' => 30
)
);
if (!empty($client_identity)) {
// WebRTC path: redirect the queued call to the Twilio Client device
// Use the original caller's number as caller ID so it shows on the agent's device
$caller_id = $call->from_number;
if (empty($caller_id)) {
$caller_id = $call->to_number;
}
if (empty($caller_id)) {
$caller_id = get_option('twp_caller_id_number', '');
}
// Update call record
$wpdb->update(
$calls_table,
array(
'status' => 'connecting',
'agent_phone' => $agent_number,
$twiml = '<Response><Dial callerId="' . htmlspecialchars($caller_id) . '"><Client>' . htmlspecialchars($client_identity) . '</Client></Dial></Response>';
error_log('TWP accept_call: call_sid=' . $call_sid . ' client=' . $client_identity . ' twiml=' . $twiml);
$result = $twilio->update_call($call_sid, array('twiml' => $twiml));
error_log('TWP accept_call result: ' . json_encode($result));
if (!$result['success']) {
return new WP_Error('twilio_error', $result['error'] ?? 'Failed to update call', array('status' => 500));
}
// Update call record
$wpdb->update(
$calls_table,
array(
'status' => 'connecting',
'agent_phone' => 'client:' . $client_identity,
),
array('call_sid' => $call_sid),
array('%s', '%s'),
array('%s')
);
// Save current status before setting busy, so we can revert after call ends
$status_table = $wpdb->prefix . 'twp_agent_status';
$current = $wpdb->get_row($wpdb->prepare(
"SELECT status FROM $status_table WHERE user_id = %d", $user_id
));
$pre_call_status = ($current && $current->status !== 'busy') ? $current->status : null;
$wpdb->update(
$status_table,
array(
'status' => 'busy',
'current_call_sid' => $call_sid,
'pre_call_status' => $pre_call_status,
'auto_busy_at' => null,
),
array('user_id' => $user_id),
array('%s', '%s', '%s', '%s'),
array('%d')
);
// Cancel queue alert notifications on all agents' devices
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-fcm.php';
$fcm = new TWP_FCM();
$fcm->cancel_queue_alert_for_queue($call->queue_id, $call_sid);
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call accepted via WebRTC client',
'call_sid' => $call_sid
), 200);
} else {
// Phone-based path (original flow): dial the agent's phone number
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
if (empty($agent_number)) {
return new WP_Error('no_phone', 'No phone number configured for agent and no client_identity provided', array('status' => 400));
}
// Connect agent to call
$agent_call = $twilio->create_call(
$agent_number,
$call->to_number,
array(
'url' => site_url('/wp-json/twilio-webhook/v1/connect-agent'),
'statusCallback' => site_url('/wp-json/twilio-webhook/v1/agent-call-status'),
'statusCallbackEvent' => array('completed', 'no-answer', 'busy', 'failed'),
'timeout' => 30
)
);
// Update call record
$wpdb->update(
$calls_table,
array(
'status' => 'connecting',
'agent_phone' => $agent_number,
'agent_call_sid' => $agent_call->sid
),
array('call_sid' => $call_sid),
array('%s', '%s', '%s'),
array('%s')
);
// Update agent status
$status_table = $wpdb->prefix . 'twp_agent_status';
$wpdb->update(
$status_table,
array('status' => 'busy', 'current_call_sid' => $call_sid),
array('user_id' => $user_id),
array('%s', '%s'),
array('%d')
);
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call accepted, connecting to agent',
'agent_call_sid' => $agent_call->sid
),
array('call_sid' => $call_sid),
array('%s', '%s', '%s'),
array('%s')
);
// Update agent status
$status_table = $wpdb->prefix . 'twp_agent_status';
$wpdb->update(
$status_table,
array('status' => 'busy', 'current_call_sid' => $call_sid),
array('user_id' => $user_id),
array('%s', '%s'),
array('%d')
);
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call accepted, connecting to agent',
'agent_call_sid' => $agent_call->sid
), 200);
), 200);
}
} catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
@@ -690,11 +775,35 @@ class TWP_Mobile_API {
return new WP_Error('token_error', 'Twilio credentials not configured', array('status' => 500));
}
// AccessToken for mobile Voice SDK (not ClientToken which is browser-only)
$token = new \Twilio\Jwt\AccessToken($account_sid, $account_sid, $auth_token, 3600, $identity);
// AccessToken requires an API Key (not account credentials).
// Auto-create and cache one if it doesn't exist yet.
$api_key_sid = get_option('twp_twilio_api_key_sid');
$api_key_secret = get_option('twp_twilio_api_key_secret');
if (empty($api_key_sid) || empty($api_key_secret)) {
$client = new \Twilio\Rest\Client($account_sid, $auth_token);
$newKey = $client->newKeys->create(['friendlyName' => 'TWP Mobile Voice']);
$api_key_sid = $newKey->sid;
$api_key_secret = $newKey->secret;
update_option('twp_twilio_api_key_sid', $api_key_sid);
update_option('twp_twilio_api_key_secret', $api_key_secret);
}
$token = new \Twilio\Jwt\AccessToken($account_sid, $api_key_sid, $api_key_secret, 3600, $identity);
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
$voiceGrant->setIncomingAllow(true);
// Include FCM push credential for incoming call notifications.
// Auto-create from the stored Firebase service account JSON if not yet created.
$push_credential_sid = get_option('twp_twilio_push_credential_sid');
if (empty($push_credential_sid)) {
$push_credential_sid = $this->ensure_push_credential($account_sid, $auth_token);
}
if (!empty($push_credential_sid)) {
$voiceGrant->setPushCredentialSid($push_credential_sid);
}
$token->addGrant($voiceGrant);
return new WP_REST_Response(array(
@@ -766,6 +875,79 @@ class TWP_Mobile_API {
return (bool)$is_assigned;
}
/**
* Admin endpoint to force re-creation of the Twilio Push Credential.
*/
public function setup_push_credential($request) {
$user_id = $this->auth->get_current_user_id();
$user = get_userdata($user_id);
if (!user_can($user, 'manage_options')) {
return new WP_Error('forbidden', 'Admin access required', array('status' => 403));
}
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
new TWP_Twilio_API();
$account_sid = get_option('twp_twilio_account_sid');
$auth_token = get_option('twp_twilio_auth_token');
// Force re-creation by clearing existing SID
delete_option('twp_twilio_push_credential_sid');
$sid = $this->ensure_push_credential($account_sid, $auth_token);
if (empty($sid)) {
return new WP_Error('credential_error', 'Failed to create push credential. Check that Firebase service account JSON is configured in Mobile App Settings.', array('status' => 500));
}
return new WP_REST_Response(array(
'success' => true,
'credential_sid' => $sid,
), 200);
} catch (Exception $e) {
error_log('TWP setup_push_credential error: ' . $e->getMessage());
return new WP_Error('credential_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Auto-create Twilio Push Credential from the stored Firebase service account JSON.
* Returns the credential SID or empty string on failure.
*/
private function ensure_push_credential($account_sid, $auth_token) {
$sa_json = get_option('twp_fcm_service_account_json', '');
if (empty($sa_json)) {
return '';
}
$sa = json_decode($sa_json, true);
if (!$sa || empty($sa['project_id']) || empty($sa['private_key'])) {
error_log('TWP: Firebase service account JSON is invalid');
return '';
}
try {
$client = new \Twilio\Rest\Client($account_sid, $auth_token);
$credential = $client->notify->v1->credentials->create(
'fcm',
[
'friendlyName' => 'TWP Mobile FCM',
'secret' => $sa_json,
]
);
update_option('twp_twilio_push_credential_sid', $credential->sid);
error_log('TWP: Created Twilio push credential: ' . $credential->sid);
return $credential->sid;
} catch (Exception $e) {
error_log('TWP ensure_push_credential error: ' . $e->getMessage());
return '';
}
}
/**
* Calculate wait time in seconds
*/

View File

@@ -423,13 +423,21 @@ class TWP_Mobile_Auth {
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
$wpdb->update(
$table,
array('fcm_token' => $fcm_token),
array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1),
array('%s'),
array('%d', '%s', '%d')
);
if (!empty($refresh_token)) {
$wpdb->update(
$table,
array('fcm_token' => $fcm_token),
array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1),
array('%s'),
array('%d', '%s', '%d')
);
} else {
// No refresh token — update the most recent active session for this user
$wpdb->query($wpdb->prepare(
"UPDATE $table SET fcm_token = %s WHERE user_id = %d AND is_active = 1 AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
$fcm_token, $user_id
));
}
}
/**

View File

@@ -26,14 +26,32 @@ class TWP_Mobile_SSE {
'callback' => array($this, 'stream_events'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/stream/poll', array(
'methods' => 'GET',
'callback' => array($this, 'poll_state'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
/**
* Return current state as JSON (polling alternative to SSE)
*/
public function poll_state($request) {
$user_id = $this->auth->get_current_user_id();
if (!$user_id) {
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
}
return rest_ensure_response($this->get_current_state($user_id));
}
/**
* Stream events to mobile app
*/
public function stream_events($request) {
error_log('TWP SSE: stream_events called');
$user_id = $this->auth->get_current_user_id();
error_log('TWP SSE: user_id=' . ($user_id ?: 'false'));
if (!$user_id) {
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
@@ -56,6 +74,15 @@ class TWP_Mobile_SSE {
ob_end_flush();
}
// Flush padding to overcome Apache/HTTP2 frame buffering.
// SSE comments (lines starting with ':') are ignored by clients.
// We send >4KB to ensure the first HTTP/2 DATA frame is flushed.
echo ':' . str_repeat(' ', 4096) . "\n\n";
if (ob_get_level() > 0) ob_flush();
flush();
error_log('TWP SSE: padding flushed, sending connected event');
// Send initial connection event
$this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time()));

View File

@@ -329,7 +329,13 @@ class TWP_Webhooks {
*/
public function handle_browser_voice($request) {
$params = $request->get_params();
error_log('TWP browser-voice webhook params: ' . json_encode(array(
'From' => $params['From'] ?? '',
'To' => $params['To'] ?? '',
'CallerId' => $params['CallerId'] ?? '',
'CallSid' => $params['CallSid'] ?? '',
)));
$call_data = array(
'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
'From' => isset($params['From']) ? $params['From'] : '',
@@ -371,23 +377,45 @@ class TWP_Webhooks {
if (isset($params['To']) && !empty($params['To'])) {
$to_number = $params['To'];
// Mobile SDK sends CallerId via extraOptions; browser sends From as phone number
// Mobile SDK sends From as identity (e.g. "agent2jknapp"), browser sends From as phone number
// Only use CallerId/From if it looks like a phone number (starts with + or is all digits)
$from_number = '';
if (!empty($params['CallerId']) && strpos($params['CallerId'], 'client:') !== 0) {
if (!empty($params['CallerId']) && preg_match('/^\+?\d+$/', $params['CallerId'])) {
$from_number = $params['CallerId'];
} elseif (!empty($params['From']) && strpos($params['From'], 'client:') !== 0) {
} elseif (!empty($params['From']) && preg_match('/^\+?\d+$/', $params['From'])) {
$from_number = $params['From'];
}
// Fall back to default caller ID if no valid one provided
if (empty($from_number)) {
$from_number = get_option('twp_caller_id_number', '');
}
if (empty($from_number)) {
$from_number = get_option('twp_default_sms_number', '');
}
// Last resort: fetch first Twilio number from API
if (empty($from_number)) {
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$twilio = new TWP_Twilio_API();
$numbers = $twilio->get_phone_numbers();
if (!empty($numbers['data']['incoming_phone_numbers'][0]['phone_number'])) {
$from_number = $numbers['data']['incoming_phone_numbers'][0]['phone_number'];
}
} catch (\Exception $e) {
error_log('TWP browser-voice: failed to fetch default number: ' . $e->getMessage());
}
}
// If it's an outgoing call to a phone number
if (strpos($to_number, 'client:') !== 0) {
$twiml .= '<Dial timeout="30"';
// Add caller ID if provided
if (!empty($from_number) && strpos($from_number, 'client:') !== 0) {
// Add caller ID (required for outbound calls to phone numbers)
if (!empty($from_number)) {
$twiml .= ' callerId="' . htmlspecialchars($from_number) . '"';
}
$twiml .= '>';
$twiml .= '<Number>' . htmlspecialchars($to_number) . '</Number>';
$twiml .= '</Dial>';
@@ -400,9 +428,11 @@ class TWP_Webhooks {
} else {
$twiml .= '<Say voice="alice">No destination number provided.</Say>';
}
$twiml .= '</Response>';
error_log('TWP browser-voice TwiML: ' . $twiml);
return $this->send_twiml_response($twiml);
}
@@ -914,11 +944,36 @@ class TWP_Webhooks {
// Update call status in queue if applicable
// Remove from queue for any terminal call state
if (in_array($status_data['CallStatus'], ['completed', 'busy', 'failed', 'canceled', 'no-answer'])) {
// Get queue_id before removing so we can send cancel notifications
global $wpdb;
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$queued_call = $wpdb->get_row($wpdb->prepare(
"SELECT queue_id FROM $calls_table WHERE call_sid = %s",
$status_data['CallSid']
));
$queue_removed = TWP_Call_Queue::remove_from_queue($status_data['CallSid']);
if ($queue_removed) {
TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue due to status: ' . $status_data['CallStatus']);
error_log('TWP Status Webhook: Removed call ' . $status_data['CallSid'] . ' from queue (status: ' . $status_data['CallStatus'] . ')');
// Cancel queue alert notifications on agents' devices
if ($queued_call) {
require_once plugin_dir_path(__FILE__) . 'class-twp-fcm.php';
$fcm = new TWP_FCM();
$fcm->cancel_queue_alert_for_queue($queued_call->queue_id, $status_data['CallSid']);
}
}
// Set auto_busy_at for agents whose call just ended, so they revert after 30s
$agent_status_table = $wpdb->prefix . 'twp_agent_status';
$wpdb->query($wpdb->prepare(
"UPDATE $agent_status_table
SET auto_busy_at = %s, current_call_sid = NULL
WHERE current_call_sid = %s AND status = 'busy'",
current_time('mysql'),
$status_data['CallSid']
));
}
// Empty response