diff --git a/freepbx/Containerfile b/freepbx/Containerfile
index 6acf2ac75..6e1873905 100644
--- a/freepbx/Containerfile
+++ b/freepbx/Containerfile
@@ -491,7 +491,7 @@ COPY var/www/html/freepbx/admin/brand/* /var/www/html/freepbx/admin/brand/
RUN ln -sf /var/lib/asterisk/bin/fwconsole /usr/bin/fwconsole
-RUN pip install mysql.connector asterisk-ami configparser
+RUN pip install mysql.connector configparser
# Use PHP development ini configuration and enable logging on syslog
diff --git a/freepbx/usr/sbin/recallonbusy b/freepbx/usr/sbin/recallonbusy
index 5fd1b1ad1..98560ed8d 100755
--- a/freepbx/usr/sbin/recallonbusy
+++ b/freepbx/usr/sbin/recallonbusy
@@ -1,179 +1,210 @@
#!/usr/bin/python3
-#
-# Copyright (C) 2021 Nethesis S.r.l.
-# http://www.nethesis.it - nethserver@nethesis.it
-#
-# This script is part of NethVoice.
-#
-# NethVoice is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License,
-# or any later version.
-#
-# NethVoice is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with NethVoice. If not, see COPYING.
-#
-
-import syslog
+"""Asterisk AMI client to recall on busy extensions."""
+
+"""
+ Copyright (C) 2024 Nethesis S.r.l.
+ SPDX-License-Identifier: GPL-3.0-or-later
+"""
+
+
+
+import socket
import sys
-import time
-from asterisk.ami import AMIClient,SimpleAction,AutoReconnect
+import threading
import configparser
-#import threading
-import json
+import os
+import time
import re
-DEFAULT_CONFIG = {
- 'Host': 'localhost',
- 'Port': 5038,
- 'Pidfile' : '/run/recallonbusy.pid',
- 'Username': 'recallonbusy',
- 'Secret': '',
- 'Debug' : True,
- 'CheckInterval': 60,
-}
-
-# read config file
-config = configparser.ConfigParser(DEFAULT_CONFIG)
-config.read('/etc/asterisk/recallonbusy.cfg')
-CONFIG = {}
-
-for key,value in DEFAULT_CONFIG.items():
- try:
- if key in ['Debug']:
- CONFIG[key] = config.getboolean('recallonbusy',key)
- elif key in ['Port','CheckInterval']:
- CONFIG[key] = config.getint('recallonbusy',key)
- else:
- CONFIG[key] = config.get('recallonbusy',key)
- except:
- CONFIG[key] = DEFAULT_CONFIG[key]
-
-def log_debug(msg):
- global CONFIG
- if CONFIG['Debug'] == True:
- syslog.syslog(syslog.LOG_INFO,'recallonbusy: %s' % msg)
-
-def get_extension_state(extension):
- global device_state_map
- # get all devices
- if re.match(r'9[0-9][0-9]{2,}$',extension):
- mainextension = re.sub(r'^9[0-9]([0-9]{2,})$', r'\1', extension)
- else:
- mainextension = extension
-
- extensions_states = {}
- for ext,state in device_state_map.items():
- if ext == mainextension or re.match(r'9[0-9]'+mainextension+'$',ext):
- extensions_states[ext] = state
-
- res_state = 'UNKNOWN'
- for ext,state in extensions_states.items():
- if state == 'INUSE' or state == 'RINGING':
- res_state = state
- break;
- elif state == 'NOT_INUSE' and (res_state == 'UNKNOWN' or res_state == 'UNAVAILABLE'):
- res_state = state
-
- log_debug('Final extension '+ extension + ' state: ' + res_state + ' ' + json.dumps(extensions_states))
- return res_state
-
-
-# This is called every time AMI event is emitted
-def event_listener(source, event):
- global client
- global device_state_map
- if event.name == 'DeviceStateChange':
- if re.match(r'PJSIP/([0-9]*)$', str(event['Device'])):
- if 'State' in event:
- extension = re.sub(r'PJSIP/([0-9]*)$', r'\1', str(event['Device']))
- log_debug('DeviceStateChange '+ extension + ' -> '+ event['State'])
- mainextension = re.sub(r'^9[0-9]([0-9]{2,})$', r'\1', extension)
- device_state_map[extension] = event['State']
- if get_extension_state(extension) == 'NOT_INUSE':
- # Ask for waiting ROB for this extension
- timeid = time.time()
- actionid='dbget'+str(timeid)
- action = SimpleAction(
- 'DBGet',
- ActionID=actionid,
- Family='ROB',
- Key=mainextension
- )
- client.send_action(action)
- elif event.name == 'DBGetResponse':
- if 'dbget' in event['ActionID'] and event['Val'] != '':
- waiting_extensions = event['Val'].split('&')
- ext_to_call = event['Key']
- log_debug('ext_to_call: '+ext_to_call+ ' waiting_extensions: '+ str(waiting_extensions))
- if get_extension_state(ext_to_call) == 'NOT_INUSE':
- for waiting_extension in waiting_extensions:
- if get_extension_state(waiting_extension) == 'NOT_INUSE':
- log_debug('waiting_extension '+ waiting_extension + 'is NOT_INUSE, generating the call...')
- # launch call
- action = SimpleAction(
- 'Originate',
- Channel='Local/'+waiting_extension+'@from-internal',
- Timeout=150000,
- CallerID=ext_to_call,
- Context='from-internal',
- Priority=1,
- Exten=ext_to_call
- )
- client.send_action(action)
- waiting_extensions.remove(waiting_extension)
- break
- new_waiting_extensions_string = '&'.join(waiting_extensions)
- # Write new astdb string
- timeid = time.time()
- actionid='dbput'+str(timeid)
- action = SimpleAction(
- 'DBPut',
- ActionID=actionid,
- Family='ROB',
- Key=ext_to_call,
- Val=new_waiting_extensions_string
- )
- client.send_action(action)
-
- else :
- log_debug('Unknow event: '+ str(event))
-
-def ami_client_connect_and_login(address,port,username,secret):
- global client
- try:
- client = AMIClient(address=CONFIG['Host'],port=CONFIG['Port'])
- #AutoReconnect(client)
- client.login(username=CONFIG['Username'],secret=CONFIG['Secret'])
- client.add_event_listener(event_listener, white_list=['DeviceStateChange','DBGetResponse'])
- log_debug('AMI client connected')
- return True
- except Exception as err:
- syslog.syslog(syslog.LOG_ERR,'AMI client ERROR: %s' % str(err))
- return False
-
-device_state_map = {}
-
-connected = False
-
-#MAIN LOOP
-while True:
- try:
- if not connected:
- connected = ami_client_connect_and_login(address=CONFIG['Host'],port=CONFIG['Port'],username=CONFIG['Username'],secret=CONFIG['Secret'])
- if connected:
- # Ask for device state map updates
- client.send_action(SimpleAction('DeviceStateList'))
-
- except Exception as e:
- syslog.syslog(syslog.LOG_ERR,"Error: " + str(e))
- connected = False
-
- time.sleep(CONFIG['CheckInterval'])
-
+class AMIClient:
+ def __init__(self, config_path='/etc/asterisk/recallonbusy.cfg'):
+ self.config_path = config_path
+ self._load_config()
+ self.sock = None
+ self.buffer = ''
+ self.event_listeners = []
+ self.lock = threading.Lock()
+ self.debug = self.config.getboolean('recallonbusy', 'Debug', fallback=False)
+ self.check_interval = self.config.getint('recallonbusy', 'CheckInterval', fallback=20)
+ self.actions = {}
+
+ def _load_config(self):
+ """Load configuration from the specified file."""
+ self.config = configparser.ConfigParser()
+ if not os.path.exists(self.config_path):
+ raise FileNotFoundError(f"Configuration file not found: {self.config_path}")
+ self.config.read(self.config_path)
+ self.host = self.config.get('recallonbusy', 'Host', fallback='localhost')
+ self.port = self.config.getint('recallonbusy', 'Port', fallback=5038)
+ self.username = self.config.get('recallonbusy', 'Username', fallback='')
+ self.secret = self.config.get('recallonbusy', 'Secret', fallback='')
+
+ def connect(self):
+ """Establish a connection to the AMI."""
+ print(f"[recallonbusy] Connecting to {self.host}:{self.port}")
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.sock.connect((self.host, self.port))
+ self._login()
+ threading.Thread(target=self._listen, daemon=True).start()
+
+ def _login(self):
+ """Send the login action to AMI."""
+ action = (
+ f"Action: Login\r\n"
+ f"Username: {self.username}\r\n"
+ f"Secret: {self.secret}\r\n\r\n"
+ )
+ self.sock.sendall(action.encode())
+ if self.debug:
+ print("[recallonbusy] DEBUG: Sent login action")
+
+ def _listen(self):
+ """Listen for incoming data from AMI."""
+ if self.debug:
+ print("[recallonbusy] DEBUG: Started listener thread")
+ while True:
+ try:
+ data = self.sock.recv(4096).decode()
+ if not data:
+ break
+ self.buffer += data
+ while '\r\n\r\n' in self.buffer:
+ raw_event, self.buffer = self.buffer.split('\r\n\r\n', 1)
+ event = self._parse_event(raw_event)
+ self._handle_event(event)
+ except Exception as e:
+ print(f"[recallonbusy] ERROR in listener thread: {e}", file=sys.stderr)
+ break
+
+ def _parse_event(self, data):
+ """Parse raw event data into a dictionary."""
+ event = {}
+ lines = data.strip().split('\r\n')
+ for line in lines:
+ if ': ' in line:
+ key, value = line.split(': ', 1)
+ event[key.strip()] = value.strip()
+ if self.debug: # Print all events
+ print(f"[recallonbusy] DEBUG: Parsed event: {event}")
+ return event
+
+ def _handle_event(self, event):
+ """Handle an event by notifying listeners."""
+ with self.lock:
+ for listener in self.event_listeners:
+ listener(event)
+
+ def add_event_listener(self, listener):
+ """Add a listener for AMI events."""
+ with self.lock:
+ self.event_listeners.append(listener)
+
+ def remove_event_listener(self, listener):
+ """Remove a listener for AMI events."""
+ with self.lock:
+ if listener in self.event_listeners:
+ self.event_listeners.remove(listener)
+
+ def send_action(self, action_dict):
+ """Send an action to the AMI."""
+ action = ''.join(f"{key}: {value}\r\n" for key, value in action_dict.items())
+ action += '\r\n'
+ self.sock.sendall(action.encode())
+ if self.debug:
+ print(f"[recallonbusy] DEBUG: Sent action: {action_dict}")
+
+ def close(self):
+ """Close the connection to the AMI."""
+ if self.sock:
+ self.sock.close()
+ self.sock = None
+ if self.debug:
+ print("[recallonbusy] DEBUG: Closed connection to AMI")
+
+
+if __name__ == '__main__':
+ device_state_map = {}
+ def device_state_change_event_listener(event):
+ """Handle incoming DeviceStateChange AMI events."""
+ global device_state_map
+ if not 'Event' in event or event['Event'] != 'DeviceStateChange':
+ return
+ if client.debug:
+ print(f"[recallonbusy] DEBUG: Hanlding event: {event}")
+
+ if 'State' in event and 'Device' in event and re.match(r'^PJSIP/[0-9]{2,}$', str(event['Device'])) :
+ mainextension = re.sub(r'^PJSIP/9[0-9]([0-9]+)$|^PJSIP/([^9][0-9]+)$', r'\1\2', str(event['Device']))
+
+ # update extension state in map
+ device_state_map[mainextension] = event['State']
+
+ # If the device is not in use, check if there are any waiting extensions
+ if event['State'] == 'NOT_INUSE':
+ if client.debug:
+ print(f'[recallonbusy] DEBUG: Device state changed: {mainextension} is {event["State"]} checking for waiting extensions')
+ # Ask Asterisk DB if there are any extension waiting for this extension
+ # Use mainextension as ActionID to recognize the response
+ client.send_action({
+ 'Action': 'DBGet',
+ 'ActionID': f'{mainextension}_get_waiting',
+ 'Family': 'ROB',
+ 'Key': mainextension
+ })
+
+ def db_get_response_event_listener(event):
+ """Handle incoming DBGetResponse AMI events."""
+ global device_state_map
+ if not 'Event' in event or event['Event'] != 'DBGetResponse':
+ return
+ if client.debug:
+ print(f"[recallonbusy] DEBUG: Hanlding event: {event}")
+
+ if 'Family' in event and event['Family'] == 'ROB' and 'Key' in event and 'Val' in event and event['Val'] != '' and 'ActionID' in event and re.match(r'^[0-9]+_get_waiting$', str(event['ActionID'])):
+ mainextension = re.sub(r'^([0-9]+)_get_waiting$', r'\1', str(event['ActionID']))
+ waiting_extensions = event['Val'].split('&')
+ if client.debug:
+ print(f'[recallonbusy] DEBUG: Waiting extensions for {mainextension}: {waiting_extensions}')
+ # Call the first waiting extension
+ for waiting_extension in waiting_extensions:
+ waiting_extension_state = device_state_map.get(waiting_extension)
+ if waiting_extension_state in ['NOT_INUSE', 'UNKNOWN']:
+ print(f'[recallonbusy] Calling waiting extension {waiting_extension}')
+ # Call the waiting extension
+ client.send_action({
+ 'Action': 'Originate',
+ 'Channel': f'Local/{waiting_extension}@from-internal',
+ 'Context': 'from-internal',
+ 'Timeout': 150000,
+ 'CallerID': mainextension,
+ 'Exten': mainextension,
+ 'Priority': 1
+ })
+ # Remove the waiting extension from the list
+ waiting_extensions.remove(waiting_extension)
+ # Update the waiting extensions list in Asterisk DB
+ client.send_action({
+ 'Action': 'DBPut',
+ 'Family': 'ROB',
+ 'Key': mainextension,
+ 'Val': '&'.join(waiting_extensions)
+ })
+ break
+ elif client.debug:
+ print(f'[recallonbusy] DEBUG: Skipping waiting extension {waiting_extension}: {waiting_extension_state}')
+
+
+ client = AMIClient('/etc/asterisk/recallonbusy.cfg')
+ client.add_event_listener(device_state_change_event_listener)
+ client.add_event_listener(db_get_response_event_listener)
+ client.connect()
+
+ while True:
+ try:
+ client.send_action({'Action': 'DeviceStateList'})
+ except socket.error as e:
+ # Reconnect to Asterisk if there is a socket error
+ print(f"[recallonbusy] ERROR sending action: {e}", file=sys.stderr)
+ client.connect()
+ finally:
+ time.sleep(client.check_interval)
diff --git a/freepbx/var/www/html/freepbx/admin/modules/recallonbusy/Recallonbusy.class.php b/freepbx/var/www/html/freepbx/admin/modules/recallonbusy/Recallonbusy.class.php
index 2831c22c7..3bae753bb 100644
--- a/freepbx/var/www/html/freepbx/admin/modules/recallonbusy/Recallonbusy.class.php
+++ b/freepbx/var/www/html/freepbx/admin/modules/recallonbusy/Recallonbusy.class.php
@@ -24,7 +24,7 @@ class Recallonbusy extends \FreePBX_Helpers implements \BMO
public function install()
{
if(!$this->getConfig('default')) {
- $this->setConfig('default','disabled');
+ $this->setConfig('default','enabled');
}
if(!$this->getConfig('digit')) {
$this->setConfig('digit',5);
@@ -110,6 +110,7 @@ public function doConfigPageInit($display) {
if (!empty($_REQUEST['digit'])) {
$this->setConfig('digit',$_REQUEST['digit']);
}
+ needreload();
}
}
diff --git a/freepbx/var/www/html/freepbx/admin/modules/recallonbusy/module.xml b/freepbx/var/www/html/freepbx/admin/modules/recallonbusy/module.xml
index 7bb0b4b64..9fd53d5d7 100644
--- a/freepbx/var/www/html/freepbx/admin/modules/recallonbusy/module.xml
+++ b/freepbx/var/www/html/freepbx/admin/modules/recallonbusy/module.xml
@@ -2,7 +2,7 @@
recallonbusy
unsupported
Recall On Busy
- 0.0.1
+ 0.0.2
Applications
Recall On Busy