Files
fusionpbx/app/active_conferences/resources/classes/active_conferences_service.php
frytimo 46d3eb18ea Active conferences (#7684)
* Add active conferences with web sockets

* Buttons mostly working

* Convert all methods, function, variable, const to snake case instead of standards.

* Add default settings for customized control

* Add customizable settings

* More debugging default settings added

* Add better authentication handling for websocket connections
These methods were added:
- on_ws_authenticated can be overridden in the child class if there are tasks that need to be done after authentication.
- handle_ws_authenticated was added in the parent class

Handle methods are called by the this class and then their respective 'on_ws_' method is then called.

* Mute All now working

* Add PHPDoc block comments

* More PHPDoc to better describe class and variables

* Fix accidental removal of function during PHPDoc block edits

* Remove the variable type declaration for PHP 7.1 compatibility

* Update conferences with more websocket communication to replace AJAX calls.

* Ensure interface is loaded when no members

* Move color settings to theme category

* Update page view to default settings changes
2025-12-29 22:30:08 -07:00

1318 lines
44 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Handles WebSocket connections for the central system.
*
* This service builds on the shared functionality provided by {@see base_websocket_system_service}
* to manage WebSocket sessions in a uniform way across the application. It exposes a
* single entry point for the central system to create, update, and terminate connections.
*
* In addition to the standard channel sockets, the service implements *event sockets*.
* These sockets are specifically wired to the switchevent pipeline, allowing the
* service to push realtime event payloads (e.g. switch state changes, alerts) back
* to the client without requiring a separate WebSocket implementation.
*
* @author Tim Fry <tim@fusionpbx.com>
* @version 1.0.0
*/
class active_conferences_service extends base_websocket_system_service implements websocket_service_interface {
/**
* Direct mapping of switch events using Key => Value pair
*
* This is used to only subscribe to specific events from the switch
* that are relevant to active conferences.
* @var array
*/
const switch_events = [
['API-Command' => 'conference'],
['Event-Name' => 'HEARTBEAT'],
['Event-Subclass' => 'conference::maintenance'],
];
/**
* Keys to include in the switch event payload sent to clients
*
* This is used to filter the switch event data sent to clients
* to only include relevant information about the conference events.
* @var array
*/
const event_keys = [
// Event name: CHANNEL_EXECUTE, CHANNEL_DESTROY, NEW_CALL...
'event_name',
// Unique Call Identifier to determine new/existing calls
'unique_id',
// Domain
'caller_context',
'channel_presence_id',
// Ringing, Hangup, Answered
'answer_state',
'channel_call_state',
// Time stamp
'caller_channel_created_time',
// Codecs
'channel_read_codec_name',
'channel_write_codec_name',
'channel_read_codec_rate',
'channel_write_codec_rate',
'caller_channel_name',
// Caller/Callee ID
'caller_caller_id_name',
'caller_caller_id_number',
'caller_destination_number',
// Encrypted
'secure',
// Application
'application',
'application_data',
'variable_current_application',
'playback_file_path',
// Valet parking info
'valet_extension',
'action',
'variable_referred_by_user',
'variable_pre_transfer_caller_id_name',
'variable_valet_parking_timeout',
// Direction
'call_direction',
'variable_call_direction',
'other_leg_rdnis',
'other_leg_unique_id',
'content_type',
// Conference specific - FreeSWITCH headers
'conference_name', // Name of the conference including domain (from Conference-Name header)
'conference_uuid', // UUID of the conference (from Conference-Unique-ID header)
'conference_size', // Number of members (from Conference-Size header)
'conference_profile_name', // Profile name (from Conference-Profile-Name header)
'action', // start-talking, stop-talking, add-member, del-member, etc.
'floor',
'video',
'hear',
'see',
'speak',
'talking',
'mute_detect',
'hold',
'member_id',
'member_type',
'member_ghost',
'energy_level',
'current_energy',
'new_id',
'api_command_argument',
// Member caller ID from conference events
'caller_id_name', // Member's caller ID name
'caller_id_number', // Member's caller ID number
];
/**
* Map of conference actions to required permissions
*
* This is used to check if a user has the necessary permissions
* to perform a specific action on a conference.
* @var array
*/
const permission_map = [
'lock' => 'conference_interactive_lock',
'unlock' => 'conference_interactive_lock',
'mute' => 'conference_interactive_mute',
'unmute' => 'conference_interactive_mute',
'mute_all' => 'conference_interactive_mute',
'unmute_all' => 'conference_interactive_mute',
'deaf' => 'conference_interactive_deaf',
'undeaf' => 'conference_interactive_deaf',
'kick' => 'conference_interactive_kick',
'kick_all' => 'conference_interactive_kick',
'energy' => 'conference_interactive_energy',
'volume_in' => 'conference_interactive_volume',
'volume_out' => 'conference_interactive_gain',
];
/**
* Event filter used to filter conference events
*
* @var mixed
*/
protected $event_filter;
/**
* @var mixed $switch_socket The socket connection to the FreeSWITCH server
* Used for communicating with the switch to manage
* active conference sessions
*/
protected $switch_socket;
/**
* @var mixed $event_socket The event socket connection used to receive events
* from the FreeSWITCH server
* @access protected
*/
protected $event_socket;
/**
* Debug show permissions mode setting
* Values: 'bytes' (minimal), 'full' (detailed), or 'off' (disabled)
*
* @var string
*/
protected $debug_show_permissions_mode;
/**
* Debug show switch event setting
* When true, switch events are logged to debug output
*
* @var bool
*/
protected $debug_show_switch_event;
/**
* Cache for conference UUID to name mapping
* Key: conference_uuid, Value: conference_name (e.g., "room@domain.com")
*
* @var array
*/
protected array $conference_name_cache = [];
/**
* Builds a filter for the subscriber
*
* @param subscriber $subscriber
*
* @return filter
*/
public static function create_filter_chain_for(subscriber $subscriber): filter {
// Domain filtering for conferences
if ($subscriber->has_permission('conference_active_view')) {
return filter_chain::and_link([
new caller_context_filter([$subscriber->get_domain_name()]),
]);
}
// No special filtering for conferences, they are domain-specific by design
return filter_chain::or_link(self::event_keys);
}
/**
* Returns the service name for this service that is used when the web browser clients subscriber
* to this service for updates
*
* @return string
*/
public static function get_service_name(): string {
return "active.conferences";
}
/**
* Returns a string used to execute a conference command
*
* @param string $uuid The UUID of the conference (optional)
* @param string $domain_name The domain name of the conference (optional)
*
* @return string
* @access public
*/
public static function get_conference_command(string $uuid = '', string $domain_name = ''): string {
if (!empty($uuid) && !empty($domain_name)) {
$name = "$uuid@$domain_name";
} else {
$name = "";
}
return "api conference " . ($name ? $name . " " : "") . "json_list";
}
/**
* Reloads the settings for the service so the service does not have to be restarted
*
* @return void
*/
protected function reload_settings(): void {
// Re-read the config file to get any possible changes
parent::$config->read();
// Load default settings from database
$database = database::new(['config' => parent::$config]);
$settings = new settings(['database' => $database]);
$this->debug_show_permissions_mode = $settings->get('active_conferences', 'debug_show_permissions_mode', 'off');
$this->debug_show_switch_event = $settings->get('active_conferences', 'debug_show_switch_event', false) === true;
$this->debug("Loaded debug_show_permissions_mode: " . $this->debug_show_permissions_mode);
$this->debug("Loaded debug_show_switch_event: " . ($this->debug_show_switch_event ? 'true' : 'false'));
// Re-connect to the websocket server
$this->connect_to_ws_server();
// Re-connect to the switch server
if ($this->connect_to_switch_server()) {
$this->register_event_socket_filters();
}
// Add the switch event socket to the base websocket listener
$this->add_listener($this->switch_socket, [$this, 'handle_switch_events']);
}
/**
* Handles incoming FreeSWITCH events from the event socket
*
* @return void
*/
protected function handle_switch_events(): void {
$event = $this->event_socket->read_event();
$event_message = event_message::create_from_switch_event($event, $this->event_filter);
// Set the event message topic as the event name
$topic = $event_message->topic = $event_message->event_name;
switch ($topic) {
case 'conference':
case 'conference::maintenance':
$this->on_conference_maintenance($event_message);
break;
case 'heartbeat':
$this->on_heartbeat($event_message);
break;
default:
break;
}
return;
}
/**
* Called when a HEARTBEAT event is received from the switch
*
* @param event_message $event_message The event message object
*
* @return void
*/
protected function on_heartbeat($event_message): void {
$this->debug('HEARTBEAT');
}
/**
* Called when the websocket connection is established
*
* @return void
*/
protected function on_ws_connected(): void {
// Call the parent on connected function
parent::on_ws_connected();
// Show the registered service name
if ($this->ws_client->is_connected()) {
$this->info('Registered: ' . $this->get_service_name());
}
}
/**
* Registers the switch events needed for active conferences
*
* @return void
*/
protected function register_event_socket_filters(): void {
$this->event_socket->request('event plain all');
//
// CUSTOM and API are required to handle events such as:
// - 'conference::maintenance'
// - 'SMS::SEND_MESSAGE'
// - 'cache::flush'
// - 'sofia::register'
//
// $event_filter = [
// 'CUSTOM', // Event-Name is swapped with Event-Subclass
// 'API', // Event-Name is swapped with API-Command
// ];
// Merge API and CUSTOM with the events listening
// $events = array_merge(ws_active_conference_service::switch_events, $event_filter);
// Add filters for active conference events only
foreach (self::switch_events as $events) {
foreach ($events as $event_key => $event_name) {
$this->debug("Requesting event filter for [$event_key]=[$event_name]");
$response = $this->event_socket->request("filter $event_key $event_name");
while (!is_array($response)) {
$response = $this->event_socket->read_event();
}
if (is_array($response)) {
while (($response = array_pop($response)) !== "+OK filter added. [$event_key]=[$event_name]") {
$response = $this->event_socket->read_event();
usleep(1000);
}
}
$this->info("Response: " . $response);
}
}
// Create the filter to remove extra array entries in the event
// because we don't need for this event on the switch.
// This allows us to less data on websockets when an event occurs.
$this->event_filter = filter_chain::and_link([
new event_key_filter(self::event_keys)
]);
return;
}
/**
* Establishes a connection to the switch server.
*
* @return bool Returns true if the connection was successfully established, false otherwise.
*/
protected function connect_to_switch_server(): bool {
// Get configuration data from the config.conf file
$host = parent::$config->get('switch.event_socket.host', '127.0.0.1');
$port = intval(parent::$config->get('switch.event_socket.port', 8021));
$password = parent::$config->get('switch.event_socket.password', 'ClueCon');
// Create a new switch server connection object
try {
$this->switch_socket = stream_socket_client("tcp://$host:$port", $errno, $errstr, 5);
} catch (\RuntimeException $re) {
$this->warning('Unable to connect to event socket');
}
if (!$this->switch_socket) {
return false;
}
// Block (wait) for responses so we can authenticate
stream_set_blocking($this->switch_socket, true);
// Create the event_socket object using the connected socket
$this->event_socket = new event_socket($this->switch_socket);
// The host and port are already provided when we connect the socket so just provide password
$this->event_socket->connect(null, null, $password);
// No longer need to wait for events
stream_set_blocking($this->switch_socket, false);
return $this->event_socket->is_connected();
}
/**
* Displays the version of the active conferences service in the console
*
* @return void
* @override base_websocket_system_service
*/
protected static function display_version(): void {
echo "Active Conferences Service 1.0\n";
}
/**
* Handles FreeSWITCH events for active conferences.
*
* This method processes incoming switch events related to conference
* activity and performs the necessary actions based on event types.
*
* @return void
* @throws Exception
*/
protected function register_topics(): void {
$this->on_topic('in_progress', [$this, 'request_in_progress']);
$this->on_topic('room', [$this, 'subscribe_room']);
$this->on_topic('ping', [$this, 'handle_ping']);
$this->on_topic('action', [$this, 'handle_action']);
$this->on_topic('*', [$this, 'subscribe_all']);
$this->reload_settings();
}
/**
* Handle ping requests to keep the connection alive
*
* @param websocket_message $message
*
* @return void
*/
protected function handle_ping(websocket_message $message): void {
$this->debug('Ping received from client. Sending pong response.');
// Create a pong response
$response = new websocket_message();
$response
->payload(['pong' => time()])
->service_name(self::get_service_name())
->topic('pong')
->status_string('ok')
->status_code(200)
->request_id($message->request_id())
->resource_id($message->resource_id())
;
// Send the response back to the client
websocket_client::send($this->ws_client->socket(), $response);
}
/**
* Handle conference action requests from clients
*
* Actions: lock, unlock, mute, unmute, deaf, undeaf, kick, kick_all,
* mute_all, unmute_all, energy, volume_in, volume_out
*
* @param websocket_message $message
*
* @return void
*/
protected function handle_action(websocket_message $message): void {
$payload = $message->payload();
$action = $payload['action'] ?? '';
$conference_name = $payload['conference_name'] ?? '';
$member_id = $payload['member_id'] ?? '';
$uuid = $payload['uuid'] ?? '';
$direction = $payload['direction'] ?? '';
$domain_name = $payload['domain_name'] ?? '';
// Decode any URL or HTML entity encoding
$conference_name = html_entity_decode(urldecode($conference_name));
$this->debug("Action request: $action for conference: $conference_name member: $member_id");
// Get permissions from the message (attached by websocket_service)
$permissions = $message->get_permissions();
// Debug permissions based on setting (loaded in reload_settings)
if ($this->debug_show_permissions_mode === 'full') {
$this->debug("Permission check - Action: $action, Required: " . (self::permission_map[$action] ?? 'unknown'));
$this->debug("User permissions: " . json_encode($permissions));
} elseif ($this->debug_show_permissions_mode === 'bytes') {
$perm_count = count($permissions);
$perm_bytes = strlen(json_encode($permissions));
$this->debug("Permissions: $perm_count items, $perm_bytes bytes");
}
// Validate action
if (!isset(self::permission_map[$action])) {
$this->send_action_response($message, false, 'Invalid action: ' . $action);
return;
}
// Check permission
$required_permission = self::permission_map[$action];
if (!isset($permissions[$required_permission])) {
if ($this->debug_show_permissions_mode === 'full') {
$this->debug("Permission denied - Required: $required_permission, Has: " . implode(', ', array_keys($permissions)));
}
$this->warning("Permission denied: $required_permission for action: $action");
$this->send_action_response($message, false, 'Permission denied');
return;
}
if ($this->debug_show_permissions_mode === 'full') {
$this->debug("Permission granted: $required_permission for action: $action");
}
// Validate conference name (must include a domain - basic validation)
if (empty($conference_name) || strpos($conference_name, '@') === false) {
$this->warning("Invalid conference name: $conference_name");
$this->send_action_response($message, false, 'Invalid conference name');
return;
}
// Execute the action
$result = $this->execute_conference_action($action, $conference_name, $member_id, $uuid, $direction);
$this->send_action_response($message, $result['success'], $result['message']);
}
/**
* Execute a conference action via event socket
*
* @param string $action The action to execute
* @param string $conference_name The conference name
* @param string $member_id The member ID (optional)
* @param string $uuid The call UUID (optional)
* @param string $direction Direction for energy/volume (up/down)
*
* @return array ['success' => bool, 'message' => string]
*/
private function execute_conference_action(string $action, string $conference_name, string $member_id, string $uuid, string $direction): array {
$this->debug("Executing action: $action on $conference_name");
try {
switch ($action) {
case 'lock':
case 'unlock':
$cmd = "conference $conference_name $action";
event_socket::api($cmd);
break;
case 'mute':
case 'unmute':
if (empty($member_id)) {
return ['success' => false, 'message' => 'Member ID required'];
}
$cmd = "conference $conference_name $action $member_id";
event_socket::api($cmd);
// Clear hand raised flag on mute/unmute
if (!empty($uuid)) {
event_socket::api("uuid_setvar $uuid hand_raised false");
}
break;
case 'mute_all':
$cmd = "conference $conference_name mute non_moderator";
$this->debug("Executing command: $cmd");
$result = event_socket::api($cmd);
$this->debug("Command result: " . print_r($result, true));
break;
case 'unmute_all':
$cmd = "conference $conference_name unmute non_moderator";
$this->debug("Executing command: $cmd");
$result = event_socket::api($cmd);
$this->debug("Command result: " . print_r($result, true));
break;
case 'deaf':
case 'undeaf':
if (empty($member_id)) {
return ['success' => false, 'message' => 'Member ID required'];
}
$cmd = "conference $conference_name $action $member_id";
event_socket::api($cmd);
break;
case 'kick':
if (empty($uuid)) {
return ['success' => false, 'message' => 'UUID required'];
}
event_socket::api("uuid_kill $uuid");
break;
case 'kick_all':
$this->kick_all_members($conference_name);
break;
case 'energy':
if (empty($member_id) || empty($direction)) {
return ['success' => false, 'message' => 'Member ID and direction required'];
}
$current = event_socket::api("conference $conference_name energy $member_id");
$current = trim($current);
if (preg_match('/=(\d+)/', $current, $matches)) {
$value = (int)$matches[1];
$value = ($direction === 'up') ? $value + 100 : $value - 100;
event_socket::api("conference $conference_name energy $member_id $value");
}
break;
case 'volume_in':
if (empty($member_id) || empty($direction)) {
return ['success' => false, 'message' => 'Member ID and direction required'];
}
$current = event_socket::api("conference $conference_name volume_in $member_id");
$current = trim($current);
if (preg_match('/=(-?\d+)/', $current, $matches)) {
$value = (int)$matches[1];
$value = ($direction === 'up') ? $value + 1 : $value - 1;
event_socket::api("conference $conference_name volume_in $member_id $value");
}
break;
case 'volume_out':
if (empty($member_id) || empty($direction)) {
return ['success' => false, 'message' => 'Member ID and direction required'];
}
$current = event_socket::api("conference $conference_name volume_out $member_id");
$current = trim($current);
if (preg_match('/=(-?\d+)/', $current, $matches)) {
$value = (int)$matches[1];
$value = ($direction === 'up') ? $value + 1 : $value - 1;
event_socket::api("conference $conference_name volume_out $member_id $value");
}
break;
default:
return ['success' => false, 'message' => 'Unknown action'];
}
return ['success' => true, 'message' => 'Action executed'];
} catch (\Exception $e) {
$this->error("Action failed: " . $e->getMessage());
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* Kick all members from a conference
*
* @param string $conference_name
*
* @return void
*/
private function kick_all_members(string $conference_name): void {
// Get conference member list
$json_str = event_socket::api("conference '$conference_name' json_list");
$conferences = json_decode($json_str, true);
if (!is_array($conferences) || empty($conferences)) {
return;
}
$conference = $conferences[0];
$members = $conference['members'] ?? [];
$first = true;
foreach ($members as $member) {
$member_uuid = $member['uuid'] ?? '';
if (!empty($member_uuid)) {
event_socket::api("uuid_kill $member_uuid");
if ($first) {
usleep(500000); // 0.5 seconds for first member
$first = false;
} else {
usleep(10000); // 0.01 seconds for others
}
}
}
}
/**
* Send action response back to client
*
* @param websocket_message $message Original message
* @param bool $success Whether action succeeded
* @param string $status_message Status message
*
* @return void
*/
private function send_action_response(websocket_message $message, bool $success, string $status_message): void {
$response = new websocket_message();
$response
->payload(['success' => $success, 'message' => $status_message])
->service_name(self::get_service_name())
->topic('action_response')
->status_string($success ? 'ok' : 'error')
->status_code($success ? 200 : 400)
->request_id($message->request_id())
->resource_id($message->resource_id())
;
websocket_client::send($this->ws_client->socket(), $response);
}
/**
* Subscribe to all events (wildcard) - useful for debugging
*
* @param websocket_message $message
*
* @return void
*/
protected function subscribe_all(websocket_message $message): void {
$this->debug('Wildcard subscription requested - subscribing to all events');
// Forward to websocket server to register this subscriber for all events from this service
$response = new websocket_message();
$response
->payload(['subscribed' => '*'])
->service_name(self::get_service_name())
->topic('*')
->status_string('ok')
->status_code(200)
->request_id($message->request_id())
->resource_id($message->resource_id())
;
// Send the response back to the client
websocket_client::send($this->ws_client->socket(), $response);
}
/**
* Subscribe to room events (placeholder for specific room filtering)
*
* @param websocket_message $message
* @return void
*/
protected function subscribe_room(websocket_message $message): void {
$this->debug('Room subscription requested');
$response = new websocket_message();
$response
->payload(['subscribed' => 'room'])
->service_name(self::get_service_name())
->topic('room')
->status_string('ok')
->status_code(200)
->request_id($message->request_id())
->resource_id($message->resource_id())
;
websocket_client::send($this->ws_client->socket(), $response);
}
/**
* Handles requests for conferences in progress
*
* @param websocket_message $message The incoming websocket message
*
* @return void
*/
protected function request_in_progress(websocket_message $message): void {
$this->debug('Conferences in progress requested by websocket client');
// Get required parameters from the message payload
$payload = $message->payload();
$domain_name = $payload['domain_name'] ?? '';
$uuid = $payload['uuid'] ?? '';
$this->debug("in_progress request - domain_name: $domain_name, uuid: $uuid");
// Get the list of active conferences
// Note: get_conference_command returns "api conference ... json_list"
$command = self::get_conference_command($uuid, $domain_name);
// Remove "api " prefix if present to use with event_socket::api
if (substr($command, 0, 4) === 'api ') {
$command = substr($command, 4);
}
// Use a dedicated event socket for API command so we don't get any the wrong events
$json_str = trim(event_socket::api($command));
$conferences = json_decode($json_str, true);
// Enrich conferences with display names from database
if (is_array($conferences)) {
foreach ($conferences as &$conference) {
$conf_name = $conference['conference_name'] ?? '';
if (!empty($conf_name)) {
$conference['conference_display_name'] = $this->lookup_conference_display_name($conf_name);
}
}
unset($conference); // Break reference
}
// If a specific UUID was requested but no active conference found,
// check if the conference exists in the database (Conference Center room or simple Conference)
if (!empty($uuid) && (empty($conferences) || !is_array($conferences))) {
$conference_info = $this->lookup_conference_info($uuid, $domain_name);
if ($conference_info !== null) {
// Conference exists in database but has no active members
// Return an empty conference structure so the UI can show the room as valid but empty
$conferences = [[
'conference_name' => $conference_info['conference_name'],
'conference_display_name' => $conference_info['display_name'],
'conference_uuid' => $uuid,
'member_count' => 0,
'members' => [],
'locked' => false,
'recording' => false,
'exists_in_database' => true,
]];
$this->debug("Conference exists in database but has no active members: " . $conference_info['display_name']);
} else {
// Conference does not exist in database
$conferences = [[
'conference_name' => $uuid . '@' . $domain_name,
'conference_uuid' => $uuid,
'member_count' => 0,
'members' => [],
'exists_in_database' => false,
'error' => 'not_found',
]];
$this->debug("Conference not found in database: $uuid");
}
}
// Create a response message
$response = new websocket_message();
$response
->payload($conferences)
->service_name(self::get_service_name())
->topic('in_progress')
->request_id($message->request_id())
->resource_id($message->resource_id())
;
// Send the response back to the client
websocket_client::send($this->ws_client->socket(), $response);
}
/**
* Handles the conference maintenance event
*
* This method is triggered when a conference maintenance event occurs.
* It processes the event message and performs necessary maintenance operations
* for the active conference.
*
* @param event_message $event_message The event message object containing conference maintenance data
* @return void
*/
private function on_conference_maintenance(event_message $event_message): void {
// Show switch event if debug setting is enabled
if ($this->debug_show_switch_event) {
$this->debug('Processing switch event conference::maintenance');
$this->debug('Event message: ' . $event_message);
}
$action = $event_message->action ?? '';
// Replace - with _ for action names
$action = str_replace('-', '_', $action);
// Extract conference name from event using multiple fallback methods
$conference_name = $this->extract_conference_name($event_message);
switch ($action) {
case 'start_talking':
case 'stop_talking':
$this->debug("$action event");
// Talking events only need member_id - no need to fetch full data
$this->broadcast_event($event_message, $action);
break;
case 'add_member':
$this->debug('add_member event');
// Fetch complete member data for the added member
$enriched_data = $this->enrich_member_event($event_message, $conference_name);
$this->broadcast_enriched_event($enriched_data, $action, $conference_name);
break;
case 'del_member':
$this->debug('del_member event');
// For del_member, we need member_id and updated member_count
$enriched_data = $this->enrich_del_member_event($event_message, $conference_name);
$this->broadcast_enriched_event($enriched_data, $action, $conference_name);
break;
case 'mute_member':
case 'unmute_member':
case 'deaf_member':
case 'undeaf_member':
case 'floor_change':
$this->debug("$action event");
// These events just need member_id and the new state
$this->broadcast_event($event_message, $action);
break;
case 'conference_create':
$this->debug('conference_create event');
// Include conference info for new conference
$enriched_data = $this->enrich_conference_create_event($event_message, $conference_name);
$this->broadcast_enriched_event($enriched_data, $action, $conference_name);
break;
case 'conference_destroy':
$this->debug('conference_destroy event');
// Just broadcast the conference name
$this->broadcast_event($event_message, $action);
break;
case 'lock':
case 'unlock':
$this->debug("$action event");
$this->broadcast_event($event_message, $action);
break;
case 'kick_member':
case 'play_file':
case 'play_file_done':
case 'gain_level':
case 'volume_level':
case 'play_file_member_done':
case 'energy_level':
case 'execute_app':
$this->debug("$action event");
$this->broadcast_event($event_message, $action);
break;
default:
$this->debug("Unknown conference event: $event_message");
break;
}
}
/**
* Extract conference identifier from event message (UUID@domain or extension@domain)
* This is the identifier FreeSWITCH uses for API commands.
*
* @param event_message $event_message The event message
* @return string The conference identifier or empty string if not found
*/
private function extract_conference_name(event_message $event_message): string {
// Try direct conference_name field (from Conference-Name header)
$conference_name = $event_message->conference_name ?? '';
if (!empty($conference_name)) {
$this->debug("Conference identifier from conference_name: $conference_name");
return $conference_name;
}
// Try channel_presence_id (e.g., "3001@domain.com")
$presence_id = $event_message->channel_presence_id ?? '';
if (!empty($presence_id) && strpos($presence_id, '@') !== false) {
$this->debug("Conference identifier from channel_presence_id: $presence_id");
return $presence_id;
}
// Try to extract from caller_channel_name (e.g., "sofia/internal/conference+3001@domain.com")
$channel_name = $event_message->caller_channel_name ?? '';
if (!empty($channel_name) && preg_match('/conference\+([^\/]+)/', $channel_name, $matches)) {
$this->debug("Conference identifier from caller_channel_name: " . $matches[1]);
return $matches[1];
}
// Try caller_destination_number with caller_context for domain
$dest_num = $event_message->caller_destination_number ?? '';
$context = $event_message->caller_context ?? '';
if (!empty($dest_num) && !empty($context)) {
$conference_name = $dest_num . '@' . $context;
$this->debug("Conference identifier from destination+context: $conference_name");
return $conference_name;
}
$this->debug("Could not extract conference identifier from event");
return '';
}
/**
* Cache a conference key to display name mapping
* Key can be UUID (for Conference Center) or extension (for simple Conference)
*
* @param string $conference_key The conference UUID or extension
* @param string $display_name The human-readable display name
* @return void
*/
private function cache_conference_name(string $conference_key, string $display_name): void {
if (!empty($conference_key) && !empty($display_name)) {
$this->conference_name_cache[$conference_key] = $display_name;
$this->debug("Cached conference display name: $conference_key => $display_name");
}
}
/**
* Lookup human-readable conference display name from cache or database
*
* Conference Center rooms use UUID as identifier and have conference_room_name
* Simple Conferences use extension as identifier and have conference_name
*
* @param string $conference_identifier The full conference name from FreeSWITCH (e.g., "uuid@domain" or "3001@domain")
* @return string The human-readable display name or the identifier if not found
*/
private function lookup_conference_display_name(string $conference_identifier): string {
if (empty($conference_identifier)) {
return '';
}
// Parse the identifier to get the key and domain
$parts = explode('@', $conference_identifier);
$conference_key = $parts[0] ?? '';
$domain_name = $parts[1] ?? '';
if (empty($conference_key)) {
return $conference_identifier;
}
// Check cache first
if (isset($this->conference_name_cache[$conference_key])) {
$this->debug("Conference display name cache hit for: $conference_key");
return $this->conference_name_cache[$conference_key];
}
$this->debug("Conference display name cache miss for: $conference_key - querying database");
try {
$database = database::new(['config' => parent::$config]);
// Determine type by checking if key is UUID or numeric extension
if ($this->is_uuid($conference_key)) {
// Conference Center room - lookup by UUID
$sql = "SELECT cr.conference_room_name ";
$sql .= "FROM v_conference_rooms AS cr ";
$sql .= "WHERE cr.conference_room_uuid = :conference_room_uuid ";
$parameters['conference_room_uuid'] = $conference_key;
$row = $database->select($sql, $parameters, 'row');
if (!empty($row['conference_room_name'])) {
$display_name = $row['conference_room_name'];
$this->cache_conference_name($conference_key, $display_name);
$this->debug("Found Conference Center room name: $display_name");
return $display_name;
}
unset($parameters);
}
if (is_numeric($conference_key)) {
// Simple Conference - lookup by extension
$sql = "SELECT c.conference_name ";
$sql .= "FROM v_conferences AS c ";
$sql .= "LEFT JOIN v_domains AS d ON c.domain_uuid = d.domain_uuid ";
$sql .= "WHERE c.conference_extension = :conference_extension ";
if (!empty($domain_name)) {
$sql .= "AND d.domain_name = :domain_name ";
$parameters['domain_name'] = $domain_name;
}
$parameters['conference_extension'] = $conference_key;
$row = $database->select($sql, $parameters, 'row');
if (!empty($row['conference_name'])) {
$display_name = $row['conference_name'];
$this->cache_conference_name($conference_key, $display_name);
$this->debug("Found simple Conference name: $display_name");
return $display_name;
}
}
} catch (Exception $e) {
$this->debug("Database error looking up conference display name: " . $e->getMessage());
}
// Fallback to the key itself (extension number or UUID)
$this->debug("No display name found, using identifier: $conference_key");
return $conference_key;
}
/**
* Check if a string is a valid UUID
*
* @param string $string The string to check
* @return bool True if valid UUID format
*/
private function is_uuid(string $string): bool {
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $string) === 1;
}
/**
* Lookup conference information from database by UUID or extension
* Returns conference name and display name if found, null if not found
*
* @param string $identifier The conference UUID or extension
* @param string $domain_name The domain name for context
* @return array|null Array with 'conference_name' and 'display_name' keys, or null if not found
*/
private function lookup_conference_info(string $identifier, string $domain_name = ''): ?array {
if (empty($identifier)) {
return null;
}
try {
$database = database::new(['config' => parent::$config]);
// Check if identifier is a UUID (Conference Center room)
if ($this->is_uuid($identifier)) {
$sql = "SELECT cr.conference_room_uuid, cr.conference_room_name, d.domain_name ";
$sql .= "FROM v_conference_rooms AS cr ";
$sql .= "LEFT JOIN v_domains AS d ON cr.domain_uuid = d.domain_uuid ";
$sql .= "WHERE cr.conference_room_uuid = :conference_room_uuid ";
$parameters['conference_room_uuid'] = $identifier;
$row = $database->select($sql, $parameters, 'row');
if (!empty($row['conference_room_uuid'])) {
$conf_domain = $row['domain_name'] ?? $domain_name;
$conference_name = $identifier . '@' . $conf_domain;
$display_name = $row['conference_room_name'] ?? $identifier;
$this->cache_conference_name($identifier, $display_name);
$this->debug("Found Conference Center room in database: $display_name");
return [
'conference_name' => $conference_name,
'display_name' => $display_name,
'type' => 'conference_center',
];
}
unset($parameters);
}
// Check if identifier is numeric (simple Conference extension)
if (is_numeric($identifier)) {
$sql = "SELECT c.conference_uuid, c.conference_name, c.conference_extension, d.domain_name ";
$sql .= "FROM v_conferences AS c ";
$sql .= "LEFT JOIN v_domains AS d ON c.domain_uuid = d.domain_uuid ";
$sql .= "WHERE c.conference_extension = :conference_extension ";
if (!empty($domain_name)) {
$sql .= "AND d.domain_name = :domain_name ";
$parameters['domain_name'] = $domain_name;
}
$parameters['conference_extension'] = $identifier;
$row = $database->select($sql, $parameters, 'row');
if (!empty($row['conference_extension'])) {
$conf_domain = $row['domain_name'] ?? $domain_name;
$conference_name = $identifier . '@' . $conf_domain;
$display_name = $row['conference_name'] ?? $identifier;
$this->cache_conference_name($identifier, $display_name);
$this->debug("Found simple Conference in database: $display_name");
return [
'conference_name' => $conference_name,
'display_name' => $display_name,
'type' => 'conference',
];
}
}
} catch (Exception $e) {
$this->debug("Database error looking up conference info: " . $e->getMessage());
}
$this->debug("Conference not found in database: $identifier");
return null;
}
/**
* Enrich add_member event with complete member data from FreeSWITCH
*
* @param event_message $event_message The original event
* @param string $conference_name The conference name
* @return array Enriched event data with full member details
*/
private function enrich_member_event(event_message $event_message, string $conference_name): array {
$event_data = $event_message->to_array();
$member_id = $event_data['member_id'] ?? '';
$member_found = false;
$this->debug("enrich_member_event - conference_name: $conference_name, member_id: $member_id");
// Get conference data to find the member
if (!empty($conference_name)) {
$api_cmd = "conference '$conference_name' json_list";
$this->debug("Calling API: $api_cmd");
$json_str = trim(event_socket::api($api_cmd));
$this->debug("API response length: " . strlen($json_str));
$conferences = json_decode($json_str, true);
if (is_array($conferences) && !empty($conferences)) {
$conference = $conferences[0];
$members = $conference['members'] ?? [];
$event_data['member_count'] = $conference['member_count'] ?? count($members);
$this->debug("Conference found with " . count($members) . " members");
// Find the specific member
foreach ($members as $member) {
if ((string)($member['id'] ?? '') === (string)$member_id) {
$event_data['member'] = $member;
$member_found = true;
$this->debug("Found member $member_id in conference data: " . json_encode($member));
break;
}
}
if (!$member_found) {
$this->debug("Member $member_id not found in members list. Available IDs: " . implode(', ', array_column($members, 'id')));
}
} else {
$this->debug("json_list returned no conferences or invalid JSON");
}
}
// Fallback: Build member object from event data if not found via json_list
if (!$member_found && !empty($member_id)) {
$this->debug("Building member from event data as fallback");
$event_data['member'] = [
'id' => $member_id,
'uuid' => $event_data['unique_id'] ?? '',
'caller_id_name' => $event_data['caller_id_name'] ?? $event_data['caller_caller_id_name'] ?? '',
'caller_id_number' => $event_data['caller_id_number'] ?? $event_data['caller_caller_id_number'] ?? '',
'join_time' => 0,
'last_talking' => 0,
'flags' => [
'can_hear' => ($event_data['hear'] ?? 'true') === 'true',
'can_speak' => ($event_data['speak'] ?? 'true') === 'true',
'talking' => ($event_data['talking'] ?? 'false') === 'true',
'has_video' => ($event_data['video'] ?? 'false') === 'true',
'has_floor' => ($event_data['floor'] ?? 'false') === 'true',
'is_moderator' => ($event_data['member_type'] ?? '') === 'moderator',
],
];
}
$event_data['conference_name'] = $conference_name;
$event_data['conference_display_name'] = $this->lookup_conference_display_name($conference_name);
return $event_data;
}
/**
* Enrich del_member event with updated member count
*
* @param event_message $event_message The original event
* @param string $conference_name The conference name
* @return array Enriched event data
*/
private function enrich_del_member_event(event_message $event_message, string $conference_name): array {
$event_data = $event_message->to_array();
// Try to get member count from the event first (conference_size header)
$member_count = isset($event_data['conference_size']) ? (int)$event_data['conference_size'] : null;
// Get updated conference data for member count if not in event
if ($member_count === null && !empty($conference_name)) {
$json_str = trim(event_socket::api("conference '$conference_name' json_list"));
$conferences = json_decode($json_str, true);
if (is_array($conferences) && !empty($conferences)) {
$conference = $conferences[0];
$member_count = $conference['member_count'] ?? 0;
} else {
// Conference may have been destroyed
$member_count = 0;
}
}
$event_data['member_count'] = $member_count ?? 0;
$event_data['conference_name'] = $conference_name;
$event_data['conference_display_name'] = $this->lookup_conference_display_name($conference_name);
return $event_data;
}
/**
* Enrich conference_create event with conference details
*
* @param event_message $event_message The original event
* @param string $conference_name The conference name
* @return array Enriched event data
*/
private function enrich_conference_create_event(event_message $event_message, string $conference_name): array {
$event_data = $event_message->to_array();
$event_data['conference_name'] = $conference_name;
$event_data['conference_display_name'] = $this->lookup_conference_display_name($conference_name);
$event_data['member_count'] = 0;
// Extract domain from conference name
if (strpos($conference_name, '@') !== false) {
$parts = explode('@', $conference_name);
$event_data['domain_name'] = $parts[1] ?? '';
}
return $event_data;
}
/**
* Broadcast an enriched event with additional data
*
* @param array $event_data The enriched event data
* @param string $action The action/topic name
* @param string $conference_name The conference name
* @return void
*/
private function broadcast_enriched_event(array $event_data, string $action, string $conference_name): void {
$this->debug("Broadcasting enriched event - action: $action, conference: $conference_name");
$this->debug("Payload: " . json_encode($event_data));
$message = new websocket_message();
$message
->service_name(self::get_service_name())
->topic($action)
->payload($event_data)
;
websocket_client::send($this->ws_client->socket(), $message);
$this->debug("Event sent to websocket server");
}
/**
* Broadcast an event to all subscribed clients
*
* @param event_message $event_message The event data to broadcast
* @param string $action The action/topic name for the event
*
* @return void
*/
private function broadcast_event(event_message $event_message, string $action): void {
// Create a websocket message with the service_name so the websocket server
// knows which subscribers to broadcast to
$message = new websocket_message();
$message
->service_name(self::get_service_name())
->topic($action)
->payload($event_message->to_array())
;
websocket_client::send($this->ws_client->socket(), $message);
}
}