Fix hold/resume functionality and customer number detection

CRITICAL FIXES:
- Fixed hold/resume functions to use proper Hold Queue system instead of empty TwiML
- Enhanced customer number detection for voicemail and call recording interfaces
- Resolved customer disconnections during hold/resume operations on outbound calls

Hold/Resume System Improvements:
- Updated ajax_toggle_hold() to use TWP_User_Queue_Manager for proper queue management
- Fixed resume function to redirect calls back to appropriate queues (personal/shared)
- Added comprehensive logging for hold queue operations and call leg detection
- Enhanced hold experience with proper TTS messages and queue tracking

Customer Number Detection Enhancements:
- Enhanced handle_voicemail_callback() with fallback logic for missing From parameters
- Added browser phone detection to identify client: calls and find real customer numbers
- Enhanced call recording to use find_customer_call_leg() for proper customer identification
- Fixed admin interfaces to show actual phone numbers instead of "client:agentname"

Technical Improvements:
- Integrated Hold Queue system with existing call leg detection infrastructure
- Added proper TwiML generation for hold/resume operations
- Enhanced error handling and logging for debugging complex call topologies
- Maintains database consistency with queue position tracking

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-01 09:34:07 -07:00
parent 5b6011bdb8
commit ae92ea2c81
5 changed files with 1734 additions and 682 deletions

View File

@@ -0,0 +1,220 @@
<?php
/**
* TTS Helper class to handle Text-to-Speech with ElevenLabs or Twilio
*/
class TWP_TTS_Helper {
private static $instance = null;
private $elevenlabs_api = null;
private $use_elevenlabs = false;
/**
* Get singleton instance
*/
public static function get_instance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
// Check if ElevenLabs is configured
$api_key = get_option('twp_elevenlabs_api_key');
$voice_id = get_option('twp_elevenlabs_voice_id');
if (!empty($api_key) && !empty($voice_id)) {
$this->use_elevenlabs = true;
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-elevenlabs-api.php';
$this->elevenlabs_api = new TWP_ElevenLabs_API();
}
}
/**
* Get cache key for text
*/
private function get_cache_key($text) {
$voice_id = get_option('twp_elevenlabs_voice_id');
$model_id = get_option('twp_elevenlabs_model_id', 'eleven_multilingual_v2');
return 'twp_tts_' . md5($text . $voice_id . $model_id);
}
/**
* Get cached audio URL if exists
*/
private function get_cached_audio($text) {
$cache_key = $this->get_cache_key($text);
$cached_data = get_transient($cache_key);
if ($cached_data !== false) {
// Verify the file still exists
if (file_exists($cached_data['file_path'])) {
error_log("TWP TTS: Using cached audio for text: " . substr($text, 0, 50) . "...");
return $cached_data;
} else {
// File was deleted, remove from cache
delete_transient($cache_key);
}
}
return false;
}
/**
* Save audio to cache
*/
private function cache_audio($text, $audio_data) {
$cache_key = $this->get_cache_key($text);
// Cache for 30 days
set_transient($cache_key, $audio_data, 30 * DAY_IN_SECONDS);
}
/**
* Add TTS to TwiML Response
*
* @param \Twilio\TwiML\VoiceResponse $twiml The TwiML response object
* @param string $text The text to speak
* @param array $options Options for voice settings (used for Twilio fallback)
* @return bool Success status
*/
public function add_tts_to_twiml($twiml, $text, $options = []) {
// Default Twilio voice options
$default_options = [
'voice' => 'alice',
'language' => 'en-US'
];
$options = array_merge($default_options, $options);
if ($this->use_elevenlabs) {
// First check cache
$cached_audio = $this->get_cached_audio($text);
if ($cached_audio !== false) {
$twiml->play($cached_audio['file_url']);
return true;
}
// Not in cache, generate new audio
$audio_result = $this->elevenlabs_api->text_to_speech($text);
if ($audio_result && isset($audio_result['success']) && $audio_result['success']) {
// Cache the result
$this->cache_audio($text, [
'file_url' => $audio_result['file_url'],
'file_path' => $audio_result['file_path']
]);
// Use the generated audio file
$twiml->play($audio_result['file_url']);
error_log("TWP TTS: Generated new ElevenLabs audio for text: " . substr($text, 0, 50) . "...");
return true;
} else {
// Log the failure and fall back to Twilio
error_log("TWP TTS: ElevenLabs failed, falling back to Twilio. Error: " .
(isset($audio_result['error']) ? $audio_result['error'] : 'Unknown error'));
}
}
// Fall back to Twilio's built-in TTS
$twiml->say($text, $options);
error_log("TWP TTS: Using Twilio voice for text: " . substr($text, 0, 50) . "...");
return true;
}
/**
* Generate TTS audio file (for pre-generation)
*
* @param string $text The text to convert
* @return array|false Array with file_url on success, false on failure
*/
public function generate_tts_audio($text) {
if (!$this->use_elevenlabs) {
return false;
}
// First check cache
$cached_audio = $this->get_cached_audio($text);
if ($cached_audio !== false) {
return [
'success' => true,
'file_url' => $cached_audio['file_url'],
'file_path' => $cached_audio['file_path'],
'cached' => true
];
}
// Not in cache, generate new audio
$result = $this->elevenlabs_api->text_to_speech($text);
if ($result && isset($result['success']) && $result['success']) {
// Cache the result
$this->cache_audio($text, [
'file_url' => $result['file_url'],
'file_path' => $result['file_path']
]);
return [
'success' => true,
'file_url' => $result['file_url'],
'file_path' => $result['file_path'],
'cached' => false
];
}
return false;
}
/**
* Check if ElevenLabs is configured and available
*
* @return bool
*/
public function is_elevenlabs_available() {
return $this->use_elevenlabs;
}
/**
* Get configured voice info
*
* @return array
*/
public function get_voice_info() {
if ($this->use_elevenlabs) {
return [
'provider' => 'elevenlabs',
'voice_id' => get_option('twp_elevenlabs_voice_id'),
'model_id' => get_option('twp_elevenlabs_model_id', 'eleven_multilingual_v2')
];
}
return [
'provider' => 'twilio',
'voice' => 'alice',
'language' => 'en-US'
];
}
/**
* Clean up old TTS files (maintenance)
*/
public function cleanup_old_files($hours = 24) {
$upload_dir = wp_upload_dir();
$path = $upload_dir['path'];
// Only clean up TTS files older than specified hours
$expire_time = time() - ($hours * 3600);
$files = glob($path . '/tts_*.mp3');
if ($files) {
foreach ($files as $file) {
if (filemtime($file) < $expire_time) {
unlink($file);
error_log("TWP TTS: Cleaned up old file: " . basename($file));
}
}
}
}
}

View File

@@ -1297,8 +1297,11 @@ class TWP_Webhooks {
$from = isset($params['From']) ? $params['From'] : '';
$workflow_id = isset($params['workflow_id']) ? intval($params['workflow_id']) : 0;
// Enhanced customer number detection for voicemails
$customer_number = $from;
// If From is not provided in the callback, try to get it from the call log
if (empty($from) && !empty($call_sid)) {
if (empty($customer_number) && !empty($call_sid)) {
global $wpdb;
$call_log_table = $wpdb->prefix . 'twp_call_log';
$call_record = $wpdb->get_row($wpdb->prepare(
@@ -1306,11 +1309,81 @@ class TWP_Webhooks {
$call_sid
));
if ($call_record && $call_record->from_number) {
$from = $call_record->from_number;
error_log('TWP Voicemail Callback: Retrieved from_number from call log: ' . $from);
$customer_number = $call_record->from_number;
error_log('TWP Voicemail Callback: Retrieved from_number from call log: ' . $customer_number);
}
}
// If we got a client identifier (browser phone), try to find the real customer number
if (!empty($customer_number) && strpos($customer_number, 'client:') === 0) {
error_log('TWP Voicemail Callback: Detected client identifier, looking for customer number');
try {
// Initialize Twilio API to find the customer call leg
$twilio_api = new TWP_Twilio_API();
$client = $twilio_api->get_client();
$call = $client->calls($call_sid)->fetch();
// Use similar logic to find_customer_call_leg but adapted for voicemail
$real_customer_number = null;
// Check if this call has a parent that contains a real phone number
if ($call->parentCallSid) {
try {
$parent_call = $client->calls($call->parentCallSid)->fetch();
if (strpos($parent_call->from, 'client:') === false && strpos($parent_call->from, '+') === 0) {
$real_customer_number = $parent_call->from;
} elseif (strpos($parent_call->to, 'client:') === false && strpos($parent_call->to, '+') === 0) {
$real_customer_number = $parent_call->to;
}
} catch (Exception $e) {
error_log("TWP Voicemail Callback: Could not fetch parent call: " . $e->getMessage());
}
}
// If no parent success, search related calls
if (!$real_customer_number) {
$related_calls = $client->calls->read(['status' => 'completed'], 20);
foreach ($related_calls as $related_call) {
if ($related_call->sid === $call_sid) continue;
// Check if calls are related
$is_related = false;
if ($call->parentCallSid && $related_call->parentCallSid === $call->parentCallSid) {
$is_related = true;
} elseif ($related_call->parentCallSid === $call_sid) {
$is_related = true;
} elseif ($related_call->sid === $call->parentCallSid) {
$is_related = true;
}
if ($is_related) {
if (strpos($related_call->from, 'client:') === false && strpos($related_call->from, '+') === 0) {
$real_customer_number = $related_call->from;
break;
} elseif (strpos($related_call->to, 'client:') === false && strpos($related_call->to, '+') === 0) {
$real_customer_number = $related_call->to;
break;
}
}
}
}
if ($real_customer_number) {
$customer_number = $real_customer_number;
error_log("TWP Voicemail Callback: Found real customer number: {$customer_number}");
} else {
error_log("TWP Voicemail Callback: WARNING - Could not find real customer number, keeping client identifier");
}
} catch (Exception $e) {
error_log("TWP Voicemail Callback: Error finding customer number: " . $e->getMessage());
}
}
// Update $from to use the detected customer number
$from = $customer_number;
// Debug what we extracted
error_log('TWP Voicemail Callback: recording_url=' . $recording_url . ', from=' . $from . ', workflow_id=' . $workflow_id . ', call_sid=' . $call_sid);