├── .github └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── examples ├── log_reg_attempt.py └── outbound_socket_example.py ├── greenswitch ├── __init__.py └── esl.py ├── requirements.txt ├── setup.py ├── test_requirements.txt ├── tests ├── __init__.py ├── conftest.py ├── fakeeslserver.py ├── test_lib_esl.py └── test_outbound_session.py └── tox.ini /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-20.04 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | cache: pip 22 | cache-dependency-path: | 23 | requirements.txt 24 | test_requirements.txt 25 | - name: Install dependencies 26 | run: make init 27 | - name: Test 28 | run: make test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | # Distribution / packaging 4 | .Python 5 | env/ 6 | build/ 7 | develop-eggs/ 8 | dist/ 9 | downloads/ 10 | eggs/ 11 | .eggs/ 12 | lib/ 13 | lib64/ 14 | parts/ 15 | sdist/ 16 | var/ 17 | *.egg-info/ 18 | .installed.cfg 19 | *.egg 20 | .idea/* 21 | .pytest_cache/* 22 | .tags* 23 | .vscode/ 24 | 25 | # pytest coverage plugin 26 | .coverage 27 | 28 | .tool-versions 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2016, Evolux Sistemas Ltda. 4 | 5 | This project was originally created and maintained by Ítalo Rossi. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst requirements.txt LICENSE 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | pip install -r requirements.txt 3 | pip install -r test_requirements.txt 4 | 5 | test: 6 | pytest --spec -s tests/ 7 | 8 | test-coverage: 9 | pytest --spec -s tests/ --cov=./greenswitch --cov-report term-missing 10 | 11 | build: 12 | python setup.py sdist bdist_wheel 13 | 14 | upload: 15 | python -m twine upload dist/* 16 | 17 | release: build upload 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | GreenSWITCH: FreeSWITCH Event Socket Protocol 2 | ============================================= 3 | 4 | .. image:: https://github.com/EvoluxBR/greenswitch/actions/workflows/tests.yml/badge.svg 5 | :target: https://github.com/EvoluxBR/greenswitch/actions 6 | 7 | .. image:: https://img.shields.io/pypi/v/greenswitch.svg 8 | :target: https://pypi.python.org/pypi/greenswitch 9 | 10 | .. image:: https://img.shields.io/pypi/dm/greenswitch.svg 11 | :target: https://pypi.python.org/pypi/greenswitch 12 | 13 | Battle proven FreeSWITCH Event Socket Protocol client implementation with Gevent. 14 | 15 | This is an implementation of FreeSWITCH Event Socket Protocol using Gevent 16 | Greenlets. It is already in production and processing hundreds of calls per day. 17 | 18 | Full Python3 support! 19 | 20 | Inbound Socket Mode 21 | =================== 22 | 23 | .. code-block:: python 24 | 25 | >>> import greenswitch 26 | >>> fs = greenswitch.InboundESL(host='127.0.0.1', port=8021, password='ClueCon') 27 | >>> fs.connect() 28 | >>> r = fs.send('api list_users') 29 | >>> print r.data 30 | 31 | 32 | Outbound Socket Mode 33 | ==================== 34 | 35 | Outbound is implemented with sync and async support. The main idea is to create 36 | an Application that will be called passing an OutboundSession as argument. 37 | This OutboundSession represents a call that is handled by the ESL connection. 38 | Basic functions are implemented already: 39 | 40 | - playback 41 | - play_and_get_digits 42 | - hangup 43 | - park 44 | - uuid_kill 45 | - answer 46 | - sleep 47 | 48 | With current api, it's easy to mix sync and async actions, for example: 49 | play_and_get_digits method will return the pressed DTMF digits in a block mode, 50 | that means as soon as you call that method in your Python code the execution 51 | flow will block and wait for the application to end only returning to the next 52 | line after ending the application. But after getting digits, if you need to consume 53 | an external system, like posting this to an external API you can leave the caller 54 | hearing MOH while the API call is being done, you can call the playback method 55 | with block=False, playback('my_moh.wav', block=False), after your API end we need 56 | to tell FreeSWITCH to stop playing the file and give us back the call control, 57 | for that we can use uuid_kill method. 58 | 59 | Example of Outbound Socket Mode: 60 | 61 | .. code-block:: python 62 | 63 | ''' 64 |    Add a extension on your dialplan to bound the outbound socket on FS channel 65 | as example below 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | Or see the complete doc on https://freeswitch.org/confluence/display/FREESWITCH/mod_event_socket 74 |    ''' 75 | import gevent 76 | import greenswitch 77 | 78 | import logging 79 | logging.basicConfig(level=logging.DEBUG) 80 | 81 | 82 | class MyApplication(object): 83 | def __init__(self, session): 84 | self.session = session 85 | 86 | def run(self): 87 | """ 88 | Main function that is called when a call comes in. 89 | """ 90 | try: 91 | self.handle_call() 92 | except: 93 | logging.exception('Exception raised when handling call') 94 | self.session.stop() 95 | 96 | def handle_call(self): 97 | # We want to receive events related to this call 98 | # They are also needed to know when an application is done running 99 | # for example playback 100 | self.session.myevents() 101 | print("myevents") 102 | # Send me all the events related to this call even if the call is already 103 | # hangup 104 | self.session.linger() 105 | print("linger") 106 | self.session.answer() 107 | print("answer") 108 | gevent.sleep(1) 109 | print("sleep") 110 | # Now block until the end of the file. pass block=False to 111 | # return immediately. 112 | self.session.playback('ivr/ivr-welcome') 113 | print("welcome") 114 | # blocks until the caller presses a digit, see response_timeout and take 115 | # the audio length in consideration when choosing this number 116 | digit = self.session.play_and_get_digits('1', '1', '3', '5000', '#', 117 | 'conference/conf-pin.wav', 118 | 'invalid.wav', 119 | 'test', '\d', '1000', "''", 120 | block=True, response_timeout=5) 121 | print("User typed: %s" % digit) 122 | # Start music on hold in background without blocking code execution 123 | # block=False makes the playback function return immediately. 124 | self.session.playback('local_stream://default', block=False) 125 | print("moh") 126 | # Now we can do a long task, for example, processing a payment, 127 | # consuming an APIs or even some database query to find our customer :) 128 | gevent.sleep(5) 129 | print("sleep 5") 130 | # We finished processing, stop the music on hold and do whatever you want 131 | # Note uuid_break is a general API and requires full permission 132 | self.session.uuid_break() 133 | print("break") 134 | # Bye caller 135 | self.session.hangup() 136 | print("hangup") 137 | # Close the socket so freeswitch can leave us alone 138 | self.session.stop() 139 | 140 | server = greenswitch.OutboundESLServer(bind_address='0.0.0.0', 141 | bind_port=5000, 142 | application=MyApplication, 143 | max_connections=5) 144 | server.listen() 145 | 146 | 147 | Enjoy! 148 | 149 | Feedbacks always welcome. 150 | -------------------------------------------------------------------------------- /examples/log_reg_attempt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | from __future__ import print_function 6 | 7 | import logging 8 | import gevent 9 | import greenswitch 10 | 11 | 12 | def on_sofia_register_failure(event): 13 | message = 'Failed register attempt from {network-ip} to user {to-user} profile {profile-name}' 14 | print(message.format(**event.headers)) 15 | 16 | fs = greenswitch.InboundESL(host='192.168.50.4', port=8021, password='ClueCon') 17 | fs.connect() 18 | fs.register_handle('sofia::register_failure', on_sofia_register_failure) 19 | fs.send('EVENTS PLAIN ALL') 20 | 21 | print('Connected to FreeSWITCH!') 22 | while True: 23 | try: 24 | gevent.sleep(1) 25 | except KeyboardInterrupt: 26 | fs.stop() 27 | break 28 | print('ESL Disconnected.') 29 | -------------------------------------------------------------------------------- /examples/outbound_socket_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import gevent 4 | import greenswitch 5 | 6 | import logging 7 | logging.basicConfig(level=logging.DEBUG) 8 | 9 | 10 | """ 11 | Add a extension on your dialplan to bound the outbound socket on FS channel 12 | as example below 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Or see the complete doc on https://freeswitch.org/confluence/display/FREESWITCH/mod_event_socket 21 | """ 22 | 23 | class MyApplication(object): 24 | def __init__(self, session): 25 | self.session = session 26 | 27 | def run(self): 28 | # TODO(italo): Move the safe_run logic to inside the lib, 29 | # this is not the user task. 30 | try: 31 | self.safe_run() 32 | except: 33 | print('ERRORRR') 34 | logging.exception('Exception raised when handling call') 35 | self.session.stop() 36 | 37 | def safe_run(self): 38 | """ 39 | Main function that is called when a call comes in. 40 | """ 41 | self.session.connect() 42 | self.session.myevents() 43 | print("myevents") 44 | self.session.linger() 45 | print("linger") 46 | self.session.answer() 47 | print("answer") 48 | gevent.sleep(1) 49 | print("sleep") 50 | # Now block until the end of the file. pass block=False to 51 | # return immediately. 52 | self.session.playback('ivr/ivr-welcome') 53 | print("welcome") 54 | # blocks until the caller presses a digit 55 | digit = self.session.play_and_get_digits('1', '1', '3', '5000', '#', 56 | 'conference/conf-pin.wav', 57 | 'invalid.wav', 58 | 'test', '\d', '1000', "''", 59 | block=True, response_timeout=5) 60 | print("User typed: %s" % digit) 61 | # Start music on hold in background and let's do another thing 62 | # block=False makes the playback function return immediately. 63 | self.session.playback('local_stream://default', block=False) 64 | print("moh") 65 | # Now we can do a long task, for example, processing a payment 66 | gevent.sleep(5) 67 | print("sleep 5") 68 | # Stopping the music on hold 69 | self.session.uuid_break() 70 | print("break") 71 | # self.session.hangup() 72 | # print("hangup") 73 | 74 | 75 | server = greenswitch.OutboundESLServer(bind_address='0.0.0.0', 76 | bind_port=5000, 77 | application=MyApplication, 78 | max_connections=5) 79 | server.listen() 80 | -------------------------------------------------------------------------------- /greenswitch/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | GreenSWITCH: FreeSWITCH Event Socket Protocol 5 | --------------------------------------------- 6 | 7 | Complete documentation at https://github.com/evoluxbr/greenswitch 8 | 9 | """ 10 | 11 | 12 | from .esl import InboundESL 13 | from .esl import OutboundESLServer 14 | -------------------------------------------------------------------------------- /greenswitch/esl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import errno 5 | import functools 6 | import logging 7 | import pprint 8 | import sys 9 | 10 | import gevent 11 | import gevent.socket as socket 12 | from gevent.event import Event 13 | from gevent.queue import Queue 14 | from six.moves.urllib.parse import unquote 15 | 16 | 17 | class NotConnectedError(Exception): 18 | pass 19 | 20 | 21 | class OutboundSessionHasGoneAway(Exception): 22 | pass 23 | 24 | 25 | class ESLEvent(object): 26 | def __init__(self, data): 27 | self.headers = {} 28 | self.parse_data(data) 29 | 30 | def parse_data(self, data): 31 | data = unquote(data) 32 | data = data.strip().splitlines() 33 | last_key = None 34 | value = '' 35 | for line in data: 36 | if ': ' in line: 37 | key, value = line.split(': ', 1) 38 | last_key = key 39 | else: 40 | key = last_key 41 | value += '\n' + line 42 | self.headers[key.strip()] = value.strip() 43 | 44 | 45 | class ESLProtocol(object): 46 | def __init__(self): 47 | self._run = True 48 | self._EOL = '\n' 49 | self._commands_sent = [] 50 | self._auth_request_event = Event() 51 | self._receive_events_greenlet = None 52 | self._process_events_greenlet = None 53 | self.event_handlers = {} 54 | self._esl_event_queue = Queue() 55 | self._process_esl_event_queue = True 56 | self._lingering = False 57 | self.connected = False 58 | 59 | def start_event_handlers(self): 60 | self._receive_events_greenlet = gevent.spawn(self.receive_events) 61 | self._process_events_greenlet = gevent.spawn(self.process_events) 62 | 63 | def register_handle(self, name, handler): 64 | if name not in self.event_handlers: 65 | self.event_handlers[name] = [] 66 | if handler in self.event_handlers[name]: 67 | return 68 | self.event_handlers[name].append(handler) 69 | 70 | def unregister_handle(self, name, handler): 71 | if name not in self.event_handlers: 72 | raise ValueError('No handlers found for event: %s' % name) 73 | self.event_handlers[name].remove(handler) 74 | if not self.event_handlers[name]: 75 | del self.event_handlers[name] 76 | 77 | def receive_events(self): 78 | buf = '' 79 | while self._run: 80 | try: 81 | data = self.sock_file.readline() 82 | if data is not None: 83 | data = data.decode('utf-8') 84 | except Exception: 85 | self._run = False 86 | self.connected = False 87 | self.sock.close() 88 | # logging.exception("Error reading from socket.") 89 | break 90 | 91 | if not data: 92 | if self.connected: 93 | logging.debug("Error receiving data, is FreeSWITCH running?") 94 | self.connected = False 95 | self._run = False 96 | break 97 | # Empty line 98 | if data == self._EOL: 99 | event = ESLEvent(buf) 100 | buf = '' 101 | self.handle_event(event) 102 | continue 103 | buf += data 104 | 105 | @staticmethod 106 | def _read_socket(sock, length): 107 | """Receive data from socket until the length is reached.""" 108 | data = sock.read(length) 109 | data_length = len(data) 110 | while data_length < length: 111 | logging.warn( 112 | 'Socket should read %s bytes, but actually read %s bytes. ' 113 | 'Consider increasing "net.core.rmem_default".' % 114 | (length, data_length) 115 | ) 116 | # FIXME(italo): if not data raise error 117 | data += sock.read(length - data_length) 118 | data_length = len(data) 119 | if data is not None: 120 | data = data.decode('utf-8') 121 | return data 122 | 123 | def handle_event(self, event): 124 | if event.headers['Content-Type'] == 'auth/request': 125 | self._auth_request_event.set() 126 | elif event.headers['Content-Type'] == 'command/reply': 127 | async_response = self._commands_sent.pop(0) 128 | event.data = event.headers['Reply-Text'] 129 | async_response.set(event) 130 | elif event.headers['Content-Type'] == 'api/response': 131 | length = int(event.headers['Content-Length']) 132 | data = self._read_socket(self.sock_file, length) 133 | event.data = data 134 | async_response = self._commands_sent.pop(0) 135 | async_response.set(event) 136 | elif event.headers['Content-Type'] == 'text/disconnect-notice': 137 | if event.headers.get('Content-Disposition') == 'linger': 138 | logging.debug('Linger activated') 139 | self._lingering = True 140 | else: 141 | self.connected = False 142 | # disconnect-notice is now a propagated event both for inbound 143 | # and outbound socket modes. 144 | # This is useful for outbound mode to notify all remaining 145 | # waiting commands to stop blocking and send a NotConnectedError 146 | self._esl_event_queue.put(event) 147 | elif event.headers['Content-Type'] == 'text/rude-rejection': 148 | self.connected = False 149 | length = int(event.headers['Content-Length']) 150 | self._read_socket(self.sock_file, length) 151 | self._auth_request_event.set() 152 | else: 153 | length = int(event.headers['Content-Length']) 154 | data = self._read_socket(self.sock_file, length) 155 | if event.headers.get('Content-Type') == 'log/data': 156 | event.data = data 157 | else: 158 | event.parse_data(data) 159 | self._esl_event_queue.put(event) 160 | 161 | def _safe_exec_handler(self, handler, event): 162 | try: 163 | handler(event) 164 | except: 165 | logging.exception('ESL %s raised exception.' % handler.__name__) 166 | logging.error(pprint.pformat(event.headers)) 167 | 168 | def process_events(self): 169 | logging.debug('Event Processor Running') 170 | while self._run: 171 | if not self._process_esl_event_queue: 172 | gevent.sleep(1) 173 | continue 174 | 175 | try: 176 | event = self._esl_event_queue.get(timeout=1) 177 | except gevent.queue.Empty: 178 | continue 179 | 180 | if event.headers.get('Event-Name') == 'CUSTOM': 181 | handlers = self.event_handlers.get(event.headers.get('Event-Subclass')) 182 | else: 183 | handlers = self.event_handlers.get(event.headers.get('Event-Name')) 184 | 185 | if event.headers.get('Content-Type') == 'text/disconnect-notice': 186 | handlers = self.event_handlers.get('DISCONNECT') 187 | 188 | if not handlers and event.headers.get('Content-Type') == 'log/data': 189 | handlers = self.event_handlers.get('log') 190 | 191 | if not handlers and '*' in self.event_handlers: 192 | handlers = self.event_handlers.get('*') 193 | 194 | if not handlers: 195 | continue 196 | 197 | if hasattr(self, 'before_handle'): 198 | self._safe_exec_handler(self.before_handle, event) 199 | 200 | for handle in handlers: 201 | self._safe_exec_handler(handle, event) 202 | 203 | if hasattr(self, 'after_handle'): 204 | self._safe_exec_handler(self.after_handle, event) 205 | 206 | def send(self, data): 207 | if not self.connected: 208 | raise NotConnectedError() 209 | async_response = gevent.event.AsyncResult() 210 | self._commands_sent.append(async_response) 211 | raw_msg = (data + self._EOL*2).encode('utf-8') 212 | self.sock.send(raw_msg) 213 | response = async_response.get() 214 | return response 215 | 216 | def stop(self): 217 | if self.connected: 218 | try: 219 | self.send('exit') 220 | except (NotConnectedError, socket.error, OutboundSessionHasGoneAway): 221 | pass 222 | self._run = False 223 | if self._receive_events_greenlet: 224 | logging.info("Waiting for receive greenlet exit") 225 | self._receive_events_greenlet.join() 226 | if self._process_events_greenlet: 227 | logging.info("Waiting for event processing greenlet exit") 228 | self._process_events_greenlet.join() 229 | self.sock.close() 230 | self.sock_file.close() 231 | 232 | 233 | class InboundESL(ESLProtocol): 234 | def __init__(self, host, port, password, timeout=5): 235 | super(InboundESL, self).__init__() 236 | self.host = host 237 | self.port = port 238 | self.password = password 239 | self.timeout = timeout 240 | self.connected = False 241 | 242 | def connect(self): 243 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 244 | self.sock.settimeout(self.timeout) 245 | try: 246 | self.sock.connect((self.host, self.port)) 247 | except socket.timeout: 248 | raise NotConnectedError('Connection timed out after %s seconds' 249 | % self.timeout) 250 | self.connected = True 251 | self.sock.settimeout(None) 252 | self.sock_file = self.sock.makefile('rb') 253 | self.start_event_handlers() 254 | self._auth_request_event.wait() 255 | if not self.connected: 256 | raise NotConnectedError('Server closed connection, check ' 257 | 'FreeSWITCH config.') 258 | self.authenticate() 259 | 260 | def authenticate(self): 261 | response = self.send('auth %s' % self.password) 262 | if response.headers['Reply-Text'] != '+OK accepted': 263 | raise ValueError('Invalid password.') 264 | 265 | def __enter__(self): 266 | self.connect() 267 | return self 268 | 269 | def __exit__(self, exc_type, exc_val, exc_tb): 270 | self.stop() 271 | 272 | class OutboundSession(ESLProtocol): 273 | def __init__(self, client_address, sock): 274 | super(OutboundSession, self).__init__() 275 | self.sock = sock 276 | self.sock_file = self.sock.makefile('rb') 277 | self.connected = True 278 | self.session_data = None 279 | self.start_event_handlers() 280 | self.register_handle('*', self.on_event) 281 | self.register_handle('CHANNEL_HANGUP', self.on_hangup) 282 | self.register_handle('DISCONNECT', self.on_disconnect) 283 | self.expected_events = {} 284 | self._outbound_connected = False 285 | 286 | @property 287 | def uuid(self): 288 | return self.session_data.get('variable_uuid') 289 | 290 | @property 291 | def call_uuid(self): 292 | return self.session_data.get('variable_call_uuid') 293 | 294 | @property 295 | def caller_id_number(self): 296 | return self.session_data.get('Caller-Caller-ID-Number') 297 | 298 | def on_disconnect(self, event): 299 | if self._lingering: 300 | logging.debug('Socket lingering..') 301 | elif not self.connected: 302 | logging.debug('Socket closed: %s' % event.headers) 303 | logging.debug('Raising OutboundSessionHasGoneAway for all pending' 304 | 'results') 305 | self._outbound_connected = False 306 | 307 | for event_name in self.expected_events: 308 | for variable, value, async_result in \ 309 | self.expected_events[event_name]: 310 | async_result.set_exception(OutboundSessionHasGoneAway()) 311 | 312 | for cmd in self._commands_sent: 313 | cmd.set_exception(OutboundSessionHasGoneAway()) 314 | 315 | def on_hangup(self, event): 316 | self._outbound_connected = False 317 | logging.info('Caller %s has gone away.' % self.caller_id_number) 318 | 319 | def on_event(self, event): 320 | # FIXME(italo): Decide if we really need a list of expected events 321 | # for each expected event. Since we're interacting with the call from 322 | # just one greenlet we don't have more than one item on this list. 323 | event_name = event.headers.get('Event-Name') 324 | if event_name not in self.expected_events: 325 | return 326 | 327 | for expected_event in self.expected_events[event_name]: 328 | event_variable, expected_value, async_response = expected_event 329 | expected_variable = 'variable_%s' % event_variable 330 | if expected_variable not in event.headers: 331 | return 332 | elif expected_value == event.headers.get(expected_variable): 333 | async_response.set(event) 334 | self.expected_events[event_name].remove(expected_event) 335 | 336 | def call_command(self, app_name, app_args=None, block=False, response_timeout=None): 337 | """Wraps app_name and app_args into FreeSWITCH Outbound protocol: 338 | Example: 339 | sendmsg 340 | call-command: execute 341 | execute-app-name: answer\n\n 342 | 343 | """ 344 | # We're not allowed to send more commands. 345 | # lingering True means we already received a hangup from the caller 346 | # and any commands sent at this time to the session will fail 347 | 348 | def _perform_call_command(app_name, app_args): 349 | if self._lingering: 350 | raise OutboundSessionHasGoneAway() 351 | 352 | command = "sendmsg\n" \ 353 | "call-command: execute\n" \ 354 | "execute-app-name: %s" % app_name 355 | if app_args: 356 | command += "\nexecute-app-arg: %s" % app_args 357 | 358 | return self.send(command) 359 | 360 | if not block: 361 | return _perform_call_command(app_name, app_args) 362 | 363 | async_response = gevent.event.AsyncResult() 364 | expected_event = "CHANNEL_EXECUTE_COMPLETE" 365 | expected_variable = "current_application" 366 | expected_variable_value = app_name 367 | self.register_expected_event(expected_event, expected_variable, 368 | expected_variable_value, async_response) 369 | _perform_call_command(app_name, app_args) 370 | event = async_response.get(block=True, timeout=response_timeout) 371 | return event 372 | 373 | def connect(self): 374 | if self._outbound_connected: 375 | return self.session_data 376 | 377 | try: 378 | resp = self.send('connect') 379 | except OutboundSessionHasGoneAway as e: 380 | # cleanup before raising exception 381 | self.stop() 382 | raise e 383 | self.session_data = resp.headers 384 | self._outbound_connected = True 385 | 386 | def myevents(self): 387 | self.send('myevents') 388 | 389 | def answer(self): 390 | resp = self.call_command('answer') 391 | return resp.data 392 | 393 | def park(self): 394 | self.call_command('park') 395 | 396 | def linger(self, timeout=None): 397 | if timeout is not None: 398 | self.send(f'linger {timeout}') 399 | else: 400 | self.send('linger') 401 | 402 | def playback(self, path, block=True): 403 | if not block: 404 | self.call_command('playback', path) 405 | return 406 | 407 | async_response = gevent.event.AsyncResult() 408 | expected_event = "CHANNEL_EXECUTE_COMPLETE" 409 | expected_variable = "current_application" 410 | expected_variable_value = "playback" 411 | self.register_expected_event(expected_event, expected_variable, 412 | expected_variable_value, async_response) 413 | self.call_command('playback', path) 414 | event = async_response.get(block=True) 415 | # TODO(italo): Decide what we need to return. 416 | # Returning whole event right now 417 | return event 418 | 419 | def play_and_get_digits(self, min_digits=None, max_digits=None, 420 | max_attempts=None, timeout=None, terminators=None, 421 | prompt_file=None, error_file=None, variable=None, 422 | digits_regex=None, digit_timeout=None, 423 | transfer_on_fail=None, block=True, 424 | response_timeout=30): 425 | args = "%s %s %s %s %s %s %s %s %s %s %s" % (min_digits, max_digits, 426 | max_attempts, timeout, 427 | terminators, prompt_file, 428 | error_file, variable, 429 | digits_regex, 430 | digit_timeout, 431 | transfer_on_fail) 432 | if not block: 433 | self.call_command('play_and_get_digits', args) 434 | return 435 | 436 | async_response = gevent.event.AsyncResult() 437 | expected_event = "CHANNEL_EXECUTE_COMPLETE" 438 | expected_variable = "current_application" 439 | expected_variable_value = "play_and_get_digits" 440 | self.register_expected_event(expected_event, expected_variable, 441 | expected_variable_value, async_response) 442 | self.call_command('play_and_get_digits', args) 443 | event = async_response.get(block=True, timeout=response_timeout) 444 | if not event: 445 | return 446 | digit = event.headers.get('variable_%s' % variable) 447 | return digit 448 | 449 | def say(self, module_name='en', lang=None, say_type='NUMBER', 450 | say_method='pronounced', gender='FEMININE', text=None, block=True, 451 | response_timeout=30): 452 | if lang: 453 | module_name += ':%s' % lang 454 | 455 | args = "%s %s %s %s %s" % (module_name, say_type, say_method, gender, 456 | text) 457 | if not block: 458 | self.call_command('say', args) 459 | return 460 | 461 | async_response = gevent.event.AsyncResult() 462 | expected_event = "CHANNEL_EXECUTE_COMPLETE" 463 | expected_variable = "current_application" 464 | expected_variable_value = "say" 465 | self.register_expected_event(expected_event, expected_variable, 466 | expected_variable_value, async_response) 467 | self.call_command('say', args) 468 | event = async_response.get(block=True, timeout=response_timeout) 469 | return event 470 | 471 | def bridge(self, args, block=True, response_timeout=None): 472 | return self.call_command("bridge", args, block=block, response_timeout=response_timeout) 473 | 474 | def register_expected_event(self, expected_event, expected_variable, 475 | expected_value, async_response): 476 | if expected_event not in self.expected_events: 477 | self.expected_events[expected_event] = [] 478 | self.expected_events[expected_event].append((expected_variable, 479 | expected_value, 480 | async_response)) 481 | 482 | def hangup(self, cause='NORMAL_CLEARING'): 483 | self.call_command('hangup', cause) 484 | 485 | def uuid_break(self): 486 | # TODO(italo): Properly detect when send() method should fail or not. 487 | # Not sure if this is the best way to avoid sending 488 | # session related commands, but for now it's working. 489 | # Another idea is to create a property called _socket_mode where the 490 | # values can be inbound or outbound and when running in outbound 491 | # mode we can make sure we'll only send a few permitted commands when 492 | # lingering is activated. 493 | if self._lingering: 494 | raise OutboundSessionHasGoneAway 495 | self.send('api uuid_break %s' % self.uuid) 496 | 497 | def raise_if_disconnected(self): 498 | """This function will raise the exception 499 | esl.OutboundSessionHasGoneAway if the caller hung up the call 500 | """ 501 | if not self._outbound_connected: 502 | raise OutboundSessionHasGoneAway 503 | 504 | def while_connected(self): 505 | """Returns an object that check if the session is connected in 506 | the __enter__ and __exit__ steps, if disconnected will 507 | raise greenswitch.esl.OutboundSessionHasGoneAway exception. 508 | 509 | This method can be used as a context manager or decorator. 510 | 511 | Examples: 512 | >>> with outbound_session.while_connected(): 513 | >>> do_something() 514 | >>> 515 | >>> @outbound_session.while_connected() 516 | >>> def do_something(): 517 | >>> ... 518 | """ 519 | class _while_connected(object): 520 | def __init__(self, outbound_session): 521 | self.outbound_session = outbound_session 522 | 523 | def __enter__(self): 524 | self.outbound_session.raise_if_disconnected() 525 | return self 526 | 527 | def __exit__(self, exit_type, exit_value, exit_traceback): 528 | self.outbound_session.raise_if_disconnected() 529 | 530 | def __call__(self, func): 531 | @functools.wraps(func) 532 | def decorator(*args, **kwargs): 533 | with self: 534 | return func(*args, **kwargs) 535 | 536 | return decorator 537 | 538 | return _while_connected(self) 539 | 540 | 541 | class OutboundESLServer(object): 542 | def __init__(self, bind_address='127.0.0.1', bind_port=8000, 543 | application=None, max_connections=100): 544 | self.bind_address = bind_address 545 | if not isinstance(bind_port, (list, tuple)): 546 | bind_port = [bind_port] 547 | if not bind_port: 548 | raise ValueError('bind_port must be a string or list with port ' 549 | 'numbers') 550 | 551 | self.bind_port = bind_port 552 | self.max_connections = max_connections 553 | self.connection_count = 0 554 | if not application: 555 | raise ValueError('You need an Application to control your calls.') 556 | self.application = application 557 | self._greenlets = set() 558 | self._running = False 559 | self.server = None 560 | logging.info('Starting OutboundESLServer at %s:%s' % 561 | (self.bind_address, self.bind_port)) 562 | self.bound_port = None 563 | 564 | def listen(self): 565 | self.server = socket.socket() 566 | self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 567 | 568 | for port in self.bind_port: 569 | try: 570 | self.server.bind((self.bind_address, port)) 571 | self.bound_port = port 572 | break 573 | except socket.error: 574 | logging.info('Failed to bind to port %s, ' 575 | 'trying next in range...' % port) 576 | continue 577 | if not self.bound_port: 578 | logging.error('Could not bind server, no ports available.') 579 | sys.exit() 580 | logging.info('Successfully bound to port %s' % self.bound_port) 581 | self.server.setblocking(0) 582 | self.server.listen(100) 583 | self._running = True 584 | 585 | while self._running: 586 | try: 587 | sock, client_address = self.server.accept() 588 | except socket.error as error: 589 | if error.args[0] in (errno.EWOULDBLOCK, errno.EAGAIN): 590 | # no data available 591 | gevent.sleep(0.1) 592 | continue 593 | raise 594 | 595 | session = OutboundSession(client_address, sock) 596 | gevent.spawn(self._accept_call, session) 597 | 598 | logging.info('Closing socket connection...') 599 | self.server.shutdown(socket.SHUT_RD) 600 | self.server.close() 601 | 602 | logging.info('Waiting for calls to be ended. Currently, there are ' 603 | '%s active calls' % self.connection_count) 604 | gevent.joinall(self._greenlets) 605 | self._greenlets.clear() 606 | 607 | logging.info('OutboundESLServer stopped') 608 | 609 | def _accept_call(self, session): 610 | if self.connection_count >= self.max_connections: 611 | logging.info( 612 | 'Rejecting call, server is at full capacity, current ' 613 | 'connection count is %s/%s' % 614 | (self.connection_count, self.max_connections)) 615 | session.connect() 616 | session.stop() 617 | return 618 | 619 | self._handle_call(session) 620 | 621 | def _handle_call(self, session): 622 | session.connect() 623 | app = self.application(session) 624 | handler = gevent.spawn(app.run) 625 | self._greenlets.add(handler) 626 | handler.session = session 627 | handler.link(self._handle_call_finish) 628 | self.connection_count += 1 629 | logging.debug('Connection count %d' % self.connection_count) 630 | 631 | def _handle_call_finish(self, handler): 632 | logging.info('Call from %s ended' % handler.session.caller_id_number) 633 | self._greenlets.remove(handler) 634 | self.connection_count -= 1 635 | logging.debug('Connection count %d' % self.connection_count) 636 | handler.session.stop() 637 | 638 | def stop(self): 639 | self._running = False 640 | 641 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | gevent 2 | six 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | from setuptools import setup, find_packages 6 | 7 | 8 | with open('README.rst', 'rb') as f: 9 | readme = f.read().decode('utf-8') 10 | 11 | with open('requirements.txt') as f: 12 | requires = f.readlines() 13 | 14 | setup( 15 | name='greenswitch', 16 | version='0.0.19', 17 | description=u'Battle proven FreeSWITCH Event Socket Protocol client implementation with Gevent.', 18 | long_description=readme, 19 | author=u'Ítalo Rossi', 20 | author_email=u'italorossib@gmail.com', 21 | url=u'https://github.com/evoluxbr/greenswitch', 22 | license=u'MIT', 23 | packages=find_packages(exclude=('tests', 'docs')), 24 | classifiers=[ 25 | 'Development Status :: 5 - Production/Stable', 26 | 'Intended Audience :: Developers', 27 | 'Programming Language :: Python', 28 | 'License :: OSI Approved :: MIT License', 29 | 'Programming Language :: Python :: 3.6', 30 | 'Programming Language :: Python :: 3.7', 31 | 'Programming Language :: Python :: 3.8', 32 | 'Programming Language :: Python :: 3.9', 33 | 'Programming Language :: Python :: 3.10', 34 | ], 35 | install_requires=requires 36 | ) 37 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-profiling 3 | pytest-spec 4 | pytest-cov 5 | mock 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | from gevent import monkey; monkey.patch_all() 6 | 7 | import gevent 8 | import os 9 | import unittest 10 | 11 | from . import fakeeslserver 12 | from greenswitch import esl 13 | 14 | 15 | class TestInboundESLBase(unittest.TestCase): 16 | 17 | esl_class = esl.InboundESL 18 | 19 | def setUp(self): 20 | super(TestInboundESLBase, self).setUp() 21 | self.switch_esl = fakeeslserver.FakeESLServer('0.0.0.0', 8021, 'ClueCon') 22 | self.switch_esl.start_server() 23 | self.esl = self.esl_class('127.0.0.1', 8021, 'ClueCon') 24 | self.esl.connect() 25 | 26 | def tearDown(self): 27 | super(TestInboundESLBase, self).tearDown() 28 | self.esl.stop() 29 | self.switch_esl.stop() 30 | 31 | def send_fake_event_plain(self, data): 32 | self.switch_esl.fake_event_plain(data.encode('utf-8')) 33 | gevent.sleep(0.1) 34 | 35 | def send_fake_raw_event_plain(self, data): 36 | self.switch_esl.fake_raw_event_plain(data.encode('utf-8')) 37 | gevent.sleep(0.1) 38 | 39 | 40 | def send_batch_fake_event_plain(self, events): 41 | for event in events: 42 | self.send_fake_event_plain(event) 43 | gevent.sleep(0.1) 44 | 45 | 46 | class FakeOutboundSession(esl.OutboundSession): 47 | def start_event_handlers(self): 48 | pass 49 | 50 | def send(self, command): 51 | return esl.ESLEvent('') 52 | 53 | if __name__ == '__main__': 54 | unittest.main() 55 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import mock 5 | import pytest 6 | import textwrap 7 | 8 | from greenswitch import esl 9 | from tests import FakeOutboundSession 10 | 11 | 12 | @pytest.fixture(scope="function") 13 | def outbound_session(request): 14 | sock_mock = mock.MagicMock() 15 | client_address = mock.MagicMock() 16 | outbound_session = FakeOutboundSession(client_address, sock_mock) 17 | request.cls.outbound_session = outbound_session 18 | 19 | 20 | @pytest.fixture(scope="function") 21 | def disconnect_event(request): 22 | event_plain = """ 23 | Content-Type: text/disconnect-notice 24 | Controlled-Session-UUID: e4c3f7e0-bcc1-11ea-a87f-a5a0acaa832c 25 | Content-Disposition: disconnect 26 | Content-Length: 67 27 | 28 | 29 | Disconnected, goodbye. 30 | See you at ClueCon! http://www.cluecon.com/ 31 | """ 32 | request.cls.disconnect_event = _create_esl_event(event_plain) 33 | 34 | 35 | def _create_esl_event(event_plain): 36 | return esl.ESLEvent(textwrap.dedent(event_plain) + "\n\n") 37 | -------------------------------------------------------------------------------- /tests/fakeeslserver.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import socket 4 | import threading 5 | import time 6 | 7 | 8 | class FakeESLServer(object): 9 | def __init__(self, address, port, password): 10 | self._address = address 11 | self._port = port 12 | self._password = password 13 | self._client_socket = None 14 | self._running = False 15 | self.commands = {} 16 | self.setup_commands() 17 | 18 | def setup_commands(self): 19 | self.commands['api khomp show links concise'] = ('B00L00:kes{SignalLost},sync\n' + 20 | 'B01L00:kesOk,sync\n' + 21 | 'B01L01:[ksigInactive]\n') 22 | self.commands['api fake show-special-chars'] = u'%^ć%$éí#$' 23 | 24 | def start_server(self): 25 | self.server = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) 26 | self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 27 | self.server.bind((self._address, self._port)) 28 | self.server.listen(10) 29 | self._running = True 30 | self._read_thread = threading.Thread(target=self.protocol_read) 31 | self._read_thread.setDaemon(True) 32 | self._read_thread.start() 33 | 34 | def command_reply(self, data): 35 | self._client_socket.send('Content-Type: command/reply\n'.encode('utf-8')) 36 | self._client_socket.send(('Reply-Text: %s\n\n' % data).encode('utf-8')) 37 | 38 | def protocol_send(self, lines): 39 | for line in lines: 40 | self._client_socket.send((line + '\n').encode('utf-8')) 41 | self._client_socket.send('\n'.encode('utf-8')) 42 | 43 | def api_response(self, data): 44 | data_length = len(data.encode('utf-8')) 45 | self._client_socket.send('Content-Type: api/response\n'.encode('utf-8')) 46 | self._client_socket.send(('Content-Length: %d\n\n' % data_length).encode('utf-8')) 47 | self._client_socket.send(data.encode('utf-8')) 48 | 49 | def handle_request(self, request): 50 | if request.startswith('auth'): 51 | received_password = request.split()[-1].strip() 52 | if received_password == self._password: 53 | self.command_reply('+OK accepted') 54 | else: 55 | self.command_reply('-ERR invalid') 56 | self.disconnect() 57 | elif request == 'exit': 58 | self.command_reply('+OK bye') 59 | self.disconnect() 60 | self.stop() 61 | elif request in self.commands: 62 | data = self.commands.get(request) 63 | if request.startswith('api'): 64 | self.api_response(data) 65 | else: 66 | self.command_reply(data) 67 | else: 68 | if request.startswith('api'): 69 | self.api_response('-ERR %s Command not found\n' % request.replace('api', '').split()[0]) 70 | else: 71 | self.command_reply('-ERR command not found') 72 | 73 | def protocol_read(self): 74 | self._client_socket, address = self.server.accept() 75 | self.protocol_send(['Content-Type: auth/request']) 76 | while self._running: 77 | buf = '' 78 | while self._running: 79 | try: 80 | read = self._client_socket.recv(1) 81 | except Exception: 82 | self._running = False 83 | self.server.close() 84 | break 85 | buf += read.decode('utf-8') 86 | if buf[-2:] == '\n\n' or buf[-4:] == '\r\n\r\n': 87 | request = buf 88 | break 89 | request = buf.strip() 90 | if not request and not self._running: 91 | break 92 | self.handle_request(request) 93 | 94 | def fake_event_plain(self, data): 95 | data_length = len(data) 96 | self.protocol_send(['Content-Type: text/event-plain', 97 | 'Content-Length: %s' % data_length]) 98 | self._client_socket.send(data) 99 | 100 | def fake_raw_event_plain(self, data): 101 | self._client_socket.send(data) 102 | 103 | def disconnect(self): 104 | self.protocol_send(['Content-Type: text/disconnect-notice', 105 | 'Content-Length: 67']) 106 | self._client_socket.send('Disconnected, goodbye.\n'.encode('utf-8')) 107 | self._client_socket.send('See you at ClueCon! http://www.cluecon.com/\n'.encode('utf-8')) 108 | self._running = False 109 | self._client_socket.close() 110 | 111 | def stop(self): 112 | self._client_socket.close() 113 | self.server.close() 114 | if self._running: 115 | self._running = False 116 | self._read_thread.join(5) 117 | 118 | 119 | def main(): 120 | server = FakeESLServer('0.0.0.0', 8021, 'ClueCon') 121 | server.start_server() 122 | while server._running: 123 | time.sleep(1) 124 | server.stop() 125 | 126 | 127 | if __name__ == '__main__': 128 | main() 129 | -------------------------------------------------------------------------------- /tests/test_lib_esl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | try: 5 | from unittest import mock 6 | except ImportError: 7 | import mock 8 | 9 | from textwrap import dedent 10 | import types 11 | 12 | import gevent 13 | 14 | from greenswitch import esl 15 | from tests import TestInboundESLBase 16 | from tests import fakeeslserver 17 | 18 | 19 | class TestInboundESL(TestInboundESLBase): 20 | 21 | def test_sock_read_with_special_characters(self): 22 | result = self.esl.send('api fake show-special-chars') 23 | self.assertEqual(self.switch_esl.commands['api fake show-special-chars'], result.data) 24 | 25 | def test_connect(self): 26 | """Should connect in FreeSWITCH ESL Server.""" 27 | switch_esl = fakeeslserver.FakeESLServer('0.0.0.0', 8022, 'ClueCon') 28 | switch_esl.start_server() 29 | esl_ = esl.InboundESL('127.0.0.1', 8022, 'ClueCon') 30 | esl_.connect() 31 | self.assertTrue(esl_.connected) 32 | self.assertTrue(esl_._auth_request_event.is_set()) 33 | self.assertEqual(esl_.sock.gettimeout(), None) 34 | esl_.stop() 35 | switch_esl.stop() 36 | 37 | def test_connect_wrong_password(self): 38 | """Should raises ValueError when using wrong ESL password.""" 39 | switch_esl = fakeeslserver.FakeESLServer('0.0.0.0', 8022, 'ClueCon') 40 | switch_esl.start_server() 41 | esl_ = esl.InboundESL('127.0.0.1', 8022, 'wrongpassword') 42 | self.assertRaises(ValueError, esl_.connect) 43 | switch_esl.stop() 44 | self.assertFalse(esl_.connected) 45 | esl_.stop() 46 | 47 | def test_client_disconnect(self): 48 | """Should disconnect properly.""" 49 | self.esl.stop() 50 | gevent.sleep(0.01) 51 | self.assertFalse(self.esl.connected) 52 | self.switch_esl.stop() 53 | 54 | def test_z_server_disconnect(self): 55 | """Should detect server disconnection.""" 56 | self.switch_esl.stop() 57 | gevent.sleep(0.01) 58 | self.assertFalse(self.esl.connected) 59 | 60 | def test_register_unregister_handle(self): 61 | """Should register/unregister handler for events.""" 62 | def handle(event): 63 | pass 64 | self.esl.register_handle('TEST_EVENT', handle) 65 | self.assertIn(handle, self.esl.event_handlers['TEST_EVENT']) 66 | self.esl.unregister_handle('TEST_EVENT', handle) 67 | self.assertNotIn('TEST_EVENT', self.esl.event_handlers) 68 | 69 | def test_register_a_registered_handle(self): 70 | """Should not register the same handler to same event.""" 71 | def handle(event): 72 | pass 73 | self.esl.register_handle('TEST_EVENT', handle) 74 | self.esl.register_handle('TEST_EVENT', handle) 75 | self.assertEqual([handle], self.esl.event_handlers['TEST_EVENT']) 76 | 77 | def test_unregister_a_not_registered_handle(self): 78 | """Should raise ValueError when unregistering an unknown handler.""" 79 | def handle(event): 80 | pass 81 | with self.assertRaises(ValueError): 82 | self.esl.unregister_handle('TEST_EVENT', handle) 83 | 84 | def test_custom_event(self): 85 | """Should call registered handler for CUSTOM events.""" 86 | def on_sofia_pre_register(self, event): 87 | self.pre_register = True 88 | 89 | 90 | self.esl.pre_register = False 91 | self.esl.on_sofia_pre_register = types.MethodType( 92 | on_sofia_pre_register, self.esl) 93 | 94 | self.esl.register_handle('sofia::pre_register', 95 | self.esl.on_sofia_pre_register) 96 | event_plain = dedent("""\ 97 | Event-Name: CUSTOM 98 | Event-Subclass: sofia::pre_register""") 99 | 100 | self.send_fake_event_plain(event_plain) 101 | self.assertTrue(self.esl.pre_register) 102 | 103 | def test_event(self): 104 | """Should call registered handler for events.""" 105 | def on_heartbeat(self, event): 106 | self.heartbeat = True 107 | 108 | self.esl.heartbeat = False 109 | self.esl.on_heartbeat = types.MethodType( 110 | on_heartbeat, self.esl) 111 | 112 | self.esl.register_handle('HEARTBEAT', self.esl.on_heartbeat) 113 | event_plain = dedent("""\ 114 | Event-Name: HEARTBEAT 115 | Core-UUID: cb2d5146-9a99-11e4-9291-092b1a87b375 116 | FreeSWITCH-Hostname: evoluxdev 117 | FreeSWITCH-Switchname: freeswitch 118 | FreeSWITCH-IPv4: 172.16.7.47 119 | FreeSWITCH-IPv6: %3A%3A1 120 | Event-Date-Local: 2015-01-19%2012%3A06%3A19 121 | Event-Date-GMT: Mon,%2019%20Jan%202015%2015%3A06%3A19%20GMT 122 | Event-Date-Timestamp: 1421679979428652 123 | Event-Calling-File: switch_core.c 124 | Event-Calling-Function: send_heartbeat 125 | Event-Calling-Line-Number: 70 126 | Event-Sequence: 23910 127 | Event-Info: System%20Ready 128 | Up-Time: 0%20years,%201%20day,%2016%20hours,%2053%20minutes,%2014%20seconds,%20552%20milliseconds,%2035%20microseconds 129 | FreeSWITCH-Version: 1.5.15b%2Bgit~20141226T052811Z~0a66db6f12~64bit 130 | Uptime-msec: 147194552 131 | Session-Count: 0 132 | Max-Sessions: 1000 133 | Session-Per-Sec: 30 134 | Session-Per-Sec-Max: 2 135 | Session-Per-Sec-FiveMin: 0 136 | Session-Since-Startup: 34 137 | Session-Peak-Max: 4 138 | Session-Peak-FiveMin: 0 139 | Idle-CPU: 98.700000""") 140 | self.send_fake_event_plain(event_plain) 141 | self.assertTrue(self.esl.heartbeat) 142 | 143 | def test_event_socket_data(self): 144 | """Should call registered handler for events.""" 145 | self.log = False 146 | 147 | def on_log(event): 148 | self.log = True 149 | self.esl.register_handle('log', on_log) 150 | event_plain = dedent("""\ 151 | Content-Type: log/data 152 | Content-Length: 126 153 | Log-Level: 7 154 | Text-Channel: 3 155 | Log-File: switch_core_state_machine.c 156 | Log-Func: switch_core_session_destroy_state 157 | Log-Line: 710 158 | User-Data: 4c882cc4-cd02-11e6-8b82-395b501876f9 159 | 160 | 2016-12-28 10:34:08.398763 [DEBUG] switch_core_state_machine.c:710 (sofia/internal/7071@devitor) State DESTROY going to sleep 161 | """) 162 | self.send_fake_raw_event_plain(event_plain) 163 | self.assertTrue(self.log) 164 | 165 | def test_event_with_multiline_channel_variables_content(self): 166 | """Should not break parse from ESL Event when.""" 167 | def on_channel_create(self, event): 168 | self.channel_create = True 169 | self.parsed_event = event 170 | 171 | self.esl.channel_create = False 172 | self.esl.parsed_event = None 173 | self.esl.on_channel_create = types.MethodType( 174 | on_channel_create, self.esl) 175 | 176 | self.esl.register_handle('CHANNEL_CREATE', self.esl.on_channel_create) 177 | event_plain = dedent("""\ 178 | Event-Name: CHANNEL_CREATE 179 | Core-UUID: ed56dab6-a6fc-11e4-960f-6f83a2e5e50a 180 | FreeSWITCH-Hostname: evoluxdev 181 | FreeSWITCH-Switchname: evoluxdev 182 | FreeSWITCH-IPv4: 172.16.7.69 183 | FreeSWITCH-IPv6: ::1 184 | Event-Date-Local: 2015-01-28 15:00:44 185 | Event-Date-GMT: Wed, 28 Jan 2015 18:00:44 GMT 186 | Event-Date-Timestamp: 1422468044671081 187 | Event-Calling-File: switch_core_state_machine.c 188 | Event-Calling-Function: switch_core_session_run 189 | Event-Calling-Line-Number: 509 190 | Event-Sequence: 3372 191 | Channel-State: CS_INIT 192 | Channel-Call-State: DOWN 193 | Channel-State-Number: 2 194 | Channel-Name: sofia/internal/100@192.168.50.4 195 | Unique-ID: d0b1da34-a727-11e4-9728-6f83a2e5e50a 196 | Call-Direction: inbound 197 | Presence-Call-Direction: inbound 198 | Channel-HIT-Dialplan: true 199 | Channel-Presence-ID: 100@192.168.50.4 200 | Channel-Call-UUID: d0b1da34-a727-11e4-9728-6f83a2e5e50a 201 | Answer-State: ringing 202 | Caller-Direction: inbound 203 | Caller-Logical-Direction: inbound 204 | Caller-Username: 100 205 | Caller-Dialplan: XML 206 | Caller-Caller-ID-Name: edev - 100 207 | Caller-Caller-ID-Number: 100 208 | Caller-Orig-Caller-ID-Name: edev - 100 209 | Caller-Orig-Caller-ID-Number: 100 210 | Caller-Network-Addr: 192.168.50.1 211 | Caller-ANI: 100 212 | Caller-Destination-Number: 101 213 | Caller-Unique-ID: d0b1da34-a727-11e4-9728-6f83a2e5e50a 214 | Caller-Source: mod_sofia 215 | Caller-Context: out-extensions 216 | Caller-Channel-Name: sofia/internal/100@192.168.50.4 217 | Caller-Profile-Index: 1 218 | Caller-Profile-Created-Time: 1422468044671081 219 | Caller-Channel-Created-Time: 1422468044671081 220 | Caller-Channel-Answered-Time: 0 221 | Caller-Channel-Progress-Time: 0 222 | Caller-Channel-Progress-Media-Time: 0 223 | Caller-Channel-Hangup-Time: 0 224 | Caller-Channel-Transfer-Time: 0 225 | Caller-Channel-Resurrect-Time: 0 226 | Caller-Channel-Bridged-Time: 0 227 | Caller-Channel-Last-Hold: 0 228 | Caller-Channel-Hold-Accum: 0 229 | Caller-Screen-Bit: true 230 | Caller-Privacy-Hide-Name: false 231 | Caller-Privacy-Hide-Number: false 232 | variable_direction: inbound 233 | variable_uuid: d0b1da34-a727-11e4-9728-6f83a2e5e50a 234 | variable_call_uuid: d0b1da34-a727-11e4-9728-6f83a2e5e50a 235 | variable_session_id: 9 236 | variable_sip_from_user: 100 237 | variable_sip_from_uri: 100@192.168.50.4 238 | variable_sip_from_host: 192.168.50.4 239 | variable_channel_name: sofia/internal/100@192.168.50.4 240 | variable_sip_call_id: 6bG.Hj5UCe8pDFEy1R9FO8EIfHtKrZ3H 241 | variable_ep_codec_string: GSM@8000h@20i@13200b,PCMU@8000h@20i@64000b,PCMA@8000h@20i@64000b,G722@8000h@20i@64000b 242 | variable_sip_local_network_addr: 192.168.50.4 243 | variable_sip_network_ip: 192.168.50.1 244 | variable_sip_network_port: 58588 245 | variable_sip_received_ip: 192.168.50.1 246 | variable_sip_received_port: 58588 247 | variable_sip_via_protocol: udp 248 | variable_sip_authorized: true 249 | variable_Event-Name: REQUEST_PARAMS 250 | variable_Core-UUID: ed56dab6-a6fc-11e4-960f-6f83a2e5e50a 251 | variable_FreeSWITCH-Hostname: evoluxdev 252 | variable_FreeSWITCH-Switchname: evoluxdev 253 | variable_FreeSWITCH-IPv4: 172.16.7.69 254 | variable_FreeSWITCH-IPv6: ::1 255 | variable_Event-Date-Local: 2015-01-28 15:00:44 256 | variable_Event-Date-GMT: Wed, 28 Jan 2015 18:00:44 GMT 257 | variable_Event-Date-Timestamp: 1422468044671081 258 | variable_Event-Calling-File: sofia.c 259 | variable_Event-Calling-Function: sofia_handle_sip_i_invite 260 | variable_Event-Calling-Line-Number: 8539 261 | variable_Event-Sequence: 3368 262 | variable_sip_number_alias: 100 263 | variable_sip_auth_username: 100 264 | variable_sip_auth_realm: 192.168.50.4 265 | variable_number_alias: 100 266 | variable_requested_domain_name: 192.168.50.4 267 | variable_record_stereo: true 268 | variable_transfer_fallback_extension: operator 269 | variable_toll_allow: celular_ddd,celular_local,fixo_ddd,fixo_local,ligar_para_outro_ramal,ramais_evolux_office 270 | variable_evolux_cc_position: 100 271 | variable_user_context: out-extensions 272 | variable_accountcode: dev 273 | variable_callgroup: dev 274 | variable_effective_caller_id_name: Evolux 100 275 | variable_effective_caller_id_number: 100 276 | variable_outbound_caller_id_name: Dev 277 | variable_outbound_caller_id_number: 0000000000 278 | variable_user_name: 100 279 | variable_domain_name: 192.168.50.4 280 | variable_sip_from_user_stripped: 100 281 | variable_sip_from_tag: ocZZPAo1FTdXA10orlmCaYeqc4mzYem1 282 | variable_sofia_profile_name: internal 283 | variable_recovery_profile_name: internal 284 | variable_sip_full_via: SIP/2.0/UDP 172.16.7.70:58588;rport=58588;branch=z9hG4bKPj-0Wi47Dyiq1mz3t.Bm8aluRrPEHF7-6C;received=192.168.50.1 285 | variable_sip_from_display: edev - 100 286 | variable_sip_full_from: "edev - 100" ;tag=ocZZPAo1FTdXA10orlmCaYeqc4mzYem1 287 | variable_sip_full_to: 288 | variable_sip_req_user: 101 289 | variable_sip_req_uri: 101@192.168.50.4 290 | variable_sip_req_host: 192.168.50.4 291 | variable_sip_to_user: 101 292 | variable_sip_to_uri: 101@192.168.50.4 293 | variable_sip_to_host: 192.168.50.4 294 | variable_sip_contact_params: ob 295 | variable_sip_contact_user: 100 296 | variable_sip_contact_port: 58588 297 | variable_sip_contact_uri: 100@192.168.50.1:58588 298 | variable_sip_contact_host: 192.168.50.1 299 | variable_rtp_use_codec_string: G722,PCMA,PCMU,GSM,G729 300 | variable_sip_user_agent: Telephone 1.1.4 301 | variable_sip_via_host: 172.16.7.70 302 | variable_sip_via_port: 58588 303 | variable_sip_via_rport: 58588 304 | variable_max_forwards: 70 305 | variable_presence_id: 100@192.168.50.4 306 | variable_switch_r_sdp: v=0 307 | o=- 3631463817 3631463817 IN IP4 172.16.7.70 308 | s=pjmedia 309 | b=AS:84 310 | t=0 0 311 | a=X-nat:0 312 | m=audio 4016 RTP/AVP 103 102 104 109 3 0 8 9 101 313 | c=IN IP4 172.16.7.70 314 | b=AS:64000 315 | a=rtpmap:103 speex/16000 316 | a=rtpmap:102 speex/8000 317 | a=rtpmap:104 speex/32000 318 | a=rtpmap:109 iLBC/8000 319 | a=fmtp:109 mode=30 320 | a=rtpmap:3 GSM/8000 321 | a=rtpmap:0 PCMU/8000 322 | a=rtpmap:8 PCMA/8000 323 | a=rtpmap:9 G722/8000 324 | a=rtpmap:101 telephone-event/8000 325 | a=fmtp:101 0-15 326 | a=rtcp:4017 IN IP4 172.16.7.70 327 | 328 | variable_endpoint_disposition: DELAYED NEGOTIATION""") 329 | self.send_fake_event_plain(event_plain) 330 | self.assertTrue(self.esl.channel_create) 331 | 332 | expected_variable_value = dedent("""\ 333 | v=0 334 | o=- 3631463817 3631463817 IN IP4 172.16.7.70 335 | s=pjmedia 336 | b=AS:84 337 | t=0 0 338 | a=X-nat:0 339 | m=audio 4016 RTP/AVP 103 102 104 109 3 0 8 9 101 340 | c=IN IP4 172.16.7.70 341 | b=AS:64000 342 | a=rtpmap:103 speex/16000 343 | a=rtpmap:102 speex/8000 344 | a=rtpmap:104 speex/32000 345 | a=rtpmap:109 iLBC/8000 346 | a=fmtp:109 mode=30 347 | a=rtpmap:3 GSM/8000 348 | a=rtpmap:0 PCMU/8000 349 | a=rtpmap:8 PCMA/8000 350 | a=rtpmap:9 G722/8000 351 | a=rtpmap:101 telephone-event/8000 352 | a=fmtp:101 0-15 353 | a=rtcp:4017 IN IP4 172.16.7.70""") 354 | self.assertEqual(self.esl.parsed_event.headers['variable_switch_r_sdp'], 355 | expected_variable_value) 356 | 357 | def test_api_response(self): 358 | """Should properly read api response from ESL.""" 359 | response = self.esl.send('api khomp show links concise') 360 | self.assertEqual('api/response', response.headers['Content-Type']) 361 | self.assertIn('Content-Length', response.headers) 362 | self.assertEqual(len(response.data), 363 | int(response.headers['Content-Length'])) 364 | 365 | def test_command_not_found(self): 366 | """Should properly read command response from ESL.""" 367 | response = self.esl.send('unknown_command') 368 | self.assertEqual('command/reply', response.headers['Content-Type']) 369 | self.assertEqual('-ERR command not found', 370 | response.headers['Reply-Text']) 371 | 372 | def test_event_without_handler(self): 373 | """Should not break if receive an event without handler.""" 374 | self.send_fake_event_plain('Event-Name: EVENT_UNKNOWN') 375 | self.assertTrue(self.esl.connected) 376 | 377 | 378 | class ESLProtocolTest(TestInboundESLBase): 379 | def test_receive_events_io_error_handling(self): 380 | """ 381 | `receive_events` will close the socket and stop running in 382 | case of error 383 | """ 384 | protocol = esl.ESLProtocol() 385 | protocol.sock = mock.Mock() 386 | protocol.sock_file = mock.Mock() 387 | protocol.sock_file.readline.side_effect = Exception() 388 | 389 | protocol.receive_events() 390 | self.assertTrue(protocol.sock.close.called) 391 | self.assertFalse(protocol.connected) 392 | 393 | def test_receive_events_without_data_but_connected(self): 394 | """ 395 | `receive_events` is defensive programmed to fix 396 | bad `connected` property flag if no data is read, 397 | but without trying to really closing the socket. 398 | """ 399 | protocol = esl.ESLProtocol() 400 | protocol.connected = True 401 | protocol.sock = mock.Mock() 402 | protocol.sock_file = mock.Mock() 403 | protocol.sock_file.readline.return_value = None 404 | 405 | protocol.receive_events() 406 | self.assertFalse(protocol.sock.close.called) 407 | self.assertFalse(protocol.connected) 408 | 409 | def test_handle_event_with_packet_loss(self): 410 | """ 411 | `handle_event` detects if the data read by 412 | socket doesn't have enough length that its 413 | metadata "Content-Length" header says, and 414 | concats more data on event's. 415 | """ 416 | protocol = esl.ESLProtocol() 417 | protocol._commands_sent.append(mock.Mock()) 418 | protocol.sock = mock.Mock() 419 | protocol.sock_file = mock.Mock() 420 | protocol.sock_file.read.return_value = b'123456789' 421 | event = mock.Mock() 422 | event.headers = { 423 | 'Content-Type': 'api/response', 424 | 'Content-Length': '10', 425 | } 426 | 427 | protocol.handle_event(event) 428 | self.assertEqual(event.data, '123456789123456789') 429 | 430 | def test_handle_event_disconnect_with_linger(self): 431 | """ 432 | `handle_event` handles a "text/disconnect-notice" content 433 | with "Content-Disposition" header as "linger" by not 434 | disconnecting the socket. 435 | """ 436 | protocol = esl.ESLProtocol() 437 | protocol.connected = True 438 | protocol._commands_sent.append(mock.Mock()) 439 | protocol.sock = mock.Mock() 440 | event = mock.Mock() 441 | event.headers = { 442 | 'Content-Type': 'text/disconnect-notice', 443 | 'Content-Disposition': 'linger', 444 | } 445 | 446 | protocol.handle_event(event) 447 | self.assertTrue(protocol.connected) 448 | self.assertFalse(protocol.sock.close.called) 449 | 450 | def test_handle_event_rude_rejection(self): 451 | """ 452 | `handle_event` handles a "text/rude-rejection" content 453 | by disabling `connected` flag but still reading it. 454 | """ 455 | protocol = esl.ESLProtocol() 456 | protocol.connected = True 457 | protocol.sock_file = mock.Mock() 458 | protocol.sock_file.read.return_value = b'123' 459 | event = mock.Mock() 460 | event.headers = { 461 | 'Content-Type': 'text/rude-rejection', 462 | 'Content-Length': '3', 463 | } 464 | 465 | protocol.handle_event(event) 466 | self.assertFalse(protocol.connected) 467 | self.assertTrue(protocol.sock_file.read.called) 468 | 469 | def test_private_safe_exec_handler(self): 470 | """ 471 | `_safe_exec_handler` is a private (and almost static) method 472 | to apply a function to an event without letting any exception 473 | reach the outter scope. 474 | """ 475 | protocol = esl.ESLProtocol() 476 | bad_handler = mock.Mock(side_effect=Exception()) 477 | bad_handler.__name__ = 'named-handler' 478 | event = mock.Mock() 479 | 480 | protocol._safe_exec_handler(bad_handler, event) 481 | self.assertTrue(bad_handler.called) 482 | bad_handler.assert_called_with(event) 483 | 484 | @mock.patch('greenswitch.esl.ESLProtocol._run', create=True, new_callable=mock.PropertyMock) 485 | @mock.patch('gevent.sleep') 486 | def test_process_events_quick_sleep_for_falsy_events_queue(self, 487 | gevent_sleep, 488 | private_run_property): 489 | """ 490 | `process_events` sleeps for 1s if ESL queue has falsy value. 491 | """ 492 | protocol = esl.ESLProtocol() 493 | private_run_property.side_effect = [True, False] 494 | protocol._process_esl_event_queue = False 495 | 496 | protocol.process_events() 497 | self.assertTrue(gevent_sleep.called) 498 | gevent_sleep.assert_called_with(1) 499 | 500 | @mock.patch('greenswitch.esl.ESLProtocol._run', create=True, new_callable=mock.PropertyMock) 501 | def test_process_events_with_custom_name(self, private_run_property): 502 | """ 503 | `process_events` will accept an event with "Event-Name" header as "CUSTOM" 504 | in its headers by calling the handlers indexed by its "Event-Subclass". 505 | """ 506 | protocol = esl.ESLProtocol() 507 | private_run_property.side_effect = [True, False] 508 | handlers = [mock.Mock(), mock.Mock()] 509 | protocol.event_handlers['custom-subclass'] = handlers 510 | 511 | event = mock.Mock() 512 | event.headers = { 513 | 'Event-Name': 'CUSTOM', 514 | 'Event-Subclass': 'custom-subclass', 515 | } 516 | protocol._esl_event_queue.put(event) 517 | 518 | protocol.process_events() 519 | self.assertTrue(handlers[0].called) 520 | handlers[0].assert_called_with(event) 521 | self.assertTrue(handlers[1].called) 522 | handlers[1].assert_called_with(event) 523 | 524 | @mock.patch('greenswitch.esl.ESLProtocol._run', create=True, new_callable=mock.PropertyMock) 525 | def test_process_events_with_log_type(self, private_run_property): 526 | """ 527 | `process_events` will accept an event with "log/data" type 528 | and pass it to its handlers. 529 | """ 530 | protocol = esl.ESLProtocol() 531 | private_run_property.side_effect = [True, False] 532 | handlers = [mock.Mock(), mock.Mock()] 533 | protocol.event_handlers['log'] = handlers 534 | 535 | event = mock.Mock() 536 | event.headers = { 537 | 'Content-Type': 'log/data', 538 | } 539 | protocol._esl_event_queue.put(event) 540 | 541 | protocol.process_events() 542 | self.assertTrue(handlers[0].called) 543 | handlers[0].assert_called_with(event) 544 | self.assertTrue(handlers[1].called) 545 | handlers[1].assert_called_with(event) 546 | 547 | @mock.patch('greenswitch.esl.ESLProtocol._run', create=True, new_callable=mock.PropertyMock) 548 | def test_process_events_with_no_handlers_will_rely_on_generic(self, private_run_property): 549 | """ 550 | `process_events` will rely only on handlers for "*" if 551 | a given event has no handlers. 552 | """ 553 | protocol = esl.ESLProtocol() 554 | private_run_property.side_effect = [True, False] 555 | fallback_handlers = [mock.Mock(), mock.Mock()] 556 | protocol.event_handlers['*'] = fallback_handlers 557 | other_handlers = [mock.Mock(), mock.Mock()] 558 | protocol.event_handlers['other-handlers'] = other_handlers 559 | 560 | event = mock.Mock() 561 | event.headers = { 562 | 'Event-Name': 'CUSTOM', 563 | 'Event-Subclass': 'custom-subclass-without-handlers', 564 | } 565 | protocol._esl_event_queue.put(event) 566 | 567 | protocol.process_events() 568 | self.assertTrue(fallback_handlers[0].called) 569 | fallback_handlers[0].assert_called_with(event) 570 | self.assertTrue(fallback_handlers[1].called) 571 | fallback_handlers[1].assert_called_with(event) 572 | self.assertFalse(other_handlers[0].called) 573 | self.assertFalse(other_handlers[1].called) 574 | 575 | @mock.patch('greenswitch.esl.ESLProtocol._run', create=True, new_callable=mock.PropertyMock) 576 | def test_process_events_with_pre_handler(self, private_run_property): 577 | """ 578 | `process_events` will call for `before_handle` property 579 | if it was implemented on such protocol instance, but the 580 | event will also be passed to default handlers. 581 | """ 582 | protocol = esl.ESLProtocol() 583 | private_run_property.side_effect = [True, False] 584 | protocol.before_handle = mock.Mock() 585 | some_handlers = [mock.Mock(), mock.Mock()] 586 | protocol.event_handlers['some-handlers'] = some_handlers 587 | 588 | event = mock.Mock() 589 | event.headers = { 590 | 'Event-Name': 'CUSTOM', 591 | 'Event-Subclass': 'some-handlers', 592 | } 593 | protocol._esl_event_queue.put(event) 594 | 595 | protocol.process_events() 596 | self.assertTrue(protocol.before_handle.called) 597 | protocol.before_handle.assert_called_with(event) 598 | self.assertTrue(some_handlers[0].called) 599 | some_handlers[0].assert_called_with(event) 600 | self.assertTrue(some_handlers[1].called) 601 | some_handlers[1].assert_called_with(event) 602 | 603 | @mock.patch('greenswitch.esl.ESLProtocol._run', create=True, new_callable=mock.PropertyMock) 604 | def test_process_events_with_post_handler(self, private_run_property): 605 | """ 606 | `process_events` will call for `after_handle` property 607 | if it was implemented on such protocol instance, but the 608 | event will also be passed to default handlers. 609 | """ 610 | protocol = esl.ESLProtocol() 611 | private_run_property.side_effect = [True, False] 612 | protocol.after_handle = mock.Mock() 613 | some_handlers = [mock.Mock(), mock.Mock()] 614 | protocol.event_handlers['some-handlers'] = some_handlers 615 | 616 | event = mock.Mock() 617 | event.headers = { 618 | 'Event-Name': 'CUSTOM', 619 | 'Event-Subclass': 'some-handlers', 620 | } 621 | protocol._esl_event_queue.put(event) 622 | 623 | protocol.process_events() 624 | self.assertTrue(protocol.after_handle.called) 625 | protocol.after_handle.assert_called_with(event) 626 | self.assertTrue(some_handlers[0].called) 627 | some_handlers[0].assert_called_with(event) 628 | self.assertTrue(some_handlers[1].called) 629 | some_handlers[1].assert_called_with(event) 630 | 631 | def test_stop(self): 632 | """ 633 | `stop` must, if connected, try to send "exit" 634 | but ignore any exception that `send` method may 635 | raise for `NotConnectedError` and keep 636 | process/receiving until closing the socket 637 | and its file. 638 | """ 639 | protocol = esl.ESLProtocol() 640 | protocol.connected = True 641 | protocol.send = mock.Mock() 642 | protocol.send.side_effect = esl.NotConnectedError() 643 | protocol._receive_events_greenlet = mock.Mock() 644 | protocol._process_events_greenlet = mock.Mock() 645 | protocol.sock = mock.Mock() 646 | protocol.sock_file = mock.Mock() 647 | 648 | protocol.stop() 649 | self.assertTrue(protocol.send.called) 650 | protocol.send.assert_called_with('exit') 651 | self.assertTrue(protocol._receive_events_greenlet.join.called) 652 | self.assertTrue(protocol._process_events_greenlet.join.called) 653 | self.assertTrue(protocol.sock.close.called) 654 | self.assertTrue(protocol.sock_file.close.called) 655 | 656 | -------------------------------------------------------------------------------- /tests/test_outbound_session.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import mock 5 | import unittest 6 | import pytest 7 | 8 | from greenswitch import esl 9 | 10 | 11 | @pytest.mark.usefixtures("outbound_session") 12 | @pytest.mark.usefixtures("disconnect_event") 13 | class TestOutboundSession(unittest.TestCase): 14 | def test_outbound_connected_is_updated_during_on_disconnected_event(self): 15 | self.outbound_session.connect() 16 | self.assertTrue(self.outbound_session._outbound_connected) 17 | 18 | self.outbound_session.on_disconnect(self.disconnect_event) 19 | self.assertFalse(self.outbound_session._outbound_connected) 20 | 21 | def test_outbound_connected_is_updated_during_on_hangup_event(self): 22 | self.outbound_session.connect() 23 | self.outbound_session.on_hangup(mock.MagicMock()) 24 | 25 | with self.assertRaises(esl.OutboundSessionHasGoneAway): 26 | self.outbound_session.raise_if_disconnected() 27 | 28 | def test_raising_OutboundSessionHasGoneAway_in_session_connect_will_call_sock_close(self): 29 | # Here we are socket connected, but not freeswitch connected 30 | # So any exception raised in session.connect should call sock.close 31 | self.outbound_session.connected = False 32 | 33 | # make outbound_session.send raise OutboundSessionHasGoneAway 34 | self.outbound_session.send = mock.MagicMock(side_effect=esl.OutboundSessionHasGoneAway) 35 | 36 | # mock sock.close 37 | self.outbound_session.sock.close = mock.MagicMock() 38 | 39 | # attempt to connect and assert sock.close is called 40 | with self.assertRaises(esl.OutboundSessionHasGoneAway): 41 | self.outbound_session.connect() 42 | 43 | assert self.outbound_session.sock.close.called 44 | 45 | 46 | @pytest.mark.usefixtures("outbound_session") 47 | @pytest.mark.usefixtures("disconnect_event") 48 | class TestWhileConnectedMethod(unittest.TestCase): 49 | def setUp(self): 50 | self.outbound_session.connect() 51 | self.execute_slow_task = mock.MagicMock() 52 | 53 | def simulate_caller_hangup(self): 54 | self.outbound_session.on_disconnect(self.disconnect_event) 55 | 56 | def test_context_manager(self): 57 | with self.assertRaises(esl.OutboundSessionHasGoneAway): 58 | with self.outbound_session.while_connected(): 59 | self.simulate_caller_hangup() 60 | self.execute_slow_task() 61 | 62 | self.execute_slow_task.assert_called() 63 | 64 | def test_decorator(self): 65 | @self.outbound_session.while_connected() 66 | def myflow(): 67 | self.simulate_caller_hangup() 68 | self.execute_slow_task() 69 | 70 | with self.assertRaises(esl.OutboundSessionHasGoneAway): 71 | myflow() 72 | 73 | self.execute_slow_task.assert_called() 74 | 75 | def test_skip_code_execution_if_the_outbound_session_is_disconnected(self): 76 | self.simulate_caller_hangup() 77 | 78 | with self.assertRaises(esl.OutboundSessionHasGoneAway): 79 | with self.outbound_session.while_connected(): 80 | self.execute_slow_task() 81 | 82 | self.execute_slow_task.assert_not_called() 83 | 84 | def test_raise_value_error_while_the_call_is_active(self): 85 | with self.assertRaises(ValueError): 86 | with self.outbound_session.while_connected(): 87 | raise ValueError("http exception") 88 | 89 | def test_raising_OutboundSessionHasGoneAway_in_session_stop_will_pass(self): 90 | # make outbound_session.send raise OutboundSessionHasGoneAway 91 | self.outbound_session.send = mock.MagicMock(side_effect=esl.OutboundSessionHasGoneAway) 92 | # mock sock.close 93 | self.outbound_session.sock.close = mock.MagicMock() 94 | 95 | # stop and assert sock.close is called 96 | self.outbound_session.stop() 97 | assert self.outbound_session.sock.close.called 98 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36,py37,py38,py39,py310 3 | [testenv] 4 | deps= 5 | -rrequirements.txt 6 | -rtest_requirements.txt 7 | commands= pytest --spec -s tests/ 8 | --------------------------------------------------------------------------------