Add TWP Softphone Flutter app and complete mobile backend API
All checks were successful
Create Release / build (push) Successful in 4s

Backend: Add /voice/token endpoint with AccessToken + VoiceGrant for
mobile VoIP, implement unhold_call() with call leg detection, wire FCM
push notifications into call queue and webhook missed call handlers,
add data-only FCM message support for Android background wake, and add
Twilio API Key / Push Credential settings fields.

Flutter app: Full softphone with Twilio Voice SDK integration, JWT auth
with auto-refresh, SSE real-time queue updates, FCM push notifications,
Material 3 UI with dashboard, active call screen, dialpad, and call
controls (mute/speaker/hold/transfer).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-06 13:01:23 -08:00
parent 03692608cc
commit 5c6932f1d1
49 changed files with 3243 additions and 28 deletions

View File

@@ -617,7 +617,14 @@ class TWP_Call_Queue {
// 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();
foreach ($members as $member) {
$fcm->notify_incoming_call($member->user_id, $caller_number, $queue->queue_name, '');
}
if (empty($members)) {
error_log("TWP: No members found in agent group {$queue->agent_group_id} for queue {$queue_id}");
return;

View File

@@ -19,7 +19,7 @@ class TWP_FCM {
/**
* Send push notification to user's devices
*/
public function send_notification($user_id, $title, $body, $data = array()) {
public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) {
if (empty($this->server_key)) {
error_log('TWP FCM: Server key not configured');
return false;
@@ -37,7 +37,7 @@ class TWP_FCM {
$failed_tokens = array();
foreach ($tokens as $token) {
$result = $this->send_to_token($token, $title, $body, $data);
$result = $this->send_to_token($token, $title, $body, $data, $data_only);
if ($result['success']) {
$success_count++;
@@ -59,25 +59,37 @@ class TWP_FCM {
/**
* Send notification to specific token
*/
private function send_to_token($token, $title, $body, $data = array()) {
$notification = array(
'title' => $title,
'body' => $body,
'sound' => 'default',
'priority' => 'high',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK'
);
$payload = array(
'to' => $token,
'notification' => $notification,
'data' => array_merge($data, array(
private function send_to_token($token, $title, $body, $data = array(), $data_only = false) {
if ($data_only) {
$payload = array(
'to' => $token,
'data' => array_merge($data, array(
'title' => $title,
'body' => $body,
'timestamp' => time()
)),
'priority' => 'high'
);
} else {
$notification = array(
'title' => $title,
'body' => $body,
'timestamp' => time()
)),
'priority' => 'high'
);
'sound' => 'default',
'priority' => 'high',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK'
);
$payload = array(
'to' => $token,
'notification' => $notification,
'data' => array_merge($data, array(
'title' => $title,
'body' => $body,
'timestamp' => time()
)),
'priority' => 'high'
);
}
$headers = array(
'Authorization: key=' . $this->server_key,
@@ -162,7 +174,7 @@ class TWP_FCM {
'queue_name' => $queue_name
);
return $this->send_notification($user_id, $title, $body, $data);
return $this->send_notification($user_id, $title, $body, $data, true);
}
/**

View File

@@ -99,6 +99,13 @@ class TWP_Mobile_API {
'callback' => array($this, 'update_agent_phone'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Voice token for VoIP
register_rest_route('twilio-mobile/v1', '/voice/token', array(
'methods' => 'GET',
'callback' => array($this, 'get_voice_token'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
@@ -507,11 +514,46 @@ class TWP_Mobile_API {
* Unhold a call (resume from hold queue)
*/
public function unhold_call($request) {
// Implementation would retrieve from hold queue and reconnect
return new WP_REST_Response(array(
'success' => true,
'message' => 'Unhold functionality - to be implemented with queue retrieval'
), 501);
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-admin.php';
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$admin = new TWP_Admin('twilio-wp-plugin', TWP_VERSION);
$twilio = new TWP_Twilio_API();
// Find customer call leg
$customer_call_sid = $admin->find_customer_call_leg($call_sid, $twilio);
if (!$customer_call_sid) {
return new WP_Error('call_not_found', 'Could not find customer call leg', array('status' => 404));
}
// Build identity for this agent
$user = get_userdata($user_id);
$clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
if (empty($clean_name)) {
$clean_name = 'user';
}
$identity = 'agent' . $user_id . $clean_name;
// Redirect customer back to agent's client
$twiml = new \Twilio\TwiML\VoiceResponse();
$dial = $twiml->dial();
$dial->client($identity);
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call resumed from hold'
), 200);
} catch (Exception $e) {
return new WP_Error('unhold_error', $e->getMessage(), array('status' => 500));
}
}
/**
@@ -641,6 +683,55 @@ class TWP_Mobile_API {
), 200);
}
/**
* Get Voice access token for VoIP
*/
public function get_voice_token($request) {
$user_id = $this->auth->get_current_user_id();
$user = get_userdata($user_id);
$identity = 'agent' . $user_id . preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
$account_sid = get_option('twp_twilio_account_sid');
$auth_token = get_option('twp_twilio_auth_token');
$api_key_sid = get_option('twp_twilio_api_key_sid');
$api_key_secret = get_option('twp_twilio_api_key_secret');
$twiml_app_sid = get_option('twp_twiml_app_sid');
$push_credential_sid = get_option('twp_fcm_push_credential_sid');
if (empty($api_key_sid) || empty($api_key_secret)) {
return new WP_Error('missing_api_key', 'Twilio API Key SID and Secret must be configured', array('status' => 500));
}
try {
$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);
if (!empty($push_credential_sid)) {
$voiceGrant->setPushCredentialSid($push_credential_sid);
}
$token->addGrant($voiceGrant);
return new WP_REST_Response(array(
'token' => $token->toJWT(),
'identity' => $identity,
'expires_in' => 3600
), 200);
} catch (Exception $e) {
return new WP_Error('token_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Check if user has access to a queue
*/

View File

@@ -477,8 +477,15 @@ class TWP_Webhooks {
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
}
}
// Send FCM push notifications for missed browser call
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($agents as $agent) {
$fcm->notify_incoming_call($agent->ID, $customer_number, 'Browser Phone', '');
}
}
/**
* Handle smart routing based on user preferences
*/
@@ -704,8 +711,15 @@ class TWP_Webhooks {
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
}
}
// Send FCM push notifications for missed call
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($agents as $agent) {
$fcm->notify_incoming_call($agent->ID, $customer_number, 'General', '');
}
}
/**
* Verify Twilio signature
*/