Files
twilio-wp-plugin/includes/class-twp-mobile-api.php

685 lines
24 KiB
PHP
Raw Permalink Normal View History

Add mobile app infrastructure and Gitea auto-update support This commit adds comprehensive mobile app support to enable a native Android app that won't timeout or sleep when the screen goes dark. New Features: - JWT-based authentication system (no WordPress session dependency) - REST API endpoints for mobile app (agent status, queue management, call control) - Server-Sent Events (SSE) for real-time updates to mobile app - Firebase Cloud Messaging (FCM) integration for push notifications - Gitea-based automatic plugin updates - Mobile app admin settings page New Files: - includes/class-twp-mobile-auth.php - JWT authentication with login/refresh/logout - includes/class-twp-mobile-api.php - REST API endpoints under /twilio-mobile/v1 - includes/class-twp-mobile-sse.php - Real-time event streaming - includes/class-twp-fcm.php - Push notification handling - includes/class-twp-auto-updater.php - Gitea-based auto-updates - admin/mobile-app-settings.php - Admin configuration page Modified Files: - includes/class-twp-activator.php - Added twp_mobile_sessions table - includes/class-twp-core.php - Load and initialize mobile classes - admin/class-twp-admin.php - Added Mobile App menu item and settings page Database Changes: - New table: twp_mobile_sessions (stores JWT refresh tokens and FCM tokens) API Endpoints: - POST /twilio-mobile/v1/auth/login - POST /twilio-mobile/v1/auth/refresh - POST /twilio-mobile/v1/auth/logout - GET/POST /twilio-mobile/v1/agent/status - GET /twilio-mobile/v1/queues/state - POST /twilio-mobile/v1/calls/{call_sid}/accept - GET /twilio-mobile/v1/stream/events (SSE) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 15:43:14 -08:00
<?php
/**
* Mobile App REST API Endpoints
*
* Provides REST API endpoints for mobile app functionality
*/
class TWP_Mobile_API {
private $auth;
/**
* Constructor
*/
public function __construct() {
// Initialize auth handler
require_once plugin_dir_path(__FILE__) . 'class-twp-mobile-auth.php';
$this->auth = new TWP_Mobile_Auth();
}
/**
* Register REST API endpoints
*/
public function register_endpoints() {
add_action('rest_api_init', function() {
// Agent status endpoints
register_rest_route('twilio-mobile/v1', '/agent/status', array(
'methods' => 'GET',
'callback' => array($this, 'get_agent_status'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/agent/status', array(
'methods' => 'POST',
'callback' => array($this, 'update_agent_status'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Queue state endpoint
register_rest_route('twilio-mobile/v1', '/queues/state', array(
'methods' => 'GET',
'callback' => array($this, 'get_queue_state'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Queue calls (specific queue)
register_rest_route('twilio-mobile/v1', '/queues/(?P<id>\d+)/calls', array(
'methods' => 'GET',
'callback' => array($this, 'get_queue_calls'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Call control endpoints
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/accept', array(
'methods' => 'POST',
'callback' => array($this, 'accept_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/reject', array(
'methods' => 'POST',
'callback' => array($this, 'reject_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/hold', array(
'methods' => 'POST',
'callback' => array($this, 'hold_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/unhold', array(
'methods' => 'POST',
'callback' => array($this, 'unhold_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/transfer', array(
'methods' => 'POST',
'callback' => array($this, 'transfer_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
// FCM token registration
register_rest_route('twilio-mobile/v1', '/fcm/register', array(
'methods' => 'POST',
'callback' => array($this, 'register_fcm_token'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Agent phone number
register_rest_route('twilio-mobile/v1', '/agent/phone', array(
'methods' => 'GET',
'callback' => array($this, 'get_agent_phone'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/agent/phone', array(
'methods' => 'POST',
'callback' => array($this, 'update_agent_phone'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
/**
* Get agent status
*/
public function get_agent_status($request) {
$user_id = $this->auth->get_current_user_id();
global $wpdb;
$table = $wpdb->prefix . 'twp_agent_status';
$status = $wpdb->get_row($wpdb->prepare(
"SELECT status, is_logged_in, current_call_sid, last_activity, available_for_queues FROM $table WHERE user_id = %d",
$user_id
));
if (!$status) {
// Create default status
$wpdb->insert(
$table,
array('user_id' => $user_id, 'status' => 'offline', 'is_logged_in' => 0),
array('%d', '%s', '%d')
);
$status = (object) array(
'status' => 'offline',
'is_logged_in' => 0,
'current_call_sid' => null,
'last_activity' => current_time('mysql'),
'available_for_queues' => 1
);
}
return new WP_REST_Response(array(
'success' => true,
'status' => $status->status,
'is_logged_in' => (bool)$status->is_logged_in,
'current_call_sid' => $status->current_call_sid,
'last_activity' => $status->last_activity,
'available_for_queues' => (bool)$status->available_for_queues
), 200);
}
/**
* Update agent status
*/
public function update_agent_status($request) {
$user_id = $this->auth->get_current_user_id();
$new_status = $request->get_param('status');
$is_logged_in = $request->get_param('is_logged_in');
if (!in_array($new_status, array('available', 'busy', 'offline'))) {
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
}
global $wpdb;
$table = $wpdb->prefix . 'twp_agent_status';
// Check if status exists
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table WHERE user_id = %d",
$user_id
));
$data = array(
'status' => $new_status,
'last_activity' => current_time('mysql')
);
if ($is_logged_in !== null) {
$data['is_logged_in'] = $is_logged_in ? 1 : 0;
if ($is_logged_in) {
$data['logged_in_at'] = current_time('mysql');
}
}
if ($exists) {
$wpdb->update(
$table,
$data,
array('user_id' => $user_id),
array('%s', '%s'),
array('%d')
);
} else {
$data['user_id'] = $user_id;
$wpdb->insert($table, $data);
}
return new WP_REST_Response(array(
'success' => true,
'message' => 'Status updated successfully'
), 200);
}
/**
* Get queue state (all queues user has access to)
*/
public function get_queue_state($request) {
$user_id = $this->auth->get_current_user_id();
global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
// Get queues assigned to this user
$queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
$user_id
));
// Also include personal queues
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM $queues_table WHERE user_id = %d",
$user_id
));
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
if (empty($all_queue_ids)) {
return new WP_REST_Response(array(
'success' => true,
'queues' => array()
), 200);
}
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
// Get queue information with call counts
$queues = $wpdb->get_results("
SELECT
q.id,
q.queue_name,
q.queue_type,
q.extension,
COUNT(c.id) as waiting_count
FROM $queues_table q
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
WHERE q.id IN ($queue_ids_str)
GROUP BY q.id
");
$result = array();
foreach ($queues as $queue) {
$result[] = array(
'id' => (int)$queue->id,
'name' => $queue->queue_name,
'type' => $queue->queue_type,
'extension' => $queue->extension,
'waiting_count' => (int)$queue->waiting_count
);
}
return new WP_REST_Response(array(
'success' => true,
'queues' => $result
), 200);
}
/**
* Get calls in a specific queue
*/
public function get_queue_calls($request) {
$user_id = $this->auth->get_current_user_id();
$queue_id = (int)$request['id'];
// Verify user has access to this queue
if (!$this->user_has_queue_access($user_id, $queue_id)) {
return new WP_Error('forbidden', 'You do not have access to this queue', array('status' => 403));
}
global $wpdb;
$table = $wpdb->prefix . 'twp_queued_calls';
$calls = $wpdb->get_results($wpdb->prepare(
"SELECT call_sid, from_number, to_number, position, status, joined_at, enqueued_at
FROM $table
WHERE queue_id = %d AND status = 'waiting'
ORDER BY position ASC",
$queue_id
));
$result = array();
foreach ($calls as $call) {
$result[] = array(
'call_sid' => $call->call_sid,
'from_number' => $call->from_number,
'to_number' => $call->to_number,
'position' => (int)$call->position,
'status' => $call->status,
'wait_time' => $this->calculate_wait_time($call->enqueued_at ?: $call->joined_at)
);
}
return new WP_REST_Response(array(
'success' => true,
'calls' => $result
), 200);
}
/**
* Accept a call (dequeue and connect to agent)
*/
public function accept_call($request) {
$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));
}
// Initialize Twilio API
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$twilio = new TWP_Twilio_API();
// Get call info from queue
global $wpdb;
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$call = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $calls_table WHERE call_sid = %s AND status = 'waiting'",
$call_sid
));
if (!$call) {
return new WP_Error('call_not_found', 'Call not found or no longer waiting', array('status' => 404));
}
// Verify user has access to this queue
if (!$this->user_has_queue_access($user_id, $call->queue_id)) {
return new WP_Error('forbidden', 'You do not have access to this queue', array('status' => 403));
}
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
)
);
// 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
), 200);
} catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Reject a call (send to voicemail)
*/
public function reject_call($request) {
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
global $wpdb;
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$call = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $calls_table WHERE call_sid = %s AND status = 'waiting'",
$call_sid
));
if (!$call) {
return new WP_Error('call_not_found', 'Call not found or no longer waiting', array('status' => 404));
}
// Verify user has access to this queue
if (!$this->user_has_queue_access($user_id, $call->queue_id)) {
return new WP_Error('forbidden', 'You do not have access to this queue', array('status' => 403));
}
try {
// Initialize Twilio API
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$twilio = new TWP_Twilio_API();
// Redirect call to voicemail
$twiml = new \Twilio\TwiML\VoiceResponse();
$twiml->say('The agent is unavailable. Please leave a message after the tone.');
$twiml->record(array(
'action' => site_url('/wp-json/twilio-webhook/v1/voicemail-complete'),
'maxLength' => 120,
'transcribe' => true
));
$twiml->say('We did not receive a recording. Goodbye.');
$twilio->update_call($call_sid, array('twiml' => $twiml->asXML()));
// Update call status
$wpdb->update(
$calls_table,
array('status' => 'voicemail', 'ended_at' => current_time('mysql')),
array('call_sid' => $call_sid),
array('%s', '%s'),
array('%s')
);
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call sent to voicemail'
), 200);
} catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Hold a call
*/
public function hold_call($request) {
$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));
}
// Get user's hold queue
global $wpdb;
$ext_table = $wpdb->prefix . 'twp_user_extensions';
$queues_table = $wpdb->prefix . 'twp_call_queues';
$extension = $wpdb->get_row($wpdb->prepare(
"SELECT hold_queue_id FROM $ext_table WHERE user_id = %d",
$user_id
));
if (!$extension || !$extension->hold_queue_id) {
return new WP_Error('no_hold_queue', 'No hold queue configured', array('status' => 400));
}
$hold_queue = $wpdb->get_row($wpdb->prepare(
"SELECT queue_name, wait_music_url FROM $queues_table WHERE id = %d",
$extension->hold_queue_id
));
// Put call on hold
$twiml = new \Twilio\TwiML\VoiceResponse();
$twiml->say('Please hold while we transfer your call.');
$enqueue = $twiml->enqueue($hold_queue->queue_name, array(
'waitUrl' => $hold_queue->wait_music_url ?: site_url('/wp-json/twilio-webhook/v1/queue-wait')
));
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call placed on hold'
), 200);
} catch (Exception $e) {
return new WP_Error('hold_error', $e->getMessage(), array('status' => 500));
}
}
/**
* 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);
}
/**
* Transfer a call to another extension/queue
*/
public function transfer_call($request) {
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
$target = $request->get_param('target'); // Extension number or queue ID
if (empty($target)) {
return new WP_Error('missing_target', 'Transfer target is required', array('status' => 400));
}
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));
}
// Look up target (extension or queue)
global $wpdb;
$ext_table = $wpdb->prefix . 'twp_user_extensions';
$queues_table = $wpdb->prefix . 'twp_call_queues';
// Try as extension first
$target_queue = $wpdb->get_row($wpdb->prepare(
"SELECT q.* FROM $queues_table q
JOIN $ext_table e ON q.id = e.personal_queue_id
WHERE e.extension = %s",
$target
));
// If not extension, try as queue ID
if (!$target_queue && is_numeric($target)) {
$target_queue = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $queues_table WHERE id = %d",
$target
));
}
if (!$target_queue) {
return new WP_Error('invalid_target', 'Transfer target not found', array('status' => 404));
}
// Transfer to queue
$twiml = new \Twilio\TwiML\VoiceResponse();
$twiml->say('Transferring your call.');
$twiml->enqueue($target_queue->queue_name, array(
'waitUrl' => $target_queue->wait_music_url ?: site_url('/wp-json/twilio-webhook/v1/queue-wait')
));
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call transferred successfully'
), 200);
} catch (Exception $e) {
return new WP_Error('transfer_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Register FCM token for push notifications
*/
public function register_fcm_token($request) {
$user_id = $this->auth->get_current_user_id();
$fcm_token = $request->get_param('fcm_token');
$refresh_token = $request->get_param('refresh_token');
if (empty($fcm_token)) {
return new WP_Error('missing_token', 'FCM token is required', array('status' => 400));
}
$this->auth->update_fcm_token($user_id, $refresh_token, $fcm_token);
return new WP_REST_Response(array(
'success' => true,
'message' => 'FCM token registered successfully'
), 200);
}
/**
* Get agent phone number
*/
public function get_agent_phone($request) {
$user_id = $this->auth->get_current_user_id();
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
return new WP_REST_Response(array(
'success' => true,
'phone_number' => $agent_number ?: null
), 200);
}
/**
* Update agent phone number
*/
public function update_agent_phone($request) {
$user_id = $this->auth->get_current_user_id();
$phone_number = $request->get_param('phone_number');
if (empty($phone_number)) {
return new WP_Error('missing_phone', 'Phone number is required', array('status' => 400));
}
// Validate E.164 format
if (!preg_match('/^\+[1-9]\d{1,14}$/', $phone_number)) {
return new WP_Error('invalid_phone', 'Phone number must be in E.164 format (+1XXXXXXXXXX)', array('status' => 400));
}
update_user_meta($user_id, 'twp_agent_phone', $phone_number);
return new WP_REST_Response(array(
'success' => true,
'message' => 'Phone number updated successfully'
), 200);
}
/**
* Check if user has access to a queue
*/
private function user_has_queue_access($user_id, $queue_id) {
global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
// Check if it's user's personal queue
$is_personal = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $queues_table WHERE id = %d AND user_id = %d",
$queue_id, $user_id
));
if ($is_personal) {
return true;
}
// Check if user is assigned to this queue
$is_assigned = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $assignments_table WHERE queue_id = %d AND user_id = %d",
$queue_id, $user_id
));
return (bool)$is_assigned;
}
/**
* Calculate wait time in seconds
*/
private function calculate_wait_time($start_time) {
if (!$start_time) {
return 0;
}
$start = strtotime($start_time);
$now = current_time('timestamp');
return max(0, $now - $start);
}
}