Files
twilio-wp-plugin/includes/class-twp-call-queue.php
jknapp 534d343526 Implement Discord and Slack notifications for call events
Settings & Configuration:
- Added Discord webhook URL, Slack webhook URL settings in admin
- Added notification type toggles (incoming calls, queue timeouts, missed calls)
- Added queue timeout threshold setting (30-1800 seconds)
- Registered all new settings with WordPress options system

Notification System:
- Created TWP_Notifications class for Discord/Slack webhook handling
- Rich message formatting with embeds/attachments for both platforms
- Color-coded notifications (blue=incoming, yellow=timeout, red=missed)
- Comprehensive error handling and logging

Integration Points:
- Incoming calls: Notifications sent when calls enter queues
- Queue timeouts: Automated monitoring via cron job (every minute)
- Missed calls: Notifications for browser phone and general missed calls
- Added notified_timeout column to prevent duplicate timeout notifications

Features:
- Professional Discord embeds with fields and timestamps
- Slack attachments with proper formatting and colors
- Automatic cron job setup for queue timeout monitoring
- Fallback to SMS notifications while Discord/Slack also work
- Configurable notification types and timeout thresholds

This provides real-time call notifications to Discord channels and Slack channels,
helping teams stay informed about incoming calls and queue issues even when
SMS notifications aren't working due to validation delays.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-13 10:47:59 -07:00

619 lines
21 KiB
PHP

<?php
/**
* Call queue management class
*/
class TWP_Call_Queue {
/**
* Add call to queue
*/
public static function add_to_queue($queue_id, $call_data) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
// Get current position in queue
$max_position = $wpdb->get_var($wpdb->prepare(
"SELECT MAX(position) FROM $table_name WHERE queue_id = %d AND status = 'waiting'",
$queue_id
));
$position = $max_position ? $max_position + 1 : 1;
$result = $wpdb->insert(
$table_name,
array(
'queue_id' => $queue_id,
'call_sid' => sanitize_text_field($call_data['call_sid']),
'from_number' => sanitize_text_field($call_data['from_number']),
'to_number' => sanitize_text_field($call_data['to_number']),
'position' => $position,
'status' => 'waiting'
),
array('%d', '%s', '%s', '%s', '%d', '%s')
);
if ($result !== false) {
// Notify agents via SMS when a new call enters the queue
self::notify_agents_for_queue($queue_id, $call_data['from_number']);
return $position;
}
return false;
}
/**
* Remove call from queue
*/
public static function remove_from_queue($call_sid) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
// Get call info before removing
$call = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name WHERE call_sid = %s",
$call_sid
));
if ($call) {
// Update status
$wpdb->update(
$table_name,
array(
'status' => 'completed',
'ended_at' => current_time('mysql')
),
array('call_sid' => $call_sid),
array('%s', '%s'),
array('%s')
);
// Reorder queue positions
self::reorder_queue($call->queue_id);
return true;
}
return false;
}
/**
* Get next call in queue
*/
public static function get_next_call($queue_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name
WHERE queue_id = %d AND status = 'waiting'
ORDER BY position ASC
LIMIT 1",
$queue_id
));
}
/**
* Answer queued call
*/
public static function answer_call($call_sid, $agent_number) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
// Update call status
$wpdb->update(
$table_name,
array(
'status' => 'answered',
'answered_at' => current_time('mysql')
),
array('call_sid' => $call_sid),
array('%s', '%s'),
array('%s')
);
// Connect call to agent
$twilio = new TWP_Twilio_API();
$twilio->forward_call($call_sid, $agent_number);
return true;
}
/**
* Process waiting calls
*/
public function process_waiting_calls() {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
$queue_table = $wpdb->prefix . 'twp_call_queues';
error_log('TWP Queue Process: Starting queue processing');
// Get all active queues
$queues = $wpdb->get_results("SELECT * FROM $queue_table");
foreach ($queues as $queue) {
error_log('TWP Queue Process: Processing queue ' . $queue->queue_name . ' (ID: ' . $queue->id . ')');
// First, try to assign agents to waiting calls
$this->assign_agents_to_waiting_calls($queue);
// Check for timed out calls
$timeout_time = date('Y-m-d H:i:s', strtotime('-' . $queue->timeout_seconds . ' seconds'));
$timed_out_calls = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table_name
WHERE queue_id = %d
AND status = 'waiting'
AND joined_at <= %s",
$queue->id,
$timeout_time
));
foreach ($timed_out_calls as $call) {
error_log('TWP Queue Process: Handling timeout for call ' . $call->call_sid);
// Handle timeout
$this->handle_timeout($call, $queue);
}
// Update caller positions and play position messages
$this->update_queue_positions($queue->id);
}
error_log('TWP Queue Process: Finished queue processing');
}
/**
* Handle call timeout
*/
private function handle_timeout($call, $queue) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
// Update status
$wpdb->update(
$table_name,
array(
'status' => 'timeout',
'ended_at' => current_time('mysql')
),
array('id' => $call->id),
array('%s', '%s'),
array('%d')
);
// Offer callback instead of hanging up
$callback_twiml = TWP_Callback_Manager::create_callback_twiml($queue->id, $call->from_number);
$twilio = new TWP_Twilio_API();
$twilio->update_call($call->call_sid, array(
'twiml' => $callback_twiml
));
// Reorder queue
self::reorder_queue($queue->id);
}
/**
* Assign agents to waiting calls
*/
private function assign_agents_to_waiting_calls($queue) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
// Get waiting calls in order
$waiting_calls = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table_name
WHERE queue_id = %d AND status = 'waiting'
ORDER BY position ASC",
$queue->id
));
if (empty($waiting_calls)) {
return;
}
error_log('TWP Queue Process: Found ' . count($waiting_calls) . ' waiting calls in queue ' . $queue->queue_name);
// Get available agents for this queue
$available_agents = $this->get_available_agents_for_queue($queue);
if (empty($available_agents)) {
error_log('TWP Queue Process: No available agents for queue ' . $queue->queue_name);
return;
}
error_log('TWP Queue Process: Found ' . count($available_agents) . ' available agents');
// Assign agents to calls (one agent per call)
$assignments = 0;
foreach ($waiting_calls as $call) {
if ($assignments >= count($available_agents)) {
break; // No more agents available
}
$agent = $available_agents[$assignments];
error_log('TWP Queue Process: Attempting to assign call ' . $call->call_sid . ' to agent ' . $agent['phone']);
// Try to bridge the call to the agent
if ($this->bridge_call_to_agent($call, $agent, $queue)) {
$assignments++;
error_log('TWP Queue Process: Successfully initiated bridge for call ' . $call->call_sid);
} else {
error_log('TWP Queue Process: Failed to bridge call ' . $call->call_sid . ' to agent');
}
}
error_log('TWP Queue Process: Made ' . $assignments . ' call assignments');
}
/**
* Get available agents for a queue
*/
private function get_available_agents_for_queue($queue) {
// If queue has assigned agent groups, get agents from those groups
if (!empty($queue->agent_groups)) {
$group_ids = explode(',', $queue->agent_groups);
$agents = array();
foreach ($group_ids as $group_id) {
$group_agents = TWP_Agent_Manager::get_available_agents(intval($group_id));
if ($group_agents) {
$agents = array_merge($agents, $group_agents);
}
}
return $agents;
}
// Fallback to all available agents
return TWP_Agent_Manager::get_available_agents();
}
/**
* Bridge call to agent
*/
private function bridge_call_to_agent($call, $agent, $queue) {
$twilio = new TWP_Twilio_API();
try {
// Create a new call to the agent
$agent_call_data = array(
'to' => $agent['phone'],
'from' => $queue->caller_id ?: $call->to_number, // Use queue caller ID or original number
'url' => home_url('/wp-json/twilio-webhook/v1/agent-connect?' . http_build_query(array(
'customer_call_sid' => $call->call_sid,
'customer_number' => $call->from_number,
'queue_id' => $queue->id,
'agent_phone' => $agent['phone'],
'queued_call_id' => $call->id
))),
'method' => 'POST',
'timeout' => 20,
'statusCallback' => home_url('/wp-json/twilio-webhook/v1/agent-call-status'),
'statusCallbackEvent' => array('answered', 'completed', 'busy', 'no-answer'),
'statusCallbackMethod' => 'POST'
);
error_log('TWP Queue Bridge: Creating agent call with data: ' . json_encode($agent_call_data));
$agent_call_response = $twilio->create_call($agent_call_data);
if ($agent_call_response['success']) {
// Update call status to indicate agent is being contacted
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
$updated = $wpdb->update(
$table_name,
array(
'status' => 'connecting',
'agent_phone' => $agent['phone'],
'agent_call_sid' => $agent_call_response['data']['sid']
),
array('call_sid' => $call->call_sid),
array('%s', '%s', '%s'),
array('%s')
);
if ($updated) {
error_log('TWP Queue Bridge: Updated call status to connecting');
return true;
} else {
error_log('TWP Queue Bridge: Failed to update call status');
}
} else {
error_log('TWP Queue Bridge: Failed to create agent call: ' . ($agent_call_response['error'] ?? 'Unknown error'));
}
} catch (Exception $e) {
error_log('TWP Queue Bridge: Exception bridging call: ' . $e->getMessage());
}
return false;
}
/**
* Update queue positions
*/
private function update_queue_positions($queue_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
$waiting_calls = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table_name
WHERE queue_id = %d AND status = 'waiting'
ORDER BY position ASC",
$queue_id
));
foreach ($waiting_calls as $index => $call) {
$position = $index + 1;
// Update position if changed
if ($call->position != $position) {
$wpdb->update(
$table_name,
array('position' => $position),
array('id' => $call->id),
array('%d'),
array('%d')
);
}
// Announce position every 30 seconds
$last_announcement = get_transient('twp_queue_announce_' . $call->call_sid);
if (!$last_announcement) {
$this->announce_position($call, $position);
set_transient('twp_queue_announce_' . $call->call_sid, true, 30);
}
}
}
/**
* Announce queue position
*/
private function announce_position($call, $position) {
$twilio = new TWP_Twilio_API();
$elevenlabs = new TWP_ElevenLabs_API();
$message = "You are currently number $position in the queue. Please hold and an agent will be with you shortly.";
// Generate TTS audio
$audio_result = $elevenlabs->text_to_speech($message);
if ($audio_result['success']) {
// Create TwiML with audio
$twiml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
$play = $twiml->addChild('Play', $audio_result['file_url']);
$play->addAttribute('loop', '0');
// Add wait music
$queue = self::get_queue($call->queue_id);
if ($queue && $queue->wait_music_url) {
$play_music = $twiml->addChild('Play', $queue->wait_music_url);
$play_music->addAttribute('loop', '0');
}
$twilio->update_call($call->call_sid, array(
'twiml' => $twiml->asXML()
));
}
}
/**
* Reorder queue positions
*/
private static function reorder_queue($queue_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_queued_calls';
$waiting_calls = $wpdb->get_results($wpdb->prepare(
"SELECT id FROM $table_name
WHERE queue_id = %d AND status = 'waiting'
ORDER BY position ASC",
$queue_id
));
foreach ($waiting_calls as $index => $call) {
$wpdb->update(
$table_name,
array('position' => $index + 1),
array('id' => $call->id),
array('%d'),
array('%d')
);
}
}
/**
* Create queue
*/
public static function create_queue($data) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_call_queues';
$insert_data = array(
'queue_name' => sanitize_text_field($data['queue_name']),
'notification_number' => !empty($data['notification_number']) ? sanitize_text_field($data['notification_number']) : '',
'agent_group_id' => !empty($data['agent_group_id']) ? intval($data['agent_group_id']) : null,
'max_size' => intval($data['max_size']),
'wait_music_url' => esc_url_raw($data['wait_music_url']),
'tts_message' => sanitize_textarea_field($data['tts_message']),
'timeout_seconds' => intval($data['timeout_seconds'])
);
$insert_format = array('%s', '%s');
if ($insert_data['agent_group_id'] === null) {
$insert_format[] = null;
} else {
$insert_format[] = '%d';
}
$insert_format = array_merge($insert_format, array('%d', '%s', '%s', '%d'));
return $wpdb->insert($table_name, $insert_data, $insert_format);
}
/**
* Update queue
*/
public static function update_queue($queue_id, $data) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_call_queues';
$update_data = array(
'queue_name' => sanitize_text_field($data['queue_name']),
'notification_number' => !empty($data['notification_number']) ? sanitize_text_field($data['notification_number']) : '',
'agent_group_id' => !empty($data['agent_group_id']) ? intval($data['agent_group_id']) : null,
'max_size' => intval($data['max_size']),
'wait_music_url' => esc_url_raw($data['wait_music_url']),
'tts_message' => sanitize_textarea_field($data['tts_message']),
'timeout_seconds' => intval($data['timeout_seconds'])
);
$update_format = array('%s', '%s');
if ($update_data['agent_group_id'] === null) {
$update_format[] = null;
} else {
$update_format[] = '%d';
}
$update_format = array_merge($update_format, array('%d', '%s', '%s', '%d'));
return $wpdb->update(
$table_name,
$update_data,
array('id' => intval($queue_id)),
$update_format,
array('%d')
);
}
/**
* Get queue
*/
public static function get_queue($queue_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_call_queues';
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d",
$queue_id
));
}
/**
* Get all queues
*/
public static function get_all_queues() {
global $wpdb;
$table_name = $wpdb->prefix . 'twp_call_queues';
return $wpdb->get_results("SELECT * FROM $table_name ORDER BY queue_name ASC");
}
/**
* Delete queue
*/
public static function delete_queue($queue_id) {
global $wpdb;
$queue_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls';
// First delete all queued calls for this queue
$wpdb->delete($calls_table, array('queue_id' => $queue_id), array('%d'));
// Then delete the queue itself
return $wpdb->delete($queue_table, array('id' => $queue_id), array('%d'));
}
/**
* Get queue status
*/
public static function get_queue_status() {
global $wpdb;
$queue_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$queues = $wpdb->get_results("SELECT * FROM $queue_table");
$status = array();
foreach ($queues as $queue) {
$waiting_count = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $calls_table WHERE queue_id = %d AND status = 'waiting'",
$queue->id
));
$status[] = array(
'queue_id' => $queue->id,
'queue_name' => $queue->queue_name,
'waiting_calls' => $waiting_count,
'max_size' => $queue->max_size,
'available_slots' => $queue->max_size - $waiting_count
);
}
return $status;
}
/**
* Notify agents via SMS when a call enters the queue
*/
private static function notify_agents_for_queue($queue_id, $caller_number) {
global $wpdb;
// Get queue information including assigned agent group and phone number
$queue_table = $wpdb->prefix . 'twp_call_queues';
$queue = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $queue_table WHERE id = %d",
$queue_id
));
if (!$queue || !$queue->agent_group_id) {
error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications");
return;
}
// Send Discord/Slack notification for incoming call
require_once dirname(__FILE__) . '/class-twp-notifications.php';
TWP_Notifications::send_call_notification('incoming_call', array(
'type' => 'incoming_call',
'caller' => $caller_number,
'queue' => $queue->queue_name,
'queue_id' => $queue_id
));
// Get members of the assigned agent group
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
if (empty($members)) {
error_log("TWP: No members found in agent group {$queue->agent_group_id} for queue {$queue_id}");
return;
}
$twilio = new TWP_Twilio_API();
// Use the queue's notification number as the from number, or fall back to default
$from_number = !empty($queue->notification_number) ? $queue->notification_number : TWP_Twilio_API::get_sms_from_number();
if (empty($from_number)) {
error_log("TWP: No SMS from number available for queue notifications");
return;
}
$message = "Call waiting in queue '{$queue->queue_name}' from {$caller_number}. Text '1' to this number to receive the next available call.";
foreach ($members as $member) {
$agent_phone = get_user_meta($member->user_id, 'twp_phone_number', true);
if (!empty($agent_phone)) {
// Send SMS notification using the queue's phone number
$twilio->send_sms($agent_phone, $message, $from_number);
// Log the notification
error_log("TWP: Queue SMS notification sent to agent {$member->user_id} at {$agent_phone} from {$from_number} for queue {$queue_id}");
}
}
}
}