├── .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 |
--------------------------------------------------------------------------------