mirror of
https://github.com/fusionpbx/fusionpbx.git
synced 2025-12-30 00:53:50 +00:00
Websockets (#7393)
* Initial commit of websockets * Move app_menu to the active_calls websockets * Fix hangup function * Remove connection wait-state on web socket server so events can process * Add timestamp and debug level to console for service debug output * Remove debug exit * Fix typo for ws_client instead of ws_server * Update app_config.php * Fix typo and remove empty function * Remove call to empty function * Fix the menu to point to the correct location * Remove Logging Class * Rename service file * Rename service file * Fix the in progress browser request * Fix browser reload and implement 'active_calls' default values * Add apply_filter function * Create new permission_filter object * In progress active calls now use filter * Add invalid_uuid_exception class * add event_key_filter to honor user permissions * add and_link and or_link for filters * Fix disconnected subscriber and add filters to honor permissions * Add $key and $value for filter * define a service name * catch throwable instead of exception * Add $key and $value for filter and allow returning null * Update permission checks when loading page * Add apply_filter function to honor subscriber permissions * Add create_filter_chain_for function to honor subscriber permissions * Add apply_filter function to honor subscriber permissions * Add apply_filter function to honor subscriber permissions * create interface to allow filterable payload * create interface to define functions required for websocket services * Pass in service class when creating a service token * Allow key/name and return null for filter * Adjust subscriber exceptions to return the ID of the subscriber * Add event filter to filter chain * Add command line options for ip and port for websockets and switch * update service to use is_a syntax * initial commit of base class for websockets system services * initial commit of the system cpu status service * remove extra line feed * fix path on active_calls * initial proof of concept for cpu status updated by websockets * Allow returning null * Use default settings to set the interval for cpu status broadcast * Improve the CPU percent function for Linux systems * Show more debug information * Allow child processes to re-connect to the web socket service * Fix websockets as plural instead of singular * Add class name list-row * Update active_calls.php * Update active_calls.php * Update websocket_client.js * Update app_config.php * Update app_menu.php * Update debian-websockets.service * Update debian-active_calls.service --------- Co-authored-by: FusionPBX <markjcrane@gmail.com>
This commit is contained in:
1027
app/active_calls/active_calls.php
Normal file
1027
app/active_calls/active_calls.php
Normal file
File diff suppressed because it is too large
Load Diff
494
app/active_calls/app_config.php
Normal file
494
app/active_calls/app_config.php
Normal file
@@ -0,0 +1,494 @@
|
||||
<?php
|
||||
|
||||
//application details
|
||||
$apps[$x]['name'] = "Websockets Service";
|
||||
$apps[$x]['uuid'] = "c43e956a-cd38-4b27-838b-db43dc3f3204";
|
||||
$apps[$x]['category'] = "";
|
||||
$apps[$x]['subcategory'] = "";
|
||||
$apps[$x]['version'] = "1.0";
|
||||
$apps[$x]['license'] = "Mozilla Public License 1.1";
|
||||
$apps[$x]['url'] = "http://www.fusionpbx.com";
|
||||
$apps[$x]['description']['en-us'] = "Web sockets service";
|
||||
$apps[$x]['description']['en-gb'] = "Web sockets service";
|
||||
$apps[$x]['description']['ar-eg'] = "";
|
||||
$apps[$x]['description']['de-at'] = "";
|
||||
$apps[$x]['description']['de-ch'] = "";
|
||||
$apps[$x]['description']['de-de'] = "";
|
||||
$apps[$x]['description']['es-cl'] = "";
|
||||
$apps[$x]['description']['es-mx'] = "";
|
||||
$apps[$x]['description']['fr-ca'] = "";
|
||||
$apps[$x]['description']['fr-fr'] = "";
|
||||
$apps[$x]['description']['he-il'] = "";
|
||||
$apps[$x]['description']['it-it'] = "";
|
||||
$apps[$x]['description']['ka-ge'] = "";
|
||||
$apps[$x]['description']['nl-nl'] = "";
|
||||
$apps[$x]['description']['pl-pl'] = "";
|
||||
$apps[$x]['description']['pt-br'] = "";
|
||||
$apps[$x]['description']['pt-pt'] = "";
|
||||
$apps[$x]['description']['ro-ro'] = "";
|
||||
$apps[$x]['description']['ru-ru'] = "";
|
||||
$apps[$x]['description']['sv-se'] = "";
|
||||
$apps[$x]['description']['uk-ua'] = "";
|
||||
|
||||
//default settings
|
||||
$y=0;
|
||||
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "f7d404f6-7184-4eef-9487-7b2ec213c1fc";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_category'] = "active_calls";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "auto_reload_seconds";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_value'] = "5";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "false";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Number of seconds before refreshing the active calls page. Use 0 to disable or set to false (default).";
|
||||
$y++;
|
||||
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "bfa7aa18-2e66-429d-8756-216f84de2667";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_category'] = "active_calls";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "remove_completed_calls";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_name'] = "boolean";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_value'] = "true";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Remove calls when they are completed. Default is True";
|
||||
$y++;
|
||||
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "ac1afb07-6b46-43de-8fa0-d30e5868a7fb";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_category'] = "active_calls";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "truncate_application_data_length";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_name'] = "numeric";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_value'] = "80";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
|
||||
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Number of characters to show in the application column data. Default value is 80. To disable truncating use 0.";
|
||||
|
||||
//permission details
|
||||
$y=0;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_view";
|
||||
$apps[$x]['permissions'][$y]['menu']['uuid'] = "eba3d07f-dd5c-6b7b-6880-493b44113ade";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
//$y++;
|
||||
//$apps[$x]['permissions'][$y]['name'] = "call_active_transfer";
|
||||
//$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_eavesdrop";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_hangup";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
//$y++;
|
||||
//$apps[$x]['permissions'][$y]['name'] = "call_active_park";
|
||||
//$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
//$y++;
|
||||
//$apps[$x]['permissions'][$y]['name'] = "call_active_rec";
|
||||
//$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_all";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_direction";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_profile";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_application";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_codec";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_secure";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$y++;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Permissions that could be implemented on a per switch event capture //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
/*
|
||||
//
|
||||
// The following permissions relate to the switch event socket events that are emitted
|
||||
// Each of the permissions can be assigned to the subscriber so they can receive the
|
||||
// event when it arrives on the web socket server instance.
|
||||
//
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_all";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_command";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_custom";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_clone";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_create";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_destroy";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_state";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_callstate";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_answer";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_hangup";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_hangup_complete";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_execute";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_execute_complete";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_hold";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_unhold";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_bridge";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_unbridge";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_progress";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_progress_media";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_outgoing";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_application";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_originate";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_uuid";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_api";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_log";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_inbound_chan";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_outbound_chan";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_startup";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_shutdown";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_publish";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_unpublish";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_talk";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_notalk";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_session_crash";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_module_load";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_module_unload";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_dtmf";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_message";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_presence_in";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_notify_in";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_presence_out";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_presence_probe";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_message_waiting";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_message_query";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_roster";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_codec";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_background_job";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_detected_speech";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_detected_tone";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_private_command";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_heartbeat";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_trap";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_add_schedule";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_del_schedule";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_exe_schedule";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_re_schedule";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_reloadxml";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_notify";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_phone_feature";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_phone_feature_subscribe";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_send_message";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_recv_message";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_request_params";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_data";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_general";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_command";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_session_heartbeat";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_client_disconnected";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_server_disconnected";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_send_info";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_recv_info";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_recv_rtcp_message";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_send_rtcp_message";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_call_secure";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_nat";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_record_start";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_record_stop";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_playback_start";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_playback_stop";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_call_update";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_failure";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_socket_data";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_media_bug_start";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_media_bug_stop";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_conference_data_query";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_conference_data";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_call_setup_req";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_call_setup_result";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_call_detail";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_device_state";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_text";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_shutdown_requested";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_all";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_park";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_channel_unpark";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
|
||||
// These events are the API commands and can be expanded for more modules
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "event_valet_parking::info";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "admin";
|
||||
*/
|
||||
1068
app/active_calls/app_languages.php
Normal file
1068
app/active_calls/app_languages.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
$y=0;
|
||||
$apps[$x]['menu'][$y]['title']['en-us'] = "Active Calls";
|
||||
$apps[$x]['menu'][$y]['title']['en-gb'] = "Active Calls";
|
||||
@@ -29,10 +29,9 @@
|
||||
$apps[$x]['menu'][$y]['parent_uuid'] = "0438b504-8613-7887-c420-c837ffb20cb1";
|
||||
$apps[$x]['menu'][$y]['category'] = "internal";
|
||||
$apps[$x]['menu'][$y]['icon'] = "";
|
||||
$apps[$x]['menu'][$y]['path'] = "/app/calls_active/calls_active.php";
|
||||
$apps[$x]['menu'][$y]['path'] = "/app/active_calls/active_calls.php";
|
||||
$apps[$x]['menu'][$y]['order'] = "";
|
||||
$apps[$x]['menu'][$y]['groups'][] = "superadmin";
|
||||
$apps[$x]['menu'][$y]['groups'][] = "admin";
|
||||
$y++;
|
||||
|
||||
?>
|
||||
*/
|
||||
853
app/active_calls/resources/classes/active_calls_service.php
Normal file
853
app/active_calls/resources/classes/active_calls_service.php
Normal file
@@ -0,0 +1,853 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of active_calls_service
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class active_calls_service extends service implements websocket_service_interface {
|
||||
|
||||
const SWITCH_EVENTS = [
|
||||
['Event-Name' => 'CHANNEL_CREATE'],
|
||||
['Event-Name' => 'CHANNEL_CALLSTATE'],
|
||||
['Event-Name' => 'CALL_UPDATE'],
|
||||
['Event-Name' => 'PLAYBACK_START'],
|
||||
['Event-Name' => 'PLAYBACK_STOP'],
|
||||
['Event-Name' => 'CHANNEL_DESTROY'],
|
||||
['Event-Name' => 'CHANNEL_PARK'],
|
||||
['Event-Name' => 'CHANNEL_UNPARK'],
|
||||
['Event-Name' => 'CHANNEL_EXECUTE'],
|
||||
['Event-Name' => 'HEARTBEAT'], // Ensures that the switch is still responding
|
||||
['Event-Subclass' => 'valet_parking::info'],
|
||||
];
|
||||
|
||||
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',
|
||||
// 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',
|
||||
];
|
||||
|
||||
//
|
||||
// Maps the event key to the permission name
|
||||
//
|
||||
const PERMISSION_MAP = [
|
||||
'channel_read_codec_name' => 'call_active_codec',
|
||||
'channel_read_codec_rate' => 'call_active_codec',
|
||||
'channel_write_codec_name' => 'call_active_codec',
|
||||
'channel_write_codec_rate' => 'call_active_codec',
|
||||
'caller_channel_name' => 'call_active_profile',
|
||||
'secure' => 'call_active_secure',
|
||||
'application' => 'call_active_application',
|
||||
'playback_file_path' => 'call_active_application',
|
||||
'variable_current_application'=> 'call_active_application',
|
||||
];
|
||||
|
||||
/**
|
||||
* Switch Event Socket
|
||||
* @var event_socket
|
||||
*/
|
||||
private $event_socket;
|
||||
|
||||
/**
|
||||
* Web Socket Client
|
||||
* @var websocket_client
|
||||
*/
|
||||
private $ws_client;
|
||||
|
||||
/**
|
||||
* Resource for the Switch Event Socket used to control blocking
|
||||
* @var resource
|
||||
*/
|
||||
private $switch_socket;
|
||||
private $topics;
|
||||
|
||||
/**
|
||||
* Event Filter
|
||||
* @var filter
|
||||
*/
|
||||
private $event_filter;
|
||||
|
||||
private static $switch_port = null;
|
||||
private static $switch_host = null;
|
||||
private static $switch_password = null;
|
||||
|
||||
private static $websocket_port = null;
|
||||
private static $websocket_host = null;
|
||||
|
||||
/**
|
||||
* Checks if an event exists in the SWITCH_EVENTS.
|
||||
* @param string $event_name The value to search for.
|
||||
* @return bool True if the value is found, false otherwise.
|
||||
*/
|
||||
public static function event_exists(string $event_name): bool {
|
||||
if (!empty($event_name)) {
|
||||
foreach (active_calls_service::SWITCH_EVENTS as $events) {
|
||||
if (in_array($event_name, $events)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an event exists in the SWITCH_EVENTS ignoring case.
|
||||
* This check is slower then the event_exists function. Whenever possible, it is recommended to use that function instead.
|
||||
* @param string $event_name
|
||||
* @return bool
|
||||
*/
|
||||
public static function event_exists_ignore_case(string $event_name): bool {
|
||||
foreach (self::SWITCH_EVENTS as $events) {
|
||||
foreach ($events as $value) {
|
||||
if (strtolower($value) === strtolower($event_name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a filter for the subscriber
|
||||
* @param subscriber $subscriber
|
||||
* @return filter
|
||||
*/
|
||||
public static function create_filter_chain_for(subscriber $subscriber): filter {
|
||||
return filter_chain::and_link([
|
||||
new event_filter(self::SWITCH_EVENTS),
|
||||
new permission_filter(self::PERMISSION_MAP, $subscriber->get_permissions()),
|
||||
new event_key_filter(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.calls";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string used to execute a hangup command
|
||||
* @param string $uuid
|
||||
* @return string
|
||||
*/
|
||||
public static function get_hangup_command(string $uuid): string {
|
||||
return "bgapi uuid_kill $uuid";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// use the connection information in the config file
|
||||
|
||||
// re-connect to the event socket
|
||||
if ($this->connect_to_event_socket()) {
|
||||
$this->register_event_socket_filters();
|
||||
}
|
||||
// re-connect to the websocket server
|
||||
$this->connect_to_ws_server();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the version of the active calls service in the console
|
||||
* @return void
|
||||
*/
|
||||
protected static function display_version(): void {
|
||||
echo "Active Calls Service 1.0\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the command line options
|
||||
*/
|
||||
protected static function set_command_options() {
|
||||
parent::append_command_option(
|
||||
command_option::new()
|
||||
->description('Set the Port to connect to the switch')
|
||||
->short_option('p')
|
||||
->short_description('-p <port>')
|
||||
->long_option('switch-port')
|
||||
->long_description('--switch-port <port>')
|
||||
->callback('set_switch_port')
|
||||
);
|
||||
parent::append_command_option(
|
||||
command_option::new()
|
||||
->description('Set the IP address for the switch')
|
||||
->short_option('i')
|
||||
->short_description('-i <ip_addr>')
|
||||
->long_option('switch-ip')
|
||||
->long_description('--switch-ip <ip_addr>')
|
||||
->callback('set_switch_host_address')
|
||||
);
|
||||
parent::append_command_option(
|
||||
command_option::new()
|
||||
->description('Set the password to be used with switch')
|
||||
->short_option('o')
|
||||
->short_description('-o <password>')
|
||||
->long_option('switch-password')
|
||||
->long_description('--switch-password <password>')
|
||||
->callback('set_switch_password')
|
||||
);
|
||||
parent::append_command_option(
|
||||
command_option::new()
|
||||
->description('Set the Port to connect to the websockets service')
|
||||
->short_option('w')
|
||||
->short_description('-w <port>')
|
||||
->long_option('websockets-port')
|
||||
->long_description('--websockets-port <port>')
|
||||
->callback('set_websockets_port')
|
||||
);
|
||||
parent::append_command_option(
|
||||
command_option::new()
|
||||
->description('Set the IP address for the websocket')
|
||||
->short_option('k')
|
||||
->short_description('-k <ip_addr>')
|
||||
->long_option('websockets-address')
|
||||
->long_description('--websockets-address <ip_addr>')
|
||||
->callback('set_websockets_host_address')
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
protected static function set_websockets_port($port): void {
|
||||
self::$websocket_port = $port;
|
||||
}
|
||||
|
||||
protected static function set_websockets_host_address($host): void {
|
||||
self::$websocket_host = $host;
|
||||
}
|
||||
|
||||
protected static function set_switch_host_address($host): void {
|
||||
self::$switch_host = $host;
|
||||
}
|
||||
|
||||
protected static function set_switch_port($port): void {
|
||||
self::$switch_port = $port;
|
||||
}
|
||||
|
||||
protected static function set_switch_password($password): void {
|
||||
self::$switch_password = $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point
|
||||
* @return int Non-zero exit indicates an error has occurred
|
||||
*/
|
||||
public function run(): int {
|
||||
|
||||
// Notify connected web server socket when we close
|
||||
register_shutdown_function(function ($ws_client) {
|
||||
if ($ws_client !== null)
|
||||
$ws_client->disconnect();
|
||||
}, $this->ws_client);
|
||||
|
||||
// Create an active call filter using filter objects to create an 'OR' chain
|
||||
$this->event_filter = filter_chain::or_link([new event_key_filter(active_calls_service::EVENT_KEYS)]);
|
||||
|
||||
// Register callbacks for the topics
|
||||
$this->on_topic('in.progress', [$this, 'on_in_progress'] );
|
||||
$this->on_topic('hangup', [$this, 'on_hangup'] );
|
||||
$this->on_topic('eavesdrop', [$this, 'on_eavesdrop'] );
|
||||
$this->on_topic('authenticate', [$this, 'on_authenticate']);
|
||||
|
||||
$this->info("Starting " . self::class . " service");
|
||||
// Suppress the WebSocket Server Error Message so it doesn't flood the system logs
|
||||
$suppress_ws_message = false;
|
||||
// Suppress the Event Socket Error Message so it doesn't flood the system logs
|
||||
$suppress_es_message = false;
|
||||
while ($this->running) {
|
||||
$read = [];
|
||||
// reconnect to event_socket
|
||||
if ($this->event_socket === null || !$this->event_socket->is_connected()) {
|
||||
if (!$this->connect_to_event_socket()) {
|
||||
if (!$suppress_es_message) $this->error("Unable to connect to switch event server");
|
||||
$suppress_es_message = true;
|
||||
} else {
|
||||
$this->register_event_socket_filters();
|
||||
}
|
||||
}
|
||||
|
||||
// reconnect to websocket server
|
||||
if ($this->ws_client === null || !$this->ws_client->is_connected()) {
|
||||
//$this->warn("Web socket disconnected");
|
||||
if (!$this->connect_to_ws_server()) {
|
||||
if (!$suppress_ws_message) $this->error("Unable to connect to websocket server.");
|
||||
$suppress_ws_message = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The switch _socket_ is used to read the 'data ready' on the stream
|
||||
if ($this->event_socket !== null && $this->event_socket->is_connected()) {
|
||||
$read[] = $this->switch_socket;
|
||||
$suppress_es_message = false;
|
||||
}
|
||||
|
||||
if ($this->ws_client !== null && $this->ws_client->is_connected()) {
|
||||
$read[] = $this->ws_client->socket();
|
||||
$suppress_ws_message = false;
|
||||
}
|
||||
|
||||
if (!empty($read)) {
|
||||
$write = $except = [];
|
||||
// Wait for an event and timeout at 1/3 of a second so we can re-check all connections
|
||||
if (false === stream_select($read, $write, $except, 0, 333333)) {
|
||||
// severe error encountered so exit
|
||||
$this->running = false;
|
||||
// Exit with non-zero exit code
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!empty($read)) {
|
||||
$this->debug("Received event");
|
||||
// Iterate over each socket event
|
||||
foreach ($read as $resource) {
|
||||
// Switch event
|
||||
if ($resource === $this->switch_socket) {
|
||||
$this->handle_switch_event();
|
||||
// No need to process more in the loop
|
||||
continue;
|
||||
}
|
||||
|
||||
// Web socket event
|
||||
if ($resource === $this->ws_client->socket()) {
|
||||
$this->handle_websocket_event($this->ws_client);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal termination
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function debug(string $message) {
|
||||
self::log($message, LOG_DEBUG);
|
||||
}
|
||||
|
||||
private function warn(string $message) {
|
||||
self::log($message, LOG_WARNING);
|
||||
}
|
||||
|
||||
private function error(string $message) {
|
||||
self::log($message, LOG_ERR);
|
||||
}
|
||||
|
||||
private function info(string $message) {
|
||||
self::log($message, LOG_INFO);
|
||||
}
|
||||
|
||||
private 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);
|
||||
|
||||
// Request authentication as a service
|
||||
$this->ws_client->authenticate($token_name, $token_hash);
|
||||
}
|
||||
|
||||
private function on_in_progress(websocket_message $websocket_message) {
|
||||
// Check permission
|
||||
if (!$websocket_message->has_permission('call_active_view')) {
|
||||
$this->warn("Permission 'call_active_show' not found in subscriber request");
|
||||
websocket_client::send($this->ws_client->socket(), websocket_message::request_forbidden($websocket_message->request_id, SERVICE_NAME, $websocket_message->topic));
|
||||
}
|
||||
|
||||
// Make sure we are connected
|
||||
if (!$this->event_socket->is_connected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up the response array
|
||||
$response = [];
|
||||
$response['service_name'] = SERVICE_NAME;
|
||||
// Attach the original request ID and subscriber ID given from websocket server so it can route it back
|
||||
$response['request_id'] = $websocket_message->request_id;
|
||||
$response['resource_id'] = $websocket_message->resource_id;
|
||||
$response['status_string'] = 'ok';
|
||||
$response['status_code'] = 200;
|
||||
|
||||
// Get the active calls from the helper function
|
||||
$calls = $this->get_active_calls($this->event_socket, $this->ws_client);
|
||||
$count = count($calls);
|
||||
$this->debug("Sending calls in progress ($count)");
|
||||
|
||||
// Use the subscribers permissions to filter out the event keys not permitted
|
||||
$filter = filter_chain::or_link([new permission_filter(self::PERMISSION_MAP, $websocket_message->permissions())]);
|
||||
|
||||
/** @var event_message $event */
|
||||
foreach ($calls as $event) {
|
||||
// Remove keys that are not permitted by filter
|
||||
$event->apply_filter($filter);
|
||||
$response['payload'] = $event;
|
||||
$response['topic'] = $event->name;
|
||||
$websocket_response = new websocket_message($response);
|
||||
websocket_client::send($this->ws_client->socket(), $websocket_response);
|
||||
}
|
||||
}
|
||||
|
||||
private function on_hangup(websocket_message $websocket_message) {
|
||||
// Check permission
|
||||
if (!$websocket_message->has_permission('call_active_hangup')) {
|
||||
$this->warn("Permission 'call_active_hangup' not found in subscriber request");
|
||||
websocket_client::send($this->ws_client->socket(), websocket_message::request_forbidden($websocket_message->request_id, SERVICE_NAME, $websocket_message->topic));
|
||||
}
|
||||
|
||||
// Make sure we are connected
|
||||
if (!$this->event_socket->is_connected()) {
|
||||
$this->warn("Failed to hangup call because event socket no longer connected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up the response array
|
||||
$response = [];
|
||||
$response['service_name'] = SERVICE_NAME;
|
||||
$response['topic'] = $websocket_message->topic;
|
||||
$response['request_id'] = $websocket_message->request_id;
|
||||
|
||||
// Get the payload
|
||||
$payload = $websocket_message->payload;
|
||||
|
||||
// Get the UUID from the payloadc
|
||||
$uuid = $payload['unique_id'] ?? '';
|
||||
|
||||
$response['status_message'] = 'success';
|
||||
$response['status_code'] = 200;
|
||||
|
||||
//Notify switch to hangup and ignore the response
|
||||
$response = $this->event_socket->request("bgapi uuid_kill $uuid");
|
||||
|
||||
// Notify websocket server of the result
|
||||
websocket_client::send($this->ws_client->socket(), new websocket_message($response));
|
||||
}
|
||||
|
||||
private function on_eavesdrop(websocket_message $websocket_message) {
|
||||
// Check permission
|
||||
if (!$websocket_message->has_permission('call_active_eavesdrop')) {
|
||||
$this->warn("Permission 'call_active_eavesdrop' not found in subscriber request");
|
||||
websocket_client::send($this->ws_client->socket(), websocket_message::request_forbidden($websocket_message->request_id, SERVICE_NAME, $websocket_message->topic));
|
||||
}
|
||||
|
||||
// Make sure we are connected
|
||||
if (!$this->event_socket->is_connected()) {
|
||||
$this->warn("Failed to hangup call because event socket no longer connected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up the response array
|
||||
$response = [];
|
||||
$response['service_name'] = SERVICE_NAME;
|
||||
$response['topic'] = $websocket_message->topic;
|
||||
$response['request_id'] = $websocket_message->request_id;
|
||||
|
||||
// Get the payload
|
||||
$payload = $websocket_message->payload();
|
||||
|
||||
// Get the eavesdrop information from the payload to send to the switch
|
||||
$uuid = $payload['unique_id'] ?? '';
|
||||
$origination_caller_id_name = $payload['origination_caller_id_name'] ?? '';
|
||||
$caller_caller_id_number = $payload['caller_caller_id_number'] ?? '';
|
||||
$origination_caller_contact = $payload['origination_caller_contact'] ?? '';
|
||||
$domain_name = $payload['domain_name'] ?? '';
|
||||
|
||||
$response['status_message'] = 'success';
|
||||
$response['status_code'] = 200;
|
||||
|
||||
$api_cmd = "bgapi originate {origination_caller_id_name=$origination_caller_id_name,origination_caller_id_number=$caller_caller_id_number}user/$origination_caller_contact@$domain_name &eavesdrop($uuid)";
|
||||
|
||||
// Log the eavesdrop
|
||||
$this->info("Eavesdrop on $uuid by $origination_caller_contact@$domain_name");
|
||||
|
||||
//
|
||||
// Send to the switch and ignore the result
|
||||
// Ignoring the switch information is important because on a busy system there will be more
|
||||
// events so the response is not necessarily correct
|
||||
//
|
||||
$this->event_socket->request($api_cmd);
|
||||
|
||||
// Execute eavesdrop command
|
||||
$response['status_message'] = 'success';
|
||||
$response['status_code'] = 200;
|
||||
|
||||
// Notify websocket server of the result
|
||||
websocket_client::send($this->ws_client->socket(), new websocket_message($response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the web socket server using a websocket_client object
|
||||
* @return bool
|
||||
*/
|
||||
private function connect_to_ws_server(): bool {
|
||||
$host = self::$websocket_host ?? self::$config->get('websocket.host', '127.0.0.1');
|
||||
$port = self::$websocket_port ?? self::$config->get('websocket.port', 8080);
|
||||
try {
|
||||
// Create a websocket client
|
||||
$this->ws_client = new websocket_client("ws://$host:$port");
|
||||
|
||||
// Block stream for handshake and authentication
|
||||
$this->ws_client->set_blocking(true);
|
||||
|
||||
// Connect to web socket server
|
||||
$this->ws_client->connect();
|
||||
|
||||
// Disable the stream blocking
|
||||
$this->ws_client->set_blocking(false);
|
||||
|
||||
$this->debug(self::class . " RESOURCE ID: " . $this->ws_client->socket());
|
||||
} catch (\RuntimeException $re) {
|
||||
//unable to connect
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the switch event socket
|
||||
* @return bool
|
||||
*/
|
||||
private function connect_to_event_socket(): bool {
|
||||
|
||||
// check if we have defined it already
|
||||
if (!isset($this->switch_socket)) {
|
||||
//default to false for the while loop below
|
||||
$this->switch_socket = false;
|
||||
}
|
||||
|
||||
// When no command line option is used to set the switch host, port, or password, get it from
|
||||
// the config file. If it is not in the config file, then set a default value
|
||||
$host = self::$switch_host ?? parent::$config->get('switch.event_socket.host', '127.0.0.1');
|
||||
$port = self::$switch_port ?? parent::$config->get('switch.event_socket.port', 8021);
|
||||
$password = self::$switch_password ?? parent::$config->get('switch.event_socket.password', 'ClueCon');
|
||||
|
||||
try {
|
||||
//set up the socket away from the event_socket object so we have control over blocking
|
||||
$this->switch_socket = stream_socket_client("tcp://$host:$port", $errno, $errstr, 5);
|
||||
} catch (\RuntimeException $re) {
|
||||
$this->warn('Unable to connect to event socket');
|
||||
}
|
||||
|
||||
// If we didn't connect then return back false
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the switch events needed for active calls
|
||||
*/
|
||||
private function register_event_socket_filters() {
|
||||
$this->event_socket->request('event plain all');
|
||||
|
||||
//
|
||||
// CUSTOM and API are required to handle events such as:
|
||||
// - 'valet_parking::info'
|
||||
// - '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_calls::SWITCH_EVENTS, $event_filter);
|
||||
// Add filters for active calls only
|
||||
foreach (active_calls_service::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->debug("Response: " . $response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function get_active_calls(): array {
|
||||
$calls = [];
|
||||
|
||||
//
|
||||
// We use a new socket connection to get the response because the switch
|
||||
// can be firing events while we are processing so we need only this
|
||||
// request answered
|
||||
//
|
||||
$event_socket = new event_socket();
|
||||
$event_socket->connect();
|
||||
|
||||
// Make sure we are connected
|
||||
if (!$event_socket->is_connected()) {
|
||||
return $calls;
|
||||
}
|
||||
|
||||
// Send the command on a new channel
|
||||
$json = trim($event_socket->request('api show channels as json'));
|
||||
$event_socket->close();
|
||||
|
||||
$json_array = json_decode($json, true);
|
||||
if (empty($json_array["rows"])) {
|
||||
return $calls;
|
||||
}
|
||||
|
||||
// Map the rows returned to the active call format
|
||||
foreach ($json_array["rows"] as $call) {
|
||||
$message = new event_message($call, $this->event_filter);
|
||||
$this->debug("MESSAGE: $message");
|
||||
// adjust basic info to match an event setting the callstate to ringing
|
||||
// so that a row can be created for it
|
||||
$message->event_name = 'CHANNEL_CALLSTATE';
|
||||
$message->answer_state = 'ringing';
|
||||
$message->channel_call_state = 'RINGING';
|
||||
$message->unique_id = $call['uuid'];
|
||||
$message->call_direction = $call['direction'];
|
||||
|
||||
//set the codecs
|
||||
$message->caller_channel_created_time = intval($call['created_epoch']) * 1000000;
|
||||
$message->channel_read_codec_name = $call['read_codec'];
|
||||
$message->channel_read_codec_rate = $call['read_rate'];
|
||||
$message->channel_write_codec_name = $call['write_codec'];
|
||||
$message->channel_write_codec_rate = $call['write_rate'];
|
||||
|
||||
//get the profile name
|
||||
$message->caller_channel_name = $call['name'];
|
||||
|
||||
//domain or context
|
||||
$message->caller_context = $call['context'];
|
||||
$message->caller_caller_id_name = $call['initial_cid_name'];
|
||||
$message->caller_caller_id_number = $call['initial_cid_num'];
|
||||
$message->caller_destination_number = $call['initial_dest'];
|
||||
$message->application = $call['application'] ?? '';
|
||||
$message->secure = $call['secure'] ?? '';
|
||||
|
||||
if (true) {
|
||||
$this->debug("-------- ACTIVE CALL ----------");
|
||||
$this->debug($message);
|
||||
$this->debug("In Progress: '$message->name', $message->unique_id");
|
||||
$this->debug("-------------------------------");
|
||||
}
|
||||
$calls[] = $message;
|
||||
}
|
||||
|
||||
return $calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call each of the registered events for the websocket topic that has arrived
|
||||
* @param string $topic
|
||||
* @param websocket_message $websocket_message
|
||||
*/
|
||||
private function trigger_topic(string $topic, websocket_message $websocket_message) {
|
||||
if (empty($topic) || empty($websocket_message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!empty($this->topics[$topic])) {
|
||||
foreach ($this->topics[$topic] as $callback) {
|
||||
call_user_func($callback, $websocket_message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the service to register a callback so when the topic arrives the callable is called
|
||||
* @param type $topic
|
||||
* @param type $callable
|
||||
*/
|
||||
public function on_topic($topic, $callable) {
|
||||
if (!isset($this->topics[$topic])) {
|
||||
$this->topics[$topic] = [];
|
||||
}
|
||||
$this->topics[$topic][] = $callable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the message from the web socket client and triggers the appropriate requested topic event
|
||||
* @param resource $ws_client
|
||||
* @return void
|
||||
*/
|
||||
private function handle_websocket_event() {
|
||||
// Read the JSON string
|
||||
$json_string = $this->ws_client->read();
|
||||
|
||||
// Nothing to do
|
||||
if ($json_string === null) {
|
||||
$this->warn('Message received from Websocket is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
// Nothing to do
|
||||
if (empty($message->topic())) {
|
||||
$this->error("Message received does not have topic");
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the registered topic event
|
||||
$this->trigger_topic($message->topic, $message, $this->ws_client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a switch event by reading the event and then dispatching to the web socket server
|
||||
*/
|
||||
private function handle_switch_event() {
|
||||
|
||||
$raw_event = $this->event_socket->read_event();
|
||||
|
||||
//$this->debug("=====================================");
|
||||
//$this->debug("RAW EVENT: " . ($raw_event['$'] ?? ''));
|
||||
//$this->debug("=====================================");
|
||||
|
||||
// get the switch message event object
|
||||
$event = event_message::create_from_switch_event($raw_event, $this->event_filter);
|
||||
|
||||
// Log the event
|
||||
$this->debug("EVENT: '" . $event->name . "'");
|
||||
|
||||
if (!$this->ws_client->is_connected()) {
|
||||
$this->debug('Not connected to websocket host. Dropping Event');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure it is an event that we are looking for
|
||||
if (active_calls_service::event_exists($event->name)) {
|
||||
// Create a message to send on websocket
|
||||
$message = new websocket_message();
|
||||
|
||||
// Set the service name so subscribers can filter
|
||||
$message->service(SERVICE_NAME);
|
||||
|
||||
// Set the topic to the event name
|
||||
$message->topic = $event->name;
|
||||
|
||||
// The event is the payload
|
||||
$message->payload($event->to_array());
|
||||
|
||||
// Notify system log of the message and event name
|
||||
$this->debug("Sending Event: '$event->event_name'");
|
||||
|
||||
//send event to the web socket routing service
|
||||
websocket_client::send($this->ws_client->socket(), $message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the array of enabled domains using the UUID as the array key and the domain name as the array value
|
||||
* @return array
|
||||
*/
|
||||
private static function get_domain_names(database $database): array {
|
||||
return array_column($database->execute("select domain_name, domain_uuid from v_domains where domain_enabled='true'") ?: [], 'domain_name', 'domain_uuid');
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the database to return the domain name for the given uuid
|
||||
* @param string $domain_uuid
|
||||
* @return string
|
||||
*/
|
||||
private static function get_domain_name_by_uuid(database $database, string $domain_uuid): string {
|
||||
return $database->execute("select domain_name from v_domains where domain_enabled='true' and domain_uuid = :domain_uuid limit 1", ['domain_uuid' => $domain_uuid], 'column') ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the database to return a single domain uuid for the given name. If more then one match is possible use the get_domain_names function.
|
||||
* @param string $domain_name
|
||||
* @return string
|
||||
*/
|
||||
private static function get_domain_uuid_by_name(database $database, string $domain_name): string {
|
||||
return $database->execute("select domain_uuid from v_domains where domain_enabled='true' and domain_name = :domain_name limit 1", ['domain_name' => $domain_name], 'column') ?: '';
|
||||
}
|
||||
}
|
||||
85
app/active_calls/resources/classes/event_filter.php
Normal file
85
app/active_calls/resources/classes/event_filter.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Filter an event based on event name or event subclass or event command
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class event_filter implements filter {
|
||||
|
||||
private $event_names;
|
||||
|
||||
public function __construct(array $event_names) {
|
||||
$this->add_event_names($event_names);
|
||||
}
|
||||
|
||||
public function __invoke(string $key, $value): ?bool {
|
||||
if ($key !== 'event_name') {
|
||||
return true;
|
||||
}
|
||||
return $this->has_event_name($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single event name filter
|
||||
* @param string $name
|
||||
*/
|
||||
public function add_event_name(string $name) {
|
||||
$this->event_names[$name] = $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the array list to the filters.
|
||||
* @param array $event_names
|
||||
*/
|
||||
public function add_event_names(array $event_names) {
|
||||
// Add all event key filters passed
|
||||
foreach ($event_names as $event_name) {
|
||||
if (is_array($event_name)) {
|
||||
$this->add_event_names($event_name);
|
||||
} else {
|
||||
$this->add_event_name($event_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function has_event_name(string $name): ?bool {
|
||||
if (isset($this->event_names[$name]))
|
||||
return true;
|
||||
//
|
||||
// If the event name is not allowed by the permissions given in
|
||||
// this object, then the entire event must be dropped. I could
|
||||
// not figure out a better way to do this except to throw an
|
||||
// exception so that the caller can drop the message.
|
||||
//
|
||||
// TODO: Find another way not so expensive to reject the payload
|
||||
//
|
||||
return null;
|
||||
}
|
||||
}
|
||||
90
app/active_calls/resources/classes/event_key_filter.php
Normal file
90
app/active_calls/resources/classes/event_key_filter.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Active call filter class definition
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class event_key_filter implements filter {
|
||||
|
||||
private $filters;
|
||||
|
||||
public function __construct(array $filters = []) {
|
||||
$this->add_filters($filters);
|
||||
}
|
||||
|
||||
public function __invoke(string $key, $value): ?bool {
|
||||
return $this->has_filter_key($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single filter
|
||||
* @param string $key
|
||||
*/
|
||||
public function add_filter(string $key) {
|
||||
$this->filters[$key] = $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current list of filters
|
||||
* @return array
|
||||
*/
|
||||
public function get_filters(): array {
|
||||
return array_values($this->filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single list of filters
|
||||
* @param string $key
|
||||
*/
|
||||
public function remove_filter(string $key) {
|
||||
unset($this->filters[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all filters
|
||||
*/
|
||||
public function clear_filters() {
|
||||
$this->filters = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the array list to the filters.
|
||||
* @param array $list_of_keys
|
||||
*/
|
||||
public function add_filters(array $list_of_keys) {
|
||||
// Add all event key filters passed
|
||||
foreach ($list_of_keys as $key) {
|
||||
$this->filters[$key] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
public function has_filter_key(string $key): bool {
|
||||
return isset($this->filters[$key]);
|
||||
}
|
||||
}
|
||||
323
app/active_calls/resources/classes/event_message.php
Normal file
323
app/active_calls/resources/classes/event_message.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tracks switch events in an object instead of array
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class event_message implements filterable_payload {
|
||||
|
||||
const BODY_ARRAY_KEY = '_body';
|
||||
|
||||
const EVENT_SWAP_API = 0x01;
|
||||
const EVENT_USE_SUBCLASS = 0x02;
|
||||
|
||||
// Default keys in the event to capture
|
||||
public static $keys = [];
|
||||
|
||||
/**
|
||||
* Associative array to store the event with the key name always lowercase and the hyphen replaced with an underscore
|
||||
* @var array
|
||||
*/
|
||||
private $event;
|
||||
private $body;
|
||||
|
||||
/**
|
||||
* Only permitted keys on this list are allowed to be inserted in to the event_message object
|
||||
* @var filter
|
||||
*/
|
||||
private $event_filter;
|
||||
|
||||
/**
|
||||
* Creates an event message
|
||||
* @param array $event_array
|
||||
* @param filter $filter
|
||||
*/
|
||||
public function __construct(array $event_array, ?filter $filter = null) {
|
||||
|
||||
// Set the event to an empty array
|
||||
$this->event = [];
|
||||
|
||||
// Clear the memory area for body and key_filter
|
||||
$this->body = null;
|
||||
|
||||
$this->event_filter = $filter;
|
||||
|
||||
// Set the event array to match
|
||||
foreach ($event_array as $name => $value) {
|
||||
$this->__set($name, $value);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the key name and then stores the value in the event property as an associative array
|
||||
* @param string $name
|
||||
* @param string $value
|
||||
* @return void
|
||||
*/
|
||||
public function __set(string $name, $value) {
|
||||
self::sanitize_event_key($name);
|
||||
|
||||
// Use the filter chain to ensure the key is allowed
|
||||
if ($this->event_filter === null || ($this->event_filter)($name, $value)) {
|
||||
$this->event[$name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the key name and then returns the value stored in the event property
|
||||
* @param string $name Name of the event key
|
||||
* @return string Returns the stored value or an empty string
|
||||
*/
|
||||
public function __get(string $name) {
|
||||
self::sanitize_event_key($name);
|
||||
if ($name === 'name') $name = 'event_name';
|
||||
return $this->event[$name] ?? '';
|
||||
}
|
||||
|
||||
public function __toArray(): array {
|
||||
$array = [];
|
||||
foreach ($this->event as $key => $value) {
|
||||
$array[$key] = $value;
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
|
||||
public function to_array(): array {
|
||||
return $this->__toArray();
|
||||
}
|
||||
|
||||
public function apply_filter(filter $filter) {
|
||||
foreach ($this->event as $key => $value) {
|
||||
$result = ($filter)($key, $value);
|
||||
if ($result === null) {
|
||||
$this->event = [];
|
||||
} elseif (!$result) {
|
||||
unset($this->event[$key]);
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public static function parse_active_calls($json_string): array {
|
||||
$calls = [];
|
||||
$json_array = json_decode($json_string, true);
|
||||
if (empty($json_array["rows"])) {
|
||||
return $calls;
|
||||
}
|
||||
foreach ($json_array["rows"] as $call) {
|
||||
$message = new event_message($call);
|
||||
// adjust basic info to match an event setting the callstate to ringing
|
||||
// so that a row can be created for it
|
||||
$message['event_name'] = 'CHANNEL_CALLSTATE';
|
||||
$message['answer_state'] = 'ringing';
|
||||
$message['channel_call_state'] = 'ACTIVE';
|
||||
$message['unique_id'] = $call['uuid'];
|
||||
$message['call_direction'] = $call['direction'];
|
||||
|
||||
//set the codecs
|
||||
$message['caller_channel_created_time'] = intval($call['created_epoch']) * 1000000;
|
||||
$message['channel_read_codec_name'] = $call['read_codec'];
|
||||
$message['channel_read_codec_rate'] = $call['read_rate'];
|
||||
$message['channel_write_codec_name'] = $call['write_codec'];
|
||||
$message['channel_write_codec_rate'] = $call['write_rate'];
|
||||
|
||||
//get the profile name
|
||||
$message['caller_channel_name'] = $call['name'];
|
||||
|
||||
//domain or context
|
||||
$message['caller_context'] = $call['context'];
|
||||
$message['caller_caller_id_name'] = $call['initial_cid_name'];
|
||||
$message['caller_caller_id_number'] = $call['initial_cid_num'];
|
||||
$message['caller_destination_number'] = $call['initial_dest'];
|
||||
$message['application'] = $call['application'] ?? '';
|
||||
$message['secure'] = $call['secure'] ?? '';
|
||||
$calls[] = $message;
|
||||
}
|
||||
return $calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a websocket_message_event object from a json string
|
||||
* @param type $json_string
|
||||
* @return self|null
|
||||
*/
|
||||
public static function create_from_json($json_string) {
|
||||
if (is_array($json_string)) {
|
||||
print_r(debug_backtrace());
|
||||
die();
|
||||
}
|
||||
$array = json_decode($json_string, true);
|
||||
if ($array !== false) {
|
||||
return new static($array);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function create_from_switch_event($raw_event, filter $filter = null, $flags = 3): self {
|
||||
|
||||
// Set the options from the flags passed
|
||||
$swap_api_name_with_event_name = ($flags & self::EVENT_SWAP_API) !== 0;
|
||||
$swap_subclass_event_name_with_event_name = ($flags & self::EVENT_USE_SUBCLASS) !== 0;
|
||||
|
||||
// Get the payload and ignore the headers
|
||||
if (is_array($raw_event) && isset($raw_event['$'])) {
|
||||
$raw_event = $raw_event['$'];
|
||||
}
|
||||
|
||||
//check if it is still an array
|
||||
if (is_array($raw_event)) {
|
||||
$raw_event = array_pop($raw_event);
|
||||
}
|
||||
|
||||
$event_array = [];
|
||||
foreach (explode("\n", $raw_event) as $line) {
|
||||
$parts = explode(':', $line, 2);
|
||||
$key = '';
|
||||
$value = '';
|
||||
if (count($parts) > 0) {
|
||||
$key = $parts[0];
|
||||
}
|
||||
if (count($parts) > 1) {
|
||||
$value = urldecode(trim($parts[1]));
|
||||
}
|
||||
if (!empty($key)) {
|
||||
$event_array[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
//check for body
|
||||
if (!empty($event_array['Content-Length'])) {
|
||||
$event_array['_body'] = substr($raw_event, -1*$event_array['Content-Length']);
|
||||
}
|
||||
|
||||
// Instead of using 'CUSTOM' for the Event-Name we use the actual API-Command when it is available instead
|
||||
if ($swap_api_name_with_event_name && !empty($event_array['API-Command'])) {
|
||||
// swap the values
|
||||
[$event_array['Event-Name'], $event_array['API-Command']] = [$event_array['API-Command'], $event_array['Event-Name']];
|
||||
}
|
||||
|
||||
// Promote the Event-Subclass name to the Event-Name
|
||||
if ($swap_subclass_event_name_with_event_name && !empty($event_array['Event-Subclass'])) {
|
||||
// swap the values
|
||||
[$event_array['Event-Name'], $event_array['Event-Subclass']] = [$event_array['Event-Subclass'], $event_array['Event-Name']];
|
||||
}
|
||||
|
||||
// Return the new object
|
||||
return new static($event_array, $filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a Json representation for this object when the object is echoed or printed
|
||||
* @return string
|
||||
* @override websocket_message
|
||||
*/
|
||||
public function __toString(): string {
|
||||
return json_encode($this->to_array());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or Get the body
|
||||
* @param null|string $body
|
||||
* @return self|string
|
||||
*/
|
||||
public function body(?string $body = null) {
|
||||
|
||||
// Check if we are setting the value for body
|
||||
if (func_num_args() > 0) {
|
||||
|
||||
// Set the value
|
||||
$this->body = $body;
|
||||
|
||||
// Return the object for chaining
|
||||
return $this;
|
||||
}
|
||||
|
||||
// A request was made to get the value from body
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function event_to_array(): array {
|
||||
$array = [];
|
||||
foreach ($this->event as $key => $value) {
|
||||
$array[$key] = $value;
|
||||
}
|
||||
if ($this->body !== null) {
|
||||
$array[self::BODY_ARRAY_KEY] = $this->body;
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
|
||||
public function getIterator(): \Traversable {
|
||||
yield from $this->event_to_array();
|
||||
}
|
||||
|
||||
public function offsetExists(mixed $offset): bool {
|
||||
self::sanitize_event_key($offset);
|
||||
return isset($this->event[$offset]);
|
||||
}
|
||||
|
||||
public function offsetGet(mixed $offset): mixed {
|
||||
self::sanitize_event_key($offset);
|
||||
if ($offset === self::BODY_ARRAY_KEY) {
|
||||
return $this->body;
|
||||
}
|
||||
return $this->event[$offset];
|
||||
}
|
||||
|
||||
public function offsetSet(mixed $offset, mixed $value): void {
|
||||
self::sanitize_event_key($offset);
|
||||
if ($offset === self::BODY_ARRAY_KEY) {
|
||||
$this->body = $value;
|
||||
} else {
|
||||
$this->event[$offset] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
public function offsetUnset(mixed $offset): void {
|
||||
self::sanitize_event_key($offset);
|
||||
if ($offset === self::BODY_ARRAY_KEY) {
|
||||
$this->body = null;
|
||||
} else {
|
||||
unset($this->event[$offset]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes key by replacing '-' with '_', converts to lowercase, and only allows digits 0-9 and letters a-z
|
||||
* @param string $key
|
||||
* @return string
|
||||
*/
|
||||
public static function sanitize_event_key(string &$key) /* : never */ {
|
||||
$key = preg_replace('/[^a-z0-9_]/', '', str_replace('-', '_', strtolower($key)));
|
||||
//rewrite 'name' to 'event_name'
|
||||
if ($key === 'name') $key = 'event_name';
|
||||
}
|
||||
}
|
||||
297
app/active_calls/resources/javascript/arrows.js
Normal file
297
app/active_calls/resources/javascript/arrows.js
Normal file
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
function create_arrow(direction, color) {
|
||||
switch (direction) {
|
||||
case 'inbound':
|
||||
arrow = create_arrow_inbound(color);
|
||||
break;
|
||||
case 'outbound':
|
||||
arrow = create_arrow_outbound(color);
|
||||
break;
|
||||
case 'local':
|
||||
arrow = create_arrow_local(color);
|
||||
break;
|
||||
case 'voicemail':
|
||||
arrow = create_voicemail_icon(color);
|
||||
break;
|
||||
case 'missed':
|
||||
arrow = create_inbound_missed(color);
|
||||
break;
|
||||
}
|
||||
return arrow;
|
||||
}
|
||||
|
||||
function create_arrow_outbound(color, gridSize = 25) {
|
||||
// Create SVG from SVG Namespace
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
|
||||
// compute how much to scale the original 24-unit grid
|
||||
const scale = gridSize / 25;
|
||||
|
||||
// Set color
|
||||
svg.setAttribute("stroke", color);
|
||||
// Set brush width
|
||||
svg.setAttribute("width", gridSize);
|
||||
svg.setAttribute("height", gridSize);
|
||||
svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`);
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke-width", 2 * scale);
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
|
||||
// Create line
|
||||
const line = document.createElementNS(SVG_NS, "line");
|
||||
line.setAttribute("x1", (4 * scale).toString());
|
||||
line.setAttribute("y1", (20 * scale).toString());
|
||||
line.setAttribute("x2", (20 * scale).toString());
|
||||
line.setAttribute("y2", (4 * scale).toString());
|
||||
svg.appendChild(line);
|
||||
|
||||
// Create the arrow head (a right-angle triangle)
|
||||
const head = document.createElementNS(SVG_NS, "polygon");
|
||||
// head.setAttribute("points", "20,4 10,9 15,14");
|
||||
head.setAttribute("points", [[20, 4], [10, 9], [15, 14]]
|
||||
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
|
||||
);
|
||||
head.setAttribute("fill", color);
|
||||
svg.appendChild(head);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function create_arrow_inbound(color, gridSize = 25) {
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
|
||||
// compute how much to scale the original 24-unit grid
|
||||
const scale = gridSize / 25;
|
||||
|
||||
// size and viewport
|
||||
svg.setAttribute("width", gridSize);
|
||||
svg.setAttribute("height", gridSize);
|
||||
svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`);
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", color);
|
||||
svg.setAttribute("stroke-width", 2 * scale);
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
|
||||
// scaled line from (4,4) → (20,20)
|
||||
const line = document.createElementNS(SVG_NS, "line");
|
||||
line.setAttribute("x1", (4 * scale).toString());
|
||||
line.setAttribute("y1", (4 * scale).toString());
|
||||
line.setAttribute("x2", (20 * scale).toString());
|
||||
line.setAttribute("y2", (20 * scale).toString());
|
||||
svg.appendChild(line);
|
||||
|
||||
// scaled triangle head: (20,20), (10,15), (15,10)
|
||||
const head = document.createElementNS(SVG_NS, "polygon");
|
||||
head.setAttribute("points", [[20, 20], [10, 15], [15, 10]]
|
||||
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
|
||||
);
|
||||
head.setAttribute("fill", color);
|
||||
svg.appendChild(head);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
function create_arrow_local(color, gridSize = 25) {
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
|
||||
// compute how much to scale the original 25-unit grid
|
||||
const scale = gridSize / 25;
|
||||
|
||||
// sizing & styling
|
||||
svg.setAttribute("width", gridSize);
|
||||
svg.setAttribute("height", gridSize);
|
||||
svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`);
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", color);
|
||||
svg.setAttribute("stroke-width", 2 * scale);
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
|
||||
// shaft
|
||||
const line = document.createElementNS(SVG_NS, "line");
|
||||
line.setAttribute("x1", 6 * scale);
|
||||
line.setAttribute("y1", 12 * scale);
|
||||
line.setAttribute("x2", 18 * scale);
|
||||
line.setAttribute("y2", 12 * scale);
|
||||
svg.appendChild(line);
|
||||
|
||||
// left arrow head
|
||||
const leftHead = document.createElementNS(SVG_NS, "polygon");
|
||||
leftHead.setAttribute("points", [[6,8], [2,12], [6,16]]
|
||||
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
|
||||
);
|
||||
leftHead.setAttribute("fill", color);
|
||||
svg.appendChild(leftHead);
|
||||
|
||||
// right arrow head
|
||||
const rightHead = document.createElementNS(SVG_NS, "polygon");
|
||||
rightHead.setAttribute("points", [[18,8], [22,12], [18,16]]
|
||||
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
|
||||
);
|
||||
rightHead.setAttribute("fill", color);
|
||||
svg.appendChild(rightHead);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
function create_inbound_missed(color, gridSize = 25) {
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
|
||||
// compute how much to scale the original 25-unit grid
|
||||
const scale = gridSize / 25;
|
||||
|
||||
// size and viewport
|
||||
svg.setAttribute("width", gridSize);
|
||||
svg.setAttribute("height", gridSize);
|
||||
svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`);
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", color);
|
||||
svg.setAttribute("stroke-width", 2 * scale);
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
|
||||
// 5. Reflective bounce polyline
|
||||
const bounce = document.createElementNS(SVG_NS, 'polyline');
|
||||
bounce.setAttribute('points', [[4, 4], [12, 12], [20, 4]]
|
||||
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
|
||||
);
|
||||
bounce.setAttribute('stroke', color);
|
||||
bounce.setAttribute('stroke-width', 2 * scale);
|
||||
bounce.setAttribute('fill', 'none');
|
||||
bounce.setAttribute('stroke-linecap', 'round');
|
||||
bounce.setAttribute('marker-end', 'url(#arrowhead)');
|
||||
svg.appendChild(bounce);
|
||||
|
||||
// scaled triangle head: tip[20,4], left wing[17,5], right wing[19, 7]
|
||||
const head = document.createElementNS(SVG_NS, "polygon");
|
||||
head.setAttribute("points", [[20, 4], [17, 5], [19, 7]]
|
||||
.map(([x, y]) => `${x * scale},${y * scale}`).join(" ")
|
||||
);
|
||||
head.setAttribute("fill", color);
|
||||
svg.appendChild(head);
|
||||
|
||||
// Left earpiece
|
||||
const left = document.createElementNS(SVG_NS, 'ellipse');
|
||||
left.setAttribute("cx", 4 * scale);
|
||||
left.setAttribute("cy", 17 * scale);
|
||||
left.setAttribute("rx", 2 * scale);
|
||||
left.setAttribute("ry", 1 * scale);
|
||||
left.setAttribute("fill", color);
|
||||
svg.appendChild(left);
|
||||
|
||||
// Right earpiece
|
||||
const right = document.createElementNS(SVG_NS, 'ellipse');
|
||||
right.setAttribute("cx", 18 * scale);
|
||||
right.setAttribute("cy", 17 * scale);
|
||||
right.setAttribute("rx", 2 * scale);
|
||||
right.setAttribute("ry", 1 * scale);
|
||||
right.setAttribute("fill", color);
|
||||
svg.appendChild(right);
|
||||
|
||||
// Arc to join left and right
|
||||
const startX = 3 * scale; // left cx + rx
|
||||
const startY = 17 * scale; // cy - ry
|
||||
const endX = 19 * scale; // right cx - rx
|
||||
const endY = startY;
|
||||
|
||||
// choose radii so the handle bows upwards
|
||||
const rx = (endX - startX) / 2; // half the distance
|
||||
const ry = 2.2 * scale; // controls how tall the arc is
|
||||
|
||||
const arc = document.createElementNS(SVG_NS, 'path');
|
||||
// Move to the left‐earpiece top, then arc to the right‐earpiece top
|
||||
const d = `M${startX},${startY} A${rx},${ry} 0 0,1 ${endX},${endY}`;
|
||||
arc.setAttribute('d', d);
|
||||
arc.setAttribute('stroke', color);
|
||||
arc.setAttribute('stroke-width', 2 * scale);
|
||||
arc.setAttribute('stroke-linecap', 'round');
|
||||
|
||||
svg.appendChild(arc);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function create_voicemail_icon(fillColor, gridSize = 25) {
|
||||
// SVG namespace
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
const scale = gridSize / 25;
|
||||
const width = scale * 25;
|
||||
const height = scale * 25;
|
||||
|
||||
// Create the root SVG element
|
||||
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||
svg.setAttribute('width', width);
|
||||
svg.setAttribute('height', height);
|
||||
svg.setAttribute('viewBox', '0 0 25 25');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// Border rectangle (inserted first so it's underneath)
|
||||
const border = document.createElementNS(SVG_NS, 'rect');
|
||||
y = 7;
|
||||
border.setAttribute('x', 1);
|
||||
border.setAttribute('y', y);
|
||||
border.setAttribute('width', 23);
|
||||
border.setAttribute('height', 21 - y);
|
||||
border.setAttribute('fill', 'none');
|
||||
border.setAttribute('stroke', fillColor);
|
||||
border.setAttribute('stroke-width', '2');
|
||||
svg.appendChild(border);
|
||||
|
||||
// Left circle
|
||||
const left_circle = document.createElementNS(SVG_NS, 'circle');
|
||||
left_circle.setAttribute('cx', 7);
|
||||
left_circle.setAttribute('cy', 14);
|
||||
left_circle.setAttribute('r', 3);
|
||||
left_circle.setAttribute('fill', 'none');
|
||||
left_circle.setAttribute('stroke', fillColor);
|
||||
left_circle.setAttribute('stroke-width', '2');
|
||||
svg.appendChild(left_circle);
|
||||
|
||||
// Right circle
|
||||
const right_circle = document.createElementNS(SVG_NS, 'circle');
|
||||
right_circle.setAttribute('cx', 18);
|
||||
right_circle.setAttribute('cy', 14);
|
||||
right_circle.setAttribute('r', 3);
|
||||
right_circle.setAttribute('fill', 'none');
|
||||
right_circle.setAttribute('stroke', fillColor);
|
||||
right_circle.setAttribute('stroke-width', '2');
|
||||
svg.appendChild(right_circle);
|
||||
|
||||
// Connecting line
|
||||
const bar = document.createElementNS(SVG_NS, 'line');
|
||||
bar.setAttribute('x1', 6);
|
||||
bar.setAttribute('y1', 11);
|
||||
bar.setAttribute('x2', 19);
|
||||
bar.setAttribute('y2', 11);
|
||||
bar.setAttribute('stroke', fillColor);
|
||||
bar.setAttribute('stroke-width', '2');
|
||||
svg.appendChild(bar);
|
||||
|
||||
return svg;
|
||||
}
|
||||
136
app/active_calls/resources/javascript/websocket_client.js
Normal file
136
app/active_calls/resources/javascript/websocket_client.js
Normal file
@@ -0,0 +1,136 @@
|
||||
class ws_client {
|
||||
constructor(url, token) {
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.addEventListener('message', this._onMessage.bind(this));
|
||||
this._nextId = 1;
|
||||
this._pending = new Map();
|
||||
this._eventHandlers = new Map();
|
||||
// The token is submitted on every request
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
// internal message handler called when event occurs on the socket
|
||||
_onMessage(ev) {
|
||||
let message;
|
||||
let switch_event;
|
||||
try {
|
||||
//console.log(ev.data);
|
||||
message = JSON.parse(ev.data);
|
||||
// check for authentication request
|
||||
if (message.status_code === 407) {
|
||||
console.log('Authentication Required');
|
||||
return;
|
||||
}
|
||||
switch_event = message.payload;
|
||||
//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,
|
||||
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) {
|
||||
resolve({service, topic, payload, code, message});
|
||||
} 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
|
||||
this._dispatchEvent(message.service_name, switch_event);
|
||||
}
|
||||
|
||||
// Send a request to the websocket server using JSON string
|
||||
request(service, topic = null, payload = {}) {
|
||||
const request_id = String(this._nextId++);
|
||||
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.calls', topic);
|
||||
}
|
||||
|
||||
unsubscribe(topic) {
|
||||
return this.request('active.calls', topic);
|
||||
}
|
||||
|
||||
// register a callback for server-pushes
|
||||
onEvent(topic, handler) {
|
||||
console.log('registering event listener for ' + topic);
|
||||
if (!this._eventHandlers.has(topic)) {
|
||||
this._eventHandlers.set(topic, []);
|
||||
}
|
||||
this._eventHandlers.get(topic).push(handler);
|
||||
}
|
||||
/**
|
||||
* Dispatch a server‑push event envelope to all registered handlers.
|
||||
* @param {object} env
|
||||
*/
|
||||
_dispatchEvent(service, env) {
|
||||
// if service==='event', topic carries the real event name:
|
||||
let event = (typeof env === 'string')
|
||||
? JSON.parse(env)
|
||||
: env;
|
||||
|
||||
// dispatch event handlers
|
||||
if (service === 'active.calls') {
|
||||
const topic = event.event_name;
|
||||
|
||||
let handlers = this._eventHandlers.get(topic) || [];
|
||||
if (handlers.length === 0) {
|
||||
handlers = this._eventHandlers.get('*') || [];
|
||||
}
|
||||
for (const fn of handlers) {
|
||||
try {
|
||||
fn(event);
|
||||
} catch (err) {
|
||||
console.error(`Error in handler for "${topic}":`, err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const handlers = this._eventHandlers.get(service) || [];
|
||||
for (const fn of handlers) {
|
||||
try {
|
||||
fn(event.data, event);
|
||||
} catch (err) {
|
||||
console.error(`Error in handler for "${service}":`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
64
app/active_calls/resources/service/active_calls.php
Executable file
64
app/active_calls/resources/service/active_calls.php
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* The purpose of this file is to subscribe to the switch events and then notify the web socket service of the event.
|
||||
*
|
||||
* Requirements:
|
||||
* - PHP 7.1 or higher
|
||||
*
|
||||
* When an event is received from the switch, it is sent to the web socket service. The web socket service then
|
||||
* broadcasts the event to all subscribers that have subscribed to the service that broadcasted the event. The
|
||||
* web socket service only has information about who is connected. Each connection to the web socket service
|
||||
* is called a subscriber. Subscribers can be either a service or a web client. When a token is created and
|
||||
* given to the subscriber class using the "save_token" method, the subscriber is a web client. When the
|
||||
* method used is "save_service_token", the subscriber is still a subscriber but now has elevated privileges.
|
||||
* Each service can still subscribe to other events from other services just like regular subscribers. But,
|
||||
* services have the added ability to broadcast events to other subscribers.
|
||||
*
|
||||
* Line 1 of this file allows the script to be executable
|
||||
*/
|
||||
|
||||
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_calls_service::get_service_name());
|
||||
|
||||
try {
|
||||
$active_calls_service = active_calls_service::create();
|
||||
// Exit using whatever status run returns
|
||||
exit($active_calls_service->run());
|
||||
} catch (Exception $ex) {
|
||||
echo "Error occurred in " . $ex->getFile() . ' (' . $ex->getLine() . '):' . $ex->getMessage();
|
||||
// Exit with error code
|
||||
exit($ex->getCode());
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Active Calls Websocket Service
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/php /var/www/fusionpbx/app/active_calls/resources/service/active_calls.php --no-fork
|
||||
Restart=on-failure
|
||||
User=www-data
|
||||
Group=www-data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -29,7 +29,11 @@
|
||||
$apps[$x]['description']['ru-ru'] = "Активные каналы в системе";
|
||||
$apps[$x]['description']['sv-se'] = "";
|
||||
$apps[$x]['description']['uk-ua'] = "";
|
||||
|
||||
/*
|
||||
*
|
||||
* Permissions have been migrated to the active_calls app
|
||||
*
|
||||
*
|
||||
//permission details
|
||||
$y=0;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_view";
|
||||
@@ -68,4 +72,4 @@
|
||||
$y++;
|
||||
$apps[$x]['permissions'][$y]['name'] = "call_active_secure";
|
||||
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
|
||||
?>
|
||||
*/
|
||||
|
||||
55
app/system/resources/classes/bsd_system_information.php
Normal file
55
app/system/resources/classes/bsd_system_information.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* The MIT License
|
||||
*
|
||||
* Copyright 2025 Tim Fry <tim@fusionpbx.com>.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of bsd_system_information
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class bsd_system_information extends system_information {
|
||||
|
||||
public function get_cpu_cores(): int {
|
||||
$result = shell_exec("dmesg | grep -i --max-count 1 CPUs | sed 's/[^0-9]*//g'");
|
||||
$cpu_cores = trim($result);
|
||||
return $cpu_cores;
|
||||
}
|
||||
|
||||
//get the CPU details
|
||||
public function get_cpu_percent(): float {
|
||||
$result = shell_exec('ps -A -o pcpu');
|
||||
$percent_cpu = 0;
|
||||
foreach (explode("\n", $result) as $value) {
|
||||
if (is_numeric($value)) {
|
||||
$percent_cpu = $percent_cpu + $value;
|
||||
}
|
||||
}
|
||||
return $percent_cpu;
|
||||
}
|
||||
|
||||
public function get_uptime() {
|
||||
return shell_exec('uptime');
|
||||
}
|
||||
}
|
||||
78
app/system/resources/classes/linux_system_information.php
Normal file
78
app/system/resources/classes/linux_system_information.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* The MIT License
|
||||
*
|
||||
* Copyright 2025 Tim Fry <tim@fusionpbx.com>.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of linux_system_information
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class linux_system_information extends system_information {
|
||||
|
||||
public function get_cpu_cores(): int {
|
||||
$result = @trim(shell_exec("grep -P '^processor' /proc/cpuinfo"));
|
||||
$cpu_cores = count(explode("\n", $result));
|
||||
return $cpu_cores;
|
||||
}
|
||||
|
||||
//get the CPU details
|
||||
public function get_cpu_percent(): float {
|
||||
$stat1 = file_get_contents('/proc/stat');
|
||||
usleep(500000);
|
||||
$stat2 = file_get_contents('/proc/stat');
|
||||
|
||||
$lines1 = explode("\n", trim($stat1));
|
||||
$lines2 = explode("\n", trim($stat2));
|
||||
|
||||
$percent_cpu = 0;
|
||||
$core_count = 0;
|
||||
|
||||
foreach ($lines1 as $i => $line1) {
|
||||
if (strpos($line1, 'cpu') !== 0 || $line1 === 'cpu') continue;
|
||||
|
||||
$parts1 = preg_split('/\s+/', $line1);
|
||||
$parts2 = preg_split('/\s+/', $lines2[$i]);
|
||||
|
||||
$total1 = array_sum(array_slice($parts1, 1));
|
||||
$total2 = array_sum(array_slice($parts2, 1));
|
||||
|
||||
$idle1 = $parts1[4];
|
||||
$idle2 = $parts2[4];
|
||||
|
||||
$total_delta = $total2 - $total1;
|
||||
$idle_delta = $idle2 - $idle1;
|
||||
|
||||
$cpu_usage = ($total_delta - $idle_delta) / $total_delta * 100;
|
||||
$percent_cpu += $cpu_usage;
|
||||
$core_count++;
|
||||
}
|
||||
|
||||
return round($percent_cpu / $core_count, 2);
|
||||
}
|
||||
|
||||
public function get_uptime() {
|
||||
return shell_exec('uptime');
|
||||
}
|
||||
}
|
||||
134
app/system/resources/classes/system_dashboard_service.php
Normal file
134
app/system/resources/classes/system_dashboard_service.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
class system_dashboard_service extends base_websocket_system_service {
|
||||
|
||||
const PERMISSIONS = [
|
||||
'system_view_cpu',
|
||||
'system_view_backup',
|
||||
'system_view_database',
|
||||
'system_view_hdd',
|
||||
'system_view_info',
|
||||
'system_view_memcache',
|
||||
'system_view_ram',
|
||||
'system_view_support',
|
||||
];
|
||||
|
||||
const CPU_STATUS_TOPIC = 'cpu_status';
|
||||
|
||||
/**
|
||||
*
|
||||
* @var system_information $system_information
|
||||
*/
|
||||
protected static $system_information;
|
||||
|
||||
/**
|
||||
* Settings object
|
||||
* @var settings
|
||||
*/
|
||||
private $settings;
|
||||
|
||||
/**
|
||||
* Integer representing the number of seconds to broadcast the CPU usage
|
||||
* @var int
|
||||
*/
|
||||
private $cpu_status_refresh_interval;
|
||||
|
||||
protected function reload_settings(): void {
|
||||
static::set_system_information();
|
||||
|
||||
// re-read the config file to get any possible changes
|
||||
parent::$config->read();
|
||||
|
||||
// re-connect to the websocket server if required
|
||||
$this->connect_to_ws_server();
|
||||
|
||||
// Connect to the database
|
||||
$database = new database(['config' => parent::$config]);
|
||||
|
||||
// get the interval
|
||||
$this->settings = new settings(['database' => $database]);
|
||||
|
||||
// get the settings from the global defaults
|
||||
$this->cpu_status_refresh_interval = $this->settings->get('dashboard', 'cpu_status_refresh_interval', 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override base_websocket_system_service
|
||||
* @return void
|
||||
*/
|
||||
protected function on_timer(): void {
|
||||
// Send the CPU status
|
||||
$this->on_cpu_status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes once
|
||||
* @return void
|
||||
*/
|
||||
protected function register_topics(): void {
|
||||
|
||||
// get the settings from the global defaults
|
||||
$this->reload_settings();
|
||||
|
||||
// Create a system information object that can tell us the cpu regardless of OS
|
||||
self::$system_information = system_information::new();
|
||||
|
||||
// Register the call back to respond to cpu_status requests
|
||||
$this->on_topic('cpu_status', [$this, 'on_cpu_status']);
|
||||
|
||||
// Set a timer
|
||||
$this->set_timer($this->cpu_status_refresh_interval);
|
||||
|
||||
// Notify the user of the interval
|
||||
$this->info("Broadcasting CPU Status every {$this->cpu_status_refresh_interval}s");
|
||||
}
|
||||
|
||||
public function on_cpu_status($message = null): void {
|
||||
// Get the CPU status
|
||||
$cpu_percent = self::$system_information->get_cpu_percent();
|
||||
|
||||
// Send the response
|
||||
$response = new websocket_message();
|
||||
$response
|
||||
->payload([self::CPU_STATUS_TOPIC => $cpu_percent])
|
||||
->service_name(self::get_service_name())
|
||||
->topic(self::CPU_STATUS_TOPIC);
|
||||
|
||||
// Check if we are responding
|
||||
if ($message !== null && $message instanceof websocket_message) {
|
||||
$response->id($message->id());
|
||||
}
|
||||
|
||||
// Log the broadcast
|
||||
$this->debug("Broadcasting CPU percent '$cpu_percent'");
|
||||
|
||||
// Send to subscribers
|
||||
$this->respond($response);
|
||||
}
|
||||
|
||||
public static function get_service_name(): string {
|
||||
return "dashboard.system.information";
|
||||
}
|
||||
|
||||
public static function create_filter_chain_for(subscriber $subscriber): ?filter {
|
||||
// Get the subscriber permissions
|
||||
$permissions = $subscriber->get_permissions();
|
||||
|
||||
// Create a filter
|
||||
$filter = filter_chain::and_link([new permission_filter()]);
|
||||
|
||||
// Match them to create a filter
|
||||
foreach (self::PERMISSIONS as $permission) {
|
||||
if (in_array($permission, $permissions)) {
|
||||
$filter->add_permission($permission);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the filter with user permissions to ensure they can't receive information they shouldn't
|
||||
return $filter;
|
||||
}
|
||||
|
||||
public static function set_system_information(): void {
|
||||
self::$system_information = system_information::new();
|
||||
}
|
||||
}
|
||||
51
app/system/resources/classes/system_information.php
Normal file
51
app/system/resources/classes/system_information.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* The MIT License
|
||||
*
|
||||
* Copyright 2025 Tim Fry <tim@fusionpbx.com>.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of system_information
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
abstract class system_information {
|
||||
|
||||
abstract public function get_cpu_cores(): int;
|
||||
abstract public function get_uptime();
|
||||
abstract public function get_cpu_percent(): float;
|
||||
|
||||
public function get_load_average() {
|
||||
return sys_getloadavg();
|
||||
}
|
||||
|
||||
public static function new(): ?system_information {
|
||||
if (stristr(PHP_OS, 'BSD')) {
|
||||
return new bsd_system_information();
|
||||
}
|
||||
if (stristr(PHP_OS, 'Linux')) {
|
||||
return new linux_system_information();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
//includes files
|
||||
//includes files
|
||||
require_once dirname(__DIR__, 4) . "/resources/require.php";
|
||||
|
||||
//check permisions
|
||||
//check permisions
|
||||
require_once "resources/check_auth.php";
|
||||
if (permission_exists('xml_cdr_view')) {
|
||||
//access granted
|
||||
@@ -13,19 +13,19 @@
|
||||
exit;
|
||||
}
|
||||
|
||||
//add multi-lingual support
|
||||
//add multi-lingual support
|
||||
$language = new text;
|
||||
$text = $language->get($_SESSION['domain']['language']['code'], 'app/system');
|
||||
|
||||
//system cpu status
|
||||
//system cpu status
|
||||
echo "<div class='hud_box'>\n";
|
||||
|
||||
//set the row style class names
|
||||
//set the row style class names
|
||||
$c = 0;
|
||||
$row_style["0"] = "row_style0";
|
||||
$row_style["1"] = "row_style1";
|
||||
|
||||
//get the CPU details
|
||||
//get the CPU details
|
||||
if (stristr(PHP_OS, 'BSD') || stristr(PHP_OS, 'Linux')) {
|
||||
|
||||
$result = shell_exec('ps -A -o pcpu');
|
||||
@@ -50,17 +50,88 @@
|
||||
|
||||
}
|
||||
|
||||
//show the content
|
||||
//show the content
|
||||
echo "<div class='hud_content' ".($dashboard_details_state == "disabled" ?: "onclick=\"$('#hud_system_cpu_status_details').slideToggle('fast'); toggle_grid_row_end('".$dashboard_name."')\"").">\n";
|
||||
echo " <span class='hud_title'><a onclick=\"document.location.href='".PROJECT_PATH."/app/system/system.php'\">".$text['label-cpu_usage']."</a></span>\n";
|
||||
|
||||
//add half doughnut chart
|
||||
if (!isset($dashboard_chart_type) || $dashboard_chart_type == "doughnut") {
|
||||
?>
|
||||
$token = (new token())->create($_SERVER['PHP_SELF']);
|
||||
|
||||
echo " <input id='token' type='hidden' name='" . $token['name'] . "' value='" . $token['hash'] . "'>\n";
|
||||
|
||||
subscriber::save_token($token, [system_dashboard_service::get_service_name()]);
|
||||
|
||||
//break the caching with version
|
||||
$version = md5(file_get_contents(__DIR__, '/resources/javascript/websocket_client.js'));
|
||||
|
||||
//set script source
|
||||
echo "<script src='/app/system/resources/javascript/websocket_client.js?v=$version'></script>\n";
|
||||
|
||||
//add half doughnut chart
|
||||
if (!isset($dashboard_chart_type) || $dashboard_chart_type == "doughnut"): ?>
|
||||
<div class='hud_chart' style='width: 175px;'><canvas id='system_cpu_status_chart'></canvas></div>
|
||||
|
||||
<script>
|
||||
const system_cpu_status_chart = new Chart(
|
||||
const authToken = {
|
||||
name: "<?= $token['name']; ?>",
|
||||
hash: "<?= $token['hash']; ?>"
|
||||
}
|
||||
|
||||
const serviceName = '<?php echo system_dashboard_service::get_service_name(); ?>'
|
||||
const cpuStatusTopic = '<?php echo system_dashboard_service::CPU_STATUS_TOPIC; ?>';
|
||||
const dashboard_cpu_usage_chart_main_color = [
|
||||
'<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[0] ?? '#03c04a'); ?>',
|
||||
'<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[1] ?? '#ff9933'); ?>',
|
||||
'<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[2] ?? '#ea4c46'); ?>'
|
||||
];
|
||||
|
||||
function connectWebsocket() {
|
||||
client = new ws_client(`wss://${window.location.hostname}/websockets/`, authToken);
|
||||
client.ws.addEventListener("open", async () => {
|
||||
try {
|
||||
console.log('Connected');
|
||||
console.log('Requesting authentication');
|
||||
await client.request('authentication');
|
||||
client.onEvent(cpuStatusTopic, updateCpuChart);
|
||||
client.request(serviceName, cpuStatusTopic);
|
||||
} catch (err) {
|
||||
console.error("WS setup failed: ", err);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
client.ws.addEventListener("close", async () => {
|
||||
console.warn("Websocket Disconnected");
|
||||
});
|
||||
}
|
||||
|
||||
function bindEventHandlers(client) {
|
||||
client.onEvent(cpuStatusTopic, updateCpuChart);
|
||||
}
|
||||
|
||||
function updateCpuChart(payload) {
|
||||
let cpuPercent = payload.cpu_status;
|
||||
const chart = window.system_cpu_status_chart;
|
||||
|
||||
if (!chart) return;
|
||||
|
||||
// Update chart data
|
||||
cpuPercent = Math.round(cpuPercent);
|
||||
chart.data.datasets[0].data = [cpuPercent, 100 - cpuPercent];
|
||||
|
||||
// Update color based on threshold
|
||||
if (cpuPercent <= 60) {
|
||||
chart.data.datasets[0].backgroundColor[0] = dashboard_cpu_usage_chart_main_color[0];
|
||||
} else if (cpuPercent <= 80) {
|
||||
chart.data.datasets[0].backgroundColor[0] = dashboard_cpu_usage_chart_main_color[1];
|
||||
} else {
|
||||
chart.data.datasets[0].backgroundColor[0] = dashboard_cpu_usage_chart_main_color[2];
|
||||
}
|
||||
|
||||
chart.options.plugins.chart_number_2.text = cpuPercent;
|
||||
chart.update();
|
||||
}
|
||||
|
||||
window.system_cpu_status_chart = new Chart(
|
||||
document.getElementById('system_cpu_status_chart').getContext('2d'),
|
||||
{
|
||||
type: 'doughnut',
|
||||
@@ -80,7 +151,7 @@
|
||||
'<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_sub_color') ?? '#d4d4d4'); ?>'
|
||||
],
|
||||
borderColor: '<?php echo $settings->get('theme', 'dashboard_chart_border_color'); ?>',
|
||||
borderWidth: '<?php echo $settings->get('theme', 'dashboard_chart_border_width'); ?>',
|
||||
borderWidth: '<?php echo $settings->get('theme', 'dashboard_chart_border_width'); ?>'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
@@ -92,7 +163,7 @@
|
||||
},
|
||||
tooltip: {
|
||||
yAlign: 'bottom',
|
||||
displayColors: false,
|
||||
displayColors: false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -110,9 +181,11 @@
|
||||
}]
|
||||
}
|
||||
);
|
||||
|
||||
connectWebsocket();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
if ($dashboard_chart_type == "number") {
|
||||
echo "<span class='hud_stat'>".round($percent_cpu)."%</span>";
|
||||
}
|
||||
|
||||
173
app/system/resources/dashboard/system_cpu_status_1.php
Normal file
173
app/system/resources/dashboard/system_cpu_status_1.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
//includes files
|
||||
require_once dirname(__DIR__, 4) . "/resources/require.php";
|
||||
|
||||
//check permisions
|
||||
require_once "resources/check_auth.php";
|
||||
if (permission_exists('xml_cdr_view')) {
|
||||
//access granted
|
||||
}
|
||||
else {
|
||||
echo "access denied";
|
||||
exit;
|
||||
}
|
||||
|
||||
//add multi-lingual support
|
||||
$language = new text;
|
||||
$text = $language->get($_SESSION['domain']['language']['code'], 'app/system');
|
||||
|
||||
//system cpu status
|
||||
echo "<div class='hud_box'>\n";
|
||||
|
||||
//set the row style class names
|
||||
$c = 0;
|
||||
$row_style["0"] = "row_style0";
|
||||
$row_style["1"] = "row_style1";
|
||||
|
||||
//get the CPU details
|
||||
if (stristr(PHP_OS, 'BSD') || stristr(PHP_OS, 'Linux')) {
|
||||
|
||||
$result = shell_exec('ps -A -o pcpu');
|
||||
$percent_cpu = 0;
|
||||
foreach (explode("\n", $result) as $value) {
|
||||
if (is_numeric($value)) { $percent_cpu = $percent_cpu + $value; }
|
||||
}
|
||||
if (stristr(PHP_OS, 'BSD')) {
|
||||
$result = shell_exec("dmesg | grep -i --max-count 1 CPUs | sed 's/[^0-9]*//g'");
|
||||
$cpu_cores = trim($result);
|
||||
}
|
||||
if (stristr(PHP_OS, 'Linux')) {
|
||||
$result = @trim(shell_exec("grep -P '^processor' /proc/cpuinfo"));
|
||||
$cpu_cores = count(explode("\n", $result));
|
||||
}
|
||||
if ($cpu_cores > 1) { $percent_cpu = $percent_cpu / $cpu_cores; }
|
||||
$percent_cpu = round($percent_cpu, 2);
|
||||
|
||||
//uptime
|
||||
$result = shell_exec('uptime');
|
||||
$load_average = sys_getloadavg();
|
||||
|
||||
}
|
||||
|
||||
//show the content
|
||||
echo "<div class='hud_content' ".($dashboard_details_state == "disabled" ?: "onclick=\"$('#hud_system_cpu_status_details').slideToggle('fast'); toggle_grid_row_end('".$dashboard_name."')\"").">\n";
|
||||
echo " <span class='hud_title'><a onclick=\"document.location.href='".PROJECT_PATH."/app/system/system.php'\">".$text['label-cpu_usage']."</a></span>\n";
|
||||
|
||||
//add half doughnut chart
|
||||
if (!isset($dashboard_chart_type) || $dashboard_chart_type == "doughnut") {
|
||||
?>
|
||||
<div class='hud_chart' style='width: 175px;'><canvas id='system_cpu_status_chart'></canvas></div>
|
||||
|
||||
<script>
|
||||
const system_cpu_status_chart = new Chart(
|
||||
document.getElementById('system_cpu_status_chart').getContext('2d'),
|
||||
{
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: ['<?php echo $percent_cpu; ?>', 100 - '<?php echo $percent_cpu; ?>'],
|
||||
backgroundColor: [
|
||||
<?php
|
||||
if ($percent_cpu <= 60) {
|
||||
echo "'".($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[0] ?? '#03c04a')."',\n";
|
||||
} else if ($percent_cpu <= 80) {
|
||||
echo "'".($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[1] ?? '#ff9933')."',\n";
|
||||
} else if ($percent_cpu > 80) {
|
||||
echo "'".($settings->get('theme', 'dashboard_cpu_usage_chart_main_color')[2] ?? '#ea4c46')."',\n";
|
||||
}
|
||||
?>
|
||||
'<?php echo ($settings->get('theme', 'dashboard_cpu_usage_chart_sub_color') ?? '#d4d4d4'); ?>'
|
||||
],
|
||||
borderColor: '<?php echo $settings->get('theme', 'dashboard_chart_border_color'); ?>',
|
||||
borderWidth: '<?php echo $settings->get('theme', 'dashboard_chart_border_width'); ?>',
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
circumference: 180,
|
||||
rotation: 270,
|
||||
plugins: {
|
||||
chart_number_2: {
|
||||
text: '<?php echo round($percent_cpu); ?>'
|
||||
},
|
||||
tooltip: {
|
||||
yAlign: 'bottom',
|
||||
displayColors: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [{
|
||||
id: 'chart_number_2',
|
||||
beforeDraw(chart, args, options){
|
||||
const {ctx, chartArea: {top, right, bottom, left, width, height} } = chart;
|
||||
ctx.font = chart_text_size + ' ' + chart_text_font;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = '<?php echo $dashboard_number_text_color; ?>';
|
||||
ctx.fillText(options.text + '%', width / 2, top + (height / 2) + 35);
|
||||
ctx.save();
|
||||
}
|
||||
}]
|
||||
}
|
||||
);
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
if ($dashboard_chart_type == "number") {
|
||||
echo "<span class='hud_stat'>".round($percent_cpu)."%</span>";
|
||||
}
|
||||
echo "</div>\n";
|
||||
|
||||
if ($dashboard_details_state != 'disabled') {
|
||||
echo "<div class='hud_details hud_box' id='hud_system_cpu_status_details'>";
|
||||
echo "<table class='tr_hover' width='100%' cellpadding='0' cellspacing='0' border='0'>\n";
|
||||
echo "<tr>\n";
|
||||
echo "<th class='hud_heading' width='50%'>".$text['label-name']."</th>\n";
|
||||
echo "<th class='hud_heading' style='text-align: right;'>".$text['label-value']."</th>\n";
|
||||
echo "</tr>\n";
|
||||
|
||||
if (PHP_OS == 'FreeBSD' || PHP_OS == 'Linux') {
|
||||
if (!empty($percent_cpu)) {
|
||||
echo "<tr class='tr_link_void'>\n";
|
||||
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-cpu_usage']."</td>\n";
|
||||
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$percent_cpu."%</td>\n";
|
||||
echo "</tr>\n";
|
||||
$c = ($c) ? 0 : 1;
|
||||
}
|
||||
|
||||
if (!empty($cpu_cores)) {
|
||||
echo "<tr class='tr_link_void'>\n";
|
||||
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-cpu_cores']."</td>\n";
|
||||
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$cpu_cores."</td>\n";
|
||||
echo "</tr>\n";
|
||||
$c = ($c) ? 0 : 1;
|
||||
}
|
||||
|
||||
echo "<tr class='tr_link_void'>\n";
|
||||
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-load_average']." (1)</td>\n";
|
||||
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$load_average[0]."</td>\n";
|
||||
echo "</tr>\n";
|
||||
$c = ($c) ? 0 : 1;
|
||||
|
||||
echo "<tr class='tr_link_void'>\n";
|
||||
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-load_average']." (5)</td>\n";
|
||||
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$load_average[1]."</td>\n";
|
||||
echo "</tr>\n";
|
||||
$c = ($c) ? 0 : 1;
|
||||
|
||||
echo "<tr class='tr_link_void'>\n";
|
||||
echo "<td valign='top' class='".$row_style[$c]." hud_text'>".$text['label-load_average']." (15)</td>\n";
|
||||
echo "<td valign='top' class='".$row_style[$c]." hud_text' style='text-align: right;'>".$load_average[2]."</td>\n";
|
||||
echo "</tr>\n";
|
||||
$c = ($c) ? 0 : 1;
|
||||
}
|
||||
|
||||
echo "</table>\n";
|
||||
echo "</div>";
|
||||
//$n++;
|
||||
|
||||
echo "<span class='hud_expander' onclick=\"$('#hud_system_cpu_status_details').slideToggle('fast'); toggle_grid_row_end('".$dashboard_name."')\"><span class='fas fa-ellipsis-h'></span></span>";
|
||||
}
|
||||
echo "</div>\n";
|
||||
|
||||
?>
|
||||
116
app/system/resources/javascript/websocket_client.js
Normal file
116
app/system/resources/javascript/websocket_client.js
Normal file
@@ -0,0 +1,116 @@
|
||||
class ws_client {
|
||||
constructor(url, token) {
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.addEventListener('message', this._onMessage.bind(this));
|
||||
this._nextId = 1;
|
||||
this._pending = new Map();
|
||||
this._eventHandlers = new Map();
|
||||
// The token is submitted on every request
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
// internal message handler called when event occurs on the socket
|
||||
_onMessage(ev) {
|
||||
let message;
|
||||
let switch_event;
|
||||
try {
|
||||
console.log(ev.data);
|
||||
message = JSON.parse(ev.data);
|
||||
// check for authentication request
|
||||
if (message.status_code === 407) {
|
||||
console.log('Authentication Required');
|
||||
return;
|
||||
}
|
||||
switch_event = message.payload;
|
||||
//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,
|
||||
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) {
|
||||
resolve({service, topic, payload, code, message});
|
||||
} 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
|
||||
this._dispatchEvent(message);
|
||||
}
|
||||
|
||||
// Send a request to the websocket server using JSON string
|
||||
request(service, topic = null, payload = {}) {
|
||||
const request_id = String(this._nextId++);
|
||||
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.calls', topic);
|
||||
}
|
||||
|
||||
unsubscribe(topic) {
|
||||
return this.request('active.calls', topic);
|
||||
}
|
||||
|
||||
// register a callback for server-pushes
|
||||
onEvent(topic, handler) {
|
||||
console.log('registering event listener for ' + topic);
|
||||
if (!this._eventHandlers.has(topic)) {
|
||||
this._eventHandlers.set(topic, []);
|
||||
}
|
||||
this._eventHandlers.get(topic).push(handler);
|
||||
}
|
||||
/**
|
||||
* Dispatch a server‑push event envelope to all registered handlers.
|
||||
* @param {object} env
|
||||
*/
|
||||
_dispatchEvent(message) {
|
||||
const service = message.service_name;
|
||||
const topic = message.topic;
|
||||
const handlers = this._eventHandlers.get(topic) || [];
|
||||
for (const fn of handlers) {
|
||||
try {
|
||||
fn(message.payload);
|
||||
} catch (err) {
|
||||
console.error(`Error in handler for "${topic}":`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
47
app/system/resources/services/system.php
Normal file
47
app/system/resources/services/system.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
try {
|
||||
|
||||
// Create the service
|
||||
$system_dashboard_service = system_dashboard_service::create();
|
||||
|
||||
// Exit using whatever status run returns
|
||||
exit($system_dashboard_service->run());
|
||||
|
||||
} catch (Throwable $ex) {
|
||||
echo "Error occurred in " . $ex->getFile() . ' (' . $ex->getLine() . '):' . $ex->getMessage();
|
||||
// Exit with error code
|
||||
exit($ex->getCode());
|
||||
}
|
||||
218
core/websockets/resources/classes/base_message.php
Normal file
218
core/websockets/resources/classes/base_message.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* A base message for communication
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
* @param string $payload;
|
||||
*/
|
||||
class base_message {
|
||||
|
||||
/**
|
||||
* The id is contained to the base_message class. Subclasses or child classes should not adjust this value
|
||||
* @var int
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* Payload can be any value
|
||||
* @var mixed
|
||||
*/
|
||||
protected $payload;
|
||||
|
||||
/**
|
||||
* Constructs a base_message object.
|
||||
* When the array is provided as an associative array, the object properties
|
||||
* are filled using the array key as the property name and the value of the array
|
||||
* for the value of the property in the object.
|
||||
* @param array $associative_properties_array
|
||||
*/
|
||||
public function __construct($associative_properties_array = []) {
|
||||
|
||||
// Assign the unique object id given by PHP to identify the object
|
||||
$this->id = spl_object_id($this);
|
||||
|
||||
// Assign the object properties using the associative array provided in constructor
|
||||
foreach ($associative_properties_array as $property_or_method => $value) {
|
||||
$this->__set($property_or_method, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the property from the object.
|
||||
* If the method exists then the method will be called to get the value in the object property.
|
||||
* If the method is not in the object then the property name is checked to see if it is valid. If the
|
||||
* name is not available then an exception is thrown.
|
||||
* @param string $name Name of the property
|
||||
* @return mixed
|
||||
* @throws InvalidProperty
|
||||
*/
|
||||
public function __get(string $name) {
|
||||
if ($name === 'class') {
|
||||
return static::class;
|
||||
} elseif (method_exists($this, "get_$name")) {
|
||||
// call function with 'get_' prefixed
|
||||
return $this->{"get_$name"}();
|
||||
} elseif (method_exists($this, $name)) {
|
||||
// call function with name only
|
||||
return $this->{$name}();
|
||||
} elseif (property_exists($this, $name)) {
|
||||
// return the property from the object
|
||||
return $this->{$name};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the object property in the given name to be the given value
|
||||
* @param string $name Name of the object property
|
||||
* @param mixed $value Value of the object property
|
||||
* @return void
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function __set(string $name, $value): void {
|
||||
if (method_exists($this, "set_$name")){
|
||||
//
|
||||
// By calling the method with the setter name of the property first, we give
|
||||
// the child object the opportunity to modify the value before it is
|
||||
// stored in the object. In the case of the key names for an event this
|
||||
// allows that child class to adjust the event name from a key value of
|
||||
// 'Unique-Id' to be standardized to 'unique_id'.
|
||||
//
|
||||
$this->{"set_$name"}($value);
|
||||
} elseif (method_exists($this, $name)) {
|
||||
//
|
||||
// We next check for a function with the same name as the property. If the
|
||||
// method exists then we call the method with the same name instead of
|
||||
// setting the property directly. This allows the value to be adjusted
|
||||
// before it is set in the object. Similar to the previous check.
|
||||
//
|
||||
$this->{$name}($value);
|
||||
} elseif (property_exists($this, $name)) {
|
||||
//
|
||||
// Lastly, we check for the property to exist and set it directly. This
|
||||
// is so the property of the child message or base message can be set.
|
||||
//
|
||||
$this->{$name} = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a method that PHP will call if the object is echoed or printed.
|
||||
* @return string JSON string representing the object
|
||||
* @depends to_json
|
||||
*/
|
||||
public function __toString(): string {
|
||||
return $this->to_json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this object ID given by PHP
|
||||
* @return int
|
||||
*/
|
||||
public function get_id(): int {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message payload to be delivered
|
||||
* @param mixed $payload Payload for the message to carry
|
||||
* @return $this Returns this object for chaining
|
||||
*/
|
||||
public function set_payload($payload) {
|
||||
$this->payload = $payload;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload contained in this message
|
||||
* @return mixed Payload in the message
|
||||
*/
|
||||
public function get_payload() {
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of get_payload and set_payload. When the parameter
|
||||
* is used to call the method, the payload property of the object
|
||||
* is set to the payload provided and this object is returned. When
|
||||
* the method is called with no parameters given, the payload is
|
||||
* returned to the caller.
|
||||
* Payload the message object is delivering
|
||||
* @param mixed $payload If set, payload is set to the value. Otherwise, the payload is returned.
|
||||
* @return mixed If payload was given to call the method, this object is returned. If no value was provided the payload is returned.
|
||||
* @see set_payload
|
||||
* @see get_payload
|
||||
*/
|
||||
public function payload($payload = null) {
|
||||
if (func_num_args() > 0) {
|
||||
return $this->set_payload($payload);
|
||||
}
|
||||
return $this->get_payload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively convert this object or child object to an array.
|
||||
* @param mixed $iterate Private value to be set while iterating over the object properties
|
||||
* @return array Array representing the properties of this object
|
||||
*/
|
||||
public function to_array($iterate = null): array {
|
||||
$array = [];
|
||||
if ($iterate === null) {
|
||||
$iterate = $this;
|
||||
}
|
||||
foreach ($iterate as $property => $value) {
|
||||
if (is_array($value)) {
|
||||
$value = $this->to_array($value);
|
||||
} elseif (is_object($value) && method_exists($value, 'to_array')) {
|
||||
$value = $value->to_array();
|
||||
} elseif (is_object($value) && method_exists($value, '__toArray')) { // PHP array casting
|
||||
$value = $value->__toArray();
|
||||
}
|
||||
$array[$property] = $value;
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a json string
|
||||
* @return string
|
||||
* @depends to_array
|
||||
*/
|
||||
public function to_json(): string {
|
||||
return json_encode($this->to_array());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array representing this object or child object.
|
||||
* @return array Array of object properties
|
||||
*/
|
||||
public function __toArray(): array {
|
||||
return $this->to_array();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Description of system_dashboard_service
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
abstract class base_websocket_system_service extends service implements websocket_service_interface {
|
||||
|
||||
private static $websocket_port = null;
|
||||
private static $websocket_host = null;
|
||||
|
||||
/**
|
||||
* Sets a time to fire the on_timer function
|
||||
* @var int|null
|
||||
*/
|
||||
protected $timer_expire_time = null;
|
||||
|
||||
/**
|
||||
* Websocket client
|
||||
* @var websocket_client $ws_client
|
||||
*/
|
||||
protected $ws_client;
|
||||
|
||||
abstract protected function reload_settings(): void;
|
||||
|
||||
protected static function display_version(): void {
|
||||
echo "System Dashboard Service 1.0\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a timer to trigger the on_timer function every $seconds. To stop the timer, set the value to null
|
||||
* @param int $seconds
|
||||
* @return void
|
||||
* @see on_timer
|
||||
*/
|
||||
protected function set_timer(?int $seconds): void {
|
||||
if ($seconds !== null) $this->timer_expire_time = time() + $seconds;
|
||||
else $this->timer_expire_time = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the set_timer is used to set a timer, this function will run. Override
|
||||
* the function in the child class.
|
||||
* @return void
|
||||
* @see set_timer
|
||||
*/
|
||||
protected function on_timer(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
protected static function set_command_options() {
|
||||
parent::append_command_option(
|
||||
command_option::new()
|
||||
->description('Set the Port to connect to the websockets service')
|
||||
->short_option('w')
|
||||
->short_description('-w <port>')
|
||||
->long_option('websockets-port')
|
||||
->long_description('--websockets-port <port>')
|
||||
->callback('set_websockets_port')
|
||||
);
|
||||
parent::append_command_option(
|
||||
command_option::new()
|
||||
->description('Set the IP address for the websocket')
|
||||
->short_option('k')
|
||||
->short_description('-k <ip_addr>')
|
||||
->long_option('websockets-address')
|
||||
->long_description('--websockets-address <ip_addr>')
|
||||
->callback('set_websockets_host_address')
|
||||
);
|
||||
}
|
||||
|
||||
protected static function set_websockets_port($port): void {
|
||||
self::$websocket_port = $port;
|
||||
}
|
||||
|
||||
protected static function set_websockets_host_address($host): void {
|
||||
self::$websocket_host = $host;
|
||||
}
|
||||
|
||||
public function run(): int {
|
||||
// re-read the config file to get any possible changes
|
||||
parent::$config->read();
|
||||
|
||||
// re-connect to the websocket server
|
||||
$this->connect_to_ws_server();
|
||||
|
||||
// Notify connected web server socket when we close
|
||||
register_shutdown_function(function ($ws_client) {
|
||||
if ($ws_client !== null)
|
||||
$ws_client->disconnect();
|
||||
}, $this->ws_client);
|
||||
|
||||
$this->register_topics();
|
||||
|
||||
// Register the authenticate request
|
||||
$this->on_topic('authenticate', [$this, 'on_authenticate']);
|
||||
|
||||
// Track the WebSocket Server Error Message so it doesn't flood the system logs
|
||||
$suppress_ws_message = false;
|
||||
|
||||
while ($this->running) {
|
||||
$read = [];
|
||||
// reconnect to websocket server
|
||||
if ($this->ws_client === null || !$this->ws_client->is_connected()) {
|
||||
// reconnect failed
|
||||
if (!$this->connect_to_ws_server()) {
|
||||
if (!$suppress_ws_message) $this->error("Unable to connect to websocket server.");
|
||||
$suppress_ws_message = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->ws_client !== null && $this->ws_client->is_connected()) {
|
||||
$read[] = $this->ws_client->socket();
|
||||
$suppress_ws_message = false;
|
||||
}
|
||||
|
||||
// Check if we have sockets to read
|
||||
if (!empty($read)) {
|
||||
$write = $except = [];
|
||||
// Wait for an event and timeout at 1/3 of a second so we can re-check all connections
|
||||
if (false === stream_select($read, $write, $except, 0, 333333)) {
|
||||
// severe error encountered so exit
|
||||
$this->running = false;
|
||||
// Exit with non-zero exit code
|
||||
return 1;
|
||||
}
|
||||
// stream_select will update $read so re-check it
|
||||
if (!empty($read)) {
|
||||
$this->debug("Received event");
|
||||
// Iterate over each socket event
|
||||
foreach ($read as $resource) {
|
||||
// Web socket event
|
||||
if ($resource === $this->ws_client->socket()) {
|
||||
$this->handle_websocket_event($this->ws_client);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timers can be set by child classes
|
||||
if ($this->timer_expire_time !== null && time() >= $this->timer_expire_time) {
|
||||
$this->on_timer();
|
||||
// Set another timer to fire again
|
||||
$this->set_timer(3);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function debug(string $message) {
|
||||
self::log($message, LOG_DEBUG);
|
||||
}
|
||||
|
||||
protected function warn(string $message) {
|
||||
self::log($message, LOG_WARNING);
|
||||
}
|
||||
|
||||
protected function error(string $message) {
|
||||
self::log($message, LOG_ERR);
|
||||
}
|
||||
|
||||
protected function info(string $message) {
|
||||
self::log($message, LOG_INFO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the web socket server using a websocket_client object
|
||||
* @return bool
|
||||
*/
|
||||
protected function connect_to_ws_server(): bool {
|
||||
if ($this->ws_client !== null && $this->ws_client->is_connected()) return true;
|
||||
|
||||
$host = self::$websocket_host ?? self::$config->get('websocket.host', '127.0.0.1');
|
||||
$port = self::$websocket_port ?? self::$config->get('websocket.port', 8080);
|
||||
try {
|
||||
// Create a websocket client
|
||||
$this->ws_client = new websocket_client("ws://$host:$port");
|
||||
|
||||
// Block stream for handshake and authentication
|
||||
$this->ws_client->set_blocking(true);
|
||||
|
||||
// Connect to web socket server
|
||||
$this->ws_client->connect();
|
||||
|
||||
// Disable the stream blocking
|
||||
$this->ws_client->set_blocking(false);
|
||||
|
||||
$this->debug(self::class . " RESOURCE ID: " . $this->ws_client->socket());
|
||||
} catch (\RuntimeException $re) {
|
||||
//unable to connect
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the message from the web socket client and triggers the appropriate requested topic event
|
||||
* @param resource $ws_client
|
||||
* @return void
|
||||
*/
|
||||
private function handle_websocket_event() {
|
||||
// Read the JSON string
|
||||
$json_string = $this->ws_client->read();
|
||||
|
||||
// Nothing to do
|
||||
if ($json_string === null) {
|
||||
$this->warn('Message received from Websocket is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
// Nothing to do
|
||||
if (empty($message->topic())) {
|
||||
$this->error("Message received does not have topic");
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the registered topic event
|
||||
$this->trigger_topic($message->topic, $message, $this->ws_client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call each of the registered events for the websocket topic that has arrived
|
||||
* @param string $topic
|
||||
* @param websocket_message $websocket_message
|
||||
*/
|
||||
private function trigger_topic(string $topic, websocket_message $websocket_message) {
|
||||
if (empty($topic) || empty($websocket_message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!empty($this->topics[$topic])) {
|
||||
foreach ($this->topics[$topic] as $callback) {
|
||||
call_user_func($callback, $websocket_message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Request authentication as a service
|
||||
$this->ws_client->authenticate($token_name, $token_hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the service to register a callback so when the topic arrives the callable is called
|
||||
* @param type $topic
|
||||
* @param type $callable
|
||||
*/
|
||||
protected function on_topic($topic, $callable) {
|
||||
if (!isset($this->topics[$topic])) {
|
||||
$this->topics[$topic] = [];
|
||||
}
|
||||
$this->topics[$topic][] = $callable;
|
||||
}
|
||||
|
||||
protected function respond(websocket_message $websocket_message): void {
|
||||
websocket_client::send($this->ws_client->socket(), $websocket_message);
|
||||
}
|
||||
|
||||
abstract protected function register_topics(): void;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* A file not found exception
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class file_not_found_exception extends \Exception {
|
||||
public function __construct(string $message = "File not found", int $code = 0, ?\Throwable $previous = null) {
|
||||
return parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
79
core/websockets/resources/classes/permission_filter.php
Normal file
79
core/websockets/resources/classes/permission_filter.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of permission_filter
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class permission_filter implements filter {
|
||||
|
||||
private $field_map;
|
||||
private $permissions;
|
||||
|
||||
public function __construct(array $event_field_key_to_permission_map, array $permissions = []) {
|
||||
$this->field_map = $event_field_key_to_permission_map;
|
||||
$this->add_permissions($permissions);
|
||||
}
|
||||
|
||||
public function __invoke(string $key, $value): ?bool {
|
||||
$permission = $this->field_map[$key] ?? null;
|
||||
if ($permission === null || $this->has_permission($permission)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an associative array of permissions where $key is the name of the permission and $value is ignored as it should always be set to true.
|
||||
* @param array $permissions
|
||||
*/
|
||||
public function add_permissions(array $permissions) {
|
||||
// Add all event key filters passed
|
||||
foreach (array_keys($permissions) as $key) {
|
||||
$this->add_permission($key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single permission
|
||||
* @param string $key
|
||||
*/
|
||||
public function add_permission(string $key) {
|
||||
$this->permissions[$key] = $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the filter has a permission
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
public function has_permission(string $key): bool {
|
||||
return isset($this->permissions[$key]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* A socket disconnects exception
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class socket_disconnected_exception extends \socket_exception {
|
||||
public function __construct($resource_id, string $message = "Socket Disconnected", int $code = 0, ?\Throwable $previous = null) {
|
||||
return parent::__construct($resource_id, $message, $code, $previous);
|
||||
}
|
||||
}
|
||||
41
core/websockets/resources/classes/socket_exception.php
Normal file
41
core/websockets/resources/classes/socket_exception.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* General socket exception class
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class socket_exception extends \Exception {
|
||||
public $id;
|
||||
public function __construct($id = null, string $message = "", int $code = 0, ?\Throwable $previous = null) {
|
||||
$this->id = $id;
|
||||
return parent::__construct($message, $code, $previous);
|
||||
}
|
||||
public function getResourceId() { return $this->resource_id; }
|
||||
}
|
||||
650
core/websockets/resources/classes/subscriber.php
Normal file
650
core/websockets/resources/classes/subscriber.php
Normal file
@@ -0,0 +1,650 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of subscriber
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class subscriber {
|
||||
|
||||
public $show_all;
|
||||
|
||||
/**
|
||||
* The ID of the object given by PHP
|
||||
* @var spl_object_id
|
||||
*/
|
||||
private $id;
|
||||
private $socket;
|
||||
|
||||
/**
|
||||
* Stores the original socket ID used when the subscriber object was created.
|
||||
* The resource is cast to an integer and then saved in order to match the
|
||||
* a resource to the original socket. This is primarily used in the equals
|
||||
* method to test for equality.
|
||||
* @var int
|
||||
*/
|
||||
private $socket_id;
|
||||
|
||||
private $remote_ip;
|
||||
private $remote_port;
|
||||
private $services;
|
||||
private $permissions;
|
||||
private $domain_name;
|
||||
private $domain_uuid;
|
||||
private $token_hash;
|
||||
private $token_name;
|
||||
private $token_time;
|
||||
private $token_limit;
|
||||
private $enable_token_time_limit;
|
||||
private $service;
|
||||
private $service_class;
|
||||
private $service_name;
|
||||
private $filter;
|
||||
|
||||
/**
|
||||
* Function or method name to call when sending information through the socket
|
||||
* @var callable
|
||||
*/
|
||||
private $callback;
|
||||
private $send_all;
|
||||
private $subscriptions;
|
||||
private $authenticated;
|
||||
|
||||
/**
|
||||
* Creates a subscriber object.
|
||||
* @param resource|stream $socket Connected socket
|
||||
* @param callable $frame_wrapper The callback used to wrap communication in a web socket frame. Sending NULL to the frame wrapper should send a disconnect.
|
||||
* @throws \socket_exception Thrown when the passed socket is already closed
|
||||
* @throws \InvalidArgumentException Thrown when the $callback is not a valid callback
|
||||
*/
|
||||
public function __construct($socket, callable $frame_wrapper) {
|
||||
if (!is_resource($socket)) {
|
||||
throw new \socket_exception('Socket must be a valid resource');
|
||||
}
|
||||
// check for valid callback so we can send websocket data when required
|
||||
if (!is_callable($frame_wrapper)) {
|
||||
throw new \InvalidArgumentException('Websocket callable method must be a valid callable function or method');
|
||||
}
|
||||
|
||||
// set object identifiers
|
||||
$this->id = md5(spl_object_hash($this)); // PHP unique object hash is similar to 000000000000000f0000000000000000 so we use md5
|
||||
$this->socket = $socket;
|
||||
$this->socket_id = (int) $socket;
|
||||
|
||||
$this->domain_name = '';
|
||||
$this->domain_uuid = '';
|
||||
|
||||
// always use the same formula from the static functions
|
||||
[$this->remote_ip, $this->remote_port] = self::get_remote_information_from_socket($socket);
|
||||
|
||||
// set defaults
|
||||
$this->authenticated = false;
|
||||
$this->permissions = [];
|
||||
$this->services = [];
|
||||
$this->show_all = false;
|
||||
$this->enable_token_time_limit = false;
|
||||
$this->subscriptions = [];
|
||||
$this->service = false;
|
||||
$this->service_name = '';
|
||||
|
||||
// Save the websocket frame wrapper used to communicate to this subscriber
|
||||
$this->callback = $frame_wrapper;
|
||||
|
||||
// No filter initially
|
||||
$this->filter = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the subscribed to services
|
||||
* @param array $services
|
||||
* @return $this|array
|
||||
*/
|
||||
public function subscribed_to($services = []) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->services = array_flip($services);
|
||||
return $this;
|
||||
}
|
||||
return array_keys($this->services);
|
||||
}
|
||||
|
||||
public function service_class($service_class = null) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->service_class = $service_class;
|
||||
return $this;
|
||||
}
|
||||
return $this->service_class;
|
||||
}
|
||||
|
||||
public function set_filter(filter $filter) {
|
||||
$this->filter = $filter;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function get_filter() {
|
||||
return $this->filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* When there is no more references to the object we ensure that we disconnect from the subscriber
|
||||
*/
|
||||
public function __destruct() {
|
||||
// disconnect the socket
|
||||
$this->disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the socket resource used for this subscriber
|
||||
* @return bool true on success and false on failure
|
||||
*/
|
||||
public function disconnect(): bool {
|
||||
//return success if close was successful
|
||||
if (is_resource($this->socket)) {
|
||||
//self::$logger->info("Subscriber $this->id has been disconnected");
|
||||
// Send null to the frame wrapper to send a disconnect frame
|
||||
call_user_func($this->callback, $this->socket_id, null);
|
||||
return (@fclose($this->socket) !== false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the current object with another object to see if they are exactly the same object
|
||||
* @param subscriber|resource $object_or_resource_or_id
|
||||
* @return bool
|
||||
*/
|
||||
public function equals($object_or_resource_or_id): bool {
|
||||
// Compare by resource
|
||||
if (is_resource($object_or_resource_or_id)) {
|
||||
return $object_or_resource_or_id === $this->socket;
|
||||
}
|
||||
// Compare by spl_object_id or spl_object_hash
|
||||
if (gettype($object_or_resource_or_id) === 'integer' || gettype($object_or_resource_or_id) === 'string') {
|
||||
return $object_or_resource_or_id === $this->id;
|
||||
}
|
||||
// Ensure it is the same type of object
|
||||
if (!($object_or_resource_or_id instanceof subscriber)) {
|
||||
// Not a subscriber object
|
||||
return false;
|
||||
}
|
||||
// Compare by object using the spl_object_id to match
|
||||
return $object_or_resource_or_id->id() === $this->id;
|
||||
}
|
||||
|
||||
public function not_equals($object_or_resource): bool {
|
||||
return !$this->equals($object_or_resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow accessing copies of the private values
|
||||
* @param string $name
|
||||
* @return mixed
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function __get(string $name) {
|
||||
switch ($name) {
|
||||
case 'id':
|
||||
case 'socket_id':
|
||||
case 'remote_ip':
|
||||
case 'remote_port':
|
||||
case 'token_name':
|
||||
case 'token_hash':
|
||||
case 'token_time':
|
||||
case 'domain_name':
|
||||
case 'permissions':
|
||||
case 'services':
|
||||
return $this->{$name};
|
||||
default:
|
||||
throw new \InvalidArgumentException("Property '$name' does not exist or direct access is prohibited. Try using '$name()' for access.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current ID of this subscriber.
|
||||
* The ID is set in the constructor using the spl_object_id given by PHP
|
||||
* @return string
|
||||
*/
|
||||
public function id(): string {
|
||||
return "$this->id";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this subscriber has the permission given in $permission
|
||||
* @param string $permission
|
||||
* @return bool True when this subscriber has the permission and false otherwise
|
||||
*/
|
||||
public function has_permission(string $permission): bool {
|
||||
// Do not allow empty names
|
||||
if (empty($this->permissions) || strlen($permission) === 0) {
|
||||
return false;
|
||||
}
|
||||
return isset($this->permissions[$permission]);
|
||||
}
|
||||
|
||||
public function get_permissions(): array {
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
public function get_domain_name(): string {
|
||||
return $this->domain_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current socket resource used to communicate with this subscriber
|
||||
* @return resource|stream Resource Id or stream used
|
||||
*/
|
||||
public function socket() {
|
||||
return $this->socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the socket ID that was cast to an integer when the object was
|
||||
* created
|
||||
*/
|
||||
public function socket_id(): int {
|
||||
return $this->socket_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the given token against the loaded token in the this subscriber
|
||||
* @param array $token Must be an associative array with name and hash as the keys.
|
||||
* @return bool
|
||||
*/
|
||||
public function is_valid_token(array $token): bool {
|
||||
if (!is_array($token)) {
|
||||
throw new \InvalidArgumentException('Token must be an array');
|
||||
}
|
||||
|
||||
// get the name and hash from array
|
||||
$token_name = $token['name'] ?? '';
|
||||
$token_hash = $token['hash'] ?? '';
|
||||
|
||||
// empty values are not allowed
|
||||
if (empty($token_name) || empty($token_hash)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// validate the name and hash
|
||||
$valid = ($token_name === $this->token_name && $token_hash === $this->token_hash);
|
||||
|
||||
// Get the current epoch time
|
||||
$server_time = time();
|
||||
|
||||
// check time validation required
|
||||
if ($this->enable_token_time_limit) {
|
||||
// compare against time limit in minutes
|
||||
$valid = $valid && ($server_time - $this->token_time < $this->token_limit * 60);
|
||||
}
|
||||
//self::$logger->debug("------------------ Token Compare ------------------");
|
||||
//self::$logger->debug("Subscriber token time: $this->token_time");
|
||||
//self::$logger->debug(" Server time: $server_time");
|
||||
//self::$logger->debug("Subscriber token name: $this->token_name");
|
||||
//self::$logger->debug(" Server token name: $token_name");
|
||||
//self::$logger->debug("Subscriber token hash: $this->token_hash");
|
||||
//self::$logger->debug(" Server token hash: $token_hash");
|
||||
//self::$logger->debug("Returning: " . ($valid ? 'true' : 'false'));
|
||||
//self::$logger->debug("---------------------------------------------------");
|
||||
return $valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the given token array against the token previously saved in the file system. When the token is valid
|
||||
* the token will be saved in this object and the file removed. This method should not be called a second time
|
||||
* once a token has be authenticated.
|
||||
* @param array $request_token
|
||||
* @return bool
|
||||
*/
|
||||
public function authenticate_token(array $request_token): bool {
|
||||
// Check connection
|
||||
if (!$this->is_connected()) {
|
||||
throw new \socket_disconnected_exception($this->id);
|
||||
}
|
||||
|
||||
// Check for required fields
|
||||
if (empty($request_token)) {
|
||||
$date = date('Y/m/d H:i:s', time());
|
||||
//self::$logger->warn("Empty token given for $this->id");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set local storage
|
||||
$token_file = self::get_token_file($request_token['name'] ?? '');
|
||||
|
||||
// Set default return value of false
|
||||
$valid = false;
|
||||
|
||||
//self::$logger->debug("Using file: $token_file");
|
||||
// Ensure the file is there
|
||||
if (file_exists($token_file)) {
|
||||
//self::$logger->debug("Using $token_file for token");
|
||||
// Get the token using PHP engine parsing (fastest method)
|
||||
$array = include($token_file);
|
||||
|
||||
// Assign to local variables to reflect local storage
|
||||
$token_name = $array['token']['name'] ?? '';
|
||||
$token_hash = $array['token']['hash'] ?? '';
|
||||
$token_time = intval($array['token']['time'] ?? 0);
|
||||
$token_limit = intval($array['token']['limit'] ?? 0);
|
||||
|
||||
// Compare the token given in the request with the one that was in local storage
|
||||
$valid = $token_name === $request_token['name'] && $token_hash === $request_token['hash'];
|
||||
|
||||
// If the token is supposed to have a time limit then check the token time
|
||||
if ($token_limit > 0) {
|
||||
// check time has expired or not and put it in valid
|
||||
$valid = $valid && (time() - $token_time < $token_limit * 60); // token_time_limit * 60 seconds = 15 minutes
|
||||
}
|
||||
|
||||
// Debug information
|
||||
if (true) {
|
||||
//self::$logger->debug("------------------ Authenticate Token Compare ------------------");
|
||||
//self::$logger->debug(" Subscriber token name: ".$request_token['name']);
|
||||
//self::$logger->debug(" Subscriber token hash: ".$request_token['hash']);
|
||||
//self::$logger->debug(" Server token name: $token_name");
|
||||
//self::$logger->debug(" Server token hash: $token_hash");
|
||||
//self::$logger->debug(" Server token time: $token_time");
|
||||
//self::$logger->debug(" Server token limit: $token_limit");
|
||||
//self::$logger->debug("Valid: " . ($valid ? 'yes' : 'no'));
|
||||
//self::$logger->debug("----------------------------------------------------------------");
|
||||
}
|
||||
|
||||
// When token is valid
|
||||
if ($valid) {
|
||||
|
||||
// Store the valid token information in this object
|
||||
$this->token_name = $token_name;
|
||||
$this->token_hash = $token_hash;
|
||||
$this->token_time = $token_time;
|
||||
$this->enable_token_time_limit = $token_limit > 0;
|
||||
$this->token_limit = $token_limit * 60; // convert to seconds for time() comparison
|
||||
// Add the domain
|
||||
$this->domain_name = $array['domain']['name'] ?? '';
|
||||
$this->domain_uuid = $array['domain']['uuid'] ?? '';
|
||||
|
||||
// Add subscriptions for services
|
||||
$services = $array['services'] ?? [];
|
||||
foreach ($services as $service) {
|
||||
$this->subscribe($service);
|
||||
}
|
||||
|
||||
// Store the permissions
|
||||
$this->permissions = $array['permissions'] ?? [];
|
||||
|
||||
// Check for service
|
||||
if (isset($array['service'])) {
|
||||
//
|
||||
// Set the service information in the object
|
||||
//
|
||||
$this->service_name = "" . ($array['service_name'] ?? '');
|
||||
$this->service_class = "" . ($array['service_class'] ?? '');
|
||||
|
||||
//
|
||||
// Ensure we can call the method we need by checking for the interface.
|
||||
// Using the interface instead of calling method_exists means we only have to check once
|
||||
// for the interface instead of checking for each individual method required for it to be
|
||||
// considered a service. We can also adjust the interface with new methods and this code
|
||||
// remains the same. It is also possbile for us to use the 'instanceof' operator to check
|
||||
// that the object is what we require. However, using the instanceof operator requires anc
|
||||
// object first. Here we only check that the class has implemented the interface allowing
|
||||
// us to call static methods without first creating an object.
|
||||
//
|
||||
$this->service = is_a($this->service_class, 'websocket_service_interface', true);
|
||||
}
|
||||
|
||||
//self::$logger->debug("Permission count(".count($this->permissions) . ")");
|
||||
}
|
||||
|
||||
// Remove the token from local storage
|
||||
@unlink($token_file);
|
||||
}
|
||||
// store the result
|
||||
$this->authenticated = $valid;
|
||||
|
||||
// return success or failed
|
||||
return $valid;
|
||||
}
|
||||
|
||||
public function is_authenticated(): bool {
|
||||
return $this->authenticated;
|
||||
}
|
||||
|
||||
public function set_authenticated(bool $authenticated): self {
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function set_domain(string $uuid, string $name): self {
|
||||
if (is_uuid($uuid)) {
|
||||
$this->uuid = $uuid;
|
||||
} else {
|
||||
throw new invalid_uuid_exception("UUID is not valid");
|
||||
}
|
||||
$this->domain_name = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function is_service(): bool {
|
||||
return $this->service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or set the service_name
|
||||
* @param string|null $service_name
|
||||
* @return string|$this
|
||||
*/
|
||||
public function service_name($service_name = null) { /* : string|self */
|
||||
if (func_num_args() > 0) {
|
||||
$this->service_name = $service_name;
|
||||
return $this;
|
||||
}
|
||||
return $this->service_name;
|
||||
}
|
||||
|
||||
public function service_equals(string $service_name): bool {
|
||||
return ($this->service && $this->service_name === $service_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the socket/stream is still open (not at EOF).
|
||||
* @return bool Returns true if connected and false if the connection has closed
|
||||
*/
|
||||
public function is_connected(): bool {
|
||||
return is_resource($this->socket) && !feof($this->socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the subscriber is no longer connected
|
||||
* @return bool Returns true if the subscriber is no longer connected
|
||||
*/
|
||||
public function is_not_connected(): bool {
|
||||
return !$this->is_connected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this subscriber is subscribed to the given service name
|
||||
* @param string $service_name The service name ie. active.calls
|
||||
* @return bool
|
||||
* @see subscriber::subscribe
|
||||
*/
|
||||
public function has_subscribed_to(string $service_name): bool {
|
||||
return isset($this->services[$service_name]);
|
||||
}
|
||||
|
||||
public function subscribe(string $service_name): self {
|
||||
$this->services[$service_name] = true;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a response to the subscriber using the provided callback web socket wrapper in the constructor
|
||||
* @param string $json Valid JSON response to send to the connected client
|
||||
* @throws subscriber_token_expired_exception Thrown when the time limit set in the token has expired
|
||||
*/
|
||||
public function send(string $json) {
|
||||
//ensure the token is still valid
|
||||
if (!$this->token_time_exceeded()) {
|
||||
call_user_func($this->callback, $this->socket, $json);
|
||||
} else {
|
||||
throw new subscriber_token_expired_exception($this->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the given message through the websocket
|
||||
* @param websocket_message $message
|
||||
* @throws socket_disconnected_exception
|
||||
*/
|
||||
public function send_message(websocket_message $message) {
|
||||
|
||||
// Filter the message
|
||||
if ($this->filter !== null) {
|
||||
$message->apply_filter($this->filter);
|
||||
}
|
||||
|
||||
if (empty($message->service_name())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that we are subscribed to the event
|
||||
if (!$this->has_subscribed_to($message->service_name())) {
|
||||
//self::$logger->warn("Subscriber not subscribed to " . $message->service_name());
|
||||
throw new subscriber_not_subscribed_exception($this->id);
|
||||
}
|
||||
|
||||
// Ensure we are still connected
|
||||
if (!$this->is_connected()) {
|
||||
throw new \socket_disconnected_exception($this->id);
|
||||
}
|
||||
|
||||
$this->send((string) $message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
public static function get_remote_information_from_socket($socket): array {
|
||||
return explode(':', stream_socket_get_name($socket, true), 2);
|
||||
}
|
||||
|
||||
public static function get_remote_ip_from_socket($socket): string {
|
||||
$array = explode(':', stream_socket_get_name($socket, true), 2);
|
||||
return $array[0] ?? '';
|
||||
}
|
||||
|
||||
public static function get_remote_port_from_socket($socket): string {
|
||||
$array = explode(':', stream_socket_get_name($socket, true), 2);
|
||||
return $array[1] ?? '';
|
||||
}
|
||||
|
||||
public static function get_token_file($token_name): string {
|
||||
// Try to store in RAM first
|
||||
if (is_dir('/dev/shm') && is_writable('/dev/shm')) {
|
||||
$token_file = '/dev/shm/' . $token_name . '.php';
|
||||
} else {
|
||||
// Use the filesystem
|
||||
$token_file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $token_name . '.php';
|
||||
}
|
||||
return $token_file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the token array to local file system
|
||||
*
|
||||
* The web socket server runs in a separate process so it is unable to use
|
||||
* sessions. Therefor, the token must be stored in a temp folder to be
|
||||
* verified by the web socket server. It is possible to use a database
|
||||
* but the database connection process is very slow compared to the file
|
||||
* system. If the database resides on a remote system instead of local,
|
||||
* the web socket service may not yet have access to the token before the
|
||||
* web socket client requests authorization.
|
||||
*
|
||||
* @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
|
||||
* @see token::create()
|
||||
*/
|
||||
public static function save_token(array $token, array $services, int $time_limit_in_minutes = 0) {
|
||||
|
||||
//
|
||||
// Put the domain_name, permissions, and token in local storage so we can use all the information
|
||||
// to authenticate an incoming connection from the websocket service.
|
||||
//
|
||||
$array['permissions'] = $_SESSION['permissions'];
|
||||
|
||||
//
|
||||
// Store the token service and events
|
||||
//
|
||||
$array['services'] = $services;
|
||||
|
||||
//
|
||||
// Store the name and hash of the token
|
||||
//
|
||||
$array['token']['name'] = $token['name'];
|
||||
$array['token']['hash'] = $token['hash'];
|
||||
|
||||
//
|
||||
// Store the epoch time and time limit
|
||||
//
|
||||
$array['token']['time'] = "" . time();
|
||||
$array['token']['limit'] = $time_limit_in_minutes;
|
||||
|
||||
//
|
||||
// Store the domain name in this session
|
||||
//
|
||||
$array['domain']['name'] = $_SESSION['domain_name'];
|
||||
$array['domain']['uuid'] = $_SESSION['domain_uuid'];
|
||||
|
||||
//
|
||||
// Get the full path and file name for storing the token
|
||||
//
|
||||
$token_file = self::get_token_file($token['name']);
|
||||
|
||||
$file_contents = "<?php\nreturn " . var_export($array, true) . ";\n";
|
||||
|
||||
//
|
||||
// Put the contents in the file using the PHP method var_export. This is the fastest method to import
|
||||
// later because we can use the speed of the Zend Engine to import it with a simple include statement
|
||||
// The include can be used as a function: "$array = include($token_file);"
|
||||
//
|
||||
file_put_contents($token_file, $file_contents);
|
||||
}
|
||||
|
||||
public function token_time_exceeded(): bool {
|
||||
if (!$this->enable_token_time_limit)
|
||||
return false;
|
||||
|
||||
//self::$logger->debug("------------- TOKEN TIME LIMIT -------------");
|
||||
//self::$logger->debug(" Token Limit: $this->token_limit");
|
||||
//self::$logger->debug(" Token Time: $this->token_time");
|
||||
//self::$logger->debug(" Current Time: " . time());
|
||||
//self::$logger->debug("time-token_time: " . (time() - $this->token_time));
|
||||
//self::$logger->debug(" Time Exceeded: " . ((time() - $this->token_time) > $this->token_limit ? 'Yes' : 'No'));
|
||||
//self::$logger->debug("--------------------------------------------");
|
||||
//test the time on the token to ensure it is valid
|
||||
return (time() - $this->token_time) > $this->token_limit;
|
||||
}
|
||||
}
|
||||
42
core/websockets/resources/classes/subscriber_exception.php
Normal file
42
core/websockets/resources/classes/subscriber_exception.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of SubscriberException
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class subscriber_exception extends \Exception {
|
||||
public $subscriber_id;
|
||||
public function __construct($subscriber_id, string $message = "", int $code = 0, ?\Throwable $previous = null) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
$this->subscriber_id = $subscriber_id;
|
||||
}
|
||||
|
||||
public function getSubscriberId() { return $this->subscriber_id; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of SubscriberMissingPermissionException
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class subscriber_missing_permission_exception extends \subscriber_exception {
|
||||
public function __construct($subscriber_id, string $message = "Subscriber is missing required permission", int $code = 0, ?\Throwable $previous = null): \Exception {
|
||||
return parent::__construct($subscriber_id, $message, $code, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of subscriber_not_subscribed_exception
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class subscriber_not_subscribed_exception extends subscriber_exception {
|
||||
public function __construct($subscriber_id, string $message = "Subscriber is not subscribed", int $code = 0, ?\Throwable $previous = null) {
|
||||
parent::__construct($subscriber_id, $message, $code, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of TokenExpired
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class subscriber_token_expired_exception extends \subscriber_exception {
|
||||
public function __construct($subscriber_id = null, string $message = "Subscriber token expired", int $code = 0, ?\Throwable $previous = null) {
|
||||
return parent::__construct($subscriber_id, $message, $code, $previous);
|
||||
}
|
||||
}
|
||||
424
core/websockets/resources/classes/websocket_client.php
Normal file
424
core/websockets/resources/classes/websocket_client.php
Normal file
@@ -0,0 +1,424 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simple WebSocket client class in pure PHP (PHP 8.1+).
|
||||
* Provides connect, send_message, and disconnect methods.
|
||||
*/
|
||||
class websocket_client {
|
||||
|
||||
protected $url;
|
||||
protected $resource;
|
||||
protected $host;
|
||||
protected $port;
|
||||
protected $path;
|
||||
protected $origin;
|
||||
protected $key;
|
||||
private $stream_blocking;
|
||||
|
||||
/**
|
||||
* @param string $url WebSocket URL (e.g. ws://127.0.0.1:8080/)
|
||||
*/
|
||||
public function __construct(string $url) {
|
||||
$this->url = $url;
|
||||
//blocking should be enabled until we perform a handshake
|
||||
$this->stream_blocking = true;
|
||||
}
|
||||
|
||||
public function socket() {
|
||||
return $this->resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the WebSocket server and performs handshake.
|
||||
*/
|
||||
public function connect(): void {
|
||||
$parts = parse_url($this->url);
|
||||
$this->host = $parts['host'] ?? '';
|
||||
$this->port = $parts['port'] ?? 80;
|
||||
$this->path = $parts['path'] ?? '/';
|
||||
$this->origin = ($parts['scheme'] ?? 'http') . '://' . $this->host;
|
||||
|
||||
$this->resource = stream_socket_client("tcp://{$this->host}:{$this->port}", $errno, $errstr, 5);
|
||||
if (!$this->resource) {
|
||||
throw new \RuntimeException("Unable to connect: ({$errno}) {$errstr}");
|
||||
}
|
||||
|
||||
// block the stream
|
||||
$is_blocking = $this->is_blocking();
|
||||
if (!$is_blocking) {
|
||||
$this->block();
|
||||
}
|
||||
|
||||
// generate WebSocket key
|
||||
$this->key = base64_encode(random_bytes(16));
|
||||
|
||||
// send handshake request
|
||||
$header = "GET {$this->path} HTTP/1.1\r\n";
|
||||
$header .= "Host: {$this->host}:{$this->port}\r\n";
|
||||
$header .= "Upgrade: websocket\r\n";
|
||||
$header .= "Connection: Upgrade\r\n";
|
||||
$header .= "Sec-WebSocket-Key: {$this->key}\r\n";
|
||||
$header .= "Sec-WebSocket-Version: 13\r\n";
|
||||
$header .= "Origin: {$this->origin}\r\n\r\n";
|
||||
fwrite($this->resource, $header);
|
||||
|
||||
// read response headers
|
||||
$response = '';
|
||||
while (!feof($this->resource)) {
|
||||
$line = fgets($this->resource);
|
||||
if ($line === "\r\n") {
|
||||
break;
|
||||
}
|
||||
$response .= $line;
|
||||
}
|
||||
if (!preg_match('/Sec-WebSocket-Accept: (.*)\r\n/', $response, $m)) {
|
||||
throw new \RuntimeException("Handshake failed: no Accept header");
|
||||
}
|
||||
$accept = trim($m[1]);
|
||||
$expected = base64_encode(sha1($this->key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
|
||||
if ($accept !== $expected) {
|
||||
throw new \RuntimeException("Handshake failed: invalid Accept key");
|
||||
}
|
||||
|
||||
// Put the blocking back to the previous state
|
||||
if (!$is_blocking) {
|
||||
$this->disable_block();
|
||||
}
|
||||
}
|
||||
|
||||
public function set_blocking(bool $block) {
|
||||
if ($this->is_connected())
|
||||
stream_set_blocking($this->resource, $block);
|
||||
}
|
||||
|
||||
public function block() {
|
||||
$this->set_blocking(true);
|
||||
}
|
||||
|
||||
public function unblock() {
|
||||
$this->set_blocking(false);
|
||||
}
|
||||
|
||||
public function is_blocking(): bool {
|
||||
if ($this->is_connected()) {
|
||||
//
|
||||
// We allow the socket() function to return the socket as a reference
|
||||
// so we have to check the actual socket data to see if blocking was
|
||||
// modified outside of the object.
|
||||
// $meta_data['blocked'] = 0 // not blocking for event
|
||||
// $meta_data['blocked'] = 1 // blocking for event
|
||||
//
|
||||
$meta_data = stream_get_meta_data($this->resource);
|
||||
return !empty($meta_data['blocked']);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if socket is connected.
|
||||
*/
|
||||
public function is_connected(): bool {
|
||||
return isset($this->resource) && is_resource($this->resource) && !feof($this->resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends text to the web socket server.
|
||||
* The web socket client wraps the payload in a web frame socket before sending on the socket.
|
||||
* @param string|null $payload
|
||||
*/
|
||||
public static function send($resource, ?string $payload): bool {
|
||||
if (!is_resource($resource)) {
|
||||
throw new \RuntimeException("Not connected");
|
||||
}
|
||||
|
||||
// Check for a null message and send a disconnect frame
|
||||
if ($payload === null) {
|
||||
@fwrite($resource, chr(0x88) . chr(0x00));
|
||||
return true;
|
||||
}
|
||||
|
||||
$frame_header = "\x81"; // FIN=1, opcode=1 (text frame)
|
||||
$length = strlen($payload);
|
||||
|
||||
// Set mask bit and payload length
|
||||
if ($length <= 125) {
|
||||
$frame_header .= chr(0x80 | $length); // mask bit set
|
||||
} elseif ($length <= 65535) {
|
||||
$frame_header .= chr(0x80 | 126) . pack('n', $length);
|
||||
} else {
|
||||
$frame_header .= chr(0x80 | 127) . pack('J', $length);
|
||||
}
|
||||
|
||||
// must be masked when sending to the server
|
||||
$mask = random_bytes(4);
|
||||
$masked_payload = '';
|
||||
|
||||
for ($i = 0; $i < $length; ++$i) {
|
||||
$masked_payload .= $payload[$i] ^ $mask[$i % 4];
|
||||
}
|
||||
|
||||
$frame = $frame_header . $mask . $masked_payload;
|
||||
|
||||
$written = @fwrite($resource, $frame);
|
||||
|
||||
if ($written === false) {
|
||||
echo "[ERROR] Failed to write to socket\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($written < strlen($frame)) {
|
||||
echo "[WARNING] Partial frame sent ({$written}/" . strlen($frame) . " bytes)\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects from the server.
|
||||
*/
|
||||
public function disconnect(): void {
|
||||
if (isset($this->resource) && is_resource($this->resource)) {
|
||||
@fwrite($this->resource, "\x88\x00"); // 0x88 = close frame, no payload
|
||||
@fclose($this->resource);
|
||||
}
|
||||
}
|
||||
|
||||
public static function get_token_file($token_name): string {
|
||||
// Try to store in RAM first
|
||||
if (is_dir('/dev/shm') && is_writable('/dev/shm')) {
|
||||
$token_file = '/dev/shm/' . $token_name . '.php';
|
||||
} else {
|
||||
// Use the filesystem
|
||||
$token_file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $token_name . '.php';
|
||||
}
|
||||
return $token_file;
|
||||
}
|
||||
|
||||
private function send_control_frame(int $opcode, string $payload = ''): void {
|
||||
$header = chr(0x80 | $opcode); // FIN=1, control frame
|
||||
$payload_len = strlen($payload);
|
||||
|
||||
// Payload length
|
||||
if ($payload_len <= 125) {
|
||||
$header .= chr($payload_len);
|
||||
} elseif ($payload_len <= 65535) {
|
||||
$header .= chr(126) . pack('n', $payload_len);
|
||||
} else {
|
||||
// Control frames should never be this large; truncate to 125
|
||||
$payload = substr($payload, 0, 125);
|
||||
$header .= chr(125);
|
||||
}
|
||||
|
||||
@fwrite($this->resource, $header . $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a web socket data frame and converts it to a regular string
|
||||
* @param resource $this->resource
|
||||
* @return string
|
||||
*/
|
||||
public function read(): ?string {
|
||||
if (!is_resource($this->resource)) {
|
||||
throw new \RuntimeException("Not connected");
|
||||
}
|
||||
|
||||
$final_frame = false;
|
||||
$payload_data = '';
|
||||
|
||||
while (!$final_frame) {
|
||||
$header = $this->read_bytes(2);
|
||||
if ($header === null)
|
||||
return null;
|
||||
|
||||
$byte1 = ord($header[0]);
|
||||
$byte2 = ord($header[1]);
|
||||
|
||||
$final_frame = ($byte1 >> 7) & 1;
|
||||
$opcode = $byte1 & 0x0F;
|
||||
$masked = ($byte2 >> 7) & 1;
|
||||
$payload_len = $byte2 & 0x7F;
|
||||
|
||||
// Extended payload length
|
||||
if ($payload_len === 126) {
|
||||
$extended = $this->read_bytes(2);
|
||||
if ($extended === null)
|
||||
return null;
|
||||
$payload_len = unpack('n', $extended)[1];
|
||||
} elseif ($payload_len === 127) {
|
||||
$extended = $this->read_bytes(8);
|
||||
if ($extended === null)
|
||||
return null;
|
||||
$payload_len = 0;
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$payload_len = ($payload_len << 8) | ord($extended[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Read mask
|
||||
$mask = '';
|
||||
if ($masked) {
|
||||
$mask = $this->read_bytes(4);
|
||||
if ($mask === null)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read payload
|
||||
$payload = $this->read_bytes($payload_len);
|
||||
if ($payload === null) {
|
||||
echo "[ERROR] Incomplete payload received\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unmask if needed
|
||||
if ($masked) {
|
||||
$unmasked = '';
|
||||
for ($i = 0; $i < $payload_len; $i++) {
|
||||
$unmasked .= $payload[$i] ^ $mask[$i % 4];
|
||||
}
|
||||
$payload = $unmasked;
|
||||
}
|
||||
|
||||
// Handle control frames
|
||||
switch ($opcode) {
|
||||
case 0x9: // PING
|
||||
// Respond with PONG using same payload
|
||||
$this->send_control_frame(0xA, $payload);
|
||||
echo "[INFO] Received PING, sent PONG\n";
|
||||
continue; // Skip returning PING
|
||||
case 0xA: // PONG
|
||||
echo "[INFO] Received PONG\n";
|
||||
continue; // Skip returning PONG
|
||||
case 0x1: // TEXT frame
|
||||
case 0x0: // Continuation frame
|
||||
$payload_data .= $payload;
|
||||
break;
|
||||
default:
|
||||
echo "[WARNING] Unsupported opcode: $opcode\n";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$meta = stream_get_meta_data($this->resource);
|
||||
if ($meta['unread_bytes'] > 0) {
|
||||
echo "[WARNING] {$meta['unread_bytes']} bytes left in socket after read\n";
|
||||
}
|
||||
|
||||
return $payload_data;
|
||||
}
|
||||
|
||||
// Helper function to fully read N bytes
|
||||
private function read_bytes(int $length): ?string {
|
||||
$data = '';
|
||||
while (strlen($data) < $length) {
|
||||
$chunk = fread($this->resource, $length - strlen($data));
|
||||
if ($chunk === false || $chunk === '') {
|
||||
break;
|
||||
}
|
||||
$data .= $chunk;
|
||||
}
|
||||
return strlen($data) === $length ? $data : null;
|
||||
}
|
||||
|
||||
public function authenticate($token_name, $token_hash) {
|
||||
return self::send($this->resource, json_encode(['service' => 'authentication', 'token' => ['name' => $token_name, 'hash' => $token_hash]]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a token for a service that can broadcast a message
|
||||
* @param string $service_name
|
||||
* @param string $service_class
|
||||
* @param array $permissions
|
||||
* @param int $time_limit_in_minutes
|
||||
* @return array
|
||||
*/
|
||||
public static function create_service_token(string $service_name, string $service_class, array $permissions = [], int $time_limit_in_minutes = 0) {
|
||||
|
||||
//
|
||||
// Create a service token
|
||||
//
|
||||
$token = (new token())->create($service_name);
|
||||
|
||||
//
|
||||
// Put the permissions, and token in local storage so we can use all the information
|
||||
// to authenticate an incoming connection from the websocket service.
|
||||
//
|
||||
$array = $permissions;
|
||||
|
||||
//
|
||||
// Store the name and hash of the token
|
||||
//
|
||||
$array['token']['name'] = $token['name'];
|
||||
$array['token']['hash'] = $token['hash'];
|
||||
|
||||
//
|
||||
// Store the epoch time and time limit
|
||||
//
|
||||
$array['token']['time'] = "" . time();
|
||||
$array['token']['limit'] = $time_limit_in_minutes;
|
||||
|
||||
//
|
||||
// Store the service name used by web browser to subscribe
|
||||
// and store the class name of this service
|
||||
//
|
||||
$array['service'] = true;
|
||||
$array['service_name'] = $service_name;
|
||||
$array['service_class'] = $service_class;
|
||||
|
||||
//
|
||||
// Get the full path and file name for storing the token
|
||||
//
|
||||
$token_file = self::get_token_file($token['name']);
|
||||
|
||||
$file_contents = "<?php\nreturn " . var_export($array, true) . ";\n";
|
||||
|
||||
//
|
||||
// Put the contents in the file using the PHP method var_export. This is the fastest method to import
|
||||
// later because we can use the speed of the Zend Engine to import it with a simple include statement
|
||||
// The include can be used as a function: "$array = include($token_file);"
|
||||
//
|
||||
file_put_contents($token_file, $file_contents);
|
||||
|
||||
return [$array['token']['name'], $array['token']['hash']];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage:
|
||||
*/
|
||||
// require_once 'websocket_client.php';
|
||||
//$client = new websocket_client('ws://127.0.0.1:8080/');
|
||||
//try {
|
||||
// $client->connect();
|
||||
// $client->send_message('Hello from PHP client!');
|
||||
// // ... do more send_message() calls as needed
|
||||
// $client->disconnect();
|
||||
//} catch (\Throwable $e) {
|
||||
// echo "Error: " . $e->getMessage() . "\n";
|
||||
//}
|
||||
429
core/websockets/resources/classes/websocket_message.php
Normal file
429
core/websockets/resources/classes/websocket_message.php
Normal file
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* A structured web socket message easily converted to and from a json string
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
* @param string $service_name;
|
||||
* @param string $token_name;
|
||||
* @param string $token_hash;
|
||||
* @param string $status_string;
|
||||
* @param string $status_code;
|
||||
* @param string $request_id;
|
||||
* @param string $resource_id;
|
||||
* @param string $domain_uuid;
|
||||
* @param string $permissions;
|
||||
* @param string $topic;
|
||||
*/
|
||||
class websocket_message extends base_message {
|
||||
|
||||
// By setting these to protected we ensure the __set and __get methods are used in the parent class
|
||||
protected $service_name;
|
||||
protected $token_name;
|
||||
protected $token_hash;
|
||||
protected $status_string;
|
||||
protected $status_code;
|
||||
protected $request_id;
|
||||
protected $resource_id;
|
||||
protected $domain_uuid;
|
||||
protected $domain_name;
|
||||
protected $permissions;
|
||||
protected $topic;
|
||||
|
||||
public function __construct($associative_properties_array = []) {
|
||||
// Initialize empty default values
|
||||
$this->service_name = '';
|
||||
$this->token_name = '';
|
||||
$this->token_hash = '';
|
||||
$this->status_string = '';
|
||||
$this->status_code = '';
|
||||
$this->request_id = '';
|
||||
$this->resource_id = '';
|
||||
$this->domain_uuid = '';
|
||||
$this->domain_name = '';
|
||||
$this->permissions = [];
|
||||
$this->topic = '';
|
||||
//
|
||||
// Send to parent (base_message) constructor
|
||||
//
|
||||
parent::__construct($associative_properties_array);
|
||||
}
|
||||
|
||||
public function has_permission($permission_name) {
|
||||
return isset($this->permissions[$permission_name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of service_name.
|
||||
* @param string $service_name
|
||||
* @return $this
|
||||
* @see service_name
|
||||
*/
|
||||
public function service($service_name = null) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->service_name = $service_name;
|
||||
return $this;
|
||||
}
|
||||
return $this->service_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or Sets the service name
|
||||
* If no parameters are provided then the service_name is returned. If the service name is provided, then the
|
||||
* service_name is set to the value provided.
|
||||
* @param string $service_name
|
||||
* @return $this
|
||||
*/
|
||||
public function service_name($service_name = null) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->service_name = $service_name;
|
||||
return $this;
|
||||
}
|
||||
return $this->service_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the permissions array
|
||||
* @param array $permissions
|
||||
* @return $this
|
||||
*/
|
||||
public function permissions($permissions = []) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->permissions = $permissions;
|
||||
return $this;
|
||||
}
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a filter to the payload of this message.
|
||||
* When a filter returns null then the payload is set to null
|
||||
* @param filter $filter
|
||||
*/
|
||||
public function apply_filter(?filter $filter) {
|
||||
if ($filter !== null && is_array($this->payload)) {
|
||||
foreach ($this->payload as $key => $value) {
|
||||
$result = ($filter)($key, $value);
|
||||
// Check if a filter requires dropping the payload
|
||||
if ($result === null) {
|
||||
$this->payload = null;
|
||||
return;
|
||||
}
|
||||
// Remove a key if filter does not pass
|
||||
elseif(!$result) {
|
||||
unset($this->payload[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the domain UUID
|
||||
* @param string $domain_uuid
|
||||
* @return $this or $domain_uuid
|
||||
*/
|
||||
public function domain_uuid($domain_uuid = '') {
|
||||
if (func_num_args() > 0) {
|
||||
$this->domain_uuid = $domain_uuid;
|
||||
return $this;
|
||||
}
|
||||
return $this->domain_uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the domain name
|
||||
* @param string $domain_name
|
||||
* @return $this or $domain_name
|
||||
*/
|
||||
public function domain_name($domain_name = '') {
|
||||
if (func_num_args() > 0) {
|
||||
$this->domain_name = $domain_name;
|
||||
return $this;
|
||||
}
|
||||
return $this->domain_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or Sets the service name
|
||||
* If no parameters are provided then the service_name is returned. If the service name is provided, then the
|
||||
* topic is set to the value provided.
|
||||
* @param string $topic
|
||||
* @return $this
|
||||
*/
|
||||
public function topic($topic = null) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->topic = $topic;
|
||||
return $this;
|
||||
}
|
||||
return $this->topic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the token array using the key values of 'name' and 'hash'
|
||||
* @param array $token_array
|
||||
* @return array|$this
|
||||
* @see token_name
|
||||
* @see token_hash
|
||||
*/
|
||||
public function token($token_array = []) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->token_name($token_array['name'] ?? '')->token_hash($token_array['hash'] ?? '');
|
||||
return $this;
|
||||
}
|
||||
return ['name' => $this->token_name, 'hash' => $this->token_hash];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the token name
|
||||
* @param string $token_name
|
||||
* @return $this
|
||||
* @see token_hash
|
||||
*/
|
||||
public function token_name($token_name = '') {
|
||||
if (func_num_args() > 0) {
|
||||
$this->token_name = $token_name;
|
||||
return $this;
|
||||
}
|
||||
return $this->token_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the status code of this message
|
||||
* @param int $status_code
|
||||
* @return $this
|
||||
*/
|
||||
public function status_code($status_code = '') {
|
||||
if (func_num_args() > 0) {
|
||||
$this->status_code = $status_code;
|
||||
return $this;
|
||||
}
|
||||
return $this->status_code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the resource id
|
||||
* @param type $resource_id
|
||||
* @return $this
|
||||
*/
|
||||
public function resource_id($resource_id = null) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->resource_id = $resource_id;
|
||||
return $this;
|
||||
}
|
||||
return $this->resource_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the request ID
|
||||
* @param type $request_id
|
||||
* @return $this
|
||||
*/
|
||||
public function request_id($request_id = null) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->request_id = $request_id;
|
||||
return $this;
|
||||
}
|
||||
return $this->request_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the status string
|
||||
* @param type $status_string
|
||||
* @return $this
|
||||
*/
|
||||
public function status_string( $status_string = null) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->status_string = $status_string;
|
||||
return $this;
|
||||
}
|
||||
return $this->status_string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the token hash
|
||||
* @param type $token_hash
|
||||
* @return $this
|
||||
* @see token_name
|
||||
*/
|
||||
public function token_hash($token_hash = null) {
|
||||
if (func_num_args() > 0) {
|
||||
$this->token_hash = $token_hash;
|
||||
return $this;
|
||||
}
|
||||
return $this->token_hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the 'statusString' key that comes from javascript
|
||||
* @param type $status_string
|
||||
* @return type
|
||||
*/
|
||||
public function statusString($status_string = '') {
|
||||
return $this->status_string($status_string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the 'statusCode' key that comes from javascript
|
||||
* @param type $status_code
|
||||
* @return $this
|
||||
*/
|
||||
public function statusCode($status_code = 200) {
|
||||
return $this->status_code($status_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a JSON message to an associative array
|
||||
* @param string $json_string
|
||||
* @return array
|
||||
*/
|
||||
public static function unwrap($json_string = '') {
|
||||
return json_decode($json_string, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to respond with a connected message
|
||||
* @param type $request_id
|
||||
* @return type
|
||||
*/
|
||||
public static function connected($request_id = '') {
|
||||
return static::request_authentication($request_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to respond with a authentication message
|
||||
* @param type $request_id
|
||||
* @return type
|
||||
*/
|
||||
public static function request_authentication($request_id = '') {
|
||||
$class = static::class;
|
||||
return (new $class())
|
||||
->request_id($request_id)
|
||||
->service_name('authentication')
|
||||
->status_code(407)
|
||||
->status_string('Authentication Required')
|
||||
->topic('authenticate')
|
||||
->__toString()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to respond with a bad request message
|
||||
* @param type $request_id
|
||||
* @param type $service
|
||||
* @param type $topic
|
||||
* @return type
|
||||
*/
|
||||
public static function request_is_bad($request_id = '', $service = '', $topic = '') {
|
||||
$class = static::class;
|
||||
return (new $class())
|
||||
->request_id($request_id)
|
||||
->service_name($service)
|
||||
->topic($topic)
|
||||
->status_code(400)
|
||||
->__toString()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to respond with an authenticated message
|
||||
* @param type $request_id
|
||||
* @param type $service
|
||||
* @param type $topic
|
||||
* @return type
|
||||
*/
|
||||
public static function request_authenticated($request_id = '', $service = '', $topic = 'authenticated') {
|
||||
$class = static::class;
|
||||
return (new $class())
|
||||
->request_id($request_id)
|
||||
->service_name($service)
|
||||
->topic($topic)
|
||||
->status_code(200)
|
||||
->status_string('OK')
|
||||
->__toString()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to respond with an unauthorized request message
|
||||
* @param type $request_id
|
||||
* @param type $service
|
||||
* @param type $topic
|
||||
* @return type
|
||||
*/
|
||||
public static function request_unauthorized($request_id = '', $service = '', $topic = 'unauthorized') {
|
||||
$class = static::class;
|
||||
return (new $class())
|
||||
->request_id($request_id)
|
||||
->service_name($service)
|
||||
->topic($topic)
|
||||
->status_code(401)
|
||||
->__toString()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to respond with a forbidden message
|
||||
* @param type $request_id
|
||||
* @param type $service
|
||||
* @param type $topic
|
||||
* @return type
|
||||
*/
|
||||
public static function request_forbidden($request_id = '', $service = '', $topic = 'forbidden') {
|
||||
$class = static::class;
|
||||
return (new $class())
|
||||
->request_id($request_id)
|
||||
->service_name($service)
|
||||
->topic($topic)
|
||||
->status_code(403)
|
||||
->__toString()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a websocket_message object (or child object) using the provided JSON string or JSON array
|
||||
* @param string|array $websocket_message_json JSON array or JSON string
|
||||
* @return static|null Returns a new websocket_message object (or child object)
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public static function create_from_json_message($websocket_message_json) {
|
||||
if (empty($websocket_message_json)) {
|
||||
// Nothing to do
|
||||
return null;
|
||||
} elseif (is_string($websocket_message_json)) {
|
||||
$json_array = json_decode($websocket_message_json, true);
|
||||
} elseif (is_array($websocket_message_json)) {
|
||||
$json_array = $websocket_message_json;
|
||||
} else {
|
||||
throw new \InvalidArgumentException("create_from_websocket_message_json expected string or array but got " . gettype($websocket_message_json));
|
||||
}
|
||||
|
||||
return new static($json_array);
|
||||
}
|
||||
|
||||
}
|
||||
571
core/websockets/resources/classes/websocket_server.php
Normal file
571
core/websockets/resources/classes/websocket_server.php
Normal file
@@ -0,0 +1,571 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simple WebSocket server class. Supporting chunking, PING, PONG.
|
||||
*
|
||||
* The on_connect, on_disconnect, on_message events require a function to be passed
|
||||
* so the websocket_server can call that function when the specific events occur. Each
|
||||
* of the functions must accept one parameter for the resource that the event occurred on.
|
||||
* Supports multiple clients and broadcasts messages from one to all others.
|
||||
*/
|
||||
class websocket_server {
|
||||
|
||||
/**
|
||||
* Address to bind to. (Default 8080)
|
||||
* @var string
|
||||
*/
|
||||
protected $address;
|
||||
|
||||
/**
|
||||
* Port to bind to. (Default 0.0.0.0 - all PHP detected IP addresses of the system)
|
||||
* @var int
|
||||
*/
|
||||
protected $port;
|
||||
|
||||
/**
|
||||
* Tracks if the server is running
|
||||
* @var bool
|
||||
*/
|
||||
protected $running;
|
||||
|
||||
/**
|
||||
* Resource or stream of the server socket binding
|
||||
* @var resource|stream
|
||||
*/
|
||||
protected $server_socket;
|
||||
|
||||
/**
|
||||
* List of connected client sockets
|
||||
* @var array
|
||||
*/
|
||||
protected $clients;
|
||||
|
||||
/**
|
||||
* Used to track on_message events
|
||||
* @var array
|
||||
*/
|
||||
private $message_callbacks;
|
||||
|
||||
/**
|
||||
* Used to track on_connect events
|
||||
* @var array
|
||||
*/
|
||||
private $connect_callbacks;
|
||||
|
||||
/**
|
||||
* Used to track on_disconnect events
|
||||
* @var array
|
||||
*/
|
||||
private $disconnect_callbacks;
|
||||
|
||||
/**
|
||||
* Used to track switch listeners or other socket connection types
|
||||
* @var array
|
||||
*/
|
||||
private $listeners;
|
||||
|
||||
/**
|
||||
* Creates a websocket_server instance
|
||||
* @param string $address IP to bind (default 0.0.0.0)
|
||||
* @param int $port TCP port (default 8080)
|
||||
*/
|
||||
public function __construct(string $address = '127.0.0.1', int $port = 8080) {
|
||||
$this->running = false;
|
||||
$this->address = $address;
|
||||
$this->port = $port;
|
||||
|
||||
// Initialize arrays
|
||||
$this->listeners = [];
|
||||
$this->clients = [];
|
||||
$this->message_callbacks = [];
|
||||
$this->connect_callbacks = [];
|
||||
$this->disconnect_callbacks = [];
|
||||
}
|
||||
|
||||
private function debug(string $message) {
|
||||
self::log($message, LOG_DEBUG);
|
||||
}
|
||||
|
||||
private function warn(string $message) {
|
||||
self::log($message, LOG_WARNING);
|
||||
}
|
||||
|
||||
private function error(string $message) {
|
||||
self::log($message, LOG_ERR);
|
||||
}
|
||||
|
||||
private function info(string $message) {
|
||||
self::log($message, LOG_INFO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts server: accepts new clients, reads frames, and broadcasts messages.
|
||||
* @returns int A non-zero indicates an abnormal termination
|
||||
*/
|
||||
public function run(): int {
|
||||
|
||||
$this->server_socket = stream_socket_server("tcp://{$this->address}:{$this->port}", $errno, $errstr);
|
||||
if (!$this->server_socket) {
|
||||
throw new \RuntimeException("Cannot bind socket ({$errno}): {$errstr}");
|
||||
}
|
||||
stream_set_blocking($this->server_socket, false);
|
||||
|
||||
// We are now running
|
||||
$this->running = true;
|
||||
|
||||
while ($this->running) {
|
||||
$listeners = array_column($this->listeners, 0);
|
||||
$read = array_merge([$this->server_socket], $listeners, $this->clients);
|
||||
$write = $except = [];
|
||||
// Server connection issue
|
||||
if (false === stream_select($read, $write, $except, null)) {
|
||||
$this->running = false;
|
||||
break;
|
||||
}
|
||||
// new connection
|
||||
if (in_array($this->server_socket, $read, true)) {
|
||||
$conn = @stream_socket_accept($this->server_socket, 0);
|
||||
if ($conn) {
|
||||
// complete handshake on blocking socket
|
||||
stream_set_blocking($conn, true);
|
||||
$this->handshake($conn);
|
||||
// switch to non-blocking for further reads
|
||||
stream_set_blocking($conn, false);
|
||||
// add them to the websocket list
|
||||
$this->clients[] = $conn;
|
||||
// notify websocket on_connect listeners
|
||||
$this->trigger_connect($conn);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// handle other sockets
|
||||
foreach ($read as $client_socket) {
|
||||
|
||||
// check switch listeners
|
||||
if (in_array($client_socket, $listeners, true)) {
|
||||
// Process external listeners
|
||||
$index = array_search($client_socket, $listeners, true);
|
||||
try {
|
||||
//send the switch event to the registered callback function
|
||||
call_user_func($this->listeners[$index][1], $client_socket);
|
||||
} catch (\socket_disconnected_exception $s) {
|
||||
$this->info("[INFO] Removed client $s->id from list");
|
||||
$success = $this->disconnect_client($client_socket);
|
||||
// By attaching the socket_disconnect error message to \socket_exception we can see where something went wrong
|
||||
if (!$success)
|
||||
throw new socket_exception('Socket does not exist in tracking array', 256, $s);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process web socket client communication
|
||||
$message = $this->receive_frame($client_socket);
|
||||
if ($message === '') {
|
||||
continue;
|
||||
}
|
||||
$this->trigger_message($client_socket, $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a non-blocking socket to listen for traffic on
|
||||
* @param resource $socket
|
||||
* @param callable $on_data_ready_callback Callable function to call when data arrives on the socket
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function add_listener($socket, callable $on_data_ready_callback) {
|
||||
if (!is_callable($on_data_ready_callback)) {
|
||||
throw new \InvalidArgumentException('The callable on_data_ready_callback must be a valid callable function');
|
||||
}
|
||||
$this->listeners[] = [$socket, $on_data_ready_callback];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there are connected web socket clients.
|
||||
* @return bool
|
||||
*/
|
||||
public function has_clients(): bool {
|
||||
return !empty($this->clients);
|
||||
}
|
||||
|
||||
/**
|
||||
* When a web socket message is received the $on_message_callback function is called.
|
||||
* Multiple on_message functions can be specified.
|
||||
* @param callable $on_message_callback Callable function to call when data arrives on the socket
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function on_message(callable $on_message_callback) {
|
||||
if (!is_callable($on_message_callback)) {
|
||||
throw new \InvalidArgumentException('The callable on_message_callback must be a valid callable function');
|
||||
}
|
||||
$this->message_callbacks[] = $on_message_callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls all the on_message functions
|
||||
* @param resource $socket
|
||||
* @param string $message
|
||||
* @return void
|
||||
*/
|
||||
private function trigger_message($socket, string $message) {
|
||||
foreach ($this->message_callbacks as $callback) {
|
||||
$response = call_user_func($callback, $socket, $message);
|
||||
if ($response !== null) {
|
||||
$this->send($socket, $response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a web socket handshake has completed, the $on_connect_callback function is called.
|
||||
* Multiple on_connect functions can be specified.
|
||||
* @param callable $on_connect_callback Callable function to call when a new connection occurs.
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function on_connect(callable $on_connect_callback) {
|
||||
if (!is_callable($on_connect_callback)) {
|
||||
throw new \InvalidArgumentException('The callable on_connect_callback must be a valid callable function');
|
||||
}
|
||||
$this->connect_callbacks[] = $on_connect_callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls all the on_connect functions
|
||||
* @param resource $socket
|
||||
*/
|
||||
private function trigger_connect($socket) {
|
||||
foreach ($this->connect_callbacks as $callback) {
|
||||
$response = call_user_func($callback, $socket);
|
||||
if ($response !== null) {
|
||||
self::send($socket, $response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a web socket has disconnected, the $on_disconnect_callback function is called.
|
||||
* Multiple functions can be specified with subsequent calls
|
||||
* @param string|callable $on_disconnect_callback Callable function to call when a socket disconnects. The function must accept a single parameter for the socket that was disconnected.
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function on_disconnect($on_disconnect_callback) {
|
||||
if (!is_callable($on_disconnect_callback)) {
|
||||
throw new \InvalidArgumentException('The callable on_disconnect_callback must be a valid callable function');
|
||||
}
|
||||
$this->disconnect_callbacks[] = $on_disconnect_callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls all the on_disconnect_callback functions
|
||||
* @param type $socket
|
||||
*/
|
||||
private function trigger_disconnect($socket) {
|
||||
foreach ($this->disconnect_callbacks as $callback) {
|
||||
call_user_func($callback, $socket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the socket used in the server connection
|
||||
* @return resource
|
||||
*/
|
||||
public function get_socket() {
|
||||
return $this->server_socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a client socket on disconnect.
|
||||
* @return bool Returns true on client disconnect and false when the client is not found in the tracking array
|
||||
*/
|
||||
protected function disconnect_client($socket, $error = null): bool {
|
||||
$index = array_search($resource, $this->clients, true);
|
||||
if ($index !== false) {
|
||||
self::disconnect($resource);
|
||||
unset($this->clients[$index]);
|
||||
$this->trigger_disconnect($socket);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a disconnect frame with no payload
|
||||
* @param type $resource
|
||||
*/
|
||||
public static function disconnect($resource) {
|
||||
if (is_resource($resource)) {
|
||||
//send OPCODE
|
||||
@fwrite($resource, "\x88\x00"); // 0x88 = close frame, no payload
|
||||
@fclose($resource);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs web socket handshake on new connection.
|
||||
* @param type $socket Socket to perform the handshake on.
|
||||
*/
|
||||
protected function handshake($socket) {
|
||||
// ensure blocking to read full header
|
||||
stream_set_blocking($socket, true);
|
||||
$request_header = '';
|
||||
while (($line = fgets($socket)) !== false) {
|
||||
$request_header .= $line;
|
||||
if (rtrim($line) === '') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $request_header, $matches)) {
|
||||
throw new \RuntimeException("Invalid WebSocket handshake");
|
||||
}
|
||||
$key = trim($matches[1]);
|
||||
$accept_key = base64_encode(
|
||||
sha1($key . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true)
|
||||
);
|
||||
$response_header = "HTTP/1.1 101 Switching Protocols\r\n"
|
||||
. "Upgrade: websocket\r\n"
|
||||
. "Connection: Upgrade\r\n"
|
||||
. "Sec-WebSocket-Accept: {$accept_key}\r\n\r\n";
|
||||
fwrite($socket, $response_header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read specific number of bytes from a web socket
|
||||
* @param resource $socket
|
||||
* @param int $length
|
||||
* @return string
|
||||
*/
|
||||
private function read_bytes($socket, int $length): string {
|
||||
$data = '';
|
||||
while (strlen($data) < $length && is_resource($socket)) {
|
||||
$chunk = fread($socket, $length - strlen($data));
|
||||
if ($chunk === false || $chunk === '' || !is_resource($socket)) {
|
||||
$this->disconnect_client($socket);
|
||||
return '';
|
||||
}
|
||||
$data .= $chunk;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a web socket data frame and converts it to a regular string
|
||||
* @param resource $socket
|
||||
* @return string
|
||||
*/
|
||||
private function receive_frame($socket): string {
|
||||
if (!is_resource($socket)) {
|
||||
throw new \RuntimeException("Not connected");
|
||||
}
|
||||
|
||||
$final_frame = false;
|
||||
$payload_data = '';
|
||||
|
||||
while (!$final_frame) {
|
||||
$header = $this->read_bytes($socket, 2);
|
||||
if ($header === null)
|
||||
return null;
|
||||
|
||||
$byte1 = ord($header[0]);
|
||||
$byte2 = ord($header[1]);
|
||||
|
||||
$final_frame = ($byte1 >> 7) & 1;
|
||||
$opcode = $byte1 & 0x0F;
|
||||
$masked = ($byte2 >> 7) & 1;
|
||||
$payload_len = $byte2 & 0x7F;
|
||||
|
||||
// Extended payload length
|
||||
if ($payload_len === 126) {
|
||||
$extended = $this->read_bytes($socket, 2);
|
||||
if ($extended === null)
|
||||
return null;
|
||||
$payload_len = unpack('n', $extended)[1];
|
||||
} elseif ($payload_len === 127) {
|
||||
$extended = $this->read_bytes($socket, 8);
|
||||
if ($extended === null)
|
||||
return null;
|
||||
$payload_len = 0;
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$payload_len = ($payload_len << 8) | ord($extended[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Read mask
|
||||
$mask = '';
|
||||
if ($masked) {
|
||||
$mask = $this->read_bytes($socket, 4);
|
||||
if ($mask === null)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read payload
|
||||
$payload = $this->read_bytes($socket, $payload_len);
|
||||
if ($payload === null) {
|
||||
$this->error("[ERROR] Incomplete payload received");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unmask if needed
|
||||
if ($masked) {
|
||||
$unmasked = '';
|
||||
for ($i = 0; $i < $payload_len; $i++) {
|
||||
$unmasked .= $payload[$i] ^ $mask[$i % 4];
|
||||
}
|
||||
$payload = $unmasked;
|
||||
}
|
||||
|
||||
// Handle control frames
|
||||
switch ($opcode) {
|
||||
case 0x9: // PING
|
||||
// Respond with PONG using same payload
|
||||
$this->send_control_frame(0xA, $payload);
|
||||
$this->info("Received PING, sent PONG");
|
||||
continue; // Skip returning PING
|
||||
case 0x8: // CLOSE frame
|
||||
$this->info("Received CLOSE frame, connection will be closed.");
|
||||
$this->disconnect_client($socket);
|
||||
return null;
|
||||
case 0xA: // PONG
|
||||
$this->info("Received PONG");
|
||||
$reason = $this->read_bytes($socket, 2);
|
||||
$this->info("Reason: $reason");
|
||||
continue; // Skip returning PONG
|
||||
case 0x1: // TEXT frame
|
||||
case 0x0: // Continuation frame
|
||||
$payload_data .= $payload;
|
||||
break;
|
||||
default:
|
||||
$this->warn("Unsupported opcode: $opcode");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$meta = stream_get_meta_data($socket);
|
||||
if ($meta['unread_bytes'] > 0) {
|
||||
$this->warn("{$meta['unread_bytes']} bytes left in socket after read");
|
||||
}
|
||||
|
||||
return $payload_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send text frame to client. If the socket connection is not a valid resource, the send
|
||||
* method will fail silently and return false.
|
||||
* @param resource $resource The socket or resource id to communicate on.
|
||||
* @param string|null $payload The message to send to the clients. Sending null as the message sends a close frame packet.
|
||||
* @return bool True if message was sent on the provided resource or false if there was an error.
|
||||
*/
|
||||
public static function send($resource, ?string $payload): bool {
|
||||
if (!is_resource($resource)) {
|
||||
throw new \socket_disconnected_exception($resource);
|
||||
}
|
||||
|
||||
// Check for a null message and send a disconnect frame
|
||||
if ($payload === null) {
|
||||
// 88 = CLOSE, 00 = NO REASON
|
||||
@fwrite($resource, chr(0x88) . chr(0x00));
|
||||
return true;
|
||||
}
|
||||
|
||||
$chunk_size = 4096; // 4 KB
|
||||
$payload_len = strlen($payload);
|
||||
$offset = 0;
|
||||
$first = true;
|
||||
|
||||
while ($offset < $payload_len) {
|
||||
$remaining = $payload_len - $offset;
|
||||
$chunk = substr($payload, $offset, min($chunk_size, $remaining));
|
||||
$chunk_len = strlen($chunk);
|
||||
|
||||
// Determine FIN bit and opcode
|
||||
$fin = ($offset + $chunk_size >= $payload_len) ? 0x80 : 0x00; // 0x80 if final
|
||||
$opcode = $first ? 0x1 : 0x0; // text for first frame, continuation for rest
|
||||
$first = false;
|
||||
|
||||
// Build header
|
||||
$header = chr($fin | $opcode);
|
||||
|
||||
// Payload length
|
||||
if ($chunk_len <= 125) {
|
||||
$header .= chr($chunk_len);
|
||||
} elseif ($chunk_len <= 65535) {
|
||||
$header .= chr(126) . pack('n', $chunk_len);
|
||||
} else {
|
||||
// 64-bit big-endian
|
||||
$length_bytes = '';
|
||||
for ($i = 7; $i >= 0; $i--) {
|
||||
$length_bytes .= chr(($chunk_len >> ($i * 8)) & 0xFF);
|
||||
}
|
||||
$header .= chr(127) . $length_bytes;
|
||||
}
|
||||
|
||||
// Send frame (header + chunk)
|
||||
$bytes_written = @fwrite($resource, $header . $chunk);
|
||||
if ($bytes_written === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$offset += $chunk_len;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IP and port of the connected remote system.
|
||||
* @param socket $socket The socket stream of the connection
|
||||
* @return array An associative array of remote_ip and remote_port
|
||||
*/
|
||||
public static function get_remote_info($socket): array {
|
||||
[$remote_ip, $remote_port] = explode(':', stream_socket_get_name($socket, true), 2);
|
||||
return ['remote_ip' => $remote_ip, 'remote_port' => $remote_port];
|
||||
}
|
||||
|
||||
/**
|
||||
* Print socket information
|
||||
* @param resource $resource
|
||||
* @param bool $return If you would like to capture the output of print_r(), use the return parameter. When this
|
||||
* parameter is set to true, print_r() will return the information rather than print it.
|
||||
*/
|
||||
public static function print_stream_info($resource, $return = false) {
|
||||
if (is_resource($resource)) {
|
||||
$meta_data = stream_get_meta_data($resource);
|
||||
[$remote_ip, $remote_port] = explode(':', stream_socket_get_name($resource, true), 2);
|
||||
$meta_data['remote_addr'] = $remote_ip;
|
||||
$meta_data['remote_port'] = $remote_port;
|
||||
|
||||
if ($return)
|
||||
return $meta_data;
|
||||
print_r($meta_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
917
core/websockets/resources/classes/websocket_service.php
Normal file
917
core/websockets/resources/classes/websocket_service.php
Normal file
@@ -0,0 +1,917 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of websocket_service
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class websocket_service extends service {
|
||||
|
||||
/**
|
||||
* Address to bind to. (Default 8080)
|
||||
* @var string
|
||||
*/
|
||||
protected $ip;
|
||||
|
||||
/**
|
||||
* Port to bind to. (Default 0.0.0.0 - all PHP detected IP addresses of the system)
|
||||
* @var int
|
||||
*/
|
||||
protected $port;
|
||||
|
||||
/**
|
||||
* Resource or stream of the server socket binding
|
||||
* @var resource|stream
|
||||
*/
|
||||
protected $server_socket;
|
||||
|
||||
/**
|
||||
* List of connected client sockets
|
||||
* @var array
|
||||
*/
|
||||
protected $clients;
|
||||
|
||||
/**
|
||||
* Used to track on_message events
|
||||
* @var array
|
||||
*/
|
||||
protected $message_callbacks;
|
||||
|
||||
/**
|
||||
* Used to track on_connect events
|
||||
* @var array
|
||||
*/
|
||||
protected $connect_callbacks;
|
||||
|
||||
/**
|
||||
* Used to track on_disconnect events
|
||||
* @var array
|
||||
*/
|
||||
protected $disconnect_callbacks;
|
||||
|
||||
/**
|
||||
* Used to track switch listeners or other socket connection types
|
||||
* @var array
|
||||
*/
|
||||
protected $listeners;
|
||||
public static $logger;
|
||||
|
||||
/**
|
||||
* Subscriber Objects
|
||||
* @var subscriber
|
||||
*/
|
||||
protected $subscribers;
|
||||
|
||||
/**
|
||||
* Array of registered services
|
||||
* @var array
|
||||
*/
|
||||
private $services;
|
||||
|
||||
public function is_debug_enabled(): bool {
|
||||
return parent::$log_level === LOG_DEBUG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload settings
|
||||
* @return void
|
||||
* @throws \RuntimeException
|
||||
* @access protected
|
||||
*/
|
||||
protected function reload_settings(): void {
|
||||
// Initialize tracking arrays
|
||||
$this->listeners = [];
|
||||
$this->clients = [];
|
||||
$this->message_callbacks = [];
|
||||
$this->connect_callbacks = [];
|
||||
$this->disconnect_callbacks = [];
|
||||
$this->subscribers = [];
|
||||
|
||||
$settings = new settings(['database' => database::new(['config' => config::load()])]);
|
||||
|
||||
$ip = $settings->get('websocket_server', 'bind_ip_address', '127.0.0.1');
|
||||
if ($ip === null) {
|
||||
throw new \RuntimeException("ERROR: Bind IP address not specified");
|
||||
}
|
||||
|
||||
// Save the setting
|
||||
$this->ip = $ip;
|
||||
|
||||
$port = intval($settings->get('websocket_server', 'bind_port', 8080));
|
||||
if (empty($port)) {
|
||||
throw new \RuntimeException("ERROR: Port address not specified");
|
||||
}
|
||||
|
||||
// Save the setting
|
||||
$this->port = $port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the version on the console
|
||||
* @return void
|
||||
* @access protected
|
||||
*/
|
||||
protected static function display_version(): void {
|
||||
echo "Web Socket Service Version 1.00\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set extra command options from the command line
|
||||
* @access protected
|
||||
*/
|
||||
protected static function set_command_options() {
|
||||
//TODO: ip address
|
||||
//TODO: port
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger disconnect callbacks
|
||||
*/
|
||||
protected function update_connected_clients() {
|
||||
$disconnected_clients = [];
|
||||
foreach ($this->clients as $index => $resource) {
|
||||
if (!is_resource($resource) || feof($resource)) {
|
||||
// Ensure resource is free
|
||||
unset($this->clients[$index]);
|
||||
$disconnected_clients[] = $resource;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($disconnected_clients)) {
|
||||
foreach ($disconnected_clients as $dis_con) {
|
||||
$this->trigger_disconnect($dis_con);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function get_subscriber_from_socket_id($socket): ?subscriber {
|
||||
$subscriber = null;
|
||||
// Get the subscriber based on their socket ID
|
||||
foreach ($this->subscribers as $s) {
|
||||
if ($s->equals($socket)) {
|
||||
$subscriber = $s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $subscriber;
|
||||
}
|
||||
|
||||
private function authenticate_subscriber(subscriber $subscriber, websocket_message $message) {
|
||||
$this->info("Authenticating client: $subscriber->id");
|
||||
|
||||
// Already authenticated
|
||||
if ($subscriber->is_authenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Authenticate their token
|
||||
if ($subscriber->authenticate_token($message->token)) {
|
||||
$subscriber->send(websocket_message::request_authenticated($message->request_id, $message->service));
|
||||
if ($subscriber->is_service()) {
|
||||
$this->info("Service $subscriber->id authenticated");
|
||||
$this->services[$subscriber->service_name()] = $subscriber;
|
||||
} else {
|
||||
$this->info("Client $subscriber->id authenticated");
|
||||
$this->info("Setting permissions on $subscriber->id");
|
||||
$subscriptions = $subscriber->subscribed_to();
|
||||
foreach ($subscriber->subscribed_to() as $subscribed_to) {
|
||||
if (isset($this->services[$subscribed_to])) {
|
||||
$service = $this->services[$subscribed_to];
|
||||
if (is_a($service, 'websocket_service_interface', true)) {
|
||||
$class = $service->get_service_name();
|
||||
$filter = $class::create_filter_chain_for($subscriber);
|
||||
if ($filter !== null) {
|
||||
$subscriber->set_filter($filter);
|
||||
}
|
||||
}
|
||||
$this->info("Set permissions for $subscriber->id for service " . $service->service_name());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$subscriber->send(websocket_message::request_unauthorized($message->request_id, $message->service));
|
||||
// Disconnect them
|
||||
$this->handle_disconnect($subscriber->socket_id());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
private function broadcast_service_message(subscriber $broadcaster, ?websocket_message $message = null) {
|
||||
|
||||
$this->debug("Processing Broadcast");
|
||||
|
||||
// Ensure we have something to do
|
||||
if ($message === null) {
|
||||
$this->warn("Unable to broadcast empty message");
|
||||
return;
|
||||
}
|
||||
|
||||
$subscribers = array_filter($this->subscribers, function ($subscriber) use ($broadcaster) {
|
||||
return $subscriber->not_equals($broadcaster);
|
||||
});
|
||||
|
||||
if (empty($subscribers)) {
|
||||
$this->debug("No subscribers to broadcast message to");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the service is not responding to a specific request
|
||||
$request_id = $message->request_id;
|
||||
if (empty($request_id)) {
|
||||
|
||||
// Get the service name from the message
|
||||
$service_name = $message->service_name;
|
||||
|
||||
// Filter subscribers to only the ones subscribed to the service name
|
||||
$send_to = $this->filter_subscribers($subscribers, $message, $service_name);
|
||||
|
||||
// Send the message to the filtered subscribers
|
||||
foreach ($send_to as $subscriber) {
|
||||
try {
|
||||
// Notify of the message we are broadcasting
|
||||
$this->debug("Broadcasting message '" . $message->payload['event_name'] . "' for service '" . $message->service_name . "' to subscriber $subscriber->id");
|
||||
$message->apply_filter($subscriber->get_filter());
|
||||
$subscriber->send_message($message);
|
||||
} catch (subscriber_token_expired_exception $ste) {
|
||||
$this->info("Subscriber $ste->id token expired");
|
||||
// Subscriber token has expired so disconnect them
|
||||
$this->handle_disconnect($subscriber->socket_id());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Route a specific request from a service back to a subscriber
|
||||
else {
|
||||
// Get the subscriber object hash
|
||||
$object_id = $message->resource_id;
|
||||
if (isset($this->subscribers[$object_id])) {
|
||||
$subscriber = $this->subscribers[$object_id];
|
||||
// Remove the resource_id from the message
|
||||
$message->resource_id('');
|
||||
// TODO: Fix removal of request_id
|
||||
$message->request_id('');
|
||||
// Return the requested results back to the subscriber
|
||||
$subscriber->send_message($message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters subscribers based on the service name given
|
||||
* @param array $subscribers
|
||||
* @param websocket_message $message
|
||||
* @param string $service_name
|
||||
* @return array List of subscriber objects or an empty array if there are no subscribers to that service name
|
||||
*/
|
||||
private function filter_subscribers(array $subscribers, websocket_message $message, string $service_name): array {
|
||||
$filtered = [];
|
||||
|
||||
foreach ($subscribers as $subscriber) {
|
||||
$caller_context = strtolower($message->caller_context ?? '');
|
||||
if (!empty($caller_context) && $subscriber->has_subscribed_to($service_name) && ($subscriber->show_all || $caller_context === $subscriber->domain_name || $caller_context === 'public' || $caller_context === 'default'
|
||||
)
|
||||
) {
|
||||
$filtered[] = $subscriber;
|
||||
} else {
|
||||
if ($subscriber->has_subscribed_to($service_name))
|
||||
$filtered[] = $subscriber;
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subscriber for each connection
|
||||
* @param resource $socket
|
||||
* @return void
|
||||
*/
|
||||
private function handle_connect($socket) {
|
||||
// We catch only the socket disconnection exception as there is a general try/catch already
|
||||
try {
|
||||
$subscriber = new subscriber($socket, [websocket_service::class, 'send']);
|
||||
$this->subscribers[$subscriber->id] = $subscriber;
|
||||
$subscriber->send(websocket_message::connected());
|
||||
} catch (\socket_disconnected_exception $sde) {
|
||||
$this->warning("Client $sde->id disconnected during connection");
|
||||
// remove the connected client
|
||||
$this->handle_disconnect($sde->id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web socket client disconnected from the server or this service has requested a disconnect from the subscriber
|
||||
* @param subscriber|resource|int|string $object_or_resource_or_id
|
||||
*/
|
||||
private function handle_disconnect($object_or_resource_or_id) {
|
||||
//
|
||||
// Notify user
|
||||
//
|
||||
$this->info("Disconnecting subscriber: '$object_or_resource_or_id'");
|
||||
|
||||
//
|
||||
// Search for the socket using the equals method in subscriber
|
||||
//
|
||||
$subscriber = null;
|
||||
|
||||
/* PHP 8 syntax: $subscriber = array_find($this->subscribers, fn ($subscriber) => $subscriber->equals($socket_id)); */
|
||||
|
||||
// Find the subscriber in our array
|
||||
foreach ($this->subscribers as $s) {
|
||||
if ($s->equals($object_or_resource_or_id)) {
|
||||
$subscriber = $s;
|
||||
}
|
||||
}
|
||||
|
||||
// We have found our subscriber to be disconnected
|
||||
if ($subscriber !== null) {
|
||||
// If they are still connected then disconnect them with the proper disconnect
|
||||
if ($subscriber->is_connected()) {
|
||||
$subscriber->disconnect();
|
||||
}
|
||||
|
||||
// remove from the subscribers list
|
||||
unset($this->subscribers[$subscriber->id]);
|
||||
|
||||
// remove from services
|
||||
unset($this->services[$subscriber->service_name()]);
|
||||
|
||||
// notify user
|
||||
$this->info("Disconnected subscriber: '$subscriber->id'");
|
||||
}
|
||||
|
||||
// show the list for debugging
|
||||
$this->debug("Current Subscribers: " . implode(', ', array_keys($this->subscribers)));
|
||||
}
|
||||
|
||||
/**
|
||||
* When a message event occurs, send to all the subscribers
|
||||
* @param resource $socket
|
||||
* @param mixed $data
|
||||
*/
|
||||
private function handle_message($socket, $data) {
|
||||
$subscriber = $this->get_subscriber_from_socket_id($socket);
|
||||
|
||||
// Ensure we have someone to talk to
|
||||
if ($subscriber === null)
|
||||
return;
|
||||
|
||||
$this->debug("Received message from " . $subscriber->id);
|
||||
|
||||
// Convert the message from json string to a message array
|
||||
$json_array = json_decode($data, true);
|
||||
|
||||
if (is_array($json_array))
|
||||
try {
|
||||
|
||||
// Check for an authenticating subscriber
|
||||
if ($json_array['service'] === 'authentication') {
|
||||
$this->authenticate_subscriber($subscriber, new websocket_message($json_array));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a websocket_message object using the json data sent
|
||||
$message = websocket_message::create_from_json_message($json_array);
|
||||
|
||||
if ($message === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject subscribers that do not have not validated
|
||||
if (!$subscriber->is_authenticated()) {
|
||||
$subscriber->send(websocket_message::request_authentication($message->request_id()));
|
||||
return;
|
||||
}
|
||||
|
||||
// If the message comes from a service, broadcast it to all subscribers subscribed to that service
|
||||
if ($subscriber->is_service()) {
|
||||
$this->debug("Message is from service");
|
||||
$this->broadcast_service_message($subscriber, $message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Message is from the client so check the service_name that needs to get the message
|
||||
if (!empty($message->service_name())) {
|
||||
$this->handle_client_message($subscriber, $message);
|
||||
} else {
|
||||
// Message does not have a service name
|
||||
$this->warning("The message does not have a service name. All messages must have a service name to direct their query to.");
|
||||
$subscriber->send(websocket_message::request_is_bad($message->id, 'INVALID', $message->topic));
|
||||
}
|
||||
} catch (socket_disconnected_exception $sde) {
|
||||
$this->handle_disconnect($sde->id);
|
||||
}
|
||||
}
|
||||
|
||||
private function handle_client_message(subscriber $subscriber, websocket_message $message) {
|
||||
//find the service with that name
|
||||
foreach ($this->subscribers as $service) {
|
||||
//when we find the service send the request
|
||||
if ($service->service_equals($message->service_name())) {
|
||||
//attach the current subscriber permissions so the service can verify
|
||||
$message->permissions($subscriber->get_permissions());
|
||||
|
||||
//attach the domain name
|
||||
$message->domain_name($subscriber->get_domain_name());
|
||||
|
||||
//attach the client id so we can track the request
|
||||
$message->resource_id = $subscriber->id;
|
||||
|
||||
//send the modified web socket message to the service
|
||||
$service->send((string) $message);
|
||||
//continue searching for service providers
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the web socket server binding to the ip and port set in default settings
|
||||
* The run method will stop if the SIG_TERM or SIG_HUP signal is processed in the parent
|
||||
* @return int
|
||||
* @throws \RuntimeException
|
||||
* @throws socket_exception
|
||||
*/
|
||||
public function run(): int {
|
||||
// Reload all settings and initialize object properties
|
||||
$this->reload_settings();
|
||||
|
||||
$this->server_socket = stream_socket_server("tcp://{$this->ip}:{$this->port}", $errno, $errstr);
|
||||
if (!$this->server_socket) {
|
||||
throw new \RuntimeException("Cannot bind socket ({$errno}): {$errstr}");
|
||||
}
|
||||
stream_set_blocking($this->server_socket, false);
|
||||
|
||||
//
|
||||
// Register handlers
|
||||
// The handlers can be registered outside this class because they are standard callbacks
|
||||
//
|
||||
$this->on_connect([self::class, 'handle_connect']);
|
||||
$this->on_disconnect([self::class, 'handle_disconnect']);
|
||||
$this->on_message([self::class, 'handle_message']);
|
||||
|
||||
$stream_select_tries = 0;
|
||||
|
||||
while ($this->running) {
|
||||
|
||||
//
|
||||
// Merge all sockets to a single array
|
||||
//
|
||||
$read = array_merge([$this->server_socket], $this->clients);
|
||||
$write = $except = [];
|
||||
|
||||
//$this->debug("Waiting on event. Connected Clients: (".count($this->clients).")", LOG_DEBUG);
|
||||
//
|
||||
// Wait for activity on the sockets and timeout about 3 times per second
|
||||
//
|
||||
$result = stream_select($read, $write, $except, 0, 333333);
|
||||
if ($result === false) {
|
||||
// Check for error status 3 times in a row
|
||||
if (++$stream_select_tries > 3) {
|
||||
throw new \RuntimeException("Error occured reading socket");
|
||||
}
|
||||
// There was likely a disconnect during the wait state
|
||||
$this->update_connected_clients();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reset stream_select counter
|
||||
$stream_select_tries = 0;
|
||||
|
||||
if ($result === 0) {
|
||||
// Timeout no activity
|
||||
continue;
|
||||
}
|
||||
|
||||
//
|
||||
// Handle a socket activity
|
||||
//
|
||||
foreach ($read as $client_socket) {
|
||||
// new connection
|
||||
if ($client_socket === $this->server_socket) {
|
||||
$conn = @stream_socket_accept($this->server_socket, 0);
|
||||
if ($conn) {
|
||||
// complete handshake on blocking socket
|
||||
stream_set_blocking($conn, true);
|
||||
$this->handshake($conn);
|
||||
// switch to non-blocking for further reads
|
||||
stream_set_blocking($conn, false);
|
||||
// add them to the websocket list
|
||||
$this->clients[] = $conn;
|
||||
// notify websocket on_connect listeners
|
||||
$this->trigger_connect($conn);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Process web socket client communication
|
||||
$message = $this->receive_frame($client_socket);
|
||||
if ($message === '') {
|
||||
$this->debug("Empty message");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for control frame
|
||||
if (strlen($message) === 2) {
|
||||
$value = bin2hex($message);
|
||||
if ($value === '03e9') {
|
||||
$this->disconnect_client($client_socket);
|
||||
continue;
|
||||
}
|
||||
$this->debug("UNKNOWN CONTROL FRAME: '$value'", LOG_ERR);
|
||||
die();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->trigger_message($client_socket, $message);
|
||||
} catch (subscriber_exception $se) {
|
||||
//
|
||||
// Here we are catching any type of subscriber exception and displaying the error in the log.
|
||||
// This will disconnect the subscriber as we no longer know the state of the object.
|
||||
//
|
||||
|
||||
//
|
||||
// Get the error details
|
||||
//
|
||||
$subscriber_id = $se->getSubscriberId();
|
||||
$message = $se->getMessage();
|
||||
$code = $se->getCode();
|
||||
$file = $se->getFile();
|
||||
$line = $se->getLine();
|
||||
|
||||
//
|
||||
// Dump the details in the log
|
||||
//
|
||||
$this->err("ERROR FROM $subscriber_id: $message ($code) IN FILE $file (Line: $line)");
|
||||
$this->err($se->getTraceAsString());
|
||||
//
|
||||
// Disconnect the subscriber
|
||||
//
|
||||
$subscriber = $this->subscribers[$subscriber_id] ?? null;
|
||||
if ($subscriber !== null) $this->disconnect_client($subscriber->socket());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the parent class to shutdown all sockets
|
||||
* @override service
|
||||
*/
|
||||
public function __destruct() {
|
||||
//disconnect all clients
|
||||
foreach ($this->clients as $socket) {
|
||||
$this->disconnect_client($socket);
|
||||
}
|
||||
//finish destruct using the parent
|
||||
parent::__destruct();
|
||||
}
|
||||
|
||||
public function get_open_sockets(): array {
|
||||
return $this->clients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there are connected web socket clients.
|
||||
* @return bool
|
||||
*/
|
||||
public function has_clients(): bool {
|
||||
return !empty($this->clients);
|
||||
}
|
||||
|
||||
/**
|
||||
* When a web socket message is received the $on_message_callback function is called.
|
||||
* Multiple on_message functions can be specified.
|
||||
* @param callable $on_message_callback
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function on_message(callable $on_message_callback) {
|
||||
if (!is_callable($on_message_callback)) {
|
||||
throw new \InvalidArgumentException('The callable on_message_callback must be a valid callable function');
|
||||
}
|
||||
$this->message_callbacks[] = $on_message_callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls all the on_message functions
|
||||
* @param resource $resource
|
||||
* @param string $message
|
||||
* @return void
|
||||
* @access protected
|
||||
*/
|
||||
protected function trigger_message($resource, string $message) {
|
||||
foreach ($this->message_callbacks as $callback) {
|
||||
call_user_func($callback, $resource, $message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a web socket handshake has completed, the $on_connect_callback function is called.
|
||||
* Multiple on_connect functions can be specified.
|
||||
* @param callable $on_connect_callback
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function on_connect(callable $on_connect_callback) {
|
||||
if (!is_callable($on_connect_callback)) {
|
||||
throw new \InvalidArgumentException('The callable on_connect_callback must be a valid callable function');
|
||||
}
|
||||
$this->connect_callbacks[] = $on_connect_callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls all the on_connect functions
|
||||
* @param resource $resource
|
||||
* @access protected
|
||||
*/
|
||||
protected function trigger_connect($resource) {
|
||||
foreach ($this->connect_callbacks as $callback) {
|
||||
call_user_func($callback, $resource);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a web socket has disconnected, the $on_disconnect_callback function is called.
|
||||
* Multiple functions can be specified with subsequent calls
|
||||
* @param string|callable $on_disconnect_callback
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function on_disconnect($on_disconnect_callback) {
|
||||
if (!is_callable($on_disconnect_callback)) {
|
||||
throw new \InvalidArgumentException('The callable on_disconnect_callback must be a valid callable function');
|
||||
}
|
||||
$this->disconnect_callbacks[] = $on_disconnect_callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls all the on_disconnect_callback functions
|
||||
* @param resource $socket
|
||||
* @access protected
|
||||
*/
|
||||
protected function trigger_disconnect($socket) {
|
||||
foreach ($this->disconnect_callbacks as $callback) {
|
||||
call_user_func($callback, $socket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the socket used in the server connection
|
||||
* @return resource
|
||||
*/
|
||||
public function get_socket() {
|
||||
return $this->server_socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a client socket on disconnect.
|
||||
* @param resource $resource Resource for the socket connection
|
||||
* @return bool Returns true on client disconnect and false when the client is not found in the tracking array
|
||||
* @access protected
|
||||
*/
|
||||
protected function disconnect_client($resource): bool {
|
||||
// Close the socket
|
||||
if (is_resource($resource)) {
|
||||
@fwrite($resource, chr(0x88) . chr(0x00)); // 0x88 = close frame, no reason
|
||||
@fclose($resource);
|
||||
}
|
||||
|
||||
$this->debug("OLD Client List: " . var_dump($this->clients, true));
|
||||
|
||||
// Clean out the array
|
||||
$clients = array_filter($this->clients, function ($resource) {
|
||||
return is_resource($resource) && !feof($resource);
|
||||
});
|
||||
|
||||
$this->debug("NEW Client List: " . var_dump($clients, true));
|
||||
|
||||
// Compare to the original array
|
||||
$diff = array_diff($this->clients, $clients);
|
||||
|
||||
$this->debug("DIFF Client List: " . var_dump($diff, true));
|
||||
|
||||
// Replace the old list with only the connected ones
|
||||
$this->clients = $clients;
|
||||
|
||||
// Trigger the disconnect for each closed socket
|
||||
foreach ($diff as $socket) {
|
||||
// We must check before closing the socket that it is a resource or a fatal error will occur
|
||||
if (is_resource($socket)) {
|
||||
@fwrite($resource, "\x88\x00"); // 0x88 = close frame, no payload
|
||||
@fclose($socket);
|
||||
}
|
||||
// Trigger the disconnect so any hooks can clean up their lists
|
||||
$this->trigger_disconnect($socket);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs web socket handshake on new connection.
|
||||
* @access protected
|
||||
*/
|
||||
protected function handshake($resource): void {
|
||||
// ensure blocking to read full header
|
||||
stream_set_blocking($resource, true);
|
||||
$request_header = '';
|
||||
while (($line = fgets($resource)) !== false) {
|
||||
$request_header .= $line;
|
||||
if (rtrim($line) === '') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $request_header, $matches)) {
|
||||
throw new \RuntimeException("Invalid WebSocket handshake");
|
||||
}
|
||||
$key = trim($matches[1]);
|
||||
$accept_key = base64_encode(
|
||||
sha1($key . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true)
|
||||
);
|
||||
$response_header = "HTTP/1.1 101 Switching Protocols\r\n"
|
||||
. "Upgrade: websocket\r\n"
|
||||
. "Connection: Upgrade\r\n"
|
||||
. "Sec-WebSocket-Accept: {$accept_key}\r\n\r\n";
|
||||
fwrite($resource, $response_header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read specific number of bytes from a websocket
|
||||
* @param resource $socket
|
||||
* @param int $length
|
||||
* @return string
|
||||
*/
|
||||
private function read_bytes($socket, int $length): string {
|
||||
$data = '';
|
||||
while (strlen($data) < $length && is_resource($socket)) {
|
||||
$chunk = fread($socket, $length - strlen($data));
|
||||
if ($chunk === false || $chunk === '' || !is_resource($socket)) {
|
||||
//$this->disconnect_client($socket);
|
||||
return '';
|
||||
}
|
||||
$data .= $chunk;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a websocket data frame and converts it to a regular string
|
||||
* @param resource $socket
|
||||
* @return string
|
||||
*/
|
||||
private function receive_frame($socket): string {
|
||||
// Read first two header bytes
|
||||
$hdr = $this->read_bytes($socket, 2);
|
||||
// Ensure we have the correct number of bytes
|
||||
if (strlen($hdr) !== 2) {
|
||||
$this->warning('Header is empty!');
|
||||
$this->update_connected_clients();
|
||||
return '';
|
||||
}
|
||||
$bytes = unpack('Cfirst/Csecond', $hdr);
|
||||
$fin = ($bytes['first'] >> 7) & 0x1;
|
||||
$opcode = $bytes['first'] & 0x0F;
|
||||
$masked = ($bytes['second'] >> 7) & 0x1;
|
||||
$length = $bytes['second'] & 0x7F;
|
||||
|
||||
// Determine actual payload length
|
||||
if ($length === 126) {
|
||||
$ext = $this->read_bytes($socket, 2);
|
||||
// Ensure we have the correct number of bytes
|
||||
if (strlen($ext) < 2)
|
||||
return '';
|
||||
$length = unpack('n', $ext)[1];
|
||||
} elseif ($length === 127) {
|
||||
$ext = $this->read_bytes($socket, 8);
|
||||
// Ensure we have the correct number of bytes
|
||||
if (strlen($ext) < 8)
|
||||
return '';
|
||||
// unpack 64-bit BE; PHP 7.0+: use J, else fallback
|
||||
$arr = unpack('J', $ext);
|
||||
$length = $arr[1];
|
||||
}
|
||||
|
||||
// Read mask key if client→server frame
|
||||
$maskKey = $masked ? $this->read_bytes($socket, 4) : '';
|
||||
|
||||
// Read payload data
|
||||
$data = $this->read_bytes($socket, $length);
|
||||
|
||||
if (empty($data)) {
|
||||
$this->warning("Received empty frame (ID# $socket)");
|
||||
return '';
|
||||
}
|
||||
|
||||
// Unmask if needed
|
||||
if ($masked) {
|
||||
// Ensure we have the correct number of bytes
|
||||
if (strlen($maskKey) < 4)
|
||||
return '';
|
||||
$unmasked = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$unmasked .= $data[$i] ^ $maskKey[$i % 4];
|
||||
}
|
||||
$data = $unmasked;
|
||||
}
|
||||
|
||||
// Return completed data frame
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function debug(string $message) {
|
||||
self::log($message, LOG_DEBUG);
|
||||
}
|
||||
|
||||
private function warning(string $message) {
|
||||
self::log($message, LOG_WARNING);
|
||||
}
|
||||
|
||||
private function err(string $message) {
|
||||
self::log($message, LOG_ERR);
|
||||
}
|
||||
|
||||
private function info(string $message) {
|
||||
self::log($message, LOG_INFO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send text frame to client. If the socket connection is not a valid resource, the send
|
||||
* method will fail silently and return false.
|
||||
* @param resource $resource The socket or resource id to communicate on.
|
||||
* @param string|null $payload The string to wrap in a web socket frame to send to the clients
|
||||
* @return bool
|
||||
*/
|
||||
public static function send($resource, ?string $payload): bool {
|
||||
if (!is_resource($resource)) {
|
||||
self::log("Cannot send: invalid resource", LOG_ERR);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($payload === null) {
|
||||
@fwrite($resource, "\x88\x00"); // 0x88 = close frame, no payload
|
||||
return true;
|
||||
}
|
||||
|
||||
$payload_length = strlen($payload);
|
||||
$frame_header = "\x81"; // FIN = 1, text frame
|
||||
// Create frame header
|
||||
if ($payload_length <= 125) {
|
||||
$frame_header .= chr($payload_length);
|
||||
} elseif ($payload_length <= 65535) {
|
||||
$frame_header .= chr(126) . pack('n', $payload_length);
|
||||
} else {
|
||||
$frame_header .= chr(127) . pack('J', $payload_length); // PHP 7.1+ supports 'J' for 64-bit unsigned
|
||||
}
|
||||
|
||||
$frame = $frame_header . $payload;
|
||||
|
||||
// Attempt to write full frame
|
||||
$written = @fwrite($resource, $frame);
|
||||
if ($written === false) {
|
||||
self::log("fwrite() failed for socket " . (int) $resource, LOG_ERR);
|
||||
throw new socket_disconnected_exception($resource);
|
||||
}
|
||||
|
||||
if ($written < strlen($frame)) {
|
||||
self::log("Partial frame sent: {$written}/" . strlen($frame) . " bytes", LOG_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IP and port of the connected remote system.
|
||||
* @param resource $resource The resource or stream of the connection
|
||||
* @return array An associative array of remote_ip and remote_port
|
||||
*/
|
||||
public static function get_remote_info($resource): array {
|
||||
[$remote_ip, $remote_port] = explode(':', stream_socket_get_name($resource, true), 2);
|
||||
return ['remote_ip' => $remote_ip, 'remote_port' => $remote_port];
|
||||
}
|
||||
}
|
||||
18
core/websockets/resources/service/debian-websockets.service
Normal file
18
core/websockets/resources/service/debian-websockets.service
Normal file
@@ -0,0 +1,18 @@
|
||||
#
|
||||
# Install with:
|
||||
#
|
||||
# cp debian-websockets.service /etc/systemd/system/websockets.service
|
||||
# systemctl daemon-reload
|
||||
# systemctl enable --now websockets.service
|
||||
#
|
||||
[Unit]
|
||||
Description=Websocket Router Service
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/php /var/www/fusionpbx/core/websockets/resources/service/websockets.php --no-fork
|
||||
Restart=on-failure
|
||||
User=www-data
|
||||
Group=www-data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
81
core/websockets/resources/service/websockets.php
Executable file
81
core/websockets/resources/service/websockets.php
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
|
||||
die("This script requires PHP 7.1.0 or higher. You are running " . PHP_VERSION . "\n");
|
||||
}
|
||||
|
||||
//
|
||||
// Only run from the command line
|
||||
//
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
die('This script can only be run from the command line.');
|
||||
}
|
||||
|
||||
//
|
||||
// Get the framework files
|
||||
//
|
||||
require_once dirname(__DIR__, 4) . '/resources/require.php';
|
||||
|
||||
try {
|
||||
|
||||
//
|
||||
// Create a web socket service
|
||||
//
|
||||
$ws_server = websocket_service::create();
|
||||
|
||||
//
|
||||
// Exit with status code given by run return value
|
||||
//
|
||||
exit($ws_server->run());
|
||||
} catch (Throwable $ex) {
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
// Here we catch all exceptions and log the error //
|
||||
////////////////////////////////////////////////////
|
||||
//
|
||||
// Get the error details
|
||||
//
|
||||
$message = $ex->getMessage();
|
||||
$code = $ex->getCode();
|
||||
$file = $ex->getFile();
|
||||
$line = $ex->getLine();
|
||||
|
||||
//
|
||||
// Show user the details
|
||||
//
|
||||
echo "FATAL ERROR: '$message' (ERROR CODE: $code) FROM $file (Line: $line)\n";
|
||||
echo $ex->getTraceAsString() . "\n";
|
||||
|
||||
//
|
||||
// Exit with non-zero status code
|
||||
//
|
||||
exit($ex->getCode());
|
||||
}
|
||||
110
resources/classes/filter_chain.php
Normal file
110
resources/classes/filter_chain.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds an event filter chain link of any class implementing an event_filter interface
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
final class filter_chain {
|
||||
|
||||
/**
|
||||
* Builds a filter chain link for filter objects
|
||||
* @param array $filters Array of filter objects
|
||||
* @return filter
|
||||
*/
|
||||
public static function or_link(array $filters): filter {
|
||||
|
||||
// Create an anonymous object to end the filter
|
||||
$final = new class implements filter {
|
||||
|
||||
public function __invoke(string $key, $value): bool {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Add the final object
|
||||
$chain = $final;
|
||||
|
||||
// Iterate over the objects to add them in reverse order
|
||||
for ($i = count($filters) - 1; $i >= 0; $i--) {
|
||||
$current = $filters[$i];
|
||||
|
||||
// Remember the chain that will be called next
|
||||
$next = $chain;
|
||||
|
||||
// Create an anonymous object to start the filter
|
||||
$chain = new class($current, $next) implements filter {
|
||||
|
||||
private $current;
|
||||
private $next;
|
||||
|
||||
public function __construct(filter $current, filter $next) {
|
||||
$this->current = $current;
|
||||
$this->next = $next;
|
||||
}
|
||||
|
||||
public function __invoke(string $key, $value): ?bool {
|
||||
if (($this->current)($key, $value)) {
|
||||
// Any filter passed so return true
|
||||
return true;
|
||||
}
|
||||
// Filter did not pass so we check the next one
|
||||
return ($this->next)($key, $value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Return the completed filter chain
|
||||
return $chain;
|
||||
}
|
||||
|
||||
public static function and_link(array $filters): filter {
|
||||
return new class($filters) implements filter {
|
||||
private $filters;
|
||||
|
||||
public function __construct(array $filters) {
|
||||
$this->filters = $filters;
|
||||
}
|
||||
|
||||
public function __invoke(string $key, $value): ?bool {
|
||||
foreach ($this->filters as $filter) {
|
||||
$result = ($filter)($key, $value);
|
||||
// Check if a filter requires a null to be returned
|
||||
if ($result === null) {
|
||||
return null;
|
||||
} elseif(!$result) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// All filters passed so return true
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
38
resources/classes/invalid_uuid_exception.php
Normal file
38
resources/classes/invalid_uuid_exception.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of invalid_uuid
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
class invalid_uuid_exception extends Exception {
|
||||
public function __construct(string $message = "UUID is not valid", int $code = 0, ?\Throwable $previous = null): \Exception {
|
||||
return parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,29 @@ abstract class service {
|
||||
}
|
||||
}
|
||||
|
||||
private static function log_level_to_string(int $level = LOG_NOTICE): string {
|
||||
switch ($level){
|
||||
case 0:
|
||||
return 'EMERGENCY';
|
||||
case 1:
|
||||
return 'ALERT';
|
||||
case 2:
|
||||
return 'CRITICAL';
|
||||
case 3:
|
||||
return 'ERROR';
|
||||
case 4:
|
||||
return 'WARNING';
|
||||
case 5:
|
||||
return 'NOTICE';
|
||||
case 6:
|
||||
return 'INFO';
|
||||
case 7:
|
||||
return 'DEBUG';
|
||||
default:
|
||||
return 'INFO';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show memory usage to the user
|
||||
*/
|
||||
@@ -416,7 +439,8 @@ abstract class service {
|
||||
|
||||
//enable sending message to the console directly
|
||||
if (self::$log_level === LOG_DEBUG || !self::$forking_enabled) {
|
||||
echo $message . "\n";
|
||||
$time = date('Y-m-d H:i:s');
|
||||
echo "[$time] [" . self::log_level_to_string($level) . "] " . $message . "\n";
|
||||
}
|
||||
|
||||
// Log the message to syslog
|
||||
|
||||
45
resources/interfaces/filter.php
Normal file
45
resources/interfaces/filter.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
interface filter {
|
||||
/**
|
||||
* Uses the __invoke magic method to create a filter
|
||||
* There are three possible return values for a filter:
|
||||
* 1. True - Passed
|
||||
* 2. False - Failed
|
||||
* 3. null - Invalid
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @return bool|null
|
||||
*/
|
||||
public function __invoke(string $key, $value): ?bool;
|
||||
}
|
||||
35
resources/interfaces/filterable_payload.php
Normal file
35
resources/interfaces/filterable_payload.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
interface filterable_payload {
|
||||
public function apply_filter(filter $filter);
|
||||
}
|
||||
36
resources/interfaces/websocket_service_interface.php
Normal file
36
resources/interfaces/websocket_service_interface.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* FusionPBX
|
||||
* Version: MPL 1.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is FusionPBX
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Portions created by the Initial Developer are Copyright (C) 2008-2025
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mark J Crane <markjcrane@fusionpbx.com>
|
||||
* Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Tim Fry <tim@fusionpbx.com>
|
||||
*/
|
||||
interface websocket_service_interface {
|
||||
public static function create_filter_chain_for(subscriber $subscriber): ?filter;
|
||||
public static function get_service_name(): string;
|
||||
}
|
||||
Reference in New Issue
Block a user