Add queue timeout voicemail and Amazon SNS SMS provider support

This update adds two major features:

1. Queue Timeout Voicemail
   - Callers can now leave voicemail when queue timeout is reached
   - Configurable per-queue voicemail prompts with TTS support
   - Automatic transcription and urgent keyword detection
   - Admin setting to choose between voicemail or callback on timeout

2. Amazon SNS SMS Provider
   - Alternative SMS provider to Twilio for sending text messages
   - Useful when Twilio SMS approval is difficult to obtain
   - Provider abstraction layer allows switching between Twilio/SNS
   - Full AWS SNS configuration in admin settings
   - Supports custom sender IDs in compatible countries
   - Lower cost per SMS compared to Twilio

New Files:
- includes/class-twp-voicemail-handler.php - Voicemail recording handler
- includes/interface-twp-sms-provider.php - SMS provider interface
- includes/class-twp-sms-provider-twilio.php - Twilio SMS implementation
- includes/class-twp-sms-provider-sns.php - Amazon SNS implementation
- includes/class-twp-sms-manager.php - SMS provider abstraction manager
- QUEUE_VOICEMAIL_SMS_FEATURES.md - Complete feature documentation

Modified Files:
- includes/class-twp-call-queue.php - Added voicemail option to timeout handler
- includes/class-twp-twilio-api.php - Updated send_sms() to use provider abstraction
- admin/class-twp-admin.php - Added SMS provider and timeout action settings
- composer.json - Added AWS SDK dependency

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-21 11:13:54 -07:00
parent 82b735f5df
commit 4baa8f539a
10 changed files with 1189 additions and 53 deletions

View File

@@ -168,7 +168,7 @@ class TWP_Call_Queue {
private function handle_timeout($call, $queue) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
// Update status
$wpdb->update(
$table_name,
@@ -180,15 +180,36 @@ class TWP_Call_Queue {
array('%s', '%s'),
array('%d')
);
// Offer callback instead of hanging up
$callback_twiml = TWP_Callback_Manager::create_callback_twiml($queue->id, $call->from_number);
// Get timeout action preference (default to voicemail)
$timeout_action = get_option('twp_queue_timeout_action', 'voicemail');
$twilio = new TWP_Twilio_API();
$twilio->update_call($call->call_sid, array(
'twiml' => $callback_twiml
));
if ($timeout_action === 'voicemail') {
// Offer voicemail recording
require_once dirname(__FILE__) . '/class-twp-voicemail-handler.php';
$voicemail_twiml = TWP_Voicemail_Handler::create_voicemail_twiml(
$call->from_number,
$queue->id
);
$twilio->update_call($call->call_sid, array(
'twiml' => $voicemail_twiml
));
error_log("TWP Queue Timeout: Directing call {$call->call_sid} to voicemail");
} else {
// Offer callback (original behavior)
$callback_twiml = TWP_Callback_Manager::create_callback_twiml($queue->id, $call->from_number);
$twilio->update_call($call->call_sid, array(
'twiml' => $callback_twiml
));
error_log("TWP Queue Timeout: Offering callback to {$call->from_number}");
}
// Reorder queue
self::reorder_queue($queue->id);
}

View File

@@ -0,0 +1,150 @@
<?php
/**
* SMS Manager
*
* Manages SMS provider selection and message sending
*/
class TWP_SMS_Manager {
private static $provider = null;
/**
* Get the active SMS provider instance
*
* @return TWP_SMS_Provider|null
*/
public static function get_provider() {
if (self::$provider !== null) {
return self::$provider;
}
// Load interface and providers
require_once dirname(__FILE__) . '/interface-twp-sms-provider.php';
require_once dirname(__FILE__) . '/class-twp-sms-provider-twilio.php';
require_once dirname(__FILE__) . '/class-twp-sms-provider-sns.php';
// Get selected provider from settings
$selected_provider = get_option('twp_sms_provider', 'twilio');
switch ($selected_provider) {
case 'aws_sns':
self::$provider = new TWP_SMS_Provider_SNS();
break;
case 'twilio':
default:
self::$provider = new TWP_SMS_Provider_Twilio();
break;
}
return self::$provider;
}
/**
* Send an SMS message using the configured provider
*
* @param string $to_number Recipient phone number (E.164 format)
* @param string $message Message body
* @param string $from_number Sender phone number (E.164 format)
* @return array Response array with 'success' and 'data' or 'error'
*/
public static function send_sms($to_number, $message, $from_number = null) {
$provider = self::get_provider();
if (!$provider) {
return [
'success' => false,
'error' => 'No SMS provider configured'
];
}
// Validate provider configuration before sending
$validation = $provider->validate_configuration();
if (!$validation['success']) {
error_log('TWP SMS Error: Provider validation failed - ' . $validation['error']);
return $validation;
}
// Send SMS
$result = $provider->send_sms($to_number, $message, $from_number);
// Log the result
if ($result['success']) {
error_log(sprintf(
'TWP SMS: Message sent via %s to %s',
$provider->get_provider_name(),
$to_number
));
} else {
error_log(sprintf(
'TWP SMS Error: Failed to send via %s to %s - %s',
$provider->get_provider_name(),
$to_number,
$result['error'] ?? 'Unknown error'
));
}
return $result;
}
/**
* Get list of available SMS providers
*
* @return array Array of provider IDs and names
*/
public static function get_available_providers() {
return [
'twilio' => 'Twilio',
'aws_sns' => 'Amazon SNS'
];
}
/**
* Validate current provider configuration
*
* @return array Response array with 'success' and 'message' or 'error'
*/
public static function validate_current_provider() {
$provider = self::get_provider();
if (!$provider) {
return [
'success' => false,
'error' => 'No SMS provider configured'
];
}
return $provider->validate_configuration();
}
/**
* Get current provider name
*
* @return string Provider name
*/
public static function get_current_provider_name() {
$provider = self::get_provider();
if (!$provider) {
return 'None';
}
return $provider->get_provider_name();
}
/**
* Test SMS send
*
* @param string $to_number Test recipient number
* @return array Response array
*/
public static function send_test_sms($to_number) {
$message = sprintf(
'This is a test message from Twilio WordPress Plugin using %s provider at %s',
self::get_current_provider_name(),
current_time('Y-m-d H:i:s')
);
return self::send_sms($to_number, $message);
}
}

View File

@@ -0,0 +1,206 @@
<?php
/**
* Amazon SNS SMS Provider
*
* SMS provider implementation for Amazon SNS/SMS
*/
class TWP_SMS_Provider_SNS implements TWP_SMS_Provider {
private $sns_client;
private $default_sender_id;
/**
* Constructor
*/
public function __construct() {
$aws_access_key = get_option('twp_aws_access_key');
$aws_secret_key = get_option('twp_aws_secret_key');
$aws_region = get_option('twp_aws_region', 'us-east-1');
$this->default_sender_id = get_option('twp_aws_sns_sender_id', '');
// Initialize AWS SNS client if credentials are available
if (!empty($aws_access_key) && !empty($aws_secret_key)) {
try {
// Check if AWS SDK is available
if (!class_exists('Aws\Sns\SnsClient')) {
error_log('TWP SNS Error: AWS SDK not found. Please install via Composer: composer require aws/aws-sdk-php');
return;
}
$this->sns_client = new Aws\Sns\SnsClient([
'version' => 'latest',
'region' => $aws_region,
'credentials' => [
'key' => $aws_access_key,
'secret' => $aws_secret_key
]
]);
} catch (Exception $e) {
error_log('TWP SNS Error: Failed to initialize SNS client: ' . $e->getMessage());
}
}
}
/**
* Send an SMS message
*
* @param string $to_number Recipient phone number (E.164 format)
* @param string $message Message body
* @param string $from_number Sender phone number or Sender ID (not used by SNS in the same way)
* @return array Response array with 'success' and 'data' or 'error'
*/
public function send_sms($to_number, $message, $from_number = null) {
try {
if (!$this->sns_client) {
return [
'success' => false,
'error' => 'AWS SNS client not initialized. Check AWS credentials and ensure AWS SDK is installed.'
];
}
// Prepare message attributes
$message_attributes = [
'AWS.SNS.SMS.SMSType' => [
'DataType' => 'String',
'StringValue' => 'Transactional' // Transactional for higher reliability
]
];
// Use sender ID if provided or use default
$sender_id = $from_number ?: $this->default_sender_id;
if (!empty($sender_id)) {
// Remove '+' and non-alphanumeric characters for Sender ID
// Note: Sender ID is alphanumeric (3-11 chars) in many countries
$sender_id_clean = preg_replace('/[^a-zA-Z0-9]/', '', $sender_id);
if (strlen($sender_id_clean) >= 3 && strlen($sender_id_clean) <= 11) {
$message_attributes['AWS.SNS.SMS.SenderID'] = [
'DataType' => 'String',
'StringValue' => $sender_id_clean
];
}
}
// Send SMS via SNS
$result = $this->sns_client->publish([
'Message' => $message,
'PhoneNumber' => $to_number,
'MessageAttributes' => $message_attributes
]);
return [
'success' => true,
'provider' => 'aws_sns',
'data' => [
'message_id' => $result['MessageId'],
'to' => $to_number,
'body' => $message,
'sender_id' => !empty($sender_id_clean) ? $sender_id_clean : null
]
];
} catch (Aws\Exception\AwsException $e) {
return [
'success' => false,
'provider' => 'aws_sns',
'error' => $e->getAwsErrorMessage(),
'code' => $e->getAwsErrorCode()
];
} catch (Exception $e) {
return [
'success' => false,
'provider' => 'aws_sns',
'error' => $e->getMessage()
];
}
}
/**
* Get provider name
*
* @return string Provider name
*/
public function get_provider_name() {
return 'Amazon SNS';
}
/**
* Validate provider configuration
*
* @return array Response array with 'success' and 'message' or 'error'
*/
public function validate_configuration() {
// Check if AWS SDK is available
if (!class_exists('Aws\Sns\SnsClient')) {
return [
'success' => false,
'error' => 'AWS SDK not installed. Please run: composer require aws/aws-sdk-php'
];
}
$aws_access_key = get_option('twp_aws_access_key');
$aws_secret_key = get_option('twp_aws_secret_key');
$aws_region = get_option('twp_aws_region');
if (empty($aws_access_key)) {
return [
'success' => false,
'error' => 'AWS Access Key is not configured'
];
}
if (empty($aws_secret_key)) {
return [
'success' => false,
'error' => 'AWS Secret Key is not configured'
];
}
if (empty($aws_region)) {
return [
'success' => false,
'error' => 'AWS Region is not configured'
];
}
if (!$this->sns_client) {
return [
'success' => false,
'error' => 'Failed to initialize AWS SNS client'
];
}
return [
'success' => true,
'message' => 'Amazon SNS SMS provider is properly configured'
];
}
/**
* Set SMS spending limit (optional administrative function)
*
* @param float $monthly_limit Monthly spending limit in USD
* @return array Response array
*/
public function set_spending_limit($monthly_limit) {
try {
if (!$this->sns_client) {
return ['success' => false, 'error' => 'SNS client not initialized'];
}
$this->sns_client->setSMSAttributes([
'attributes' => [
'MonthlySpendLimit' => (string)$monthly_limit
]
]);
return [
'success' => true,
'message' => "SMS spending limit set to \$$monthly_limit per month"
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
];
}
}
}

View File

@@ -0,0 +1,137 @@
<?php
/**
* Twilio SMS Provider
*
* SMS provider implementation for Twilio
*/
class TWP_SMS_Provider_Twilio implements TWP_SMS_Provider {
private $client;
private $default_from_number;
/**
* Constructor
*/
public function __construct() {
$account_sid = get_option('twp_account_sid');
$auth_token = get_option('twp_auth_token');
$this->default_from_number = get_option('twp_sms_from_number');
if (!empty($account_sid) && !empty($auth_token)) {
$this->client = new Twilio\Rest\Client($account_sid, $auth_token);
}
}
/**
* Send an SMS message
*
* @param string $to_number Recipient phone number (E.164 format)
* @param string $message Message body
* @param string $from_number Sender phone number (E.164 format)
* @return array Response array with 'success' and 'data' or 'error'
*/
public function send_sms($to_number, $message, $from_number = null) {
try {
if (!$this->client) {
return [
'success' => false,
'error' => 'Twilio client not initialized. Check API credentials.'
];
}
// Determine the from number
$from = $from_number ?: $this->default_from_number;
// Validate we have a from number
if (empty($from)) {
error_log('TWP SMS Error: No from number available. Please configure SMS notification number in settings.');
return [
'success' => false,
'error' => 'No SMS from number configured. Please set SMS notification number in plugin settings.'
];
}
$sms = $this->client->messages->create(
$to_number,
[
'from' => $from,
'body' => $message
]
);
return [
'success' => true,
'provider' => 'twilio',
'data' => [
'sid' => $sms->sid,
'status' => $sms->status,
'from' => $sms->from,
'to' => $sms->to,
'body' => $sms->body,
'price' => $sms->price,
'priceUnit' => $sms->priceUnit
]
];
} catch (\Twilio\Exceptions\TwilioException $e) {
return [
'success' => false,
'provider' => 'twilio',
'error' => $e->getMessage(),
'code' => $e->getCode()
];
}
}
/**
* Get provider name
*
* @return string Provider name
*/
public function get_provider_name() {
return 'Twilio';
}
/**
* Validate provider configuration
*
* @return array Response array with 'success' and 'message' or 'error'
*/
public function validate_configuration() {
$account_sid = get_option('twp_account_sid');
$auth_token = get_option('twp_auth_token');
$from_number = get_option('twp_sms_from_number');
if (empty($account_sid)) {
return [
'success' => false,
'error' => 'Twilio Account SID is not configured'
];
}
if (empty($auth_token)) {
return [
'success' => false,
'error' => 'Twilio Auth Token is not configured'
];
}
if (empty($from_number)) {
return [
'success' => false,
'error' => 'SMS from number is not configured'
];
}
if (!$this->client) {
return [
'success' => false,
'error' => 'Failed to initialize Twilio client'
];
}
return [
'success' => true,
'message' => 'Twilio SMS provider is properly configured'
];
}
}

View File

@@ -275,49 +275,12 @@ class TWP_Twilio_API {
}
/**
* Send SMS
* Send SMS (uses configured SMS provider - Twilio or Amazon SNS)
*/
public function send_sms($to_number, $message, $from_number = null) {
try {
// Determine the from number
$from = $from_number ?: $this->phone_number;
// Validate we have a from number
if (empty($from)) {
error_log('TWP SMS Error: No from number available. Please configure SMS notification number in settings.');
return [
'success' => false,
'error' => 'No SMS from number configured. Please set SMS notification number in plugin settings.'
];
}
$sms = $this->client->messages->create(
$to_number,
[
'from' => $from,
'body' => $message
]
);
return [
'success' => true,
'data' => [
'sid' => $sms->sid,
'status' => $sms->status,
'from' => $sms->from,
'to' => $sms->to,
'body' => $sms->body,
'price' => $sms->price,
'priceUnit' => $sms->priceUnit
]
];
} catch (\Twilio\Exceptions\TwilioException $e) {
return [
'success' => false,
'error' => $e->getMessage(),
'code' => $e->getCode()
];
}
// Use SMS Manager to handle provider abstraction
require_once dirname(__FILE__) . '/class-twp-sms-manager.php';
return TWP_SMS_Manager::send_sms($to_number, $message, $from_number);
}
/**

View File

@@ -0,0 +1,226 @@
<?php
/**
* Voicemail Handler
*
* Handles voicemail recording prompts and processing
*/
class TWP_Voicemail_Handler {
/**
* Create TwiML for voicemail prompt and recording
*
* @param string $caller_number Caller's phone number
* @param int $queue_id Queue ID that timed out
* @param string $custom_prompt Optional custom voicemail prompt
* @return string TwiML XML
*/
public static function create_voicemail_twiml($caller_number, $queue_id = null, $custom_prompt = null) {
global $wpdb;
// Get queue information if provided
$queue = null;
if ($queue_id) {
$queue_table = $wpdb->prefix . 'twp_call_queues';
$queue = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $queue_table WHERE id = %d",
$queue_id
));
}
// Determine the prompt message
$prompt_message = $custom_prompt;
if (!$prompt_message && $queue && !empty($queue->voicemail_prompt)) {
$prompt_message = $queue->voicemail_prompt;
}
if (!$prompt_message) {
$prompt_message = "We're sorry, but all our agents are currently unavailable. Please leave a message after the tone, and we'll get back to you as soon as possible.";
}
// Generate TTS for the prompt
require_once dirname(__FILE__) . '/class-twp-tts-helper.php';
$tts_result = TWP_TTS_Helper::text_to_speech($prompt_message);
// Build TwiML response
$response = new \Twilio\TwiML\VoiceResponse();
if ($tts_result['success'] && !empty($tts_result['file_url'])) {
// Use generated TTS audio
$response->play($tts_result['file_url']);
} else {
// Fallback to Twilio's Say
$response->say($prompt_message, ['voice' => 'alice']);
}
// Record the voicemail
$record_params = [
'action' => home_url('/wp-json/twilio-webhook/v1/voicemail-complete'),
'recordingStatusCallback' => home_url('/wp-json/twilio-webhook/v1/voicemail-callback?' . http_build_query([
'from' => $caller_number,
'queue_id' => $queue_id,
'source' => 'queue_timeout'
])),
'recordingStatusCallbackMethod' => 'POST',
'maxLength' => 300, // 5 minutes max
'playBeep' => true,
'finishOnKey' => '#',
'transcribe' => true,
'transcribeCallback' => home_url('/wp-json/twilio-webhook/v1/voicemail-transcription')
];
$response->record($record_params);
// Thank you message after recording
$response->say('Thank you for your message. Goodbye.', ['voice' => 'alice']);
return $response;
}
/**
* Save voicemail to database
*
* @param array $voicemail_data Voicemail data
* @return int|false Voicemail ID or false on failure
*/
public static function save_voicemail($voicemail_data) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_voicemails';
$insert_data = array(
'call_sid' => sanitize_text_field($voicemail_data['call_sid']),
'from_number' => sanitize_text_field($voicemail_data['from_number']),
'to_number' => !empty($voicemail_data['to_number']) ? sanitize_text_field($voicemail_data['to_number']) : '',
'recording_url' => esc_url_raw($voicemail_data['recording_url']),
'recording_duration' => intval($voicemail_data['recording_duration']),
'workflow_id' => !empty($voicemail_data['workflow_id']) ? intval($voicemail_data['workflow_id']) : null,
'queue_id' => !empty($voicemail_data['queue_id']) ? intval($voicemail_data['queue_id']) : null,
'source' => !empty($voicemail_data['source']) ? sanitize_text_field($voicemail_data['source']) : 'workflow',
'status' => 'new',
'received_at' => current_time('mysql')
);
$result = $wpdb->insert($table_name, $insert_data);
if ($result) {
$voicemail_id = $wpdb->insert_id;
// Log the voicemail
if (class_exists('TWP_Call_Logger')) {
TWP_Call_Logger::log_action(
$voicemail_data['call_sid'],
'Voicemail recorded from queue timeout (' . $voicemail_data['recording_duration'] . 's)'
);
}
return $voicemail_id;
}
return false;
}
/**
* Update voicemail transcription
*
* @param int $voicemail_id Voicemail ID
* @param string $transcription Transcription text
* @param string $transcription_status Transcription status
* @return bool Success
*/
public static function update_transcription($voicemail_id, $transcription, $transcription_status = 'completed') {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_voicemails';
$result = $wpdb->update(
$table_name,
array(
'transcription' => sanitize_textarea_field($transcription),
'transcription_status' => $transcription_status
),
array('id' => $voicemail_id),
array('%s', '%s'),
array('%d')
);
// Check for urgent keywords in transcription
if ($result && !empty($transcription)) {
self::check_urgent_keywords($voicemail_id, $transcription);
}
return $result !== false;
}
/**
* Check transcription for urgent keywords
*
* @param int $voicemail_id Voicemail ID
* @param string $transcription Transcription text
*/
private static function check_urgent_keywords($voicemail_id, $transcription) {
global $wpdb;
// Get urgent keywords from settings
$urgent_keywords = get_option('twp_urgent_voicemail_keywords', array('urgent', 'emergency', 'asap', 'critical'));
if (is_string($urgent_keywords)) {
$urgent_keywords = array_map('trim', explode(',', $urgent_keywords));
}
// Check if transcription contains any urgent keywords
$transcription_lower = strtolower($transcription);
$found_keyword = null;
foreach ($urgent_keywords as $keyword) {
if (stripos($transcription_lower, strtolower(trim($keyword))) !== false) {
$found_keyword = $keyword;
break;
}
}
if ($found_keyword) {
// Mark voicemail as urgent
$table_name = $wpdb->prefix . 'twp_voicemails';
$wpdb->update(
$table_name,
array('is_urgent' => 1),
array('id' => $voicemail_id),
array('%d'),
array('%d')
);
// Send urgent notification
$voicemail = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d",
$voicemail_id
));
if ($voicemail && class_exists('TWP_Notifications')) {
TWP_Notifications::send_call_notification('urgent_voicemail', array(
'type' => 'urgent_voicemail',
'from_number' => $voicemail->from_number,
'keyword' => $found_keyword,
'transcription' => $transcription,
'voicemail_id' => $voicemail_id,
'admin_url' => admin_url('admin.php?page=twilio-wp-voicemails&voicemail_id=' . $voicemail_id)
));
}
error_log("TWP Voicemail: Urgent keyword '$found_keyword' detected in voicemail $voicemail_id");
}
}
/**
* Get voicemail by ID
*
* @param int $voicemail_id Voicemail ID
* @return object|null Voicemail object
*/
public static function get_voicemail($voicemail_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_voicemails';
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d",
$voicemail_id
));
}
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* SMS Provider Interface
*
* Interface for SMS providers (Twilio, Amazon SNS, etc.)
*/
interface TWP_SMS_Provider {
/**
* Send an SMS message
*
* @param string $to_number Recipient phone number (E.164 format)
* @param string $message Message body
* @param string $from_number Sender phone number (E.164 format)
* @return array Response array with 'success' and 'data' or 'error'
*/
public function send_sms($to_number, $message, $from_number = null);
/**
* Get provider name
*
* @return string Provider name
*/
public function get_provider_name();
/**
* Validate provider configuration
*
* @return array Response array with 'success' and 'message' or 'error'
*/
public function validate_configuration();
}