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
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user