diff --git a/app/active_calls/resources/classes/event_message.php b/app/active_calls/resources/classes/event_message.php index d619ba288e..aba605fa0e 100644 --- a/app/active_calls/resources/classes/event_message.php +++ b/app/active_calls/resources/classes/event_message.php @@ -205,7 +205,7 @@ class event_message implements filterable_payload { /** * Creates a websocket_message_event object from a json string - * @param type $json_string + * @param string $json_string * @return self|null */ public static function create_from_json($json_string) { @@ -229,7 +229,7 @@ class event_message implements filterable_payload { * * @return self */ - public static function create_from_switch_event($raw_event, filter $filter = null, $flags = 3): self { + public static function create_from_switch_event($raw_event, ?filter $filter = null, ?int $flags = 3): self { // Set the options from the flags passed $swap_api_name_with_event_name = ($flags & self::EVENT_SWAP_API) !== 0; diff --git a/app/active_conferences/active_conference_room.php b/app/active_conferences/active_conference_room.php new file mode 100644 index 0000000000..a0eb4f595d --- /dev/null +++ b/app/active_conferences/active_conference_room.php @@ -0,0 +1,273 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +//includes files +require_once dirname(__DIR__, 2) . "/resources/require.php"; +require_once "resources/check_auth.php"; + +//check permissions +if (!permission_exists('conference_interactive_view')) { + echo "access denied"; + exit; +} + +//show intended global variables +global $domain_uuid, $user_uuid, $settings, $database, $config; + +//get the domain uuid +if (empty($domain_uuid)) { + $domain_uuid = $_SESSION['domain_uuid'] ?? ''; +} + +//get the user uuid +if (empty($user_uuid)) { + $user_uuid = $_SESSION['user_uuid'] ?? ''; +} + +//load the config +if (!($config instanceof config)) { + $config = config::load(); +} + +//load the database +if (!($database instanceof database)) { + $database = new database; +} + +//load the settings +if (!($settings instanceof settings)) { + $settings = new settings(['database' => $database, 'domain_uuid' => $domain_uuid, 'user_uuid' => $user_uuid]); +} + +//add multi-lingual support +$language = new text; +$text = $language->get(); + +//get the http get or post and set it as php variables +if (!empty($_REQUEST["c"]) && is_numeric($_REQUEST["c"])) { + $conference_id = $_REQUEST["c"]; +} +elseif (!empty($_REQUEST["c"]) && is_uuid($_REQUEST["c"])) { + $conference_id = $_REQUEST["c"]; +} +else { + //exit if the conference id is invalid + exit; +} + +//replace the space with underscore +$conference_name = $conference_id.'@'.$_SESSION['domain_name']; + +//get and prepare the conference display name +$conference_display_name = str_replace("-", " ", $conference_id); +$conference_display_name = str_replace("_", " ", $conference_display_name); + +//create token +$token = (new token())->create($_SERVER['PHP_SELF']); + +// Pass the token to the subscriber class so that when this subscriber makes a websocket +// connection, the subscriber object can validate the information. +subscriber::save_token($token, ['active.conferences']); + +//show the header +$document['title'] = $text['label-interactive']; +require_once dirname(__DIR__, 2) . "/resources/header.php"; + +//break the caching +$version = md5(file_get_contents(__DIR__ . '/resources/javascript/websocket_client.js')); + +//build permissions object for client-side checks +$user_permissions = [ + 'lock' => permission_exists('conference_interactive_lock'), + 'mute' => permission_exists('conference_interactive_mute'), + 'deaf' => permission_exists('conference_interactive_deaf'), + 'kick' => permission_exists('conference_interactive_kick'), + 'energy' => permission_exists('conference_interactive_energy'), + 'volume' => permission_exists('conference_interactive_volume'), + 'gain' => permission_exists('conference_interactive_gain'), + 'video' => permission_exists('conference_interactive_video'), +]; + +//get websocket settings from default settings +$ws_settings = [ + 'reconnect_delay' => (int)$settings->get('active_conferences', 'reconnect_delay', 2000), + 'ping_interval' => (int)$settings->get('active_conferences', 'ping_interval', 30000), + 'auth_timeout' => (int)$settings->get('active_conferences', 'auth_timeout', 10000), + 'pong_timeout' => (int)$settings->get('active_conferences', 'pong_timeout', 10000), + 'refresh_interval' => (int)$settings->get('active_conferences', 'refresh_interval', 0), + 'max_reconnect_delay' => (int)$settings->get('active_conferences', 'max_reconnect_delay', 30000), + 'pong_timeout_max_retries' => (int)$settings->get('active_conferences', 'pong_timeout_max_retries', 3), +]; + +//get theme colors for status indicator +$status_colors = [ + 'connected' => $settings->get('theme', 'active_conference_status_connected', '#28a745'), + 'warning' => $settings->get('theme', 'active_conference_status_warning', '#ffc107'), + 'disconnected' => $settings->get('theme', 'active_conference_status_disconnected', '#dc3545'), + 'connecting' => $settings->get('theme', 'active_conference_status_connecting', '#6c757d'), +]; + +//get status indicator mode and icons +$status_indicator_mode = $settings->get('theme', 'active_conference_status_indicator_mode', 'color'); +$status_icons = [ + 'connected' => $settings->get('theme', 'active_conference_status_icon_connected', 'fa-solid fa-plug-circle-check'), + 'warning' => $settings->get('theme', 'active_conference_status_icon_warning', 'fa-solid fa-plug-circle-exclamation'), + 'disconnected' => $settings->get('theme', 'active_conference_status_icon_disconnected', 'fa-solid fa-plug-circle-xmark'), + 'connecting' => $settings->get('theme', 'active_conference_status_icon_connecting', 'fa-solid fa-plug fa-fade'), +]; + +//get status tooltips from translations +$status_tooltips = [ + 'connected' => $text['status-connected'], + 'warning' => $text['status-warning'], + 'disconnected' => $text['status-disconnected'], + 'connecting' => $text['status-connecting'], +]; + +?> + + + + + + + +\n"; +echo "
".$text['label-interactive']." "; +if ($status_indicator_mode === 'icon') { + echo ""; +} else { + echo "
"; +} +echo "
\n"; +echo "
\n"; +echo "
\n"; +echo "
\n"; +echo "\n"; + +echo $text['description-interactive']."\n"; +echo "

\n"; + +//show the content +echo "
\n"; +echo "

\n"; + +?> + + + + diff --git a/app/active_conferences/active_conferences.php b/app/active_conferences/active_conferences.php new file mode 100644 index 0000000000..bbba8e98d2 --- /dev/null +++ b/app/active_conferences/active_conferences.php @@ -0,0 +1,174 @@ + + Portions created by the Initial Developer are Copyright (C) 2008-2024 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane + James Rose +*/ + +//includes files + require_once dirname(__DIR__, 2) . "/resources/require.php"; + require_once "resources/check_auth.php"; + +//check permissions + if (!permission_exists('conference_active_view')) { + echo "access denied"; + exit; + } + +//add multi-lingual support + $language = new text; + $text = $language->get(); + +//create token + $token = (new token())->create($_SERVER['PHP_SELF']); + +//pass the token to the subscriber class so that when this subscriber makes a websocket +//connection, the subscriber object can validate the information. + subscriber::save_token($token, ['active.conferences']); + +//include the header + $document['title'] = $text['title-active_conferences']; + require_once "resources/header.php"; + +//break the caching + $version = md5(file_get_contents(__DIR__ . '/resources/javascript/websocket_client.js')); + +//get websocket settings from default settings + $ws_settings = [ + 'reconnect_delay' => (int)$settings->get('active_conferences', 'reconnect_delay', 2000), + 'ping_interval' => (int)$settings->get('active_conferences', 'ping_interval', 30000), + 'auth_timeout' => (int)$settings->get('active_conferences', 'auth_timeout', 10000), + 'pong_timeout' => (int)$settings->get('active_conferences', 'pong_timeout', 10000), + 'refresh_interval' => (int)$settings->get('active_conferences', 'refresh_interval', 0), + 'max_reconnect_delay' => (int)$settings->get('active_conferences', 'max_reconnect_delay', 30000), + 'pong_timeout_max_retries' => (int)$settings->get('active_conferences', 'pong_timeout_max_retries', 3), + ]; + +//get theme colors for status indicator + $status_colors = [ + 'connected' => $settings->get('theme', 'active_conference_status_connected', '#28a745'), + 'warning' => $settings->get('theme', 'active_conference_status_warning', '#ffc107'), + 'disconnected' => $settings->get('theme', 'active_conference_status_disconnected', '#dc3545'), + 'connecting' => $settings->get('theme', 'active_conference_status_connecting', '#6c757d'), + ]; + +//get status indicator mode and icons + $status_indicator_mode = $settings->get('theme', 'active_conference_status_indicator_mode', 'color'); + $status_icons = [ + 'connected' => $settings->get('theme', 'active_conference_status_icon_connected', 'fa-solid fa-plug-circle-check'), + 'warning' => $settings->get('theme', 'active_conference_status_icon_warning', 'fa-solid fa-plug-circle-exclamation'), + 'disconnected' => $settings->get('theme', 'active_conference_status_icon_disconnected', 'fa-solid fa-plug-circle-xmark'), + 'connecting' => $settings->get('theme', 'active_conference_status_icon_connecting', 'fa-solid fa-plug fa-fade'), + ]; + +//get status tooltips from translations + $status_tooltips = [ + 'connected' => $text['status-connected'], + 'warning' => $text['status-warning'], + 'disconnected' => $text['status-disconnected'], + 'connecting' => $text['status-connecting'], + ]; + +?> + + + + + + + + + +\n"; + echo "
".$text['title-active_conferences'].""; + if ($status_indicator_mode === 'icon') { + echo ""; + } else { + echo "
0
"; + } + echo "
\n"; + echo "
\n"; + echo "
\n"; + echo "
\n"; + echo "\n"; + + echo $text['description-active']."\n"; + echo "

\n"; + +//show the content + echo "
"; // Replaced ajax_response + echo "

"; + +//include the footer + require_once "resources/footer.php"; + +?> diff --git a/app/active_conferences/app_config.php b/app/active_conferences/app_config.php new file mode 100644 index 0000000000..5a3d5eceb5 --- /dev/null +++ b/app/active_conferences/app_config.php @@ -0,0 +1,237 @@ +create($_SERVER['PHP_SELF']); + +// Save the token +subscriber::save_token($token, [active_conferences_service::get_service_name()]); + +//break the caching +$version = md5(file_get_contents(__DIR__ . '/resources/javascript/websocket_client.js')); + +?> + + + + + + + + WebSocket Event Logger + + + + + + +

WebSocket Event Logger

+ +
+ + + +
+ +
+ Disconnected +
+ +
+
+ WebSocket Events (0 events) + +
+
+
+
No events received yet. Connect to WebSocket to start logging.
+
+
+
+ + + + diff --git a/app/active_conferences/resources/classes/active_conferences_service.php b/app/active_conferences/resources/classes/active_conferences_service.php new file mode 100644 index 0000000000..5d8b5a89cd --- /dev/null +++ b/app/active_conferences/resources/classes/active_conferences_service.php @@ -0,0 +1,1317 @@ + + * @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); + } +} diff --git a/app/active_conferences/resources/classes/event_type_permission_filter.php b/app/active_conferences/resources/classes/event_type_permission_filter.php new file mode 100644 index 0000000000..835e575c68 --- /dev/null +++ b/app/active_conferences/resources/classes/event_type_permission_filter.php @@ -0,0 +1,114 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + */ + +/** + * Filters events based on event type permissions. + * + * When the 'event_name' or 'action' key is encountered, this filter checks + * if the subscriber has permission to receive this event type. If not, + * returns null to drop the entire message. + * + * @author FusionPBX + */ +class event_type_permission_filter implements filter { + + /** + * Map of event types to required permissions + * @var array + */ + private $event_permission_map; + + /** + * The subscriber's permissions + * @var array + */ + private $permissions; + + /** + * Whether permission check has been performed for this message + * @var bool + */ + private $checked = false; + + /** + * Constructor + * + * @param array $event_permission_map Map of event types to required permissions + * @param array $permissions The subscriber's permissions + */ + public function __construct(array $event_permission_map, array $permissions) { + $this->event_permission_map = $event_permission_map; + $this->permissions = $permissions; + } + + /** + * Check if the subscriber has permission to receive this event type. + * + * When invoked with the 'event_name' or 'action' key, checks the event type + * against the permission map. Returns null to drop the message if subscriber + * doesn't have permission. + * + * @param string $key The key from the payload + * @param mixed $value The value from the payload + * + * @return bool|null True if permitted or not an event type key, null to drop message + */ + public function __invoke(string $key, $value): ?bool { + // Only check event_name or action keys (first one wins) + if ($this->checked || ($key !== 'event_name' && $key !== 'action')) { + return true; + } + + $this->checked = true; + + // Normalize the event name (replace hyphens with underscores) + $event_type = str_replace('-', '_', $value); + + // Look up required permission for this event type + $required_permission = $this->event_permission_map[$event_type] ?? null; + + // If event is not in map, allow by default (base view permission already checked) + if ($required_permission === null) { + return true; + } + + // Check if subscriber has the required permission + if (isset($this->permissions[$required_permission])) { + return true; + } + + // Subscriber doesn't have permission - drop the entire message + return null; + } + + /** + * Reset the filter for reuse with a new message + */ + public function reset(): void { + $this->checked = false; + } +} diff --git a/app/active_conferences/resources/images/hear.png b/app/active_conferences/resources/images/hear.png new file mode 100644 index 0000000000..0cce769800 Binary files /dev/null and b/app/active_conferences/resources/images/hear.png differ diff --git a/app/active_conferences/resources/images/moderator.png b/app/active_conferences/resources/images/moderator.png new file mode 100644 index 0000000000..ec244dfb99 Binary files /dev/null and b/app/active_conferences/resources/images/moderator.png differ diff --git a/app/active_conferences/resources/images/not_recording.png b/app/active_conferences/resources/images/not_recording.png new file mode 100644 index 0000000000..897a15abf5 Binary files /dev/null and b/app/active_conferences/resources/images/not_recording.png differ diff --git a/app/active_conferences/resources/images/participant.png b/app/active_conferences/resources/images/participant.png new file mode 100644 index 0000000000..78889529c9 Binary files /dev/null and b/app/active_conferences/resources/images/participant.png differ diff --git a/app/active_conferences/resources/images/recording.png b/app/active_conferences/resources/images/recording.png new file mode 100644 index 0000000000..8a18cb565b Binary files /dev/null and b/app/active_conferences/resources/images/recording.png differ diff --git a/app/active_conferences/resources/images/speak.png b/app/active_conferences/resources/images/speak.png new file mode 100644 index 0000000000..7f8f553dba Binary files /dev/null and b/app/active_conferences/resources/images/speak.png differ diff --git a/app/active_conferences/resources/images/video.png b/app/active_conferences/resources/images/video.png new file mode 100644 index 0000000000..67ea73752a Binary files /dev/null and b/app/active_conferences/resources/images/video.png differ diff --git a/app/active_conferences/resources/javascript/active_conferences.js b/app/active_conferences/resources/javascript/active_conferences.js new file mode 100644 index 0000000000..502dd92567 --- /dev/null +++ b/app/active_conferences/resources/javascript/active_conferences.js @@ -0,0 +1,1347 @@ +/** + * Active Conferences JS + * Handles WebSocket communication and rendering for Active Conferences List and Room views. + */ + +// Global state variables +let ws = null; +let reconnect_attempts = 0; +let ping_interval_timer = null; +let last_pong_time = Date.now(); +let ping_timeout = null; +let auth_timeout = null; +let refresh_interval_timer = null; +let pong_failure_count = 0; + +// Room specific state +let member_timers = {}; +let timer_interval = null; + +/** + * Connect to the WebSocket server + */ +function connect_websocket() { + const ws_url = `wss://${window.location.hostname}/websockets/`; + + try { + ws = new ws_client(ws_url, token); + + ws.on_event('authenticated', on_authenticated); + + // Handle authentication failure + ws.on_event('authentication_failed', function(event) { + console.error('WebSocket authentication failed - session may have expired'); + update_connection_status('disconnected'); + // Use global PROJECT_PATH if available, or just reload/redirect + const project_path = (typeof PROJECT_PATH !== 'undefined') ? PROJECT_PATH : ''; + window.location.href = project_path + '/?path=' + encodeURIComponent(window.location.pathname); + }); + + ws.ws.addEventListener("open", () => { + console.log('WebSocket connection opened'); + reconnect_attempts = 0; + update_connection_status('connecting'); + + auth_timeout = setTimeout(() => { + console.error('Authentication timeout - session may have expired'); + update_connection_status('disconnected'); + const project_path = (typeof PROJECT_PATH !== 'undefined') ? PROJECT_PATH : ''; + window.location.href = project_path + '/?path=' + encodeURIComponent(window.location.pathname); + }, ws_config.auth_timeout); + }); + + ws.ws.addEventListener("close", (event) => { + console.warn('WebSocket disconnected - code:', event.code); + if (auth_timeout) { clearTimeout(auth_timeout); auth_timeout = null; } + update_connection_status('disconnected'); + + if (ping_interval_timer) { clearInterval(ping_interval_timer); ping_interval_timer = null; } + if (refresh_interval_timer) { clearInterval(refresh_interval_timer); refresh_interval_timer = null; } + if (ping_timeout) { clearTimeout(ping_timeout); ping_timeout = null; } + + setTimeout(() => { window.location.reload(); }, ws_config.reconnect_delay); + }); + + ws.ws.addEventListener("error", (error) => { + console.error('WebSocket error:', error); + }); + + } catch (error) { + console.error('Failed to connect to WebSocket:', error); + update_connection_status('disconnected'); + } +} + +/** + * Handle successful authentication + */ +function on_authenticated(message) { + console.log('WebSocket authenticated'); + pong_failure_count = 0; + update_connection_status('warning'); + if (auth_timeout) { clearTimeout(auth_timeout); auth_timeout = null; } + + send_ping(); + if (ping_interval_timer) clearInterval(ping_interval_timer); + ping_interval_timer = setInterval(send_ping, ws_config.ping_interval); + + // No polling - websockets only for real-time updates + // refresh_interval is not used; we rely on incremental websocket events + + ws.subscribe('active.conferences'); + + // Register event handlers + ws.on_event('*', handle_websocket_event); + + // Initial load - one-time fetch on connection + refresh_data(); +} + +/** + * Universal refresh function that delegates to specific load functions + */ +function refresh_data() { + if (typeof load_conference_room_data === 'function' && document.getElementById('conference_container')) { + load_conference_room_data(); + } else if (typeof load_conference_list === 'function' && document.getElementById('conferences_container')) { + load_conference_list(); + } +} + +/** + * Universal event handler that delegates + */ +function handle_websocket_event(event) { + console.log('handle_websocket_event - received event:', JSON.stringify(event)); + console.log('handle_websocket_event - conference_container exists:', !!document.getElementById('conference_container')); + console.log('handle_websocket_event - conferences_container exists:', !!document.getElementById('conferences_container')); + + if (typeof handle_room_event === 'function' && document.getElementById('conference_container')) { + console.log('Delegating to handle_room_event'); + handle_room_event(event); + } else if (typeof handle_list_event === 'function' && document.getElementById('conferences_container')) { + console.log('Delegating to handle_list_event'); + handle_list_event(event); + } else { + console.log('No handler matched - no container found'); + } +} + +function update_connection_status(state) { + const el = document.getElementById('connection_status'); + if (!el) return; + + const color = status_colors[state] || status_colors.connecting; + const tooltip = status_tooltips[state] || status_tooltips.connecting; + + el.title = tooltip; + + if (status_indicator_mode === 'icon') { + const icon = status_icons[state] || status_icons.connecting; + el.className = icon; + el.style.color = color; + } else { + el.style.backgroundColor = color; + } +} + +function send_ping() { + if (!ws || !ws.ws || ws.ws.readyState !== WebSocket.OPEN) return; + + // Track if we had failures before this ping (indicates potential disconnect/reconnect) + const had_failures = pong_failure_count > 0; + + ping_timeout = setTimeout(() => { + pong_failure_count++; + if (pong_failure_count >= ws_config.pong_timeout_max_retries) { + update_connection_status('disconnected'); + window.location.reload(); + } else { + update_connection_status('warning'); + } + }, ws_config.pong_timeout); + + ws.request('active.conferences', 'ping', {}) + .then(() => { + if (ping_timeout) { clearTimeout(ping_timeout); ping_timeout = null; } + pong_failure_count = 0; + last_pong_time = Date.now(); + update_connection_status('connected'); + + // If we had failures before, refresh data to sync state after reconnection + if (had_failures) { + console.log('Pong received after failures - refreshing data'); + refresh_data(); + } + }) + .catch(console.error); +} + +function escapeHtml(text) { + if (text === null || text === undefined) return ''; + return text.toString() + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/* =========================== + * Active Conferences List Logic + * =========================== */ + +function load_conference_list() { + if (!ws || !ws.ws || ws.ws.readyState !== WebSocket.OPEN) return; + + // domain_name global must be defined in PHP + ws.request('active.conferences', 'in_progress', {domain_name: domain_name}) + .then(response => { + render_conference_list(response.payload || response); + }) + .catch(console.error); +} + +function render_conference_list(conferences) { + const container = document.getElementById('conferences_container'); + if (!container) return; + + if (!conferences) conferences = []; + + const filtered_conferences = conferences.filter(conf => { + return conf.conference_name.includes('@' + domain_name); + }); + + let html = "
\n"; + html += "\n"; + html += "\n"; + html += " \n"; + html += " \n"; + html += " \n"; + html += " \n"; + if (permissions && permissions.conference_interactive_view) { + html += " \n"; + } + html += "\n"; + + if (filtered_conferences.length > 0) { + filtered_conferences.forEach(row => { + const full_name = row.conference_name; + // Use conference_display_name from database if available, otherwise parse from conference_name + let display_name = row.conference_display_name || ''; + if (!display_name) { + display_name = full_name; + if (full_name.includes('@')) { + display_name = full_name.split('@')[0]; + } + display_name = display_name.replace(/-/g, ' ').replace(/_/g, ' '); + } + + // Get extension from conference_name (UUID for Conference Center, extension for simple Conference) + let extension = full_name; + if (full_name.includes('@')) { + extension = full_name.split('@')[0]; + } + + const member_count = row.member_count; + const list_row_url = 'active_conference_room.php?c=' + encodeURIComponent(extension); + + html += "\n"; + html += " \n"; + html += " \n"; + html += " \n"; + html += " \n"; + + if (permissions && permissions.conference_interactive_view && permissions.list_row_edit_button) { + html += " \n"; + } + html += "\n"; + }); + } + html += "
" + text['label-name'] + "" + text['label-extension'] + "" + text['label-participant-pin'] + "" + text['label-member-count'] + " 
"; + if (permissions && permissions.conference_interactive_view) { + html += " " + escapeHtml(display_name) + ""; + } else { + html += escapeHtml(display_name); + } + html += " " + escapeHtml(extension) + "" + member_count + ""; + html += " "; + html += " "; + html += " "; + html += "
\n"; + html += "
\n"; + + container.innerHTML = html; + + const rows = container.querySelectorAll('.list-row'); + rows.forEach(row => { + row.addEventListener('click', function(e) { + if (e.target.closest('a') || e.target.closest('.action-button')) return; + const url = this.getAttribute('href'); + if (url) window.location = url; + }); + }); +} + +function handle_list_event(event) { + const payload = event.payload || event; + const action = payload.action || event.action || event.event_name; + const evt_domain_name = payload.domain_name || event.domain_name; + const conference_name = payload.conference_name || ''; + + // Filter by domain if provided + if (evt_domain_name && evt_domain_name !== domain_name) { + // Also check if conference_name contains our domain + if (conference_name && !conference_name.includes('@' + domain_name)) { + return; + } + } + + // Handle events incrementally without full refresh + switch (action) { + case 'add-member': + case 'add_member': + handle_list_add_member(payload); + break; + case 'del-member': + case 'del_member': + handle_list_del_member(payload); + break; + case 'conference-create': + case 'conference_create': + handle_list_conference_create(payload); + break; + case 'conference-destroy': + case 'conference_destroy': + handle_list_conference_destroy(payload); + break; + default: + // Unknown event, ignore + break; + } + + // Update count display + update_conference_list_count(); +} + +/** + * Handle add_member event incrementally - update member count + */ +function handle_list_add_member(payload) { + const conference_name = payload.conference_name || ''; + const conference_display_name = payload.conference_display_name || ''; + const member_count = payload.member_count; + + if (!conference_name) return; + + // Find existing row by data-conference-name attribute or href + const rows = document.querySelectorAll('.list-row'); + let found = false; + rows.forEach(row => { + const row_conf_name = row.getAttribute('data-conference-name'); + if (row_conf_name === conference_name) { + found = true; + if (member_count !== undefined) { + const cells = row.querySelectorAll('td'); + // Member count is in the 4th column (index 3) + if (cells.length > 3) { + cells[3].textContent = member_count; + } + } + } + }); + + // If not found and it's for our domain, it might be a new conference - add row + if (!found && conference_name.includes('@' + domain_name)) { + add_conference_row(conference_name, member_count || 1, conference_display_name); + } +} + +/** + * Handle del_member event incrementally - update member count + */ +function handle_list_del_member(payload) { + const conference_name = payload.conference_name || ''; + const member_count = payload.member_count; + + if (!conference_name) return; + + // Find row by data-conference-name attribute + const rows = document.querySelectorAll('.list-row'); + rows.forEach(row => { + const row_conf_name = row.getAttribute('data-conference-name'); + if (row_conf_name === conference_name) { + if (member_count !== undefined) { + const cells = row.querySelectorAll('td'); + if (cells.length > 3) { + cells[3].textContent = member_count; + } + } + // If member count is 0, conference will be destroyed separately + } + }); +} + +/** + * Handle conference_create event - add new row to list + */ +function handle_list_conference_create(payload) { + const conference_name = payload.conference_name || ''; + const conference_display_name = payload.conference_display_name || ''; + + if (!conference_name || !conference_name.includes('@' + domain_name)) return; + + // Check if row already exists by data-conference-name attribute + const existing_rows = document.querySelectorAll('.list-row'); + let exists = false; + existing_rows.forEach(row => { + const row_conf_name = row.getAttribute('data-conference-name'); + if (row_conf_name === conference_name) { + exists = true; + } + }); + + if (!exists) { + add_conference_row(conference_name, 0, conference_display_name); + } +} + +function update_conference_list_count() { + const container = document.getElementById('conferences_container'); + if (!container) return; + const count_el = document.getElementById('conference_count'); + if (!count_el) return; + const rows = container.querySelectorAll('.list-row'); + count_el.textContent = rows.length; +} + +/** + * Handle conference_destroy event - remove row from list + */ +function handle_list_conference_destroy(payload) { + const conference_name = payload.conference_name || ''; + + if (!conference_name) return; + + // Find and remove row by data-conference-name attribute + const rows = document.querySelectorAll('.list-row'); + rows.forEach(row => { + const row_conf_name = row.getAttribute('data-conference-name'); + if (row_conf_name === conference_name) { + row.remove(); + } + }); +} + +/** + * Add a new conference row to the list + * @param {string} conference_name - The conference identifier (UUID@domain or extension@domain) + * @param {number} member_count - Number of members in the conference + * @param {string} conference_display_name - Human-readable name from database (optional) + */ +function add_conference_row(conference_name, member_count, conference_display_name) { + const table = document.querySelector('.list'); + if (!table) return; + + const full_name = conference_name; + + // Use provided display name or parse from conference_name + let display_name = conference_display_name || ''; + if (!display_name) { + display_name = full_name; + if (full_name.includes('@')) { + display_name = full_name.split('@')[0]; + } + display_name = display_name.replace(/-/g, ' ').replace(/_/g, ' '); + } + + // Get extension/UUID for URL + let extension = full_name; + if (full_name.includes('@')) { + extension = full_name.split('@')[0]; + } + + const list_row_url = 'active_conference_room.php?c=' + encodeURIComponent(extension); + + const row = document.createElement('tr'); + row.className = 'list-row'; + row.setAttribute('href', list_row_url); + row.setAttribute('data-conference-name', full_name); + + let html = ''; + html += ""; + if (permissions && permissions.conference_interactive_view) { + html += "" + escapeHtml(display_name) + ""; + } else { + html += escapeHtml(display_name); + } + html += ""; + html += "" + escapeHtml(extension) + ""; + html += ""; + html += "" + (member_count || 0) + ""; + + if (permissions && permissions.conference_interactive_view && permissions.list_row_edit_button) { + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + } + + row.innerHTML = html; + row.addEventListener('click', function(e) { + if (e.target.closest('a') || e.target.closest('.action-button')) return; + const url = this.getAttribute('href'); + if (url) window.location = url; + }); + + table.appendChild(row); +} + +/* =========================== + * Active Conference Room Logic + * =========================== */ + +function handle_room_event(event) { + const payload = event.payload || event; + const action = payload.action || event.action || event.event_name; + const member_id = payload.member_id || payload['member-id'] || event.member_id || event['member-id']; + const event_conference_name = payload.conference_name || ''; + + console.log('handle_room_event - action:', action, 'event_conf:', event_conference_name, 'page_conf:', conference_name, 'page_id:', conference_id); + + // Only handle events for this conference room + if (event_conference_name && typeof conference_name !== 'undefined') { + // Check if event conference matches page conference (either direction) + const matches = event_conference_name.includes(conference_id) || + event_conference_name.includes(conference_name) || + conference_name.includes(event_conference_name.split('@')[0]); + if (!matches) { + console.log('Event filtered out - not for this conference'); + return; + } + } + + console.log('Processing event:', action); + + // Handle events incrementally without full refresh + switch (action) { + case 'start-talking': + case 'start_talking': + if (member_id) handle_talking_event(member_id, true); + break; + case 'stop-talking': + case 'stop_talking': + if (member_id) handle_talking_event(member_id, false); + break; + case 'add-member': + case 'add_member': + handle_room_add_member(payload); + break; + case 'del-member': + case 'del_member': + handle_room_del_member(payload); + break; + case 'mute-member': + case 'mute_member': + handle_room_mute_member(member_id, true); + break; + case 'unmute-member': + case 'unmute_member': + handle_room_mute_member(member_id, false); + break; + case 'deaf-member': + case 'deaf_member': + handle_room_deaf_member(member_id, true); + break; + case 'undeaf-member': + case 'undeaf_member': + handle_room_deaf_member(member_id, false); + break; + case 'kick-member': + case 'kick_member': + handle_room_del_member(payload); + break; + case 'floor-change': + case 'floor_change': + handle_room_floor_change(payload); + break; + case 'lock': + handle_room_lock(true); + break; + case 'unlock': + handle_room_lock(false); + break; + case 'conference-destroy': + case 'conference_destroy': + // Conference ended - for valid conference rooms (UUID-based), keep the table structure + // Just remove all member rows but keep the table headers + handle_room_conference_destroy(); + break; + default: + // Unknown event, ignore + break; + } +} + +/** + * Handle add_member event - add new member row to the room table + */ +function handle_room_add_member(payload) { + console.log('handle_room_add_member called with payload:', payload); + const member = payload.member; + const member_count = payload.member_count; + + console.log('Member data:', member, 'Member count:', member_count); + + // Check if the table exists - if not, this might be the first member + // joining an empty conference, so we need to reload the full room view + const container = document.getElementById('conference_container'); + const table = container ? container.querySelector('table.list') : null; + console.log('Container found:', !!container, 'Table found:', !!table); + + if (!table) { + // No table exists - reload conference room data to show the full view + console.log('No table found - reloading room view'); + load_conference_room_data(); + return; + } + + // Update member count display + update_member_count(member_count); + + if (!member) { + console.log('No member object in payload - cannot add row'); + return; + } + + // Check if member already exists + const existing_row = table.querySelector(`tr[data-member-id="${member.id}"]`); + if (existing_row) { + console.log('Member already exists in table'); + return; + } + + console.log('Creating row for member:', member.id, member); + const row = create_member_row(member); + console.log('Row created:', row); + + // Append to tbody if exists, otherwise to table + const tbody = table.querySelector('tbody') || table; + tbody.appendChild(row); + console.log('Row appended to table'); + + // Initialize timer for new member + if (typeof member_timers !== 'undefined' && member_timers) { + member_timers[member.id] = { + uuid: member.uuid, + join_time: member.join_time || 0, + last_talking: member.last_talking || 0, + is_talking: false + }; + } +} + +/** + * Handle del_member event - remove member row from the room table + */ +function handle_room_del_member(payload) { + console.log('handle_room_del_member called with payload:', payload); + const member_id = payload.member_id || payload['member-id']; + const member_count = payload.member_count; + + console.log('Removing member_id:', member_id, 'new count:', member_count); + + // Update member count display + if (member_count !== undefined) { + update_member_count(member_count); + } + + if (!member_id) { + console.log('No member_id in payload'); + return; + } + + // Remove member row + const row = document.querySelector(`tr[data-member-id="${member_id}"]`); + console.log('Found row to remove:', !!row); + if (row) { + row.remove(); + console.log('Row removed'); + } + + // Clean up timer + if (typeof member_timers !== 'undefined' && member_timers && member_timers[member_id]) { + delete member_timers[member_id]; + } +} + +/** + * Handle conference_destroy event - remove all members but keep the table structure + * This keeps the empty conference room visible since the room UUID is still valid + */ +function handle_room_conference_destroy() { + const container = document.getElementById('conference_container'); + if (!container) return; + + const table = container.querySelector('table.list'); + if (table) { + // Remove all member rows (those with data-member-id attribute) + const member_rows = table.querySelectorAll('tr[data-member-id]'); + member_rows.forEach(row => row.remove()); + + // Update member count to 0 + update_member_count(0); + + // Clear all member timers + if (typeof member_timers !== 'undefined' && member_timers) { + for (const key in member_timers) { + delete member_timers[key]; + } + } + } + // If no table exists, the conference was never active - do nothing + // The page already shows the empty conference state +} + +/** + * Handle mute/unmute member event + */ +function handle_room_mute_member(member_id, is_muted) { + if (!member_id) return; + + const row = document.querySelector(`tr[data-member-id="${member_id}"]`); + if (!row) return; + + // Find the capabilities cell and update microphone icon + const cells = row.querySelectorAll('td'); + cells.forEach(cell => { + const mic_icon = cell.querySelector('.fa-microphone, .fa-microphone-slash'); + if (mic_icon) { + if (is_muted) { + mic_icon.classList.remove('fa-microphone'); + mic_icon.classList.add('fa-microphone-slash'); + mic_icon.title = text['label-muted'] || 'Muted'; + } else { + mic_icon.classList.remove('fa-microphone-slash'); + mic_icon.classList.add('fa-microphone'); + mic_icon.title = text['label-speak'] || 'Can speak'; + } + } + }); + + // Update mute/unmute button + const mute_btn = row.querySelector('[onclick*="mute"]'); + if (mute_btn && user_permissions.mute) { + if (is_muted) { + mute_btn.title = text['label-unmute'] || 'Unmute'; + mute_btn.setAttribute('onclick', mute_btn.getAttribute('onclick').replace("'mute'", "'unmute'")); + const icon = mute_btn.querySelector('span'); + if (icon) { + icon.classList.remove('fa-microphone-slash'); + icon.classList.add('fa-microphone'); + } + } else { + mute_btn.title = text['label-mute'] || 'Mute'; + mute_btn.setAttribute('onclick', mute_btn.getAttribute('onclick').replace("'unmute'", "'mute'")); + const icon = mute_btn.querySelector('span'); + if (icon) { + icon.classList.remove('fa-microphone'); + icon.classList.add('fa-microphone-slash'); + } + } + } +} + +/** + * Handle deaf/undeaf member event + */ +function handle_room_deaf_member(member_id, is_deaf) { + if (!member_id) return; + + const row = document.querySelector(`tr[data-member-id="${member_id}"]`); + if (!row) return; + + // Find the capabilities cell and update headphones icon + const cells = row.querySelectorAll('td'); + cells.forEach(cell => { + const hear_icon = cell.querySelector('.fa-headphones, .fa-deaf'); + if (hear_icon) { + if (is_deaf) { + hear_icon.classList.remove('fa-headphones'); + hear_icon.classList.add('fa-deaf'); + hear_icon.title = text['label-deaf'] || 'Deaf'; + } else { + hear_icon.classList.remove('fa-deaf'); + hear_icon.classList.add('fa-headphones'); + hear_icon.title = text['label-hear'] || 'Can hear'; + } + } + }); + + // Update deaf/undeaf button + const deaf_btn = row.querySelector('[onclick*="deaf"]'); + if (deaf_btn && user_permissions.deaf) { + if (is_deaf) { + deaf_btn.title = text['label-undeaf'] || 'Undeaf'; + deaf_btn.setAttribute('onclick', deaf_btn.getAttribute('onclick').replace("'deaf'", "'undeaf'")); + const icon = deaf_btn.querySelector('span'); + if (icon) { + icon.classList.remove('fa-deaf'); + icon.classList.add('fa-headphones'); + } + } else { + deaf_btn.title = text['label-deaf'] || 'Deaf'; + deaf_btn.setAttribute('onclick', deaf_btn.getAttribute('onclick').replace("'undeaf'", "'deaf'")); + const icon = deaf_btn.querySelector('span'); + if (icon) { + icon.classList.remove('fa-headphones'); + icon.classList.add('fa-deaf'); + } + } + } +} + +/** + * Handle floor change event + */ +function handle_room_floor_change(payload) { + const new_floor_member_id = payload.member_id || payload['member-id']; + + // Update all rows to remove floor indicator + const rows = document.querySelectorAll('tr[data-member-id]'); + rows.forEach(row => { + const floor_cells = row.querySelectorAll('td'); + // Floor is typically shown in one of the cells + floor_cells.forEach(cell => { + if (cell.textContent === text['label-yes'] || cell.textContent === 'Yes') { + const member_id = row.getAttribute('data-member-id'); + if (member_id !== String(new_floor_member_id)) { + cell.textContent = text['label-no'] || 'No'; + } + } + }); + }); + + // Set floor for new member + if (new_floor_member_id) { + const new_floor_row = document.querySelector(`tr[data-member-id="${new_floor_member_id}"]`); + if (new_floor_row) { + // Floor column is typically the 6th column (index 5) in hide-sm-dn class + const floor_cell = new_floor_row.querySelectorAll('td.hide-sm-dn')[1]; // Second hide-sm-dn cell + if (floor_cell) { + floor_cell.textContent = text['label-yes'] || 'Yes'; + } + } + } +} + +/** + * Handle lock/unlock event + */ +function handle_room_lock(is_locked) { + // Find and update the lock/unlock button + const lock_btns = document.querySelectorAll('[onclick*="lock"]'); + lock_btns.forEach(btn => { + if (btn.getAttribute('onclick').includes("'lock'") || btn.getAttribute('onclick').includes("'unlock'")) { + if (is_locked) { + btn.setAttribute('onclick', "conference_action('unlock');"); + btn.title = text['label-unlock'] || 'Unlock'; + const icon = btn.querySelector('.fas'); + if (icon) { + icon.classList.remove('fa-lock'); + icon.classList.add('fa-unlock'); + } + const label = btn.querySelector('.hidden-xs'); + if (label) { + label.textContent = text['label-unlock'] || 'Unlock'; + } + } else { + btn.setAttribute('onclick', "conference_action('lock');"); + btn.title = text['label-lock'] || 'Lock'; + const icon = btn.querySelector('.fas'); + if (icon) { + icon.classList.remove('fa-unlock'); + icon.classList.add('fa-lock'); + } + const label = btn.querySelector('.hidden-xs'); + if (label) { + label.textContent = text['label-lock'] || 'Lock'; + } + } + } + }); +} + +/** + * Update member count display + */ +function update_member_count(count) { + const container = document.getElementById('conference_container'); + if (!container) return; + + const strong_el = container.querySelector('strong'); + if (strong_el && strong_el.textContent.includes(text['label-members'])) { + strong_el.textContent = text['label-members'] + ': ' + (count || 0); + } +} + +/** + * Create a member row element for the table + */ +function create_member_row(member) { + const id = member.id; + const uuid = member.uuid; + const name = decodeURIComponent(member.caller_id_name || ''); + const num = member.caller_id_number || ''; + + const flags = member.flags || {}; + const can_hear = flags.can_hear !== false; + const can_speak = flags.can_speak !== false; + const talking = flags.talking === true; + const has_video = flags.has_video === true; + const has_floor = flags.has_floor === true; + const is_moderator = flags.is_moderator === true; + const hand_raised = false; + + const join_time = member.join_time || 0; + const last_talking = member.last_talking || 0; + + const format_time_val = (val) => { + const sec = parseInt(val, 10) || 0; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + return [h,m,s].map(v => v < 10 ? "0" + v : v).join(":"); + }; + + const join_formatted = format_time_val(join_time); + const quiet_formatted = format_time_val(last_talking); + + let row_onclick = ""; + let row_title = ""; + let action_mute = "mute"; + + if (user_permissions.mute) { + action_mute = can_speak ? 'mute' : 'unmute'; + row_onclick = `onclick="conference_action('${action_mute}', '${id}', '${uuid}');"`; + row_title = `title="${(text['message-click_to_' + action_mute] || action_mute)}"`; + } + + const row = document.createElement('tr'); + row.className = 'list-row'; + row.setAttribute('data-member-id', id); + row.setAttribute('data-uuid', uuid); + row.setAttribute('data-join-time', join_time); + row.setAttribute('data-last-talking', last_talking); + + let html = ''; + html += ``; + if (is_moderator) { + html += ``; + } else { + html += ``; + } + html += ""; + + const talking_vis = talking ? 'visible' : 'hidden'; + const talking_icon = ``; + html += `${escapeHtml(name)}${talking_icon}`; + + html += `${escapeHtml(num)}`; + html += `${join_formatted}`; + html += `${quiet_formatted}`; + html += `${has_floor ? text['label-yes'] : text['label-no']}`; + html += `${hand_raised ? text['label-yes'] : text['label-no']}`; + + html += ``; + html += can_speak ? `` : ``; + html += can_hear ? `` : ``; + if (user_permissions.video && has_video) { + html += ``; + } + html += ""; + + if (user_permissions.energy) { + html += ""; + html += ` `; + html += ``; + html += ""; + } + + if (user_permissions.volume) { + html += ""; + html += ` `; + html += ``; + html += ""; + } + + if (user_permissions.gain) { + html += ""; + html += ` `; + html += ``; + html += ""; + } + + html += ""; + if (user_permissions.mute) { + if (action_mute == 'mute') { + html += ` `; + } else { + html += ` `; + } + } + + if (user_permissions.deaf) { + if (can_hear) { + html += ` `; + } else { + html += ` `; + } + } + + if (user_permissions.kick) { + html += ``; + } + html += ""; + + row.innerHTML = html; + return row; +} + +function load_conference_room_data() { + console.log('load_conference_room_data called'); + if (!ws || !ws.ws || ws.ws.readyState !== WebSocket.OPEN) { + console.log('WebSocket not ready'); + return; + } + + // conference_id global must be defined in PHP + console.log('Requesting in_progress for uuid:', conference_id, 'domain:', domain_name); + ws.request('active.conferences', 'in_progress', {uuid: conference_id, domain_name: domain_name}) + .then(response => { + console.log('in_progress response:', response); + const conferences = response.payload || response; + console.log('Conferences array:', conferences); + let conf = null; + if (Array.isArray(conferences)) { + conf = conferences.find(c => c.conference_name.includes(conference_name) || c.conference_name.includes(conference_id)); + console.log('Found conference:', conf); + } else if (conferences && (conferences.conference_name || conferences.members)) { + conf = conferences; + console.log('Single conference object:', conf); + } + render_conference_room(conf); + }) + .catch(err => { + console.error('load_conference_room_data error:', err); + }); +} + +function render_conference_room(conference) { + const container = document.getElementById('conference_container'); + if (!container) return; + + // Check if conference was not found in database + if (!conference || conference.error === 'not_found' || conference.exists_in_database === false) { + //container.innerHTML = "
" + (text['message-no_conference'] || 'Conference not found') + "
"; + conference = { + members: [], + member_count: 0, + locked: false, + recording: false, + conference_display_name: '', + conference_name: conference_name + } + return; + } + + // Conference exists (either active or in database but empty) + const members = conference.members || []; + const member_count = conference.member_count || members.length || 0; + const locked = conference.locked === true; + const recording = conference.recording === true; + const display_name = conference.conference_display_name || conference.conference_name || ''; + + let mute_all = false; + let found_unmuted = false; + let found_non_moderator = false; + members.forEach(member => { + const flags = member.flags || {}; + const is_mod = flags.is_moderator; + if (!is_mod) { + found_non_moderator = true; + const speaks = flags.can_speak !== false; + if (speaks) found_unmuted = true; + } + }); + if (found_non_moderator && !found_unmuted) mute_all = true; + + let html = ""; + html += "
\n"; + + const rec_icon = recording ? "recording.png" : "not_recording.png"; + const rec_title = recording ? text['label-recording'] : text['label-not-recording']; + html += "  "; + + if (user_permissions.lock) { + if (locked) { + html += " "; + } else { + html += " "; + } + } + + if (user_permissions.mute) { + if (mute_all) { + html += " "; + } else { + html += " "; + } + } + + if (user_permissions.kick) { + html += ""; + } + html += "
\n"; + + html += "" + text['label-members'] + ": " + member_count + "

\n"; + + html += "
\n"; + html += "\n"; + html += "\n"; + html += "\n"; + html += "\n"; + html += "\n"; + html += "\n"; + html += "\n"; + html += "\n"; + html += "\n"; + html += "\n"; + if (user_permissions.energy) html += "\n"; + if (user_permissions.volume) html += "\n"; + if (user_permissions.gain) html += "\n"; + html += "\n"; + html += "\n"; + + members.forEach(member => { + const id = member.id; + const uuid = member.uuid; + const name = decodeURIComponent(member.caller_id_name || ''); + const num = member.caller_id_number || ''; + + const flags = member.flags || {}; + const can_hear = flags.can_hear !== false; + const can_speak = flags.can_speak !== false; + const talking = flags.talking === true; + const has_video = flags.has_video === true; + const has_floor = flags.has_floor === true; + const is_moderator = flags.is_moderator === true; + const hand_raised = false; + + const join_time = member.join_time || 0; + const last_talking = member.last_talking || 0; + + const format_time_val = (val) => { + const sec = parseInt(val, 10) || 0; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + return [h,m,s].map(v => v < 10 ? "0" + v : v).join(":"); + }; + + const join_formatted = format_time_val(join_time); + const quiet_formatted = format_time_val(last_talking); + + let row_onclick = ""; + let row_title = ""; + let action_mute = "mute"; + + if (user_permissions.mute) { + action_mute = can_speak ? 'mute' : 'unmute'; + row_onclick = `onclick="conference_action('${action_mute}', '${id}', '${uuid}');"`; + row_title = `title="${(text['message-click_to_' + action_mute] || action_mute)}"`; + } + + html += `\n`; + + html += `\n"; + + const talking_vis = talking ? 'visible' : 'hidden'; + const talking_icon = ``; + html += `\n`; + + html += `\n`; + html += `\n`; + html += `\n`; + html += `\n`; + html += `\n`; + + html += `\n"; + + if (user_permissions.energy) { + html += "\n"; + } + + if (user_permissions.volume) { + html += "\n"; + } + + if (user_permissions.gain) { + html += "\n"; + } + + html += "\n"; + + html += "\n"; + }); + html += "
 " + text['label-cid-name'] + "" + text['label-cid-num'] + "" + text['label-joined'] + "" + text['label-quiet'] + "" + text['label-floor'] + "" + text['label-hand_raised'] + "" + text['label-capabilities'] + "" + text['label-energy'] + "" + text['label-volume'] + "" + text['label-gain'] + " 
`; + if (is_moderator) { + html += ``; + } else { + html += ``; + } + html += "${escapeHtml(name)}${talking_icon}${escapeHtml(num)}${join_formatted}${quiet_formatted}${has_floor ? text['label-yes'] : text['label-no']}${hand_raised ? text['label-yes'] : text['label-no']}`; + html += can_speak ? `` : ``; + html += can_hear ? `` : ``; + if (user_permissions.video && has_video) { + html += ``; + } + html += "\n"; + html += ` `; + html += ``; + html += "\n"; + html += ` `; + html += ``; + html += "\n"; + html += ` `; + html += ``; + html += "\n"; + if (user_permissions.mute) { + if (action_mute == 'mute') { + html += ` `; + } else { + html += ` `; + } + } + + if (user_permissions.deaf) { + if (can_hear) { + html += ` `; + } else { + html += ` `; + } + } + + if (user_permissions.kick) { + html += ``; + } + html += "
\n"; + html += "
\n"; + + container.innerHTML = html; + + initialize_timers(); +} + +function initialize_timers() { + member_timers = {}; + const rows = document.querySelectorAll('tr[data-member-id]'); + rows.forEach(row => { + const member_id = row.getAttribute('data-member-id'); + const uuid = row.getAttribute('data-uuid'); + const join_time = parseInt(row.getAttribute('data-join-time'), 10) || 0; + const last_talking = parseInt(row.getAttribute('data-last-talking'), 10) || 0; + + member_timers[member_id] = { + uuid: uuid, + join_time: join_time, + last_talking: last_talking, + is_talking: false + }; + }); + + if (!timer_interval) { + timer_interval = setInterval(update_timer_displays, 1000); + } +} + +function update_timer_displays() { + const rows = document.querySelectorAll('tr[data-member-id]'); + rows.forEach(row => { + const member_id = row.getAttribute('data-member-id'); + const timer = member_timers[member_id]; + + if (timer) { + timer.join_time++; + if (!timer.is_talking) { + timer.last_talking++; + } + + const join_time_cell = row.querySelector('.join-time'); + const quiet_time_cell = row.querySelector('.quiet-time'); + + if (join_time_cell) join_time_cell.textContent = format_time(timer.join_time); + if (quiet_time_cell) quiet_time_cell.textContent = format_time(timer.last_talking); + } + }); +} + +function format_time(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) seconds = 0; + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + return String(hrs).padStart(2, '0') + ':' + String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0'); +} + +function handle_talking_event(member_id, is_talking) { + const timer = member_timers[member_id]; + if (timer) { + timer.is_talking = is_talking; + if (is_talking) timer.last_talking = 0; + } + + const row = document.querySelector(`tr[data-member-id="${member_id}"]`); + if (row) { + const talking_icon = row.querySelector('.talking-icon'); + if (talking_icon) { + talking_icon.style.visibility = is_talking ? 'visible' : 'hidden'; + } + } +} + +function send_action(action, options = {}) { + if (!ws || !ws.ws || ws.ws.readyState !== WebSocket.OPEN) return Promise.reject('Not connected'); + + const payload = { + action: action, + conference_name: conference_name, + domain_name: domain_name, + ...options + }; + + console.log('Sending action:', action, payload); + + return ws.request('active.conferences', 'action', payload) + .then(response => { + const result = response.payload || response; + if (!result.success) { + console.error('Action failed:', result.message); + } + // No refresh needed - websocket events will update the UI incrementally + return result; + }) + .catch(err => { + console.error('Action error:', err); + throw err; + }); +} + +function conference_action(action, member_id, uuid, direction) { + return send_action(action, { + member_id: member_id || '', + uuid: uuid || '', + direction: direction || '' + }); +} diff --git a/app/active_conferences/resources/javascript/websocket_client.js b/app/active_conferences/resources/javascript/websocket_client.js new file mode 100644 index 0000000000..8a03b15493 --- /dev/null +++ b/app/active_conferences/resources/javascript/websocket_client.js @@ -0,0 +1,199 @@ +class ws_client { + constructor(url, token) { + this.ws = new WebSocket(url); + this.ws.addEventListener('message', this._on_message.bind(this)); + this._next_id = 1; + this._pending = new Map(); + this._event_handlers = new Map(); + // The token is submitted on every request + this.token = token; + } + + authenticate() { + // + // Authentication is with websockets not the service, so we need to send a special + // request for authentication and specify the service that will be handling our + // future messages. This means the service is authentication and the topic is the + // service that will handle our future messages. This is a special case because we + // must authenticate with websockets, not the service. The service is only used to + // handle future messages. + // + // service = 'authentication' + // topic = active_conferences_service::get_service_name() + // payload = token + // + this.request('authentication', 'active.conferences', { token: this.token }); + } + + // internal message handler called when event occurs on the socket + _on_message(ev) { + let message; + let switch_event; + try { + console.log('Raw message received:', ev.data); + message = JSON.parse(ev.data); + // check for authentication request + if (message.status_code === 407) { + console.log('Authentication Required'); + this.authenticate(); + return; + } + switch_event = message.payload; + if (message.topic === 'authenticated') { + console.log('Authenticated'); + this._dispatch_event('active.conferences', {event_name: 'authenticated'}); + return; // Don't process further after authenticated + } + //console.log('envelope received: ',env); + } catch (err) { + console.error('Error parsing JSON data:', err); + //console.error('Invalid JSON:', ev.data); + return; + } + + // Pull out the request_id first + const rid = message.request_id ?? null; + + // If this is the response to a pending request + if (rid && this._pending.has(rid)) { + // Destructure with defaults in case they're missing + const { + service_name = '', + topic = '', + status = 'ok', + code = 200, + payload = {} + } = message; + + const {resolve, reject} = this._pending.get(rid); + this._pending.delete(rid); + + if (status === 'ok' && code >= 200 && code < 300) { + console.log('Response received:', {service_name, topic, payload, code}); + resolve({service_name, topic, payload, code, message}); + // Also dispatch as an event so handlers get notified + // Use topic from message as event_name if payload doesn't have one + const event_data = (typeof switch_event === 'object' && switch_event !== null) + ? { ...switch_event, event_name: switch_event.event_name || topic } + : { event_name: topic, data: switch_event }; + this._dispatch_event(service_name, event_data); + } else { + const err = new Error(message || `Error ${code}`); + err.code = code; + reject(err); + } + + return; + } + + // Otherwise it's a server‑pushed event… + // e.g. env.service === 'event' or env.topic is your event name + console.log('Server-pushed event - service_name:', message.service_name, 'service:', message.service, 'topic:', message.topic, 'payload:', switch_event); + + // Use service_name, or fall back to service, or default to 'active.conferences' + const service = message.service_name || message.service || 'active.conferences'; + + // Ensure event has event_name set from topic if not in payload + // IMPORTANT: Also preserve the topic as the action since that's what the PHP service sends + const event_data = (typeof switch_event === 'object' && switch_event !== null) + ? { ...switch_event, event_name: switch_event.event_name || message.topic, topic: message.topic } + : { event_name: message.topic, topic: message.topic, data: switch_event }; + + console.log('Dispatching event to handlers:', event_data); + this._dispatch_event(service, event_data); + } + + // Send a request to the websocket server using JSON string + request(service, topic = null, payload = {}) { + const request_id = String(this._next_id++); + const env = { + request_id: request_id, + service, + ...(topic !== null ? {topic} : {}), + token: this.token, + payload: payload + }; + const raw = JSON.stringify(env); + this.ws.send(raw); + return new Promise((resolve, reject) => { + this._pending.set(request_id, {resolve, reject}); + // TODO: get timeout working to reject if no response in X ms + }); + } + + subscribe(topic) { + return this.request('active.conferences', topic); + } + + unsubscribe(topic) { + return this.request('active.conferences', topic); + } + + // register a callback for server-pushes + on_event(topic, handler) { + console.log('registering event listener for ' + topic); + if (!this._event_handlers.has(topic)) { + this._event_handlers.set(topic, []); + } + this._event_handlers.get(topic).push(handler); + } + /** + * Dispatch a server‑push event envelope to all registered handlers. + * @param {object} env + */ + _dispatch_event(service, env) { + console.log('_dispatch_event called with service:', service, 'env:', env); + + // if service==='event', topic carries the real event name: + let event = (typeof env === 'string') + ? JSON.parse(env) + : env; + + console.log('Parsed event:', event); + console.log('Registered handlers:', Array.from(this._event_handlers.keys())); + + // dispatch event handlers + if (service === 'active.conferences') { + const topic = event.event_name; + console.log('Looking for handlers for topic:', topic); + + // Get specific handlers for this topic + const handlers = this._event_handlers.get(topic) || []; + // Always get wildcard handlers too + const wildcard_handlers = this._event_handlers.get('*') || []; + + console.log('Found handlers:', handlers.length, 'wildcard:', wildcard_handlers.length); + + // Call specific handlers + for (const fn of handlers) { + try { + fn(event); + } catch (err) { + console.error(`Error in handler for "${topic}":`, err); + } + } + // Always call wildcard handlers for all events + for (const fn of wildcard_handlers) { + try { + fn(event); + } catch (err) { + console.error(`Error in wildcard handler:`, err); + } + } + } else { + const handlers = this._event_handlers.get(service) || []; + for (const fn of handlers) { + try { + if (fn === '*') { + event(event.data, event); + } else { + fn(event.data, event); + } + } catch (err) { + console.error(`Error in handler for "${service}":`, err); + } + } + } + } + +} diff --git a/app/active_conferences/resources/services/active_conferences.php b/app/active_conferences/resources/services/active_conferences.php new file mode 100644 index 0000000000..ec1b04314c --- /dev/null +++ b/app/active_conferences/resources/services/active_conferences.php @@ -0,0 +1,51 @@ +#!/usr/bin/env php + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Active Conferences Service + * Polls conference data and broadcasts via websockets + */ + +if (version_compare(PHP_VERSION, '7.1.0', '<')) { + die("This script requires PHP 7.1.0 or higher. You are running " . PHP_VERSION . "\n"); +} + +require_once dirname(__DIR__, 4) . '/resources/require.php'; + +define('SERVICE_NAME', active_conferences_service::get_service_name()); + +try { + $active_conferences_service = active_conferences_service::create(); + // Exit using whatever status run returns + exit($active_conferences_service->run()); +} catch (Exception $ex) { + echo "Error occurred in " . $ex->getFile() . ' (' . $ex->getLine() . '):' . $ex->getMessage(); + // Exit with error code + exit($ex->getCode()); +} diff --git a/app/active_conferences/resources/services/debian-active_conferences.service b/app/active_conferences/resources/services/debian-active_conferences.service new file mode 100644 index 0000000000..b60b5189c8 --- /dev/null +++ b/app/active_conferences/resources/services/debian-active_conferences.service @@ -0,0 +1,17 @@ +[Unit] +Description=Active Conferences Websocket Service + +[Service] +WorkingDirectory=/var/www/fusionpbx +ExecStart=/usr/bin/php /var/www/fusionpbx/app/active_conferences/resources/service/active_conferences.php +RuntimeDirectory=fusionpbx +RuntimeDirectoryMode=0755 +RuntimeDirectoryPreserve=yes +User=www-data +Group=www-data +Restart=always +RestartSec=5 +StartLimitInterval=0 + +[Install] +WantedBy=multi-user.target diff --git a/app/conference_centers/app_menu.php b/app/conference_centers/app_menu.php index 7c2493caef..f1c2975027 100644 --- a/app/conference_centers/app_menu.php +++ b/app/conference_centers/app_menu.php @@ -34,29 +34,30 @@ $apps[$x]['menu'][$y]['groups'][] = "superadmin"; $apps[$x]['menu'][$y]['groups'][] = "admin"; $y++; - $apps[$x]['menu'][$y]['title']['en-us'] = "Conference Centers"; - $apps[$x]['menu'][$y]['title']['ar-eg'] = "مراكز المؤتمرات"; - $apps[$x]['menu'][$y]['title']['de-at'] = "Konferenz Zentrale"; - $apps[$x]['menu'][$y]['title']['de-ch'] = "Konferenzzentren"; - $apps[$x]['menu'][$y]['title']['de-de'] = "Konferenz Zentrale"; - $apps[$x]['menu'][$y]['title']['es-cl'] = "Cent. de Conferencias"; - $apps[$x]['menu'][$y]['title']['es-mx'] = "Conference Centers"; - $apps[$x]['menu'][$y]['title']['fr-ca'] = "Centres de conférences"; - $apps[$x]['menu'][$y]['title']['fr-fr'] = "Centre de Conférences"; - $apps[$x]['menu'][$y]['title']['he-il'] = "מרכזי כנסים"; - $apps[$x]['menu'][$y]['title']['it-it'] = "Centro Conferenze"; - $apps[$x]['menu'][$y]['title']['ka-ge'] = "კონფერენც-ცენტრები"; - $apps[$x]['menu'][$y]['title']['nl-nl'] = "Conferentie centra"; - $apps[$x]['menu'][$y]['title']['pl-pl'] = "Centrum Konferencyjne"; - $apps[$x]['menu'][$y]['title']['pt-br'] = "Centro de Conferência"; - $apps[$x]['menu'][$y]['title']['pt-pt'] = "Conferencias"; - $apps[$x]['menu'][$y]['title']['ro-ro'] = "Centre de conferințe"; - $apps[$x]['menu'][$y]['title']['ru-ru'] = "Конференц-центр"; - $apps[$x]['menu'][$y]['title']['sv-se'] = "Konferenscenter"; - $apps[$x]['menu'][$y]['title']['uk-ua'] = "Конференц-центр"; - $apps[$x]['menu'][$y]['title']['zh-cn'] = "会议中心"; - $apps[$x]['menu'][$y]['title']['ja-jp'] = "カンファレンスセンター"; - $apps[$x]['menu'][$y]['title']['ko-kr'] = "컨퍼런스 센터"; + $apps[$x]['menu'][$y]['title']['en-us'] = "Conference Rooms"; + $apps[$x]['menu'][$y]['title']['en-gb'] = "Conference Rooms"; + $apps[$x]['menu'][$y]['title']['ar-eg'] = "غرف المؤتمرات"; + $apps[$x]['menu'][$y]['title']['de-at'] = "Konferenzräume"; + $apps[$x]['menu'][$y]['title']['de-ch'] = "Konferenzräume"; + $apps[$x]['menu'][$y]['title']['de-de'] = "Konferenzräume"; + $apps[$x]['menu'][$y]['title']['es-cl'] = "Salas de Conferencias"; + $apps[$x]['menu'][$y]['title']['es-mx'] = "Salas de Conferencias"; + $apps[$x]['menu'][$y]['title']['fr-ca'] = "Salles de conférences"; + $apps[$x]['menu'][$y]['title']['fr-fr'] = "Salles de Conférences"; + $apps[$x]['menu'][$y]['title']['he-il'] = "חדרי כנסים"; + $apps[$x]['menu'][$y]['title']['it-it'] = "Sale Conferenze"; + $apps[$x]['menu'][$y]['title']['ka-ge'] = "კონფერენციის ოთახები"; + $apps[$x]['menu'][$y]['title']['nl-nl'] = "Conferentie kamers"; + $apps[$x]['menu'][$y]['title']['pl-pl'] = "Sale Konferencyjne"; + $apps[$x]['menu'][$y]['title']['pt-br'] = "Salas de Conferência"; + $apps[$x]['menu'][$y]['title']['pt-pt'] = "Salas de Conferências"; + $apps[$x]['menu'][$y]['title']['ro-ro'] = "Săli de conferințe"; + $apps[$x]['menu'][$y]['title']['ru-ru'] = "Конференц-залы"; + $apps[$x]['menu'][$y]['title']['sv-se'] = "Konferensrum"; + $apps[$x]['menu'][$y]['title']['uk-ua'] = "Конференц-зали"; + $apps[$x]['menu'][$y]['title']['zh-cn'] = "会议室"; + $apps[$x]['menu'][$y]['title']['ja-jp'] = "会議室"; + $apps[$x]['menu'][$y]['title']['ko-kr'] = "컨퍼런스 룸"; $apps[$x]['menu'][$y]['uuid'] = "b99cb768-ca19-4374-a954-02e344313d84"; $apps[$x]['menu'][$y]['parent_uuid'] = "fd29e39c-c936-f5fc-8e2b-611681b266b5"; $apps[$x]['menu'][$y]['category'] = "internal"; diff --git a/app/conference_centers/conference_rooms.php b/app/conference_centers/conference_rooms.php index 3bd3e112e2..a22f6e580a 100644 --- a/app/conference_centers/conference_rooms.php +++ b/app/conference_centers/conference_rooms.php @@ -40,16 +40,24 @@ $text = $language->get(); //set additional variables - $search = $_GET["search"] ?? null; + $show = $_REQUEST["show"] ?? ''; + $action = $_REQUEST['action'] ?? ''; + $search = $_REQUEST['search'] ?? ''; + $toggle_field = $_REQUEST['toggle_field'] ?? ''; + +//check if we are using websockets for the conference view + if ($settings->get('active_conferences', 'websockets_enabled', true)) { + $conference_view_page = '/app/active_conferences/active_conference_room.php'; + } + else { + $conference_view_page = '/app/conferences_active/conferences_active.php'; + } //set from session variables $list_row_edit_button = $settings->get('theme', 'list_row_edit_button', false); //get the http post data if (!empty($_POST['conference_rooms'])) { - $action = $_POST['action']; - $toggle_field = $_POST['toggle_field']; - $search = $_POST['search'] ?? ''; $conference_rooms = $_POST['conference_rooms']; } @@ -448,10 +456,10 @@ } echo " \n"; if (permission_exists('conference_interactive_view')) { - echo " ".$text['label-view']."\n"; + echo " ".$text['label-view']."\n"; } else if (permission_exists('conference_active_view')) { - echo " ".$text['label-view']."\n"; + echo " ".$text['label-view']."\n"; } if (permission_exists('conference_cdr_view')) { echo " ".$text['button-cdr']."\n"; diff --git a/app/conferences/conferences.php b/app/conferences/conferences.php index 7bbd943e26..32da75c589 100644 --- a/app/conferences/conferences.php +++ b/app/conferences/conferences.php @@ -40,15 +40,23 @@ $text = $language->get(); //set additional variables - $show = $_GET["show"] ?? ''; + $show = $_REQUEST["show"] ?? ''; + $action = $_REQUEST['action'] ?? ''; + $search = $_REQUEST['search'] ?? ''; + +//check if we are using websockets for the conference view + if ($settings->get('active_conferences', 'websocket_enabled', true)) { + $conference_view_page = '/app/active_conferences/active_conferences.php'; + } + else { + $conference_view_page = '/app/conferences_active/conferences_active.php'; + } //set from session variables $list_row_edit_button = $settings->get('theme', 'list_row_edit_button', false); //get posted data if (!empty($_POST['conferences'])) { - $action = $_POST['action']; - $search = $_POST['search'] ?? ''; $conferences = $_POST['conferences']; } @@ -180,7 +188,7 @@ echo "
".$text['title-conferences']."
".number_format($num_rows)."
\n"; echo "
\n"; if (permission_exists('conference_active_view')) { - echo button::create(['type'=>'button','label'=>$text['button-view_active'],'icon'=>'comments','style'=>'margin-right: 15px;','link'=>PROJECT_PATH.'/app/conferences_active/conferences_active.php']); + echo button::create(['type'=>'button','label'=>$text['button-view_active'],'icon'=>'comments','style'=>'margin-right: 15px;','link'=>PROJECT_PATH.$conference_view_page]); } if (permission_exists('conference_add')) { echo button::create(['type'=>'button','label'=>$text['button-add'],'icon'=>$settings->get('theme', 'button_icon_add'),'id'=>'btn_add','link'=>'conference_edit.php']); @@ -286,10 +294,10 @@ echo " ".escape($row['conference_order'])." \n"; echo " \n"; if (permission_exists('conference_interactive_view')) { - echo " ".$text['label-view']."\n"; + echo " ".$text['label-view']."\n"; } else if (permission_exists('conference_active_view')) { - echo " ".$text['label-view']."\n"; + echo " ".$text['label-view']."\n"; } else { echo " &nsbp;\n"; diff --git a/core/websockets/resources/classes/base_websocket_system_service.php b/core/websockets/resources/classes/base_websocket_system_service.php index 426044d04c..9f2741ce62 100644 --- a/core/websockets/resources/classes/base_websocket_system_service.php +++ b/core/websockets/resources/classes/base_websocket_system_service.php @@ -144,11 +144,15 @@ abstract class base_websocket_system_service extends service implements websocke $ws_client->disconnect(); }, $this->ws_client); + // Call the register topics in the child classes $this->register_topics(); // Register the authenticate request $this->on_topic('authenticate', [$this, 'on_authenticate']); + // Register the authenticated response handler + $this->on_topic('authenticated', [$this, 'handle_ws_authenticated']); + // Track the WebSocket Server Error Message so it doesn't flood the system logs $suppress_ws_message = false; @@ -184,7 +188,7 @@ abstract class base_websocket_system_service extends service implements websocke } // stream_select will update $read so re-check it if (!empty($read)) { - $this->debug("Received event"); + //$this->debug("Received event"); // Iterate over each socket event foreach ($read as $resource) { // Web socket event @@ -250,7 +254,8 @@ abstract class base_websocket_system_service extends service implements websocke // Disable the stream blocking $this->ws_client->set_blocking(false); - $this->debug(self::class . " RESOURCE ID: " . $this->ws_client->socket()); + // Call the on connected event function + $this->handle_ws_connected(); } catch (\RuntimeException $re) { //unable to connect return false; @@ -258,6 +263,34 @@ abstract class base_websocket_system_service extends service implements websocke return true; } + private function handle_ws_connected(): void { + $this->info("Websocket connection established to server"); + $this->debug(static::class . " RESOURCE ID: " . $this->ws_client->socket()); + $this->on_ws_connected(); + } + + /** + * This is called when the web socket is first connected + * + * @return void + */ + protected function on_ws_connected(): void { + // Override in child class if needed + } + + private function handle_ws_authenticated(websocket_message $websocket_message): void { + $this->info("Successfully authenticated with websocket server"); + $this->on_ws_authenticated(); + } + + /** + * Called when the service has successfully authenticated with the websocket server. + * Override in child class to perform actions after authentication. + */ + protected function on_ws_authenticated(): void { + // Override in child class if needed + } + /** * Handles the message from the web socket client and triggers the appropriate requested topic event * @@ -269,11 +302,11 @@ abstract class base_websocket_system_service extends service implements websocke // Nothing to do if ($json_string === null) { - $this->warn('Message received from Websocket is empty'); + $this->warning('Message received from Websocket is empty'); return; } - $this->debug("Received message on websocket: $json_string (" . strlen($json_string) . " bytes)"); + //$this->debug("Received message on websocket: $json_string (" . strlen($json_string) . " bytes)"); // Get the web socket message as an object $message = websocket_message::create_from_json_message($json_string); @@ -314,7 +347,9 @@ abstract class base_websocket_system_service extends service implements websocke protected function on_authenticate(websocket_message $websocket_message) { $this->info("Authenticating with websocket server"); // Create a service token - [$token_name, $token_hash] = websocket_client::create_service_token(active_calls_service::get_service_name(), static::class); + $service_name = static::get_service_name(); + $class_name = static::class; + [$token_name, $token_hash] = websocket_client::create_service_token($service_name, $class_name); // Request authentication as a service $this->ws_client->authenticate($token_name, $token_hash); diff --git a/core/websockets/resources/classes/subscriber.php b/core/websockets/resources/classes/subscriber.php index 4389b5a3af..56653162d6 100644 --- a/core/websockets/resources/classes/subscriber.php +++ b/core/websockets/resources/classes/subscriber.php @@ -184,6 +184,13 @@ class subscriber { */ private $authenticated; + /** + * Whether this subscriber has requested debug subscribe all mode + * + * @var bool + */ + private $debug_subscribe_all = false; + /** * User information * @@ -603,6 +610,10 @@ class subscriber { $this->service = is_a($this->service_class, 'websocket_service_interface', true); } + // Load options (e.g., debug_subscribe_all) + $options = $array['options'] ?? []; + $this->debug_subscribe_all = !empty($options['debug_subscribe_all']); + //self::$logger->debug("Permission count(".count($this->permissions) . ")"); } @@ -637,6 +648,15 @@ class subscriber { return $this; } + /** + * Returns whether this subscriber has requested debug subscribe all mode. + * + * @return bool + */ + public function has_debug_subscribe_all(): bool { + return $this->debug_subscribe_all; + } + /** * Sets the domain UUID and name * @@ -650,7 +670,7 @@ class subscriber { */ public function set_domain(string $uuid, string $name): self { if (is_uuid($uuid)) { - $this->uuid = $uuid; + $this->domain_uuid = $uuid; } else { throw new invalid_uuid_exception("UUID is not valid"); } @@ -877,16 +897,22 @@ class subscriber { * @param array $token Standard token issued from the token object * @param array $services A simple array list of service names to subscribe to * @param int $time_limit_in_minutes Set a token time limit. Setting to zero will disable the time limit + * @param array $options Optional additional options (e.g., debug_subscribe_all) * * @see token::create() */ - public static function save_token(array $token, array $services, int $time_limit_in_minutes = 0) { + public static function save_token(array $token, array $services, int $time_limit_in_minutes = 0, array $options = []) { // // Store the currently logged in user when available // $array['user'] = $_SESSION['user'] ?? []; + // + // Store additional options + // + $array['options'] = $options; + // // Store the token service and events // diff --git a/core/websockets/resources/classes/websocket_message.php b/core/websockets/resources/classes/websocket_message.php index f3e8d0c6cb..96263708c2 100644 --- a/core/websockets/resources/classes/websocket_message.php +++ b/core/websockets/resources/classes/websocket_message.php @@ -110,7 +110,7 @@ class websocket_message extends base_message { * * @param string $service_name * - * @return $this + * @return $this|string */ public function service_name($service_name = null) { if (func_num_args() > 0) { @@ -125,7 +125,7 @@ class websocket_message extends base_message { * * @param array $permissions * - * @return $this + * @return array|$this */ public function permissions($permissions = []) { if (func_num_args() > 0) { @@ -135,6 +135,15 @@ class websocket_message extends base_message { return $this->permissions; } + /** + * Returns the array of permissions + * + * @return array + */ + public function get_permissions(): array { + return $this->permissions; + } + /** * Applies a filter to the payload of this message. * When a filter returns null then the payload is set to null diff --git a/core/websockets/resources/classes/websocket_service.php b/core/websockets/resources/classes/websocket_service.php index c9891e527c..5931351c59 100644 --- a/core/websockets/resources/classes/websocket_service.php +++ b/core/websockets/resources/classes/websocket_service.php @@ -93,7 +93,7 @@ class websocket_service extends service { /** * Subscriber Objects * - * @var subscriber + * @var array */ protected $subscribers; @@ -225,8 +225,9 @@ class websocket_service extends service { $subscriber->send(websocket_message::request_authenticated($message->request_id, $message->service)); // Check for service authenticated if ($subscriber->is_service()) { - $this->info("Service $subscriber->id authenticated"); - $this->services[$subscriber->service_name()] = $subscriber; + $service_name = $subscriber->service_name(); + $this->info("Service $service_name authenticated using id $subscriber->id"); + $this->services[$service_name] = $subscriber; } else { // Subscriber authenticated $this->info("Client $subscriber->id authenticated"); @@ -237,12 +238,21 @@ class websocket_service extends service { $class_name = $subscriber_service->service_class(); // Make sure we can call the 'create_filter_chain_for' method if (is_a($class_name, 'websocket_service_interface', true)) { - // Call the service class method to validate the subscriber - $filter = $class_name::create_filter_chain_for($subscriber); - if ($filter !== null) { - // Log the filter has been set for the subscriber - $this->info("Set filter for " . $subscriber->id()); - $subscriber->set_filter($filter); + try { + // Call the service class method to validate the subscriber + $filter = $class_name::create_filter_chain_for($subscriber); + if ($filter !== null) { + // Log the filter has been set for the subscriber + $this->info("Set filter for " . $subscriber->id()); + $subscriber->set_filter($filter); + } + } catch (subscriber_missing_permission_exception $smpe) { + // Subscriber requested debug mode but service is not in debug mode + // Disconnect them for security + $this->warning("Client $subscriber->id denied: " . $smpe->getMessage()); + $subscriber->send(websocket_message::request_unauthorized($message->request_id, $message->service)); + $this->handle_disconnect($subscriber->socket_id()); + return; } } $this->info("Set permissions for $subscriber->id for service " . $subscriber_service->service_name()); @@ -271,7 +281,7 @@ class websocket_service extends service { // Ensure we have something to do if ($message === null) { - $this->warn("Unable to broadcast empty message"); + $this->warning("Unable to broadcast empty message"); return; } @@ -301,7 +311,7 @@ class websocket_service extends service { $this->debug("Broadcasting message '" . $message->topic() . "' for service '" . $message->service_name . "' to subscriber $subscriber->id"); $subscriber->send_message($message); } catch (subscriber_token_expired_exception $ste) { - $this->info("Subscriber $ste->id token expired"); + $this->info("Subscriber $ste->subscriber_id token expired"); // Subscriber token has expired so disconnect them $this->handle_disconnect($subscriber->socket_id()); } @@ -315,7 +325,7 @@ class websocket_service extends service { // Remove the resource_id from the message $message->resource_id(''); // TODO: Fix removal of request_id - $message->request_id(''); + //$message->request_id(''); // Return the requested results back to the subscriber $subscriber->send_message($message); } @@ -448,7 +458,7 @@ class websocket_service extends service { $message = websocket_message::create_from_json_message($json_array); if ($message === null) { - $this->warn("Message is empty"); + $this->warning("Message is empty"); return; } diff --git a/resources/classes/service.php b/resources/classes/service.php index c69323b640..bf74e6ec96 100644 --- a/resources/classes/service.php +++ b/resources/classes/service.php @@ -954,6 +954,18 @@ abstract class service { self::log($message, LOG_DEBUG); } + /** + * Checks if the service is running in debug mode (LOG_DEBUG level). + * + * This is useful for security checks where certain features should only + * be available when the service is explicitly started with debug logging. + * + * @return bool True if the service is running at LOG_DEBUG level + */ + public static function is_debug_mode(): bool { + return self::$log_level === LOG_DEBUG; + } + /** * Logs a message at the INFO level. *