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

@@ -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
*/