From 51dd3077d2636f0623f45c3174d55b82fb21ca42 Mon Sep 17 00:00:00 2001 From: jknapp Date: Tue, 12 Aug 2025 07:05:47 -0700 Subject: [PATCH] testing progress --- admin/class-twp-admin.php | 2169 +++++++++++++++++++++++++++++ includes/class-twp-core.php | 12 + includes/class-twp-twilio-api.php | 51 + includes/class-twp-webhooks.php | 363 +++++ includes/class-twp-workflow.php | 103 ++ 5 files changed, 2698 insertions(+) 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

+
    +
  1. Go to Twilio Console → Voice → TwiML Apps
  2. +
  3. Click "Create new TwiML App"
  4. +
  5. Enter a friendly name: Browser Phone App
  6. +
  7. Set Voice URL to: + +
  8. +
  9. Set HTTP Method to: POST
  10. +
  11. Leave Status Callback URL empty (optional)
  12. +
  13. Click "Save"
  14. +
+
+ +
+

2. Get TwiML App SID

+
    +
  1. After creating the app, copy the App SID (starts with AP...)
  2. +
  3. Paste it in the "TwiML App SID" field above
  4. +
  5. Click "Save Changes"
  6. +
+
+ +
+

3. Test Browser Phone

+
    +
  1. Go to Twilio WP Plugin → Browser Phone
  2. +
  3. Wait for status to show "Ready"
  4. +
  5. Enter a phone number and select caller ID
  6. +
  7. Click "Call" to test outbound calling
  8. +
+
+
+ +
+

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.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CustomerBusiness LineLast MessagePreviewMessagesActions
+ 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); ?> + + + +
+
+ + + + + + + +
+ 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.

+ +
+
+
+
Ready
+
+ +
+ +
+ + +
+ + + + + + + + + + + + +
+ +
+ + + +
+ + +
+
+ +
+

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 */