Add TWP Softphone Flutter app and complete mobile backend API
All checks were successful
Create Release / build (push) Successful in 4s
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user