diff --git a/admin/class-twp-admin.php b/admin/class-twp-admin.php
index 25f6967..2f9e926 100644
--- a/admin/class-twp-admin.php
+++ b/admin/class-twp-admin.php
@@ -127,6 +127,24 @@ class TWP_Admin {
'twilio-wp-outbound',
array($this, 'display_outbound_calls_page')
);
+
+ add_submenu_page(
+ 'twilio-wp-plugin',
+ 'SMS Inbox',
+ 'SMS Inbox',
+ 'manage_options',
+ 'twilio-wp-sms-inbox',
+ array($this, 'display_sms_inbox_page')
+ );
+
+ add_submenu_page(
+ 'twilio-wp-plugin',
+ 'Browser Phone',
+ 'Browser Phone',
+ 'manage_options',
+ 'twilio-wp-browser-phone',
+ array($this, 'display_browser_phone_page')
+ );
}
/**
@@ -229,6 +247,16 @@ class TWP_Admin {
+
+ TwiML App SID |
+
+
+ TwiML Application SID for Browser Phone (optional). See setup instructions below
+ |
+
+
Eleven Labs API Settings
@@ -439,6 +467,142 @@ class TWP_Admin {
+
+
+ TwiML App Setup for Browser Phone
+
+
Auto-Configuration (Recommended)
+
Let the plugin automatically set up everything for you:
+
+
+
+
+
+
+
+
Select Phone Numbers to Configure:
+
+
Loading phone numbers...
+
+
+
+
+
+
+
+
+
+
Routes calls based on agent preferences (browser vs cell phone)
+
+
+ Full Setup: Create TwiML App, set webhooks, configure selected phone numbers.
+ Numbers Only: Configure selected phone numbers with smart routing (requires TwiML App already set up).
+
+
+
+
+
+
+
Manual Setup Instructions
+
Or follow these steps to set up manually in your Twilio Console:
+
+
+
+
1. Create TwiML Application
+
+ - Go to Twilio Console → Voice → TwiML Apps
+ - Click "Create new TwiML App"
+ - Enter a friendly name:
Browser Phone App
+ - Set Voice URL to:
+
+
+ - Set HTTP Method to: POST
+ - Leave Status Callback URL empty (optional)
+ - Click "Save"
+
+
+
+
+
2. Get TwiML App SID
+
+ - After creating the app, copy the App SID (starts with
AP...
)
+ - Paste it in the "TwiML App SID" field above
+ - Click "Save Changes"
+
+
+
+
+
3. Test Browser Phone
+
+ - Go to Twilio WP Plugin → Browser Phone
+ - Wait for status to show "Ready"
+ - Enter a phone number and select caller ID
+ - Click "Call" to test outbound calling
+
+
+
+
+
+
How It Works
+
+ - Outbound Calls: Click "Call" to dial any phone number from your browser
+ - Incoming Calls: Calls can be routed to your browser instead of cell phone
+ - Call Quality: Uses your internet connection for high-quality VoIP calls
+ - No Cell Phone: Agents can work entirely from their computer
+
+
+
+
+
Troubleshooting
+
+ - "valid callerId must be provided":
+
+ - Make sure you select a Caller ID before calling
+ - The Caller ID must be a phone number you own in Twilio
+ - Go to Twilio Console → Phone Numbers to verify your numbers
+
+
+ - Status shows "Error": Check that TwiML App SID is correctly configured
+ - "Failed to initialize": Verify Twilio credentials are correct
+ - Browser blocks microphone: Allow microphone access when prompted
+ - Poor call quality: Check internet connection and try different browser
+ - "No audio" on calls: Check browser microphone permissions and refresh the page
+
+
+
+
+
+
generate_capability_token();
+
+ if ($result['success']) {
+ wp_send_json_success($result['data']);
+ } else {
+ wp_send_json_error($result['error']);
+ }
+ } catch (Exception $e) {
+ wp_send_json_error('Failed to generate capability token: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * AJAX handler for saving user's call mode preference
+ */
+ public function ajax_save_call_mode() {
+ check_ajax_referer('twp_ajax_nonce', 'nonce');
+
+ if (!current_user_can('read')) {
+ wp_send_json_error('Insufficient permissions');
+ }
+
+ $mode = isset($_POST['mode']) ? sanitize_text_field($_POST['mode']) : '';
+
+ if (!in_array($mode, ['browser', 'cell'])) {
+ wp_send_json_error('Invalid mode');
+ }
+
+ $user_id = get_current_user_id();
+ $updated = update_user_meta($user_id, 'twp_call_mode', $mode);
+
+ if ($updated !== false) {
+ wp_send_json_success([
+ 'mode' => $mode,
+ 'message' => 'Call mode updated successfully'
+ ]);
+ } else {
+ wp_send_json_error('Failed to update call mode');
+ }
+ }
+
+ /**
+ * AJAX handler for auto-configuring TwiML App for browser phone
+ */
+ public function ajax_auto_configure_twiml_app() {
+ check_ajax_referer('twp_ajax_nonce', 'nonce');
+
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error('Insufficient permissions');
+ }
+
+ $enable_smart_routing = isset($_POST['enable_smart_routing']) && $_POST['enable_smart_routing'] === 'true';
+ $selected_numbers = isset($_POST['selected_numbers']) ? json_decode(stripslashes($_POST['selected_numbers']), true) : [];
+
+ try {
+ $result = $this->auto_configure_browser_phone($enable_smart_routing, $selected_numbers);
+
+ if ($result['success']) {
+ wp_send_json_success($result['data']);
+ } else {
+ wp_send_json_error($result['error']);
+ }
+ } catch (Exception $e) {
+ wp_send_json_error('Failed to auto-configure: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Auto-configure browser phone by creating TwiML App and setting up webhooks
+ */
+ private function auto_configure_browser_phone($enable_smart_routing = true, $selected_numbers = []) {
+ $twilio = new TWP_Twilio_API();
+ $client = $twilio->get_client();
+
+ if (!$client) {
+ return [
+ 'success' => false,
+ 'error' => 'Twilio client not initialized. Please check your credentials.'
+ ];
+ }
+
+ $steps_completed = [];
+ $warnings = [];
+
+ try {
+ // Step 1: Check if TwiML App already exists
+ $current_app_sid = get_option('twp_twiml_app_sid');
+ $app_sid = null;
+
+ if ($current_app_sid) {
+ // Try to fetch existing app to verify it exists
+ try {
+ $existing_app = $client->applications($current_app_sid)->fetch();
+ $app_sid = $existing_app->sid;
+ $steps_completed[] = 'Found existing TwiML App: ' . $existing_app->friendlyName;
+ } catch (Exception $e) {
+ $warnings[] = 'Existing TwiML App SID is invalid, creating new one';
+ $current_app_sid = null;
+ }
+ }
+
+ // Step 2: Create TwiML App if needed
+ if (!$app_sid) {
+ $voice_url = home_url('/wp-json/twilio-webhook/v1/browser-voice');
+ $fallback_url = home_url('/wp-json/twilio-webhook/v1/browser-fallback');
+
+ $app = $client->applications->create([
+ 'friendlyName' => 'Browser Phone App - ' . get_bloginfo('name'),
+ 'voiceUrl' => $voice_url,
+ 'voiceMethod' => 'POST',
+ 'voiceFallbackUrl' => $fallback_url,
+ 'voiceFallbackMethod' => 'POST'
+ ]);
+
+ $app_sid = $app->sid;
+ $steps_completed[] = 'Created new TwiML App: ' . $app->friendlyName;
+ }
+
+ // Step 3: Save TwiML App SID to WordPress
+ update_option('twp_twiml_app_sid', $app_sid);
+ $steps_completed[] = 'Saved TwiML App SID to WordPress settings';
+
+ // Step 4: Test capability token generation
+ $token_result = $twilio->generate_capability_token();
+ if ($token_result['success']) {
+ $steps_completed[] = 'Successfully generated test capability token';
+ } else {
+ $warnings[] = 'Capability token generation failed: ' . $token_result['error'];
+ }
+
+ // Step 5: Update phone numbers with appropriate webhook URLs
+ $phone_result = $this->auto_configure_phone_numbers_for_browser($enable_smart_routing, $selected_numbers);
+ if ($phone_result['updated_count'] > 0) {
+ $webhook_type = $enable_smart_routing ? 'smart routing' : 'browser voice';
+ $steps_completed[] = 'Updated ' . $phone_result['updated_count'] . ' phone numbers with ' . $webhook_type . ' webhooks';
+ }
+ if ($phone_result['skipped_count'] > 0) {
+ $steps_completed[] = 'Skipped ' . $phone_result['skipped_count'] . ' phone numbers (not selected)';
+ }
+ if (!empty($phone_result['warnings'])) {
+ $warnings = array_merge($warnings, $phone_result['warnings']);
+ }
+
+ return [
+ 'success' => true,
+ 'data' => [
+ 'app_sid' => $app_sid,
+ 'steps_completed' => $steps_completed,
+ 'warnings' => $warnings,
+ 'voice_url' => home_url('/wp-json/twilio-webhook/v1/browser-voice'),
+ 'message' => 'Browser phone auto-configuration completed successfully!'
+ ]
+ ];
+
+ } catch (Exception $e) {
+ return [
+ 'success' => false,
+ 'error' => 'Auto-configuration failed: ' . $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Auto-configure phone numbers with browser webhooks (optional)
+ */
+ private function auto_configure_phone_numbers_for_browser($enable_smart_routing = true, $selected_numbers = []) {
+ $twilio = new TWP_Twilio_API();
+ $phone_numbers = $twilio->get_phone_numbers();
+
+ $updated_count = 0;
+ $skipped_count = 0;
+ $warnings = [];
+
+ if (!$phone_numbers['success']) {
+ return [
+ 'updated_count' => 0,
+ 'skipped_count' => 0,
+ 'warnings' => ['Could not retrieve phone numbers: ' . $phone_numbers['error']]
+ ];
+ }
+
+ // Create a map of selected number SIDs for quick lookup
+ $selected_sids = [];
+ if (!empty($selected_numbers)) {
+ foreach ($selected_numbers as $selected) {
+ $selected_sids[$selected['sid']] = true;
+ }
+ }
+
+ $smart_routing_url = home_url('/wp-json/twilio-webhook/v1/smart-routing');
+ $browser_voice_url = home_url('/wp-json/twilio-webhook/v1/browser-voice');
+ $target_url = $enable_smart_routing ? $smart_routing_url : $browser_voice_url;
+
+ foreach ($phone_numbers['data']['incoming_phone_numbers'] as $number) {
+ // Skip if number is not selected (when selection is provided)
+ if (!empty($selected_numbers) && !isset($selected_sids[$number['sid']])) {
+ $skipped_count++;
+ error_log('TWP: Skipping phone number ' . $number['phone_number'] . ' (not selected)');
+ continue;
+ }
+
+ try {
+ // Only update if not already using the target URL
+ if ($number['voice_url'] !== $target_url) {
+ $client = $twilio->get_client();
+ $client->incomingPhoneNumbers($number['sid'])->update([
+ 'voiceUrl' => $target_url,
+ 'voiceMethod' => 'POST'
+ ]);
+ $updated_count++;
+ error_log('TWP: Updated phone number ' . $number['phone_number'] . ' to use ' . $target_url);
+ }
+ } catch (Exception $e) {
+ $warnings[] = 'Failed to update ' . $number['phone_number'] . ': ' . $e->getMessage();
+ }
+ }
+
+ return [
+ 'updated_count' => $updated_count,
+ 'skipped_count' => $skipped_count,
+ 'warnings' => $warnings
+ ];
+ }
+
+ /**
+ * AJAX handler for configuring phone numbers only
+ */
+ public function ajax_configure_phone_numbers_only() {
+ check_ajax_referer('twp_ajax_nonce', 'nonce');
+
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error('Insufficient permissions');
+ }
+
+ $enable_smart_routing = isset($_POST['enable_smart_routing']) && $_POST['enable_smart_routing'] === 'true';
+ $selected_numbers = isset($_POST['selected_numbers']) ? json_decode(stripslashes($_POST['selected_numbers']), true) : [];
+
+ try {
+ $result = $this->configure_phone_numbers_only($enable_smart_routing, $selected_numbers);
+
+ if ($result['success']) {
+ wp_send_json_success($result['data']);
+ } else {
+ wp_send_json_error($result['error']);
+ }
+ } catch (Exception $e) {
+ wp_send_json_error('Failed to configure phone numbers: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Configure phone numbers only (no TwiML App creation)
+ */
+ private function configure_phone_numbers_only($enable_smart_routing = true, $selected_numbers = []) {
+ $twilio = new TWP_Twilio_API();
+ $client = $twilio->get_client();
+
+ if (!$client) {
+ return [
+ 'success' => false,
+ 'error' => 'Twilio client not initialized. Please check your credentials.'
+ ];
+ }
+
+ $steps_completed = [];
+ $warnings = [];
+
+ try {
+ // Configure phone numbers
+ $phone_result = $this->auto_configure_phone_numbers_for_browser($enable_smart_routing, $selected_numbers);
+
+ if ($phone_result['updated_count'] > 0) {
+ $webhook_type = $enable_smart_routing ? 'smart routing' : 'browser voice';
+ $steps_completed[] = 'Updated ' . $phone_result['updated_count'] . ' phone numbers with ' . $webhook_type . ' webhooks';
+ } else {
+ $steps_completed[] = 'All selected phone numbers already configured correctly';
+ }
+
+ if ($phone_result['skipped_count'] > 0) {
+ $steps_completed[] = 'Skipped ' . $phone_result['skipped_count'] . ' phone numbers (not selected)';
+ }
+
+ if (!empty($phone_result['warnings'])) {
+ $warnings = array_merge($warnings, $phone_result['warnings']);
+ }
+
+ // If smart routing is enabled, verify TwiML App exists
+ if ($enable_smart_routing) {
+ $app_sid = get_option('twp_twiml_app_sid');
+ if (empty($app_sid)) {
+ $warnings[] = 'Smart routing enabled but no TwiML App SID configured. You may need to run full auto-configuration.';
+ } else {
+ // Test if the app exists
+ try {
+ $client->applications($app_sid)->fetch();
+ $steps_completed[] = 'Verified TwiML App exists for smart routing';
+ } catch (Exception $e) {
+ $warnings[] = 'TwiML App SID is invalid. Smart routing may not work properly.';
+ }
+ }
+ }
+
+ $webhook_url = $enable_smart_routing ?
+ home_url('/wp-json/twilio-webhook/v1/smart-routing') :
+ home_url('/wp-json/twilio-webhook/v1/browser-voice');
+
+ return [
+ 'success' => true,
+ 'data' => [
+ 'steps_completed' => $steps_completed,
+ 'warnings' => $warnings,
+ 'webhook_url' => $webhook_url,
+ 'routing_type' => $enable_smart_routing ? 'Smart Routing' : 'Direct Browser',
+ 'message' => 'Phone number configuration completed successfully!'
+ ]
+ ];
+
+ } catch (Exception $e) {
+ return [
+ 'success' => false,
+ 'error' => 'Phone number configuration failed: ' . $e->getMessage()
+ ];
+ }
+ }
+
/**
* AJAX handler for initiating outbound calls with from number
*/
@@ -3628,4 +4343,1458 @@ class TWP_Admin {
return array('success' => false, 'error' => $agent_call_result['error']);
}
+ /**
+ * Display SMS Inbox page
+ */
+ public function display_sms_inbox_page() {
+ global $wpdb;
+ $table_name = $wpdb->prefix . 'twp_sms_log';
+
+ // Get our Twilio numbers first
+ $twilio_numbers = [];
+ try {
+ $twilio_api = new TWP_Twilio_API();
+ $numbers_result = $twilio_api->get_phone_numbers();
+ if ($numbers_result['success'] && !empty($numbers_result['data']['incoming_phone_numbers'])) {
+ foreach ($numbers_result['data']['incoming_phone_numbers'] as $number) {
+ $twilio_numbers[] = $number['phone_number'];
+ }
+ }
+ } catch (Exception $e) {
+ error_log('Failed to get Twilio numbers: ' . $e->getMessage());
+ }
+
+ // Build the NOT IN clause for Twilio numbers
+ $twilio_numbers_placeholders = !empty($twilio_numbers) ?
+ implode(',', array_fill(0, count($twilio_numbers), '%s')) :
+ "'dummy_number_that_wont_match'";
+
+ // Get unique conversations (group by customer phone number)
+ // Customer number is the one that's NOT in our Twilio numbers list
+ $query = $wpdb->prepare(
+ "SELECT
+ customer_number,
+ business_number,
+ MAX(last_message_time) as last_message_time,
+ SUM(message_count) as message_count,
+ MAX(last_message) as last_message,
+ MAX(last_direction) as last_message_direction
+ FROM (
+ SELECT
+ from_number as customer_number,
+ to_number as business_number,
+ MAX(received_at) as last_message_time,
+ COUNT(*) as message_count,
+ (SELECT body FROM $table_name t2
+ WHERE t2.from_number = t1.from_number AND t2.to_number = t1.to_number
+ ORDER BY t2.received_at DESC LIMIT 1) as last_message,
+ 'incoming' as last_direction
+ FROM $table_name t1
+ WHERE from_number NOT IN ($twilio_numbers_placeholders)
+ AND body NOT IN ('1', 'status', 'help')
+ GROUP BY from_number, to_number
+
+ UNION ALL
+
+ SELECT
+ to_number as customer_number,
+ from_number as business_number,
+ MAX(received_at) as last_message_time,
+ COUNT(*) as message_count,
+ (SELECT body FROM $table_name t3
+ WHERE t3.to_number = t1.to_number AND t3.from_number = t1.from_number
+ ORDER BY t3.received_at DESC LIMIT 1) as last_message,
+ 'outgoing' as last_direction
+ FROM $table_name t1
+ WHERE to_number NOT IN ($twilio_numbers_placeholders)
+ AND from_number IN ($twilio_numbers_placeholders)
+ GROUP BY to_number, from_number
+ ) as conversations
+ GROUP BY customer_number
+ ORDER BY last_message_time DESC
+ LIMIT 50",
+ ...$twilio_numbers,
+ ...$twilio_numbers,
+ ...$twilio_numbers
+ );
+
+ $conversations = $wpdb->get_results($query);
+ ?>
+
+
SMS Inbox
+
View conversations and respond to customer SMS messages. Click on a conversation to view the full thread.
+
+
+
+
+
+ Customer |
+ Business Line |
+ Last Message |
+ Preview |
+ Messages |
+ Actions |
+
+
+
+
+
+
+ No customer conversations yet
+ |
+
+
+
+
+
+ customer_number); ?>
+ Customer
+ |
+
+ business_number); ?>
+ Received on
+ |
+
+ last_message_time))); ?>
+
+
+ last_message_direction === 'incoming' ? '← Received' : '→ Sent'; ?>
+
+ |
+
+
+ last_message) > 100 ?
+ substr($conversation->last_message, 0, 100) . '...' :
+ $conversation->last_message;
+ echo esc_html($preview);
+ ?>
+
+ |
+
+ message_count); ?>
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading conversation...
+
+
+
+
+
+
+
+
+
+
+ prefix . 'twp_sms_log';
+
+ $deleted = $wpdb->delete(
+ $table_name,
+ array('id' => $message_id),
+ array('%d')
+ );
+
+ if ($deleted) {
+ wp_send_json_success('Message deleted successfully');
+ } else {
+ wp_send_json_error('Failed to delete message');
+ }
+ }
+
+ /**
+ * AJAX handler for deleting entire SMS conversations
+ */
+ public function ajax_delete_conversation() {
+ check_ajax_referer('twp_ajax_nonce', 'nonce');
+
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error('Insufficient permissions');
+ }
+
+ $phone_number = isset($_POST['phone_number']) ? sanitize_text_field($_POST['phone_number']) : '';
+
+ if (empty($phone_number)) {
+ wp_send_json_error('Phone number is required');
+ }
+
+ global $wpdb;
+ $table_name = $wpdb->prefix . 'twp_sms_log';
+
+ // Delete all messages involving this phone number
+ $deleted = $wpdb->query($wpdb->prepare(
+ "DELETE FROM $table_name WHERE from_number = %s OR to_number = %s",
+ $phone_number, $phone_number
+ ));
+
+ if ($deleted !== false) {
+ wp_send_json_success([
+ 'message' => 'Conversation deleted successfully',
+ 'deleted_count' => $deleted
+ ]);
+ } else {
+ wp_send_json_error('Failed to delete conversation');
+ }
+ }
+
+ /**
+ * AJAX handler for getting conversation history
+ */
+ public function ajax_get_conversation() {
+ check_ajax_referer('twp_ajax_nonce', 'nonce');
+
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error('Insufficient permissions');
+ }
+
+ $phone_number = isset($_POST['phone_number']) ? sanitize_text_field($_POST['phone_number']) : '';
+
+ if (empty($phone_number)) {
+ wp_send_json_error('Phone number is required');
+ }
+
+ global $wpdb;
+ $table_name = $wpdb->prefix . 'twp_sms_log';
+
+ // Get all messages involving this phone number (both incoming and outgoing)
+ $messages = $wpdb->get_results($wpdb->prepare(
+ "SELECT *,
+ CASE
+ WHEN from_number = %s THEN 'incoming'
+ ELSE 'outgoing'
+ END as direction
+ FROM $table_name
+ WHERE from_number = %s OR to_number = %s
+ ORDER BY received_at ASC",
+ $phone_number, $phone_number, $phone_number
+ ));
+
+ wp_send_json_success([
+ 'messages' => $messages,
+ 'phone_number' => $phone_number
+ ]);
+ }
+
+ /**
+ * AJAX handler for sending SMS replies
+ */
+ public function ajax_send_sms_reply() {
+ check_ajax_referer('twp_ajax_nonce', 'nonce');
+
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error('Insufficient permissions');
+ }
+
+ $to_number = isset($_POST['to_number']) ? sanitize_text_field($_POST['to_number']) : '';
+ $from_number = isset($_POST['from_number']) ? sanitize_text_field($_POST['from_number']) : '';
+ $message = isset($_POST['message']) ? sanitize_textarea_field($_POST['message']) : '';
+
+ if (empty($to_number) || empty($message)) {
+ wp_send_json_error('Phone number and message are required');
+ }
+
+ $twilio = new TWP_Twilio_API();
+ $result = $twilio->send_sms($to_number, $message, $from_number);
+
+ if ($result['success']) {
+ // Log the outgoing message to the database
+ global $wpdb;
+ $table_name = $wpdb->prefix . 'twp_sms_log';
+
+ $wpdb->insert(
+ $table_name,
+ array(
+ 'message_sid' => $result['data']['sid'],
+ 'from_number' => $from_number,
+ 'to_number' => $to_number,
+ 'body' => $message,
+ 'received_at' => current_time('mysql')
+ ),
+ array('%s', '%s', '%s', '%s', '%s')
+ );
+
+ wp_send_json_success([
+ 'message' => 'SMS sent successfully',
+ 'data' => $result['data']
+ ]);
+ } else {
+ wp_send_json_error('Failed to send SMS: ' . $result['error']);
+ }
+ }
+
+ /**
+ * Display Browser Phone page
+ */
+ public function display_browser_phone_page() {
+ // Check if smart routing is configured on any phone numbers
+ $smart_routing_configured = $this->check_smart_routing_status();
+
+ // Get user's queue memberships
+ $user_queues = $this->get_user_queue_memberships(get_current_user_id());
+ ?>
+
+
Browser Phone
+
Make and receive calls directly from your browser using Twilio Client.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
+
+
+
+
+
+
+
+
+
+
📞 Call Reception Mode
+
Choose how you want to receive incoming calls:
+
+
+
+
+
+
+
+
+
+
+
+ Current Mode:
+
+
+
+
+
+
+
+
Browser Mode: Keep this page open to receive calls. High-quality VoIP calling.
+
+
+
Cell Mode: Calls forwarded to your mobile phone:
+ Not configured';
+ ?>
+
+
+
+
+
+
+
+
📋 Setup Required
+
To enable mode switching, update your phone number webhook to:
+
+
+
This smart routing URL will automatically route calls based on your current mode preference.
+
Auto-Configure
+
+
+
+
+
+
📞 Call Queues
+
Queues you're a member of:
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_phone_numbers();
+
+ if (!$phone_numbers['success']) {
+ return false;
+ }
+
+ $smart_routing_url = home_url('/wp-json/twilio-webhook/v1/smart-routing');
+
+ foreach ($phone_numbers['data']['incoming_phone_numbers'] as $number) {
+ if ($number['voice_url'] === $smart_routing_url) {
+ return true;
+ }
+ }
+
+ return false;
+ } catch (Exception $e) {
+ error_log('TWP: Error checking smart routing status: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Get user's queue memberships
+ */
+ private function get_user_queue_memberships($user_id) {
+ global $wpdb;
+
+ // Get agent groups the user belongs to
+ $groups_table = $wpdb->prefix . 'twp_group_members';
+ $queues_table = $wpdb->prefix . 'twp_call_queues';
+
+ $user_groups = $wpdb->get_results($wpdb->prepare(
+ "SELECT gm.group_id, q.id as queue_id, q.name as queue_name
+ FROM $groups_table gm
+ JOIN $queues_table q ON FIND_IN_SET(gm.group_id, q.agent_groups)
+ WHERE gm.user_id = %d",
+ $user_id
+ ));
+
+ $queues = [];
+ foreach ($user_groups as $group) {
+ $queues[$group->queue_id] = [
+ 'id' => $group->queue_id,
+ 'name' => $group->queue_name
+ ];
+ }
+
+ return array_values($queues);
+ }
+
}
\ No newline at end of file
diff --git a/includes/class-twp-core.php b/includes/class-twp-core.php
index 58a12d8..ac410b6 100644
--- a/includes/class-twp-core.php
+++ b/includes/class-twp-core.php
@@ -135,6 +135,18 @@ class TWP_Core {
// Phone number maintenance
$this->loader->add_action('wp_ajax_twp_update_phone_status_callbacks', $plugin_admin, 'ajax_update_phone_status_callbacks');
$this->loader->add_action('wp_ajax_twp_toggle_number_status_callback', $plugin_admin, 'ajax_toggle_number_status_callback');
+
+ // Browser phone
+ $this->loader->add_action('wp_ajax_twp_generate_capability_token', $plugin_admin, 'ajax_generate_capability_token');
+ $this->loader->add_action('wp_ajax_twp_save_call_mode', $plugin_admin, 'ajax_save_call_mode');
+ $this->loader->add_action('wp_ajax_twp_auto_configure_twiml_app', $plugin_admin, 'ajax_auto_configure_twiml_app');
+ $this->loader->add_action('wp_ajax_twp_configure_phone_numbers_only', $plugin_admin, 'ajax_configure_phone_numbers_only');
+
+ // SMS management
+ $this->loader->add_action('wp_ajax_twp_delete_sms', $plugin_admin, 'ajax_delete_sms');
+ $this->loader->add_action('wp_ajax_twp_delete_conversation', $plugin_admin, 'ajax_delete_conversation');
+ $this->loader->add_action('wp_ajax_twp_get_conversation', $plugin_admin, 'ajax_get_conversation');
+ $this->loader->add_action('wp_ajax_twp_send_sms_reply', $plugin_admin, 'ajax_send_sms_reply');
}
/**
diff --git a/includes/class-twp-twilio-api.php b/includes/class-twp-twilio-api.php
index 6a1e46a..cb61df2 100644
--- a/includes/class-twp-twilio-api.php
+++ b/includes/class-twp-twilio-api.php
@@ -654,4 +654,55 @@ class TWP_Twilio_API {
];
}
}
+
+ /**
+ * Generate capability token for Browser Phone
+ */
+ public function generate_capability_token($client_name = null) {
+ $account_sid = get_option('twp_twilio_account_sid');
+ $auth_token = get_option('twp_twilio_auth_token');
+ $twiml_app_sid = get_option('twp_twiml_app_sid');
+
+ if (empty($account_sid) || empty($auth_token)) {
+ return [
+ 'success' => false,
+ 'error' => 'Twilio credentials not configured'
+ ];
+ }
+
+ if (empty($twiml_app_sid)) {
+ return [
+ 'success' => false,
+ 'error' => 'TwiML App SID not configured. Please set up a TwiML App in your Twilio Console.'
+ ];
+ }
+
+ try {
+ // Create client name if not provided
+ if (!$client_name) {
+ $current_user = wp_get_current_user();
+ $client_name = 'agent_' . $current_user->ID . '_' . sanitize_title($current_user->display_name);
+ }
+
+ $capability = new \Twilio\Jwt\ClientToken($account_sid, $auth_token);
+ $capability->allowClientOutgoing($twiml_app_sid);
+ $capability->allowClientIncoming($client_name);
+
+ $token = $capability->generateToken(3600); // Valid for 1 hour
+
+ return [
+ 'success' => true,
+ 'data' => [
+ 'token' => $token,
+ 'client_name' => $client_name,
+ 'expires_in' => 3600
+ ]
+ ];
+ } catch (\Exception $e) {
+ return [
+ 'success' => false,
+ 'error' => 'Failed to generate capability token: ' . $e->getMessage()
+ ];
+ }
+ }
}
\ No newline at end of file
diff --git a/includes/class-twp-webhooks.php b/includes/class-twp-webhooks.php
index d7d82ee..57f7f53 100644
--- a/includes/class-twp-webhooks.php
+++ b/includes/class-twp-webhooks.php
@@ -86,6 +86,34 @@ class TWP_Webhooks {
'permission_callback' => '__return_true'
));
+ // Browser phone voice webhook
+ register_rest_route('twilio-webhook/v1', '/browser-voice', array(
+ 'methods' => 'POST',
+ 'callback' => array($this, 'handle_browser_voice'),
+ 'permission_callback' => '__return_true'
+ ));
+
+ // Browser phone fallback webhook
+ register_rest_route('twilio-webhook/v1', '/browser-fallback', array(
+ 'methods' => 'POST',
+ 'callback' => array($this, 'handle_browser_fallback'),
+ 'permission_callback' => '__return_true'
+ ));
+
+ // Smart routing webhook (checks user preference)
+ register_rest_route('twilio-webhook/v1', '/smart-routing', array(
+ 'methods' => 'POST',
+ 'callback' => array($this, 'handle_smart_routing'),
+ 'permission_callback' => '__return_true'
+ ));
+
+ // Smart routing fallback webhook
+ register_rest_route('twilio-webhook/v1', '/smart-routing-fallback', array(
+ 'methods' => 'POST',
+ 'callback' => array($this, 'handle_smart_routing_fallback'),
+ 'permission_callback' => '__return_true'
+ ));
+
// Agent screening webhook (screen agent before connecting)
register_rest_route('twilio-webhook/v1', '/agent-screen', array(
'methods' => 'POST',
@@ -200,6 +228,341 @@ class TWP_Webhooks {
exit;
}
+ /**
+ * Handle browser phone voice webhook
+ */
+ public function handle_browser_voice($request) {
+ $params = $request->get_params();
+
+ $call_data = array(
+ 'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
+ 'From' => isset($params['From']) ? $params['From'] : '',
+ 'To' => isset($params['To']) ? $params['To'] : '',
+ 'CallStatus' => isset($params['CallStatus']) ? $params['CallStatus'] : ''
+ );
+
+ // Log the browser call
+ TWP_Call_Logger::log_call(array(
+ 'call_sid' => $call_data['CallSid'],
+ 'from_number' => $call_data['From'],
+ 'to_number' => $call_data['To'],
+ 'status' => 'browser_call_initiated',
+ 'actions_taken' => 'Browser phone call initiated'
+ ));
+
+ // For outbound calls from browser, handle caller ID properly
+ $twiml = '';
+ $twiml .= '';
+
+ if (isset($params['To']) && !empty($params['To'])) {
+ $to_number = $params['To'];
+ $from_number = isset($params['From']) ? $params['From'] : '';
+
+ // If it's an outgoing call to a phone number
+ if (strpos($to_number, 'client:') !== 0) {
+ $twiml .= '';
+ $twiml .= '';
+ } else {
+ // Incoming call to browser client
+ $twiml .= '';
+ $twiml .= '' . htmlspecialchars(str_replace('client:', '', $to_number)) . '';
+ $twiml .= '';
+ }
+ } else {
+ $twiml .= 'No destination number provided.';
+ }
+
+ $twiml .= '';
+
+ return $this->send_twiml_response($twiml);
+ }
+
+ /**
+ * Handle browser phone fallback when no browser clients answer
+ */
+ public function handle_browser_fallback($request) {
+ $params = $request->get_params();
+
+ $call_data = array(
+ 'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
+ 'From' => isset($params['From']) ? $params['From'] : '',
+ 'To' => isset($params['To']) ? $params['To'] : '',
+ 'DialCallStatus' => isset($params['DialCallStatus']) ? $params['DialCallStatus'] : ''
+ );
+
+ error_log('TWP Browser Fallback: No browser clients answered, status: ' . $call_data['DialCallStatus']);
+
+ // Log the fallback
+ TWP_Call_Logger::log_call(array(
+ 'call_sid' => $call_data['CallSid'],
+ 'from_number' => $call_data['From'],
+ 'to_number' => $call_data['To'],
+ 'status' => 'browser_fallback',
+ 'actions_taken' => 'Browser clients did not answer, using fallback'
+ ));
+
+ $twiml = '';
+ $twiml .= '';
+
+ // Fallback options based on call status
+ if ($call_data['DialCallStatus'] === 'no-answer' || $call_data['DialCallStatus'] === 'busy') {
+ // Try SMS notification to agents as fallback
+ $this->send_agent_notification_sms($call_data['From'], $call_data['To']);
+
+ $twiml .= 'All our agents are currently busy. We have been notified of your call and will get back to you shortly.';
+ $twiml .= 'Please stay on the line for voicemail, or hang up and we will call you back.';
+
+ // Redirect to voicemail
+ $voicemail_url = home_url('/wp-json/twilio-webhook/v1/voicemail-callback');
+ $twiml .= '' . $voicemail_url . '';
+ } else {
+ // Other statuses - generic message
+ $twiml .= 'We apologize, but we are unable to connect your call at this time. Please try again later.';
+ $twiml .= '';
+ }
+
+ $twiml .= '';
+
+ return $this->send_twiml_response($twiml);
+ }
+
+ /**
+ * Send SMS notification to agents about missed browser call
+ */
+ private function send_agent_notification_sms($customer_number, $twilio_number) {
+ // Get agents with phone numbers
+ $agents = get_users(array(
+ 'meta_key' => 'twp_phone_number',
+ 'meta_compare' => 'EXISTS'
+ ));
+
+ $message = "Missed call from {$customer_number}. Browser phone did not answer. Please call back or check voicemail.";
+
+ foreach ($agents as $agent) {
+ $agent_phone = get_user_meta($agent->ID, 'twp_phone_number', true);
+ if (!empty($agent_phone)) {
+ $twilio_api = new TWP_Twilio_API();
+ $twilio_api->send_sms($agent_phone, $message, $twilio_number);
+ }
+ }
+ }
+
+ /**
+ * Handle smart routing based on user preferences
+ */
+ public function handle_smart_routing($request) {
+ $params = $request->get_params();
+
+ $call_data = array(
+ 'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
+ 'From' => isset($params['From']) ? $params['From'] : '',
+ 'To' => isset($params['To']) ? $params['To'] : '',
+ 'CallStatus' => isset($params['CallStatus']) ? $params['CallStatus'] : ''
+ );
+
+ // Log the incoming call
+ TWP_Call_Logger::log_call(array(
+ 'call_sid' => $call_data['CallSid'],
+ 'from_number' => $call_data['From'],
+ 'to_number' => $call_data['To'],
+ 'status' => 'smart_routing',
+ 'actions_taken' => 'Smart routing - checking workflows first, then user preferences'
+ ));
+
+ // FIRST: Check if there's a workflow assigned to this phone number
+ $workflow = TWP_Workflow::get_workflow_by_phone_number($call_data['To']);
+ if ($workflow) {
+ error_log('TWP Smart Routing: Found workflow for ' . $call_data['To'] . ', executing workflow ID: ' . $workflow->id);
+
+ // Execute the workflow instead of direct routing
+ $workflow_twiml = TWP_Workflow::execute_workflow($workflow->id, $call_data);
+ if ($workflow_twiml) {
+ header('Content-Type: application/xml');
+ echo $workflow_twiml;
+ exit;
+ }
+ }
+
+ // FALLBACK: If no workflow found or workflow failed, use direct agent routing
+ error_log('TWP Smart Routing: No workflow found for ' . $call_data['To'] . ', falling back to direct agent routing');
+
+ // Check for any active agents and their preferences
+ $agents = get_users(array(
+ 'meta_key' => 'twp_phone_number',
+ 'meta_compare' => 'EXISTS'
+ ));
+
+ $browser_agents = [];
+ $cell_agents = [];
+
+ foreach ($agents as $agent) {
+ $call_mode = get_user_meta($agent->ID, 'twp_call_mode', true);
+ $agent_phone = get_user_meta($agent->ID, 'twp_phone_number', true);
+
+ if ($call_mode === 'browser') {
+ $client_name = 'agent_' . $agent->ID . '_' . sanitize_title($agent->display_name);
+ $browser_agents[] = $client_name;
+ } elseif (!empty($agent_phone)) {
+ $cell_agents[] = $agent_phone;
+ }
+ }
+
+ $twiml = '';
+ $twiml .= '';
+ $twiml .= 'Please hold while we connect you to an agent.';
+
+ // Try browser agents first, then cell agents
+ if (!empty($browser_agents)) {
+ $twiml .= '';
+ foreach ($browser_agents as $client_name) {
+ $twiml .= '' . htmlspecialchars($client_name) . '';
+ }
+ $twiml .= '';
+ } elseif (!empty($cell_agents)) {
+ // No browser agents, try cell phones
+ $twiml .= '';
+ foreach ($cell_agents as $cell_phone) {
+ $twiml .= '' . htmlspecialchars($cell_phone) . '';
+ }
+ $twiml .= '';
+ } else {
+ // No agents available
+ $twiml .= 'All agents are currently unavailable. Please leave a voicemail.';
+ $twiml .= '' . home_url('/wp-json/twilio-webhook/v1/voicemail-callback') . '';
+ }
+
+ $twiml .= '';
+
+ return $this->send_twiml_response($twiml);
+ }
+
+ /**
+ * Handle smart routing fallback when initial routing fails
+ */
+ public function handle_smart_routing_fallback($request) {
+ $params = $request->get_params();
+
+ $call_data = array(
+ 'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
+ 'From' => isset($params['From']) ? $params['From'] : '',
+ 'To' => isset($params['To']) ? $params['To'] : '',
+ 'DialCallStatus' => isset($params['DialCallStatus']) ? $params['DialCallStatus'] : ''
+ );
+
+ error_log('TWP Smart Routing Fallback: Initial routing failed, status: ' . $call_data['DialCallStatus']);
+
+ // Log the fallback
+ TWP_Call_Logger::log_call(array(
+ 'call_sid' => $call_data['CallSid'],
+ 'from_number' => $call_data['From'],
+ 'to_number' => $call_data['To'],
+ 'status' => 'routing_fallback',
+ 'actions_taken' => 'Smart routing failed, trying alternative methods'
+ ));
+
+ $twiml = '';
+ $twiml .= '';
+
+ // Get agents and their preferences for fallback routing
+ $agents = get_users(array(
+ 'meta_key' => 'twp_phone_number',
+ 'meta_compare' => 'EXISTS'
+ ));
+
+ $browser_agents = [];
+ $cell_agents = [];
+
+ foreach ($agents as $agent) {
+ $call_mode = get_user_meta($agent->ID, 'twp_call_mode', true);
+ $agent_phone = get_user_meta($agent->ID, 'twp_phone_number', true);
+
+ if ($call_mode === 'browser') {
+ $client_name = 'agent_' . $agent->ID . '_' . sanitize_title($agent->display_name);
+ $browser_agents[] = $client_name;
+ } elseif (!empty($agent_phone)) {
+ $cell_agents[] = $agent_phone;
+ }
+ }
+
+ // Fallback strategy based on initial failure
+ if ($call_data['DialCallStatus'] === 'no-answer' || $call_data['DialCallStatus'] === 'busy') {
+ // If browsers didn't answer, try cell phones; if cells didn't answer, try queue or voicemail
+ if (!empty($cell_agents) && !empty($browser_agents)) {
+ // We tried browsers first, now try cell phones
+ $twiml .= 'Trying to connect you to another agent.';
+ $twiml .= '';
+ foreach ($cell_agents as $cell_phone) {
+ $twiml .= '' . htmlspecialchars($cell_phone) . '';
+ }
+ $twiml .= '';
+
+ // If this also fails, fall through to final fallback below
+ $twiml .= 'All agents are currently busy.';
+ } else {
+ // No alternative agents available - go to final fallback
+ $twiml .= 'All agents are currently busy.';
+ }
+
+ // Send SMS notification to agents about missed call
+ $this->send_missed_call_notification($call_data['From'], $call_data['To']);
+
+ // Offer callback or voicemail options
+ $twiml .= '';
+ $twiml .= 'Press 1 to request a callback, or press 2 to leave a voicemail.';
+ $twiml .= '';
+
+ // Default to voicemail if no input
+ $twiml .= 'No response received. Transferring you to voicemail.';
+ $twiml .= '' . home_url('/wp-json/twilio-webhook/v1/voicemail-callback') . '';
+
+ } elseif ($call_data['DialCallStatus'] === 'failed') {
+ // Technical failure - provide different message
+ $twiml .= 'We are experiencing technical difficulties. Please try again later or leave a voicemail.';
+ $twiml .= '' . home_url('/wp-json/twilio-webhook/v1/voicemail-callback') . '';
+
+ } else {
+ // Other statuses or unknown - generic fallback
+ $twiml .= 'We apologize, but we are unable to connect your call at this time.';
+ $twiml .= '';
+ $twiml .= 'Press 1 to request a callback, or press 2 to leave a voicemail.';
+ $twiml .= '';
+ $twiml .= '' . home_url('/wp-json/twilio-webhook/v1/voicemail-callback') . '';
+ }
+
+ $twiml .= '';
+
+ return $this->send_twiml_response($twiml);
+ }
+
+ /**
+ * Send SMS notification to agents about missed call
+ */
+ private function send_missed_call_notification($customer_number, $twilio_number) {
+ // Get agents with phone numbers
+ $agents = get_users(array(
+ 'meta_key' => 'twp_phone_number',
+ 'meta_compare' => 'EXISTS'
+ ));
+
+ $message = "Missed call from {$customer_number}. All agents were unavailable. Customer offered callback/voicemail options.";
+
+ foreach ($agents as $agent) {
+ $agent_phone = get_user_meta($agent->ID, 'twp_phone_number', true);
+ if (!empty($agent_phone)) {
+ $twilio_api = new TWP_Twilio_API();
+ $twilio_api->send_sms($agent_phone, $message, $twilio_number);
+ }
+ }
+ }
+
/**
* Verify Twilio signature
*/
diff --git a/includes/class-twp-workflow.php b/includes/class-twp-workflow.php
index 9194989..340fcaa 100644
--- a/includes/class-twp-workflow.php
+++ b/includes/class-twp-workflow.php
@@ -87,6 +87,12 @@ class TWP_Workflow {
$stop_after_step = true; // Queue ends the workflow
break;
+ case 'browser_call':
+ error_log('TWP Workflow: Processing browser call step: ' . json_encode($step));
+ $step_twiml = self::create_browser_call_twiml($step, $elevenlabs);
+ $stop_after_step = true; // Browser call ends the workflow
+ break;
+
case 'ring_group':
$step_twiml = self::create_ring_group_twiml($step);
$stop_after_step = true; // Ring group ends the workflow
@@ -380,6 +386,90 @@ class TWP_Workflow {
return $twiml->asXML();
}
+ /**
+ * Create browser call TwiML
+ */
+ private static function create_browser_call_twiml($step, $elevenlabs) {
+ $step_data = $step['data'];
+ $twiml = new SimpleXMLElement('');
+
+ // Get announcement message
+ $message = '';
+ if (isset($step_data['announce_message']) && !empty($step_data['announce_message'])) {
+ $message = $step_data['announce_message'];
+ } else {
+ $message = 'Connecting you to our agent.';
+ }
+
+ // Handle audio type for announcement
+ $audio_type = isset($step_data['audio_type']) ? $step_data['audio_type'] : 'say';
+
+ switch ($audio_type) {
+ case 'tts':
+ $voice_id = isset($step_data['voice_id']) ? $step_data['voice_id'] : null;
+ $audio_result = $elevenlabs->text_to_speech($message, [
+ 'voice_id' => $voice_id
+ ]);
+
+ if ($audio_result['success']) {
+ $play = $twiml->addChild('Play', $audio_result['file_url']);
+ } else {
+ $say = $twiml->addChild('Say', $message);
+ $say->addAttribute('voice', 'alice');
+ }
+ break;
+
+ case 'audio':
+ $audio_url = isset($step_data['audio_url']) ? $step_data['audio_url'] : null;
+
+ if ($audio_url && !empty($audio_url)) {
+ $play = $twiml->addChild('Play', $audio_url);
+ } else {
+ $say = $twiml->addChild('Say', $message);
+ $say->addAttribute('voice', 'alice');
+ }
+ break;
+
+ default: // 'say'
+ $say = $twiml->addChild('Say', $message);
+ $say->addAttribute('voice', 'alice');
+ break;
+ }
+
+ // Dial browser clients
+ $dial = $twiml->addChild('Dial');
+ $dial->addAttribute('timeout', '30');
+
+ // Get browser client names (agents who might be online)
+ $browser_clients = isset($step_data['browser_clients']) ? $step_data['browser_clients'] : [];
+
+ if (empty($browser_clients)) {
+ // Default: try to call any available browser clients
+ // Get all users with agent capabilities
+ $agents = get_users(array(
+ 'meta_key' => 'twp_phone_number',
+ 'meta_compare' => 'EXISTS'
+ ));
+
+ foreach ($agents as $agent) {
+ $client_name = 'agent_' . $agent->ID . '_' . sanitize_title($agent->display_name);
+ $client = $dial->addChild('Client', $client_name);
+ }
+ } else {
+ // Call specific browser clients
+ foreach ($browser_clients as $client_name) {
+ $client = $dial->addChild('Client', $client_name);
+ }
+ }
+
+ // Add fallback if no browser clients answer
+ $action_url = home_url('/wp-json/twilio-webhook/v1/browser-fallback');
+ $dial->addAttribute('action', $action_url);
+ $dial->addAttribute('method', 'POST');
+
+ return $twiml->asXML();
+ }
+
/**
* Create forward TwiML
*/
@@ -980,6 +1070,19 @@ class TWP_Workflow {
return $wpdb->get_results("SELECT * FROM $table_name ORDER BY created_at DESC");
}
+ /**
+ * Get workflow by phone number
+ */
+ public static function get_workflow_by_phone_number($phone_number) {
+ global $wpdb;
+ $table_name = $wpdb->prefix . 'twp_workflows';
+
+ return $wpdb->get_row($wpdb->prepare(
+ "SELECT * FROM $table_name WHERE phone_number = %s AND is_active = 1 ORDER BY created_at DESC LIMIT 1",
+ $phone_number
+ ));
+ }
+
/**
* Update workflow
*/