From 86dd477d4f79123c7586992fd6c16fe7c7370eb0 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 1 Dec 2025 15:43:14 -0800 Subject: [PATCH] Add mobile app infrastructure and Gitea auto-update support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/class-twp-admin.php | 20 +- admin/mobile-app-settings.php | 353 ++++++++++++++ includes/class-twp-activator.php | 26 +- includes/class-twp-auto-updater.php | 291 ++++++++++++ includes/class-twp-core.php | 23 +- includes/class-twp-fcm.php | 214 +++++++++ includes/class-twp-mobile-api.php | 684 ++++++++++++++++++++++++++++ includes/class-twp-mobile-auth.php | 457 +++++++++++++++++++ includes/class-twp-mobile-sse.php | 308 +++++++++++++ 9 files changed, 2370 insertions(+), 6 deletions(-) create mode 100644 admin/mobile-app-settings.php create mode 100644 includes/class-twp-auto-updater.php create mode 100644 includes/class-twp-fcm.php create mode 100644 includes/class-twp-mobile-api.php create mode 100644 includes/class-twp-mobile-auth.php create mode 100644 includes/class-twp-mobile-sse.php diff --git a/admin/class-twp-admin.php b/admin/class-twp-admin.php index ae7eead..f80e6c9 100644 --- a/admin/class-twp-admin.php +++ b/admin/class-twp-admin.php @@ -119,7 +119,16 @@ class TWP_Admin { 'twilio-wp-settings', array($this, 'display_plugin_settings') ); - + + add_submenu_page( + 'twilio-wp-plugin', + 'Mobile App', + 'Mobile App', + 'manage_options', + 'twilio-wp-mobile-app', + array($this, 'display_mobile_app_settings') + ); + add_submenu_page( 'twilio-wp-plugin', 'Phone Schedules', @@ -10049,5 +10058,12 @@ class TWP_Admin { wp_send_json_error('Failed to accept transfer: ' . $e->getMessage()); } } - + + /** + * Display mobile app settings page + */ + public function display_mobile_app_settings() { + require_once TWP_PLUGIN_DIR . 'admin/mobile-app-settings.php'; + } + } \ No newline at end of file diff --git a/admin/mobile-app-settings.php b/admin/mobile-app-settings.php new file mode 100644 index 0000000..94c2d47 --- /dev/null +++ b/admin/mobile-app-settings.php @@ -0,0 +1,353 @@ +manual_check_for_updates(); +} + +// Handle test notification +if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) { + require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php'; + $fcm = new TWP_FCM(); + $test_user_id = get_current_user_id(); + $notification_sent = $fcm->send_test_notification($test_user_id); + + if ($notification_sent) { + $notification_result = array('success' => true, 'message' => 'Test notification sent successfully!'); + } else { + $notification_result = array('success' => false, 'message' => 'Failed to send test notification. Check FCM configuration.'); + } +} + +// Save settings +if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_settings')) { + update_option('twp_fcm_server_key', sanitize_text_field($_POST['twp_fcm_server_key'])); + update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0'); + update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo'])); + update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token'])); + + $settings_saved = true; +} + +// Get current settings +$fcm_server_key = get_option('twp_fcm_server_key', ''); +$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1'; +$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin'); +$gitea_token = get_option('twp_gitea_token', ''); + +// Get update status +require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php'; +$updater = new TWP_Auto_Updater(); +$update_status = $updater->get_update_status(); + +// Get mobile app statistics +global $wpdb; +$sessions_table = $wpdb->prefix . 'twp_mobile_sessions'; +$active_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table WHERE is_active = 1 AND expires_at > NOW()"); +$total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table"); + +?> + +
+

+ + +
+

Settings saved successfully!

+
+ + + +
+

+
+ + + +
+

+
+ + +
+ +
+

Mobile App Overview

+ + + + + + + + + + + + + + + +
API Endpoint:
Active Sessions: active / total
Plugin Version:
+
+ + +
+ + + +
+

Firebase Cloud Messaging (FCM)

+

Configure FCM to enable push notifications for the mobile app.

+ + + + + + +
+ + + +

+ Get your server key from Firebase Console > Project Settings > Cloud Messaging > Server Key +

+
+ + +

+ + Send a test notification to your devices +

+ +
+ + +
+

Automatic Updates

+ + + + + + + + + + + + + + + + + + + + + + +
Current Version + + + + ⚠ Update available: + + + ✓ Up to date + +
+ + + +
+ + + +

+ Format: organization/repository (e.g., wp-plugins/twilio-wp-plugin) +

+
+ + + +

+ Optional. Required only for private repositories. Create token at: + Gitea Settings > Applications +

+
Last Update Check + 0) { + echo esc_html(human_time_diff($last_check, current_time('timestamp')) . ' ago'); + } else { + echo 'Never'; + } + ?> + +
+
+ + +
+

API Endpoints

+

Available REST API endpoints for mobile app development:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointMethodDescription
/twilio-mobile/v1/auth/loginPOSTAuthenticate and get JWT tokens
/twilio-mobile/v1/auth/refreshPOSTRefresh access token
/twilio-mobile/v1/agent/statusGET/POSTGet or update agent status
/twilio-mobile/v1/queues/stateGETGet all queue states
/twilio-mobile/v1/calls/{call_sid}/acceptPOSTAccept a queued call
/twilio-mobile/v1/stream/eventsGETServer-Sent Events stream for real-time updates
+ +

+ Authentication: All endpoints (except login/refresh) require + Authorization: Bearer <access_token> header. +

+
+ +

+ +

+
+ + + 0): ?> +
+

Active Mobile Sessions

+ get_results(" + SELECT s.user_id, s.device_info, s.logged_in_at, s.last_used, u.user_login, u.display_name + FROM $sessions_table s + JOIN {$wpdb->users} u ON s.user_id = u.ID + WHERE s.is_active = 1 AND s.expires_at > NOW() + ORDER BY s.last_used DESC + LIMIT 20 + "); + ?> + + + + + + + + + + + + + + + + + +
UserDeviceLast Activity
display_name ?: $session->user_login); ?>device_info ?: 'Unknown device'); ?>last_used), current_time('timestamp')) . ' ago'); ?>
+
+ +
+
+ + diff --git a/includes/class-twp-activator.php b/includes/class-twp-activator.php index 9158eb9..0152161 100644 --- a/includes/class-twp-activator.php +++ b/includes/class-twp-activator.php @@ -52,7 +52,8 @@ class TWP_Activator { 'twp_callbacks', 'twp_call_recordings', 'twp_user_extensions', - 'twp_queue_assignments' + 'twp_queue_assignments', + 'twp_mobile_sessions' ); $missing_tables = array(); @@ -361,7 +362,25 @@ class TWP_Activator { KEY agent_id (agent_id), KEY started_at (started_at) ) $charset_collate;"; - + + // Mobile sessions table + $table_mobile_sessions = $wpdb->prefix . 'twp_mobile_sessions'; + $sql_mobile_sessions = "CREATE TABLE $table_mobile_sessions ( + id int(11) NOT NULL AUTO_INCREMENT, + user_id bigint(20) NOT NULL, + refresh_token varchar(500) NOT NULL, + fcm_token text, + device_info text, + created_at datetime DEFAULT CURRENT_TIMESTAMP, + expires_at datetime NOT NULL, + last_used datetime DEFAULT CURRENT_TIMESTAMP, + is_active tinyint(1) DEFAULT 1, + PRIMARY KEY (id), + KEY user_id (user_id), + KEY is_active (is_active), + KEY expires_at (expires_at) + ) $charset_collate;"; + dbDelta($sql_schedules); dbDelta($sql_queues); dbDelta($sql_queued_calls); @@ -377,7 +396,8 @@ class TWP_Activator { dbDelta($sql_recordings); dbDelta($sql_user_extensions); dbDelta($sql_queue_assignments); - + dbDelta($sql_mobile_sessions); + // Add missing columns for existing installations self::add_missing_columns(); } diff --git a/includes/class-twp-auto-updater.php b/includes/class-twp-auto-updater.php new file mode 100644 index 0000000..2d462d9 --- /dev/null +++ b/includes/class-twp-auto-updater.php @@ -0,0 +1,291 @@ +plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php'); + $this->current_version = defined('TWP_VERSION') ? TWP_VERSION : '0.0.0'; + $this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest'; + } + + /** + * Initialize updater hooks + */ + public function init() { + // Hook into WordPress update checks + add_filter('pre_set_site_transient_update_plugins', array($this, 'check_for_update')); + add_filter('plugins_api', array($this, 'plugin_info'), 10, 3); + + // Add settings page for manual check + add_action('admin_init', array($this, 'register_settings')); + + // Add update check to admin notices + if (get_option('twp_auto_update_enabled', '1') === '1') { + add_action('admin_init', array($this, 'maybe_auto_check_updates')); + } + } + + /** + * Register auto-update settings + */ + public function register_settings() { + register_setting('twp_settings', 'twp_auto_update_enabled'); + register_setting('twp_settings', 'twp_gitea_repo'); + register_setting('twp_settings', 'twp_gitea_token'); // Optional for private repos + } + + /** + * Check for updates periodically + */ + public function maybe_auto_check_updates() { + $last_check = get_option('twp_last_update_check', 0); + $check_interval = 12 * HOUR_IN_SECONDS; // Check every 12 hours + + if (time() - $last_check > $check_interval) { + update_option('twp_last_update_check', time()); + // Force WordPress to check for updates + wp_clean_plugins_cache(); + } + } + + /** + * Check for plugin updates + */ + public function check_for_update($transient) { + if (empty($transient->checked)) { + return $transient; + } + + // Get Gitea repo from settings if available + $custom_repo = get_option('twp_gitea_repo', ''); + if (!empty($custom_repo)) { + $this->gitea_repo = $custom_repo; + $this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest'; + } + + $update_info = $this->get_latest_release(); + + if ($update_info && version_compare($this->current_version, $update_info->version, '<')) { + $plugin_data = array( + 'id' => 'twilio-wp-plugin', + 'slug' => $this->plugin_slug, + 'plugin' => $this->plugin_basename, + 'new_version' => $update_info->version, + 'url' => $update_info->homepage, + 'package' => $update_info->download_url, + 'tested' => '6.8', + 'requires' => '5.8', + 'requires_php' => '7.4', + 'icons' => array(), + 'banners' => array(), + 'compatibility' => new stdClass(), + ); + + $transient->response[$this->plugin_basename] = (object) $plugin_data; + + error_log("TWP Auto-Updater: New version {$update_info->version} available (current: {$this->current_version})"); + } else { + error_log("TWP Auto-Updater: No updates available (current: {$this->current_version})"); + } + + return $transient; + } + + /** + * Get plugin information for update screen + */ + public function plugin_info($false, $action, $args) { + if ($action !== 'plugin_information') { + return $false; + } + + if (!isset($args->slug) || $args->slug !== $this->plugin_slug) { + return $false; + } + + $update_info = $this->get_latest_release(); + + if (!$update_info) { + return $false; + } + + $plugin_info = new stdClass(); + $plugin_info->name = 'Twilio WP Plugin'; + $plugin_info->slug = $this->plugin_slug; + $plugin_info->version = $update_info->version; + $plugin_info->author = 'Joshua Knapp'; + $plugin_info->homepage = $update_info->homepage; + $plugin_info->download_link = $update_info->download_url; + $plugin_info->requires = '5.8'; + $plugin_info->tested = '6.8'; + $plugin_info->requires_php = '7.4'; + $plugin_info->last_updated = $update_info->release_date; + $plugin_info->downloaded = 10; + + $plugin_info->sections = array( + 'description' => '

Twilio WordPress Plugin for call management and mobile app support.

', + 'changelog' => '
' . esc_html($update_info->changelog) . '
', + 'installation' => '

Upload the plugin to your WordPress site and activate it.

' + ); + + return $plugin_info; + } + + /** + * Get latest release information from Gitea + */ + private function get_latest_release() { + // Check cache first (1 hour) + $cache_key = 'twp_latest_release'; + $cached = get_transient($cache_key); + + if ($cached !== false) { + return $cached; + } + + // Use cURL for Gitea API + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $this->gitea_api_url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, array('Accept: application/json')); + curl_setopt($ch, CURLOPT_USERAGENT, 'WordPress/Twilio-WP-Plugin-Updater'); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + // Add Gitea token if configured (for private repos) + $gitea_token = get_option('twp_gitea_token', ''); + if (!empty($gitea_token)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, array( + 'Accept: application/json', + 'Authorization: token ' . $gitea_token + )); + } + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if (!$response || $http_code !== 200) { + error_log("TWP Auto-Updater: Gitea API returned status $http_code"); + return false; + } + + $release = json_decode($response); + + if (!$release || !isset($release->tag_name)) { + error_log('TWP Auto-Updater: Invalid release data from Gitea'); + return false; + } + + // Parse release information + $version = ltrim($release->tag_name, 'v'); // Remove 'v' prefix if present + $download_url = null; + + // Find the zip asset + if (isset($release->assets) && is_array($release->assets)) { + foreach ($release->assets as $asset) { + if (strpos($asset->name, '.zip') !== false) { + $download_url = $asset->browser_download_url; + break; + } + } + } + + // Fallback to zipball if no asset found + if (!$download_url) { + $download_url = $release->zipball_url; + } + + // Format changelog + $changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.'; + + // Handle empty changelog + if (empty(trim($changelog))) { + $changelog = "Version " . $version . "\n\n" . + "Released on " . date('F j, Y', strtotime($release->published_at)) . "\n\n" . + "* Updated plugin files"; + } + + $update_info = (object) array( + 'version' => $version, + 'download_url' => $download_url, + 'homepage' => $this->gitea_base_url . '/' . $this->gitea_repo, + 'release_date' => $release->published_at, + 'description' => $changelog, + 'changelog' => $changelog + ); + + // Cache for 1 hour + set_transient($cache_key, $update_info, HOUR_IN_SECONDS); + + return $update_info; + } + + /** + * Manual update check (for admin page) + */ + public function manual_check_for_updates() { + // Clear cache + delete_transient('twp_latest_release'); + update_option('twp_last_update_check', 0); + + // Force WordPress to check + wp_clean_plugins_cache(); + delete_site_transient('update_plugins'); + + $update_info = $this->get_latest_release(); + + if (!$update_info) { + return array( + 'success' => false, + 'message' => 'Failed to check for updates. Please check your internet connection and Gitea repository settings.' + ); + } + + if (version_compare($this->current_version, $update_info->version, '<')) { + return array( + 'success' => true, + 'update_available' => true, + 'current_version' => $this->current_version, + 'latest_version' => $update_info->version, + 'message' => "Update available: Version {$update_info->version}. Go to Plugins page to update." + ); + } else { + return array( + 'success' => true, + 'update_available' => false, + 'current_version' => $this->current_version, + 'message' => 'You are running the latest version.' + ); + } + } + + /** + * Get current update status + */ + public function get_update_status() { + $update_info = $this->get_latest_release(); + + return array( + 'current_version' => $this->current_version, + 'latest_version' => $update_info ? $update_info->version : 'Unknown', + 'update_available' => $update_info && version_compare($this->current_version, $update_info->version, '<'), + 'last_check' => get_option('twp_last_update_check', 0), + 'auto_update_enabled' => get_option('twp_auto_update_enabled', '1') === '1' + ); + } +} diff --git a/includes/class-twp-core.php b/includes/class-twp-core.php index e542e5f..2cc9b26 100644 --- a/includes/class-twp-core.php +++ b/includes/class-twp-core.php @@ -33,7 +33,14 @@ class TWP_Core { // API classes require_once TWP_PLUGIN_DIR . 'includes/class-twp-twilio-api.php'; require_once TWP_PLUGIN_DIR . 'includes/class-twp-elevenlabs-api.php'; - + + // Mobile app classes + require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-auth.php'; + require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-api.php'; + require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-sse.php'; + require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php'; + require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php'; + // Feature classes require_once TWP_PLUGIN_DIR . 'includes/class-twp-scheduler.php'; require_once TWP_PLUGIN_DIR . 'includes/class-twp-call-queue.php'; @@ -318,6 +325,20 @@ class TWP_Core { // Initialize webhooks $webhooks = new TWP_Webhooks(); $webhooks->register_endpoints(); + + // Initialize mobile app endpoints + $mobile_auth = new TWP_Mobile_Auth(); + $mobile_auth->register_endpoints(); + + $mobile_api = new TWP_Mobile_API(); + $mobile_api->register_endpoints(); + + $mobile_sse = new TWP_Mobile_SSE(); + $mobile_sse->register_endpoints(); + + // Initialize auto-updater + $updater = new TWP_Auto_Updater(); + $updater->init(); // Add custom cron schedules add_filter('cron_schedules', function($schedules) { diff --git a/includes/class-twp-fcm.php b/includes/class-twp-fcm.php new file mode 100644 index 0000000..69a1b2d --- /dev/null +++ b/includes/class-twp-fcm.php @@ -0,0 +1,214 @@ +server_key = get_option('twp_fcm_server_key', ''); + } + + /** + * Send push notification to user's devices + */ + public function send_notification($user_id, $title, $body, $data = array()) { + if (empty($this->server_key)) { + error_log('TWP FCM: Server key not configured'); + return false; + } + + // Get user's FCM tokens + $tokens = $this->get_user_tokens($user_id); + + if (empty($tokens)) { + error_log("TWP FCM: No tokens found for user $user_id"); + return false; + } + + $success_count = 0; + $failed_tokens = array(); + + foreach ($tokens as $token) { + $result = $this->send_to_token($token, $title, $body, $data); + + if ($result['success']) { + $success_count++; + } else { + $failed_tokens[] = $token; + + // If token is invalid, remove it from database + if ($result['error'] === 'invalid_token') { + $this->remove_invalid_token($token); + } + } + } + + error_log("TWP FCM: Sent notification to $success_count/" . count($tokens) . " devices for user $user_id"); + + return $success_count > 0; + } + + /** + * 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( + 'title' => $title, + 'body' => $body, + 'timestamp' => time() + )), + 'priority' => 'high' + ); + + $headers = array( + 'Authorization: key=' . $this->server_key, + 'Content-Type: application/json' + ); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $this->fcm_url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($http_code !== 200) { + error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response"); + + // Check if token is invalid + $response_data = json_decode($response, true); + if (isset($response_data['results'][0]['error']) && + in_array($response_data['results'][0]['error'], array('InvalidRegistration', 'NotRegistered'))) { + return array('success' => false, 'error' => 'invalid_token'); + } + + return array('success' => false, 'error' => 'http_error'); + } + + return array('success' => true); + } + + /** + * Get all active FCM tokens for a user + */ + private function get_user_tokens($user_id) { + global $wpdb; + $table = $wpdb->prefix . 'twp_mobile_sessions'; + + return $wpdb->get_col($wpdb->prepare( + "SELECT fcm_token FROM $table + WHERE user_id = %d + AND is_active = 1 + AND fcm_token IS NOT NULL + AND fcm_token != '' + AND expires_at > NOW()", + $user_id + )); + } + + /** + * Remove invalid FCM token from database + */ + private function remove_invalid_token($token) { + global $wpdb; + $table = $wpdb->prefix . 'twp_mobile_sessions'; + + $wpdb->update( + $table, + array('fcm_token' => null), + array('fcm_token' => $token), + array('%s'), + array('%s') + ); + + error_log("TWP FCM: Removed invalid token from database"); + } + + /** + * Send incoming call notification + */ + 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"; + + $data = array( + 'type' => 'incoming_call', + 'call_sid' => $call_sid, + 'from_number' => $from_number, + 'queue_name' => $queue_name + ); + + return $this->send_notification($user_id, $title, $body, $data); + } + + /** + * Send queue timeout notification + */ + public function notify_queue_timeout($user_id, $queue_name, $waiting_count) { + $title = 'Queue Alert'; + $body = "$queue_name has $waiting_count waiting call" . ($waiting_count > 1 ? 's' : ''); + + $data = array( + 'type' => 'queue_timeout', + 'queue_name' => $queue_name, + 'waiting_count' => $waiting_count + ); + + return $this->send_notification($user_id, $title, $body, $data); + } + + /** + * Send agent status change notification + */ + public function notify_status_change($user_id, $old_status, $new_status) { + $title = 'Status Changed'; + $body = "Your status changed from $old_status to $new_status"; + + $data = array( + 'type' => 'status_change', + 'old_status' => $old_status, + 'new_status' => $new_status + ); + + return $this->send_notification($user_id, $title, $body, $data); + } + + /** + * Test notification (for settings page) + */ + public function send_test_notification($user_id) { + $title = 'Test Notification'; + $body = 'This is a test notification from Twilio WordPress Plugin'; + + $data = array( + 'type' => 'test', + 'test' => true + ); + + return $this->send_notification($user_id, $title, $body, $data); + } +} diff --git a/includes/class-twp-mobile-api.php b/includes/class-twp-mobile-api.php new file mode 100644 index 0000000..8c1f013 --- /dev/null +++ b/includes/class-twp-mobile-api.php @@ -0,0 +1,684 @@ +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\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[^/]+)/accept', array( + 'methods' => 'POST', + 'callback' => array($this, 'accept_call'), + 'permission_callback' => array($this->auth, 'verify_token') + )); + + register_rest_route('twilio-mobile/v1', '/calls/(?P[^/]+)/reject', array( + 'methods' => 'POST', + 'callback' => array($this, 'reject_call'), + 'permission_callback' => array($this->auth, 'verify_token') + )); + + register_rest_route('twilio-mobile/v1', '/calls/(?P[^/]+)/hold', array( + 'methods' => 'POST', + 'callback' => array($this, 'hold_call'), + 'permission_callback' => array($this->auth, 'verify_token') + )); + + register_rest_route('twilio-mobile/v1', '/calls/(?P[^/]+)/unhold', array( + 'methods' => 'POST', + 'callback' => array($this, 'unhold_call'), + 'permission_callback' => array($this->auth, 'verify_token') + )); + + register_rest_route('twilio-mobile/v1', '/calls/(?P[^/]+)/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); + } +} diff --git a/includes/class-twp-mobile-auth.php b/includes/class-twp-mobile-auth.php new file mode 100644 index 0000000..7a235c8 --- /dev/null +++ b/includes/class-twp-mobile-auth.php @@ -0,0 +1,457 @@ +secret_key = $this->get_secret_key(); + } + + /** + * Get or generate JWT secret key + */ + private function get_secret_key() { + $key = get_option('twp_mobile_jwt_secret'); + + if (empty($key)) { + // Generate a secure random key + $key = bin2hex(random_bytes(32)); + update_option('twp_mobile_jwt_secret', $key); + } + + return $key; + } + + /** + * Register REST API endpoints + */ + public function register_endpoints() { + add_action('rest_api_init', function() { + // Login endpoint + register_rest_route('twilio-mobile/v1', '/auth/login', array( + 'methods' => 'POST', + 'callback' => array($this, 'handle_login'), + 'permission_callback' => '__return_true' + )); + + // Refresh token endpoint + register_rest_route('twilio-mobile/v1', '/auth/refresh', array( + 'methods' => 'POST', + 'callback' => array($this, 'handle_refresh'), + 'permission_callback' => '__return_true' + )); + + // Logout endpoint + register_rest_route('twilio-mobile/v1', '/auth/logout', array( + 'methods' => 'POST', + 'callback' => array($this, 'handle_logout'), + 'permission_callback' => array($this, 'verify_token') + )); + + // Validate token endpoint (for debugging) + register_rest_route('twilio-mobile/v1', '/auth/validate', array( + 'methods' => 'GET', + 'callback' => array($this, 'handle_validate'), + 'permission_callback' => array($this, 'verify_token') + )); + }); + } + + /** + * Handle login request + */ + public function handle_login($request) { + $username = $request->get_param('username'); + $password = $request->get_param('password'); + $fcm_token = $request->get_param('fcm_token'); // Optional + $device_info = $request->get_param('device_info'); // Optional + + if (empty($username) || empty($password)) { + return new WP_Error('missing_credentials', 'Username and password are required', array('status' => 400)); + } + + // Authenticate user + $user = wp_authenticate($username, $password); + + if (is_wp_error($user)) { + return new WP_Error('invalid_credentials', 'Invalid username or password', array('status' => 401)); + } + + // Check if user has phone agent capabilities + if (!user_can($user->ID, 'twp_access_browser_phone') && !user_can($user->ID, 'manage_options')) { + return new WP_Error('insufficient_permissions', 'User does not have phone agent access', array('status' => 403)); + } + + // Generate tokens + $access_token = $this->generate_token($user->ID, 'access'); + $refresh_token = $this->generate_token($user->ID, 'refresh'); + + // Store session in database + $this->store_session($user->ID, $refresh_token, $fcm_token, $device_info); + + // Get user data + $user_data = $this->get_user_data($user->ID); + + return new WP_REST_Response(array( + 'success' => true, + 'access_token' => $access_token, + 'refresh_token' => $refresh_token, + 'expires_in' => $this->token_expiry, + 'user' => $user_data + ), 200); + } + + /** + * Handle token refresh request + */ + public function handle_refresh($request) { + $refresh_token = $request->get_param('refresh_token'); + + if (empty($refresh_token)) { + return new WP_Error('missing_token', 'Refresh token is required', array('status' => 400)); + } + + // Verify refresh token + $payload = $this->decode_token($refresh_token); + + if (!$payload || $payload->type !== 'refresh') { + return new WP_Error('invalid_token', 'Invalid refresh token', array('status' => 401)); + } + + // Check if session exists and is valid + global $wpdb; + $table = $wpdb->prefix . 'twp_mobile_sessions'; + + $session = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $table WHERE user_id = %d AND refresh_token = %s AND is_active = 1 AND expires_at > NOW()", + $payload->user_id, + $refresh_token + )); + + if (!$session) { + return new WP_Error('invalid_session', 'Session expired or invalid', array('status' => 401)); + } + + // Generate new access token + $access_token = $this->generate_token($payload->user_id, 'access'); + + // Update last_used timestamp + $wpdb->update( + $table, + array('last_used' => current_time('mysql')), + array('id' => $session->id), + array('%s'), + array('%d') + ); + + return new WP_REST_Response(array( + 'success' => true, + 'access_token' => $access_token, + 'expires_in' => $this->token_expiry + ), 200); + } + + /** + * Handle logout request + */ + public function handle_logout($request) { + $user_id = $this->get_current_user_id(); + + if (!$user_id) { + return new WP_Error('unauthorized', 'Invalid token', array('status' => 401)); + } + + // Get refresh token from request + $refresh_token = $request->get_param('refresh_token'); + + global $wpdb; + $table = $wpdb->prefix . 'twp_mobile_sessions'; + + if ($refresh_token) { + // Invalidate specific session + $wpdb->update( + $table, + array('is_active' => 0), + array('user_id' => $user_id, 'refresh_token' => $refresh_token), + array('%d'), + array('%d', '%s') + ); + } else { + // Invalidate all sessions for this user + $wpdb->update( + $table, + array('is_active' => 0), + array('user_id' => $user_id), + array('%d'), + array('%d') + ); + } + + return new WP_REST_Response(array( + 'success' => true, + 'message' => 'Logged out successfully' + ), 200); + } + + /** + * Handle token validation request + */ + public function handle_validate($request) { + $user_id = $this->get_current_user_id(); + + if (!$user_id) { + return new WP_Error('unauthorized', 'Invalid token', array('status' => 401)); + } + + $user_data = $this->get_user_data($user_id); + + return new WP_REST_Response(array( + 'success' => true, + 'valid' => true, + 'user' => $user_data + ), 200); + } + + /** + * Generate JWT token + */ + private function generate_token($user_id, $type = 'access') { + $issued_at = time(); + $expiry = $type === 'refresh' ? $this->refresh_expiry : $this->token_expiry; + + $payload = array( + 'iat' => $issued_at, + 'exp' => $issued_at + $expiry, + 'user_id' => $user_id, + 'type' => $type + ); + + return $this->encode_token($payload); + } + + /** + * Simple JWT encoding (header.payload.signature) + */ + private function encode_token($payload) { + $header = array('typ' => 'JWT', 'alg' => 'HS256'); + + $segments = array(); + $segments[] = $this->base64url_encode(json_encode($header)); + $segments[] = $this->base64url_encode(json_encode($payload)); + + $signing_input = implode('.', $segments); + $signature = hash_hmac('sha256', $signing_input, $this->secret_key, true); + $segments[] = $this->base64url_encode($signature); + + return implode('.', $segments); + } + + /** + * Simple JWT decoding + */ + private function decode_token($token) { + $segments = explode('.', $token); + + if (count($segments) !== 3) { + return false; + } + + list($header_b64, $payload_b64, $signature_b64) = $segments; + + // Verify signature + $signing_input = $header_b64 . '.' . $payload_b64; + $signature = $this->base64url_decode($signature_b64); + $expected_signature = hash_hmac('sha256', $signing_input, $this->secret_key, true); + + if (!hash_equals($signature, $expected_signature)) { + return false; + } + + // Decode payload + $payload = json_decode($this->base64url_decode($payload_b64)); + + if (!$payload) { + return false; + } + + // Check expiration + if (isset($payload->exp) && $payload->exp < time()) { + return false; + } + + return $payload; + } + + /** + * Base64 URL encode + */ + private function base64url_encode($data) { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * Base64 URL decode + */ + private function base64url_decode($data) { + return base64_decode(strtr($data, '-_', '+/')); + } + + /** + * Verify token (permission callback) + */ + public function verify_token($request) { + $auth_header = $request->get_header('Authorization'); + + if (empty($auth_header)) { + return false; + } + + // Extract token from "Bearer " + if (preg_match('/Bearer\s+(.*)$/i', $auth_header, $matches)) { + $token = $matches[1]; + } else { + return false; + } + + $payload = $this->decode_token($token); + + if (!$payload || $payload->type !== 'access') { + return false; + } + + // Store user ID for later use + $request->set_param('_twp_user_id', $payload->user_id); + + return true; + } + + /** + * Get current user ID from token + */ + public function get_current_user_id() { + $request = rest_get_server()->get_request(); + return $request->get_param('_twp_user_id'); + } + + /** + * Store session in database + */ + private function store_session($user_id, $refresh_token, $fcm_token = null, $device_info = null) { + global $wpdb; + $table = $wpdb->prefix . 'twp_mobile_sessions'; + + $wpdb->insert( + $table, + array( + 'user_id' => $user_id, + 'refresh_token' => $refresh_token, + 'fcm_token' => $fcm_token, + 'device_info' => $device_info, + 'created_at' => current_time('mysql'), + 'expires_at' => date('Y-m-d H:i:s', time() + $this->refresh_expiry), + 'last_used' => current_time('mysql'), + 'is_active' => 1 + ), + array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d') + ); + } + + /** + * Get user data for response + */ + private function get_user_data($user_id) { + $user = get_userdata($user_id); + + if (!$user) { + return null; + } + + // Get agent phone number + $agent_number = get_user_meta($user_id, 'twp_agent_phone', true); + + // Get agent status + global $wpdb; + $status_table = $wpdb->prefix . 'twp_agent_status'; + $status = $wpdb->get_row($wpdb->prepare( + "SELECT status, is_logged_in, current_call_sid FROM $status_table WHERE user_id = %d", + $user_id + )); + + // Get user extension + $ext_table = $wpdb->prefix . 'twp_user_extensions'; + $extension = $wpdb->get_row($wpdb->prepare( + "SELECT extension, direct_dial_number FROM $ext_table WHERE user_id = %d", + $user_id + )); + + return array( + 'id' => $user->ID, + 'username' => $user->user_login, + 'display_name' => $user->display_name, + 'email' => $user->user_email, + 'phone_number' => $agent_number, + 'extension' => $extension ? $extension->extension : null, + 'direct_dial' => $extension ? $extension->direct_dial_number : null, + 'status' => $status ? $status->status : 'offline', + 'is_logged_in' => $status ? (bool)$status->is_logged_in : false, + 'current_call_sid' => $status ? $status->current_call_sid : null, + 'capabilities' => array( + 'can_access_browser_phone' => user_can($user_id, 'twp_access_browser_phone'), + 'can_access_voicemails' => user_can($user_id, 'twp_access_voicemails'), + 'can_access_call_log' => user_can($user_id, 'twp_access_call_log'), + 'can_access_agent_queue' => user_can($user_id, 'twp_access_agent_queue'), + 'can_access_sms_inbox' => user_can($user_id, 'twp_access_sms_inbox'), + 'is_admin' => user_can($user_id, 'manage_options') + ) + ); + } + + /** + * Update FCM token for existing session + */ + public function update_fcm_token($user_id, $refresh_token, $fcm_token) { + 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') + ); + } + + /** + * Get all active FCM tokens for a user + */ + public function get_user_fcm_tokens($user_id) { + global $wpdb; + $table = $wpdb->prefix . 'twp_mobile_sessions'; + + return $wpdb->get_col($wpdb->prepare( + "SELECT fcm_token FROM $table WHERE user_id = %d AND is_active = 1 AND fcm_token IS NOT NULL AND expires_at > NOW()", + $user_id + )); + } + + /** + * Clean up expired sessions + */ + public static function cleanup_expired_sessions() { + global $wpdb; + $table = $wpdb->prefix . 'twp_mobile_sessions'; + + $wpdb->query("UPDATE $table SET is_active = 0 WHERE expires_at < NOW() AND is_active = 1"); + } +} diff --git a/includes/class-twp-mobile-sse.php b/includes/class-twp-mobile-sse.php new file mode 100644 index 0000000..33c5ddf --- /dev/null +++ b/includes/class-twp-mobile-sse.php @@ -0,0 +1,308 @@ +auth = new TWP_Mobile_Auth(); + } + + /** + * Register SSE endpoint + */ + public function register_endpoints() { + add_action('rest_api_init', function() { + register_rest_route('twilio-mobile/v1', '/stream/events', array( + 'methods' => 'GET', + 'callback' => array($this, 'stream_events'), + 'permission_callback' => array($this->auth, 'verify_token') + )); + }); + } + + /** + * Stream events to mobile app + */ + public function stream_events($request) { + $user_id = $this->auth->get_current_user_id(); + + if (!$user_id) { + return new WP_Error('unauthorized', 'Invalid token', array('status' => 401)); + } + + // Set headers for SSE + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('Connection: keep-alive'); + header('X-Accel-Buffering: no'); // Disable nginx buffering + + // Disable PHP output buffering + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } + @ini_set('zlib.output_compression', 0); + @ini_set('implicit_flush', 1); + ob_implicit_flush(1); + while (ob_get_level() > 0) { + ob_end_flush(); + } + + // Send initial connection event + $this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time())); + + // Get initial state + $last_check = time(); + $previous_state = $this->get_current_state($user_id); + + // Stream loop - check for changes every 2 seconds + $max_duration = 300; // 5 minutes max connection time + $start_time = time(); + + while (time() - $start_time < $max_duration) { + // Check if connection is still alive + if (connection_aborted()) { + break; + } + + // Get current state + $current_state = $this->get_current_state($user_id); + + // Compare and send updates + $this->check_and_send_updates($previous_state, $current_state); + + // Update previous state + $previous_state = $current_state; + + // Send heartbeat every 15 seconds + if (time() - $last_check >= 15) { + $this->send_event('heartbeat', array('timestamp' => time())); + $last_check = time(); + } + + // Sleep for 2 seconds + sleep(2); + } + + // Connection closing + $this->send_event('disconnect', array('reason' => 'timeout', 'timestamp' => time())); + exit; + } + + /** + * Get current state for agent + */ + private function get_current_state($user_id) { + global $wpdb; + + $state = array( + 'agent_status' => $this->get_agent_status($user_id), + 'queues' => $this->get_queues_state($user_id), + 'current_call' => $this->get_current_call($user_id) + ); + + return $state; + } + + /** + * Get agent status + */ + private function get_agent_status($user_id) { + global $wpdb; + $table = $wpdb->prefix . 'twp_agent_status'; + + $status = $wpdb->get_row($wpdb->prepare( + "SELECT status, is_logged_in, current_call_sid FROM $table WHERE user_id = %d", + $user_id + )); + + if (!$status) { + return array('status' => 'offline', 'is_logged_in' => false, 'current_call_sid' => null); + } + + return array( + 'status' => $status->status, + 'is_logged_in' => (bool)$status->is_logged_in, + 'current_call_sid' => $status->current_call_sid + ); + } + + /** + * Get queues state + */ + private function get_queues_state($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 queue IDs + $queue_ids = $wpdb->get_col($wpdb->prepare( + "SELECT queue_id FROM $assignments_table WHERE user_id = %d", + $user_id + )); + + $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 array(); + } + + $queue_ids_str = implode(',', array_map('intval', $all_queue_ids)); + + $queues = $wpdb->get_results(" + SELECT + q.id, + q.queue_name, + COUNT(c.id) as waiting_count, + MIN(c.enqueued_at) as oldest_call_time + 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[$queue->id] = array( + 'id' => (int)$queue->id, + 'name' => $queue->queue_name, + 'waiting_count' => (int)$queue->waiting_count, + 'oldest_call_time' => $queue->oldest_call_time + ); + } + + return $result; + } + + /** + * Get current call for agent + */ + private function get_current_call($user_id) { + global $wpdb; + $calls_table = $wpdb->prefix . 'twp_queued_calls'; + + $agent_number = get_user_meta($user_id, 'twp_agent_phone', true); + + if (!$agent_number) { + return null; + } + + $call = $wpdb->get_row($wpdb->prepare( + "SELECT call_sid, from_number, queue_id, status, joined_at + FROM $calls_table + WHERE agent_phone = %s AND status IN ('connecting', 'in_progress') + ORDER BY joined_at DESC + LIMIT 1", + $agent_number + )); + + if (!$call) { + return null; + } + + return array( + 'call_sid' => $call->call_sid, + 'from_number' => $call->from_number, + 'queue_id' => (int)$call->queue_id, + 'status' => $call->status, + 'duration' => time() - strtotime($call->joined_at) + ); + } + + /** + * Check state changes and send updates + */ + private function check_and_send_updates($previous, $current) { + // Check agent status changes + if ($previous['agent_status'] !== $current['agent_status']) { + $this->send_event('agent_status_changed', $current['agent_status']); + } + + // Check queue changes + $this->check_queue_changes($previous['queues'], $current['queues']); + + // Check current call changes + if ($previous['current_call'] !== $current['current_call']) { + if ($current['current_call'] && !$previous['current_call']) { + // New call started + $this->send_event('call_started', $current['current_call']); + } elseif (!$current['current_call'] && $previous['current_call']) { + // Call ended + $this->send_event('call_ended', $previous['current_call']); + } elseif ($current['current_call'] && $previous['current_call']) { + // Call status changed + if ($current['current_call']['status'] !== $previous['current_call']['status']) { + $this->send_event('call_status_changed', $current['current_call']); + } + } + } + } + + /** + * Check for queue changes + */ + private function check_queue_changes($previous_queues, $current_queues) { + foreach ($current_queues as $queue_id => $current_queue) { + $previous_queue = $previous_queues[$queue_id] ?? null; + + if (!$previous_queue) { + // New queue added + $this->send_event('queue_added', $current_queue); + continue; + } + + // Check for waiting count changes + if ($current_queue['waiting_count'] !== $previous_queue['waiting_count']) { + if ($current_queue['waiting_count'] > $previous_queue['waiting_count']) { + // New call in queue + $this->send_event('call_enqueued', array( + 'queue_id' => $queue_id, + 'queue_name' => $current_queue['name'], + 'waiting_count' => $current_queue['waiting_count'] + )); + } else { + // Call removed from queue + $this->send_event('call_dequeued', array( + 'queue_id' => $queue_id, + 'queue_name' => $current_queue['name'], + 'waiting_count' => $current_queue['waiting_count'] + )); + } + } + } + + // Check for removed queues + foreach ($previous_queues as $queue_id => $previous_queue) { + if (!isset($current_queues[$queue_id])) { + $this->send_event('queue_removed', array('queue_id' => $queue_id)); + } + } + } + + /** + * Send SSE event + */ + private function send_event($event_type, $data) { + echo "event: $event_type\n"; + echo "data: " . json_encode($data) . "\n\n"; + + if (ob_get_level() > 0) { + ob_flush(); + } + flush(); + } +}