Repository navigation
Expand file tree
/
Copy pathsylk-session
More file actions
692 lines (629 loc) · 32.6 KB
/
Copy pathsylk-session
File metadata and controls
692 lines (629 loc) · 32.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
#!/usr/bin/env python3
# Copyright (C) 2026 AG Projects (https://ag-projects.com)
# Licensed under the GNU General Public License, version 3 or later (see LICENSE).
"""Call someone (1 to 1), or take calls, over WebRTC through SylkServer, without a
SIP stack: the gateway places the SIP call for the account (its session API).
sylk-session alice call alice at the account's domain
sylk-session alice@example.com call alice@example.com
sylk-session register and wait for calls: each one asks whether to accept
it (y/n), or --auto-answer
Audio both ways, and video: ours from the camera attached (an external one when
plugged in, else the built-in one; -c none for none), theirs in a window (the SIP
SIMPLE clients' video window when sipclients3 is installed, else GStreamer's own).
The account, the general settings and the local cameras are those of sylk-conference
(~/.sylkclient/, managed with sylk-settings).
The terminal takes single keys (no Enter), h or ? lists them. Waiting: c call someone,
/ a command, q quit. A call ringing in: y accept, n reject. In a call: m mute/unmute,
s RTP statistics, i call info, x hang up, 0-9 * # DTMF, Enter to type a message to
the far end, / a command. Ctrl-C hangs up; Ctrl-C again quits.
"""
import argparse
import importlib.util
import logging
import os
import signal
import sys
import threading
from application.notification import IObserver, NotificationCenter
from application.python import Null
from twisted.internet import reactor, stdio
from twisted.internet.task import deferLater
from zope.interface import implementer
from sylkrtc import sipconfig
from sylkrtc.call import Account
from sylkrtc.cli import Console, Statistics, addresses, report_discovery, report_server_info, select_cameras, setup_logging, start_log_file
from sylkrtc.discovery import Discovery
from sylkrtc.rooms import RoomTarget
from sylkrtc.videoroom import Identity
log = logging.getLogger('sylk-session')
from sylkrtc.device import USER_AGENT # one device for all the tools (sylkrtc.device)
KEYS_SHORT = {'call': 'm mute, s statistics, i info, x hang up, 0-9*# DTMF, Enter message, / command, h or ? help'}
KEYS_HELP = {
'idle': '''* keys (no Enter):
c call someone (type user or user@domain)
/ type a command (/call, /quit, ...)
q quit
h ? this''',
'incoming': '''* a call is ringing in:
y accept it
n reject it
/ type a command
h ? this''',
'calling': '''* calling:
x hang up
/ type a command
h ? this''',
'call': '''* in a call, one key each:
m mute / unmute the microphone
s RTP statistics now: per stream rate, bytes, round trip time, loss; then the totals
i who, how long, muted, video codec
x hang up
0-9*# DTMF (SIP INFO)
Enter type a message to the far end (one line; t too)
/ type a command (/hangup, /mute, /stats, /dtmf, /quit, ...)
h ? this''',
}
KEYS_HELP = {context: text + '\n Ctrl-C ' + {'idle': 'quit', 'incoming': 'reject it; Ctrl-C again quits'}.get(context, 'hang up; Ctrl-C again quits')
for context, text in KEYS_HELP.items()}
REGISTER_RETRY = [5, 10, 20, 40, 60]
def parse_arguments():
parser = argparse.ArgumentParser(description='Call someone (1 to 1) over WebRTC through SylkServer, or wait for calls.')
parser.add_argument('target', nargs='?', help='who to call: user (at the account\'s domain) or user@domain; '
'without it, wait for calls')
parser.add_argument('-d', '--config-dir', help='the folder whose config file holds the account (default: ~/.sylkclient/, else the credentials '
'of the SIP SIMPLE clients\' ~/.sipclient/ or of Blink\'s ~/.blink/)')
parser.add_argument('-a', '--account', help='the account to use (default: the default account)')
parser.add_argument('-c', '--local-camera', action='append', nargs='?', const='all', default=[], metavar='CAMERA',
help='our video: by default the camera attached, an external (USB) one when plugged in, else the built-in one; '
'none (false, 0): no video of ours; a configured local camera by name or id, or LABEL=/dev/videoN or '
'LABEL=test[:pattern], optionally followed by ,resolution=640x360 ,fps=15')
parser.add_argument('--video-output', metavar='OUTPUT',
help='where the far end\'s video goes: window (the SIP SIMPLE clients\' video window, sipclients3), auto (a GStreamer window), '
'none (not received), or a GStreamer sink description; default: window when sipclients3 is installed, else auto '
'when there is a display, else none')
parser.add_argument('--video-size', default='1280x720', metavar='WxH',
help='the size the far end\'s video is shown at, its aspect kept (default %(default)s; native: as it comes)')
parser.add_argument('--audio-input', help='auto, none or a GStreamer source description')
parser.add_argument('--audio-output', help='auto, none or a GStreamer sink description')
parser.add_argument('--muted', action='store_true', default=None, help='start with the microphone muted')
parser.add_argument('--auto-answer', type=float, nargs='?', const=0, default=None, metavar='SECONDS',
help='answer incoming calls by themselves (after this many seconds, default 0)')
parser.add_argument('--discover', action='store_true', help='only look up the SylkServer configuration of the account\'s domain, cache it and show it')
parser.add_argument('--server-info', action='store_true', help='connect to the SylkServer of the account and report its version, '
'what it supports and whether it takes the account')
parser.add_argument('--stats-interval', type=float, default=10, metavar='SECONDS',
help='log the bandwidth, round trip time and loss of the call this often (default %(default)g, 0: never); a summary is logged at its end')
parser.add_argument('--auto-disconnect', type=float, default=0, metavar='SECONDS',
help='hang up this many seconds after the call is established (default %(default)g: no limit)')
parser.add_argument('--no-input', action='store_true', help='do not read messages and commands from the terminal')
parser.add_argument('-v', '--verbose', '--debug', dest='debug', action='store_true', help='log debug messages')
parser.add_argument('--sdp', action='store_true', help='log the SDP offers and answers exchanged')
parser.add_argument('-t', '--trace', action='store_true', help='log the sylkRTC messages')
return parser.parse_args()
def video_window_available():
"""Whether the SIP SIMPLE clients' video window (sipclients3: sipclient.video and its window process) is installed."""
try:
return importlib.util.find_spec('sipclient.video') is not None and importlib.util.find_spec('sipclient._video_window_proc') is not None
except (ImportError, ValueError):
return False
class RemoteVideo(object):
"""A frame handler that opens sipclient.video.VideoWindow on the first frame (from GStreamer's thread)."""
def __init__(self, title):
self.title = title
self.window = None
self.closed = False
self._lock = threading.Lock()
def __call__(self, frame):
with self._lock:
if self.closed:
return
if self.window is None:
from sipclient.video import VideoWindow
self.window = VideoWindow(title=self.title)
self.window.start()
window = self.window
window.frame_handler(frame)
@property
def closed_by_user(self):
"""The window was opened, then closed (by the user: we did not close it)."""
window = self.window
return window is not None and not self.closed and not window.is_alive()
def update_stats(self, stats):
if self.window is not None:
self.window.update_stats(stats)
def close(self):
with self._lock:
self.closed = True
window, self.window = self.window, None
if window is not None:
window.close()
@implementer(IObserver)
class SylkCall(object):
def __init__(self, arguments):
self.arguments = arguments
os.makedirs(sipconfig.DEFAULT_DIRECTORY, mode=0o700, exist_ok=True)
try:
configuration = sipconfig.SylkConfiguration()
except sipconfig.ConfigError as e:
raise SystemExit(str(e))
reporting = arguments.discover or arguments.server_info
try:
self.account_info, self.config_dir = sipconfig.locate_account(arguments.account, os.path.expanduser(arguments.config_dir) if arguments.config_dir else None)
except sipconfig.ConfigError as e:
raise SystemExit(str(e))
if not self.account_info.password and not reporting:
raise SystemExit(f'{self.account_info.id} has no password in {os.path.join(self.config_dir, "config")}')
self.audio_input = arguments.audio_input if arguments.audio_input is not None else configuration.audio_input
self.audio_output = arguments.audio_output if arguments.audio_output is not None else configuration.audio_output
self.muted = arguments.muted if arguments.muted is not None else configuration.muted
self.verify_tls = configuration.tls_verify_server
if any(text.lower() == 'all' for text in arguments.local_camera):
raise SystemExit('-c: a call sends one camera, name it (or leave -c out for the one attached)')
cameras = select_cameras(configuration, [], arguments.local_camera, default_local=not reporting)
self.camera = cameras[0] if cameras else None
output = (arguments.video_output or '').strip()
if not output:
if video_window_available():
output = 'window'
elif os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY') or sys.platform == 'darwin':
output = 'auto'
else:
output = 'none'
elif output == 'window' and not video_window_available():
raise SystemExit('--video-output window: the SIP SIMPLE clients\' video window (sipclients3, sipclient.video) is not installed')
self.video_output = output
size = (arguments.video_size or 'native').strip().lower()
if size == 'native':
self.video_size = None
else:
try:
width, height = (int(value) for value in size.split('x'))
except ValueError:
raise SystemExit(f'--video-size {arguments.video_size}: expected WxH (1280x720) or native')
self.video_size = (width - width % 2, height - height % 2)
self.target = addresses([arguments.target], self.account_info.domain)[0] if arguments.target else None
self.discovery = Discovery(os.path.join(sipconfig.DEFAULT_DIRECTORY, 'sylkserver'))
self.identity = Identity.from_credentials(self.account_info.id, self.account_info.password, self.account_info.display_name,
auth_username=self.account_info.auth_username, instance_id=self.account_info.instance_id)
self.server = None # RoomTarget of the account's domain: wsServer and ICE servers
self.account = None
self.call = None
self.window = None
self.stopping = False
self.register_attempt = 0
self._auto_disconnect = None
self._auto_answer = None
self.waiting_call = None # a second call ringing in during a call: y takes it, n rejects it
self.switch_to = None # the waiting call to answer once the current one is gone
self.statistics = Statistics(arguments.stats_interval)
self._hud_timer = None
self._hud_previous = None # (time, bytes sent, bytes received)
NotificationCenter().add_observer(self)
# Start
def start(self):
if self.arguments.discover:
report_discovery(self.discovery, self.account_info, self.config_dir, [self.account_info.domain])
return
if self.arguments.server_info:
report_server_info(self.discovery, self.account_info, self.config_dir, self.account_info.domain, USER_AGENT, self.verify_tls)
return
from sylkrtc.gst import media_missing, media_report
log.info(f'{USER_AGENT}, {media_report()}')
missing = media_missing()
if missing:
log.error('missing: ' + ', '.join(f'{what} (install {package})' for what, package in missing))
reactor.stop()
return
log.info(f'account {self.account_info.id} (from {os.path.join(self.config_dir, "config")})'
+ (f', our video {self.camera[0]}' if self.camera else ', no video of ours')
+ f', their video: {self.video_output}')
self.discovery.discover(self.account_info.domain).addCallback(self._configured)
def _configured(self, configuration):
if not configuration or not configuration.get('wsServer'):
self.fail(f'{self.account_info.domain} publishes no SylkServer configuration with a wsServer')
return
self.server = RoomTarget('room', self.account_info.domain, configuration)
self.account = Account(self.identity, self.server.ws_server, user_agent=USER_AGENT, verify_tls=self.verify_tls)
if self.target:
self.place_call(self.target)
else:
log.info(f'registering {self.account_info.id} at {self.server.ws_server} to take calls')
self.account.register()
def fail(self, reason):
log.error(reason)
if reactor.running:
reactor.stop()
# Calls
def media_factory(self, call):
from sylkrtc.gst import CallMedia
output = self.video_output
if output == 'none':
output = None
elif output == 'window':
self.window = output = RemoteVideo(f'{call.peer} - sylk-session')
return CallMedia(call, audio_input=self.audio_input, audio_output=self.audio_output,
video_source=self.camera[1] if self.camera else None, video_output=output, ice_servers=self.server.ice_servers,
video_size=self.video_size)
def place_call(self, uri):
if self.call is not None:
print('* already in a call (/hangup first)')
return
log.info(f'calling {uri}')
self.interrupted = False
self.call = self.account.call(uri, self.media_factory, ice_servers=self.server.ice_servers, audio=not self.muted)
def answer(self):
call = self.call
if call is None or call.state != 'incoming':
print('* no call to answer')
return
if self._auto_answer is not None and self._auto_answer.active():
self._auto_answer.cancel()
call.answer(self.media_factory, ice_servers=self.server.ice_servers, audio=not self.muted)
def _auto_hangup(self):
self._auto_disconnect = None
if self.call is not None:
log.info(f'{self.arguments.auto_disconnect:g} s in the call: hanging up (--auto-disconnect)')
self.call.hangup()
def leave(self):
self.stopping = True
busy = self.call is not None and self.call.state not in ('idle', 'terminated')
if busy:
self.call.hangup()
self.statistics.stop()
if self.window is not None:
self.window.close()
if self.account is not None:
self.account.close()
return deferLater(reactor, 0.5, lambda: None) # the terminate and unregister requests go out
return None
# Notifications
def handle_notification(self, notification):
handler = getattr(self, '_NH_%s' % notification.name, Null)
handler(notification)
def _NH_SylkRTCAccountRegistrationDidChange(self, notification):
if notification.sender is not self.account:
return
state, reason = notification.data.state, notification.data.reason
if state == 'registered':
if self.register_attempt or not getattr(self, '_announced', False):
print(f'* registered as {self.account.address}: waiting for calls' + (' (answered by themselves)' if self.arguments.auto_answer is not None else '')
+ ('; h for the keys' if getattr(self, 'console', None) is not None else ', /answer or /reject them'))
self._announced = True
self.register_attempt = 0
elif state == 'failed' and not self.stopping:
delay = REGISTER_RETRY[min(self.register_attempt, len(REGISTER_RETRY) - 1)]
self.register_attempt += 1
log.warning(f'registration failed ({reason}): trying again in {delay} s')
reactor.callLater(delay, lambda: not self.stopping and self.account.register())
def _NH_SylkRTCAccountGotIncomingCall(self, notification):
call = notification.data.call
if self.target or (self.call is not None and (self.waiting_call is not None or getattr(self, 'console', None) is None)):
log.info(f'busy: rejecting the call from {call.peer}')
call.hangup('busy')
return
if self.call is not None:
# a second call: it may replace the current one
self.waiting_call = call
video = "with" if "m=video" in (call.offer or "") else "no"
print(f'* incoming call from {call.peer} ({video} video) while in a call with {self.call.peer}: '
f'accept it and hang up the call with {self.call.peer}? (y/n)\a', flush=True)
return
self.call = call
self.interrupted = False
video = "with" if "m=video" in (call.offer or "") else "no"
if self.arguments.auto_answer is not None:
print(f'* incoming call from {call.peer} ({video} video)')
self._auto_answer = reactor.callLater(self.arguments.auto_answer, self.answer)
elif getattr(self, 'console', None) is not None:
# as sip-audio-session3: one key, no Enter (keys())
print(f'* incoming call from {call.peer} ({video} video), do you want to accept? (y/n)\a', flush=True)
else:
print(f'* incoming call from {call.peer} ({video} video): /answer or /reject')
def _NH_SylkRTCAccountMissedCall(self, notification):
if notification.sender is self.account:
print(f'* missed call from {notification.data.originator}')
def _NH_SylkRTCCallDidChangeState(self, notification):
call = notification.sender
if call is self.waiting_call:
if notification.data.state == 'terminated':
print(f'* {call.peer} stopped calling: {notification.data.reason}')
self.waiting_call = None
if self.switch_to is call:
self.switch_to = None
return
if call is not self.call:
return
state = notification.data.state
if state == 'ringing':
print(f'* ringing {call.peer}')
elif state == 'established':
print(f'* in a call with {call.peer}' + (' (microphone muted)' if call.audio_muted else ''))
self.established_at = reactor.seconds()
if getattr(self, 'console', None) is not None:
print(f'* keys: {KEYS_SHORT["call"]}')
self.statistics.start(call)
self._hud_previous = None
if self._hud_timer is None:
from sylkrtc.util import RepeatingTimer
self._hud_timer = RepeatingTimer(1, self._update_hud)
self._hud_timer.start()
if self.arguments.auto_disconnect:
self._auto_disconnect = reactor.callLater(self.arguments.auto_disconnect, self._auto_hangup)
elif state == 'terminated':
print(f'* call with {call.peer} ended: {notification.data.reason}')
if self._auto_disconnect is not None and self._auto_disconnect.active():
self._auto_disconnect.cancel()
if self._auto_answer is not None and self._auto_answer.active():
self._auto_answer.cancel()
self._auto_disconnect = self._auto_answer = None
if self._hud_timer is not None:
self._hud_timer.stop()
self.statistics.summary()
self.statistics.ended()
if self.window is not None:
self.window.close()
self.window = None
self.call = None
# a call that rang in meanwhile: answered when it was chosen (y), else asked about again
waiting, switch = self.waiting_call, self.switch_to
self.waiting_call = self.switch_to = None
if waiting is not None and waiting.state == 'incoming' and not self.stopping:
self.call = waiting
if switch is waiting:
self.interrupted = False # after Ctrl-C the next one still quits
print(f'* answering the call from {waiting.peer}')
self.answer()
else:
print(f'* {waiting.peer} is still calling, do you want to accept? (y/n)\a', flush=True)
return
if self.target and not self.stopping and reactor.running:
reactor.stop() # a call placed from the command line: done
def _update_hud(self):
"""Every second: hang up when the video window was closed; the window's overlay (bandwidth, round trip
time, loss) every 2 s."""
call, window = self.call, self.window
if call is not None and window is not None and window.closed_by_user:
self._video_window_closed(call)
return
self._hud_tick = not getattr(self, '_hud_tick', False)
if not self._hud_tick:
return # the overlay every 2 s
if call is None or call.state != 'established' or window is None or call.media is None or call.media.webrtcbin is None:
return
from sylkrtc.gst import webrtcbin_stats
stats = webrtcbin_stats(call.media.webrtcbin)
now = reactor.seconds()
previous, self._hud_previous = self._hud_previous, (now, stats['sent_bytes'], stats['received_bytes'])
if previous is None:
return
elapsed = max(1e-3, now - previous[0])
sent = 8 * (stats['sent_bytes'] - previous[1]) / elapsed / 1000
received = 8 * (stats['received_bytes'] - previous[2]) / elapsed / 1000
total = stats['received_packets'] + stats['lost_received']
hud = dict(bandwidth_kbps=round(received), extra={'sent': f'{sent:.0f} kbit/s', 'received': f'{received:.0f} kbit/s',
'loss in': f'{100.0 * stats["lost_received"] / total:.1f}%' if total else '-'})
if stats['rtt'] is not None:
hud['rtt_ms'] = round(stats['rtt'] * 1000)
if stats['fraction_lost_sent'] is not None:
hud['packet_loss'] = round(100.0 * stats['fraction_lost_sent'], 1)
if call.media.video_codec:
hud['codec'] = call.media.video_codec
try:
window.update_stats(hud)
except Exception as e:
log.debug(f'video window statistics: {e}')
def _video_window_closed(self, call):
if call.state in ('idle', 'terminated'):
return
print('* video window closed: hanging up')
call.hangup('video window closed')
def _NH_SylkRTCCallVideoWindowDidClose(self, notification):
if notification.sender is self.call:
self._video_window_closed(notification.sender)
def _NH_SylkRTCCallWillTerminate(self, notification):
if notification.sender is self.call:
self.statistics.sample() # what the call carried, before its media goes
def _NH_SylkRTCCallGotRemoteVideo(self, notification):
if notification.sender is self.call:
log.info(f'their video: {notification.data.width}x{notification.data.height}'
+ (f', shown at {self.video_size[0]}x{self.video_size[1]}' if self.video_size else ''))
def _NH_SylkRTCCallGotMessage(self, notification):
if notification.sender is not self.call:
return
data = notification.data
if data.content_type.startswith('text/'):
print(f'{data.sender or self.call.peer}: {data.content}')
else:
print(f'{data.sender or self.call.peer}: [{data.content_type}, {len(data.content)} bytes]')
# Single keys, no Enter (sip-audio-session3 style), what each means depending on the call
def keys_context(self):
call = self.call
state = call.state if call is not None else None
if state is None or state == 'terminated':
return 'idle'
if state == 'incoming':
return 'incoming'
if state == 'established':
return 'call'
return 'calling'
def key(self, key):
"""One key typed. True when a line is typed next (a message, a command, who to call)."""
context = self.keys_context()
call = self.call
if key in ('?', 'h'):
print(KEYS_HELP[context])
if self.waiting_call is not None:
print(f'* {self.waiting_call.peer} is calling too:\n'
f' y accept it, hanging up the call with {call.peer}\n'
f' n reject it')
return False
waiting = self.waiting_call
if waiting is not None and key in 'yYnN':
if key in 'yY':
print(f'* accepted: hanging up the call with {call.peer} first')
self.switch_to = waiting
call.hangup()
else:
print(f'* rejected the call from {waiting.peer}')
self.waiting_call = None
waiting.reject()
return False
if key == '/':
self.line_purpose = 'command'
print('/', end='', flush=True)
return True
if context == 'idle':
if key == 'c':
if self.account is None:
print('* not connected yet')
return False
self.line_purpose = 'call'
print('call: ', end='', flush=True)
return True
if key == 'q':
self.interrupt()
return False
elif context == 'incoming':
if key in 'yY':
print('* accepted')
self.answer()
elif key in 'nN':
print('* rejected')
call.reject()
return False
elif context == 'calling':
if key == 'x':
print(f'* hanging up the call to {call.peer}')
call.hangup()
elif context == 'call':
if key in ('\n', 't'):
self.line_purpose = 'message'
print('message: ', end='', flush=True)
return True
if key == 'm':
call.set_audio_muted(not call.audio_muted)
print('* microphone muted' if call.audio_muted else '* microphone on')
elif key == 's':
lines = self.statistics.lines()
print('\n'.join(f'* {line}' for line in lines) if lines else '* no statistics yet')
elif key == 'i':
seconds = int(reactor.seconds() - (getattr(self, 'established_at', None) or reactor.seconds()))
codec = getattr(call.media, 'video_codec', None) if call.media is not None else None
print(f'* in a call with {call.peer} for {seconds // 60}:{seconds % 60:02d}'
+ (', microphone muted' if call.audio_muted else '') + (f', video {codec}' if codec else ''))
elif key == 'x':
print(f'* hanging up the call with {call.peer}')
call.hangup()
elif key in '0123456789*#':
if call.send_dtmf(key):
print(f'* DTMF {key}')
return False
def resume_keys(self):
"""Back to single keys (after a typed line, and at the start)."""
if getattr(self, 'console', None) is not None:
self.console.ask_key(self.key)
# Ctrl-C: the first hangs up the call (rejects one ringing in), the next one quits
def interrupt(self):
call = self.call
if call is not None and call.state != 'terminated' and not getattr(self, 'interrupted', False):
self.interrupted = True
if call.state == 'incoming':
print('\n* rejected (Ctrl-C again to quit)')
call.reject()
else:
print(f'\n* hanging up the call with {call.peer} (Ctrl-C again to quit)')
call.hangup()
return
if getattr(self, 'quitting', False):
return
self.quitting = True
print('\n* quitting')
try:
reactor.stop()
except Exception:
pass # already stopping
# The terminal
def input(self, line):
purpose, self.line_purpose = getattr(self, 'line_purpose', None), None
if purpose == 'command':
line = '/' + line if line else ''
elif purpose == 'call':
line = '/call ' + line if line else ''
try:
if purpose == 'message':
self._message(line) # as typed, even when it starts with /
else:
self._input(line)
finally:
self.resume_keys()
def _message(self, line):
if not line:
return
if getattr(self, 'log_file', None) is not None:
self.log_file.typed(line)
call = self.call
if call is None or call.state != 'established':
print('* not in a call')
return
call.send_message(line)
def _input(self, line):
if not line:
return
if getattr(self, 'log_file', None) is not None:
self.log_file.typed(line)
call = self.call
if line.startswith('/'):
command, _, rest = line[1:].partition(' ')
command, rest = command.lower(), rest.strip()
if command in ('quit', 'exit', 'q'):
reactor.stop()
elif command in ('answer', 'a'):
self.answer()
elif command in ('reject', 'r') and call is not None:
call.reject()
elif command in ('hangup', 'h', 'bye') and call is not None:
call.hangup()
elif command == 'call' and rest:
if self.account is None:
print('* not connected yet')
else:
self.place_call(addresses([rest], self.account_info.domain)[0])
elif command == 'mute' and call is not None:
call.set_audio_muted(True)
elif command == 'unmute' and call is not None:
call.set_audio_muted(False)
elif command == 'stats':
if call is None or call.state != 'established':
print('* not in a call')
else:
self.statistics.log()
elif command == 'dtmf' and call is not None and rest:
if not call.send_dtmf(rest.replace(' ', '')):
print('* not in a call')
else:
print('* /answer, /reject, /hangup, /call user[@domain], /mute, /unmute, /dtmf DIGITS, /stats, /quit; anything else goes to the far end as a message')
return
if call is None or call.state != 'established':
print('* not in a call')
return
call.send_message(line)
def main():
arguments = parse_arguments()
setup_logging(arguments)
log_file = start_log_file('sylk-session')
client = SylkCall(arguments)
client.log_file = log_file
client.console = None
if not arguments.no_input and sys.stdin.isatty():
client.console = Console(client)
stdio.StandardIO(client.console)
reactor.callWhenRunning(client.resume_keys)
reactor.addSystemEventTrigger('before', 'shutdown', client.leave) # SIGTERM (and the second Ctrl-C) stop the reactor
def install_sigint():
# after the reactor's own handlers: Ctrl-C hangs up first, quits the second time
signal.signal(signal.SIGINT, lambda signum, frame: reactor.callFromThread(client.interrupt))
reactor.callWhenRunning(install_sigint)
reactor.callWhenRunning(client.start)
reactor.run()
if __name__ == '__main__':
main()