diff --git a/contrib/vertobot/.gitignore b/contrib/vertobot/.gitignore new file mode 100644 index 000000000..e501ebe17 --- /dev/null +++ b/contrib/vertobot/.gitignore @@ -0,0 +1 @@ +vuc.yaml diff --git a/contrib/vertobot/bot.pl b/contrib/vertobot/bot.pl new file mode 100644 index 000000000..30cde41ce --- /dev/null +++ b/contrib/vertobot/bot.pl @@ -0,0 +1,288 @@ +#!/usr/bin/env perl + +use strict; +use warnings; +use 5.010; # // +use IO::Socket::SSL qw(SSL_VERIFY_NONE); +use IO::Async::Loop; +use Net::Async::WebSocket::Client; +use Net::Async::Matrix 0.11; +use JSON; +use YAML; +use Data::UUID; +use Getopt::Long; +use Digest::SHA qw( hmac_sha1_base64 ); +use Data::Dumper; + +binmode STDOUT, ":encoding(UTF-8)"; +binmode STDERR, ":encoding(UTF-8)"; + +my $loop = IO::Async::Loop->new; +# Net::Async::HTTP + SSL + IO::Poll doesn't play well. See +# https://rt.cpan.org/Ticket/Display.html?id=93107 +ref $loop eq "IO::Async::Loop::Poll" and + warn "Using SSL with IO::Poll causes known memory-leaks!!\n"; + +GetOptions( + 'C|config=s' => \my $CONFIG, + 'eval-from=s' => \my $EVAL_FROM, +) or exit 1; + +if( defined $EVAL_FROM ) { + # An emergency 'eval() this file' hack + $SIG{HUP} = sub { + my $code = do { + open my $fh, "<", $EVAL_FROM or warn( "Cannot read - $!" ), return; + local $/; <$fh> + }; + + eval $code or warn "Cannot eval() - $@"; + }; +} + +defined $CONFIG or die "Must supply --config\n"; + +my %CONFIG = %{ YAML::LoadFile( $CONFIG ) }; + +my %MATRIX_CONFIG = %{ $CONFIG{matrix} }; +# No harm in always applying this +$MATRIX_CONFIG{SSL_verify_mode} = SSL_VERIFY_NONE; + +# Track every Room object, so we can ->leave them all on shutdown +my %bot_matrix_rooms; + +my $bridgestate = {}; +my $roomid_by_callid = {}; + +my $bot_verto = Net::Async::WebSocket::Client->new( + on_frame => sub { + my ( $self, $frame ) = @_; + warn "[Verto] receiving $frame"; + on_verto_json($frame); + }, +); +$loop->add( $bot_verto ); + +my $bot_matrix = Net::Async::Matrix->new( + %MATRIX_CONFIG, + on_log => sub { warn "log: @_\n" }, + on_invite => sub { + my ($matrix, $invite) = @_; + warn "[Matrix] invited to: " . $invite->{room_id} . " by " . $invite->{inviter} . "\n"; + + $matrix->join_room( $invite->{room_id} )->get; + }, + on_room_new => sub { + my ($matrix, $room) = @_; + + warn "[Matrix] have a room ID: " . $room->room_id . "\n"; + + $bot_matrix_rooms{$room->room_id} = $room; + + # log in to verto on behalf of this room + my $sessid = lc new Data::UUID->create_str(); + $bridgestate->{$room->room_id}->{sessid} = $sessid; + + $room->configure( + on_message => \&on_room_message, + ); + + Future->wait_all( + send_verto_json_request("login", { + 'login' => $CONFIG{'verto-dialog-params'}{'login'}, + 'passwd' => $CONFIG{'verto-config'}{'passwd'}, + 'sessid' => $sessid, + }), + )->get; + + # we deliberately don't paginate the room, as we only care about + # new calls + }, + on_unknown_event => \&on_unknown_event, + on_error => sub { + print STDERR "Matrix failure: @_\n"; + }, +); +$loop->add( $bot_matrix ); + +sub on_unknown_event +{ + my ($matrix, $event) = @_; + print Dumper($event); + + my $room_id = $event->{room_id}; + my %dp = %{$CONFIG{'verto-dialog-params'}}; + $dp{callID} = $bridgestate->{$room_id}->{callid}; + + if ($event->{type} eq 'm.call.invite') { + $bridgestate->{$room_id}->{matrix_callid} = $event->{content}->{call_id}; + $bridgestate->{$room_id}->{callid} = lc new Data::UUID->create_str(); + $bridgestate->{$room_id}->{offer} = $event->{content}->{offer}->{sdp}; + $bridgestate->{$room_id}->{gathered_candidates} = 0; + $roomid_by_callid->{ $bridgestate->{$room_id}->{callid} } = $room_id; + # no trickle ICE in verto apparently + } + elsif ($event->{type} eq 'm.call.candidates') { + # XXX: compare call IDs + if (!$bridgestate->{$room_id}->{gathered_candidates}) { + $bridgestate->{$room_id}->{gathered_candidates} = 1; + my $offer = $bridgestate->{$room_id}->{offer}; + my $candidate_block = ""; + foreach (@{$event->{content}->{candidates}}) { + $candidate_block .= "a=" . $_->{candidate} . "\r\n"; + } + # XXX: collate using the right m= line - for now assume audio call + $offer =~ s/(a=rtcp.*[\r\n]+)/$1$candidate_block/; + + send_verto_json_request("verto.invite", { + "sdp" => $offer, + "dialogParams" => \%dp, + "sessid" => $bridgestate->{$room_id}->{sessid}, + })->get; + } + else { + # ignore them, as no trickle ICE, although we might as well + # batch them up + # foreach (@{$event->{content}->{candidates}}) { + # push @{$bridgestate->{$room_id}->{candidates}}, $_; + # } + } + } + elsif ($event->{type} eq 'm.call.hangup') { + send_verto_json_request("verto.bye", { + "dialogParams" => \%dp, + "sessid" => $bridgestate->{$room_id}->{sessid}, + })->get; + } + else { + warn "Unhandled event: $event->{type}"; + } +} + +sub on_room_message +{ + my ($room, $from, $content) = @_; + my $room_id = $room->room_id; + warn "[Matrix] in $room_id: $from: " . $content->{body} . "\n"; +} + +my $verto_connecting = $loop->new_future; +$bot_verto->connect( + %{ $CONFIG{"verto-bot"} }, + on_connected => sub { + warn("[Verto] connected to websocket"); + $verto_connecting->done($bot_verto) if not $verto_connecting->is_done; + }, + on_connect_error => sub { die "Cannot connect to verto - $_[-1]" }, + on_resolve_error => sub { die "Cannot resolve to verto - $_[-1]" }, +); + +Future->needs_all( + $bot_matrix->login( %{ $CONFIG{"matrix-bot"} } )->then( sub { + $bot_matrix->start; + }), + + $verto_connecting, +)->get; + +$loop->attach_signal( + PIPE => sub { warn "pipe\n" } +); +$loop->attach_signal( + INT => sub { $loop->stop }, +); +$loop->attach_signal( + TERM => sub { $loop->stop }, +); + +eval { + $loop->run; +} or my $e = $@; + +# When the bot gets shut down, have it leave the rooms so it's clear to observers +# that it is no longer running. +# if( $CONFIG{"leave-on-shutdown"} // 1 ) { +# print STDERR "Removing bot from Matrix rooms...\n"; +# Future->wait_all( map { $_->leave->else_done() } values %bot_matrix_rooms )->get; +# } +# else { +# print STDERR "Leaving bot users in Matrix rooms.\n"; +# } + +die $e if $e; + +exit 0; + +{ + my $json_id; + my $requests; + + sub send_verto_json_request + { + $json_id ||= 1; + + my ($method, $params) = @_; + my $json = { + jsonrpc => "2.0", + method => $method, + params => $params, + id => $json_id, + }; + my $text = JSON->new->encode( $json ); + warn "[Verto] sending $text"; + $bot_verto->send_frame ( $text ); + my $request = $loop->new_future; + $requests->{$json_id} = $request; + $json_id++; + return $request; + } + + sub send_verto_json_response + { + my ($result, $id) = @_; + my $json = { + jsonrpc => "2.0", + result => $result, + id => $id, + }; + my $text = JSON->new->encode( $json ); + warn "[Verto] sending $text"; + $bot_verto->send_frame ( $text ); + } + + sub on_verto_json + { + my $json = JSON->new->decode( $_[0] ); + if ($json->{method}) { + if ($json->{method} eq 'verto.answer') { + + my $room_id = $roomid_by_callid->{$json->{params}->{callID}}; + my $room = $bot_matrix_rooms{$room_id}; + + # HACK HACK HACK HACK + $room->_do_POST_json( "/send/m.call.answer", { + call_id => $bridgestate->{$room_id}->{matrix_callid}, + version => 0, + answer => { + sdp => $json->{params}->{sdp}, + type => "answer", + }, + })->then( sub { + send_verto_json_response( { + method => "verto.answer", + }, $json->{id}); + })->get; + } + else { + warn ("[Verto] unhandled method: " . $json->{method}); + } + } + elsif ($json->{result}) { + $requests->{$json->{id}}->done($json->{result}); + } + elsif ($json->{error}) { + $requests->{$json->{id}}->fail($json->{error}->{message}, $json->{error}); + } + } +} + diff --git a/contrib/vertobot/config.yaml b/contrib/vertobot/config.yaml new file mode 100644 index 000000000..b251e405f --- /dev/null +++ b/contrib/vertobot/config.yaml @@ -0,0 +1,33 @@ +# Generic Matrix connection params +matrix: + server: 'matrix.org' + SSL: 1 + +# Bot-user connection details +matrix-bot: + user_id: '@vertobot:matrix.org' + password: '' + +# Extra registration params required for creating new "ghost" users +matrix-register: + captcha_bypass_secret: '' + +verto-bot: + host: webrtc.freeswitch.org + service: 8081 + url: "ws://webrtc.freeswitch.org:8081/" + +verto-config: + passwd: 1234 + +verto-dialog-params: + useVideo: false + useStereo: false + tag: "webcam" + login: "1008@webrtc.freeswitch.org" + destination_number: "9664" + caller_id_name: "FreeSWITCH User" + caller_id_number: "1008" + callID: "" + remote_caller_id_name: "Outbound Call" + remote_caller_id_number: "9664" diff --git a/contrib/vertobot/cpanfile b/contrib/vertobot/cpanfile new file mode 100644 index 000000000..239258d4a --- /dev/null +++ b/contrib/vertobot/cpanfile @@ -0,0 +1,17 @@ +requires 'parent', 0; +requires 'Future', '>= 0.29'; +requires 'Net::Async::Matrix', '>= 0.11'; +requires 'Net::Async::Matrix::Utils'; +requires 'Net::Async::WebSocket::Protocol', 0; +requires 'Data::UUID', 0; +requires 'IO::Async', '>= 0.63'; +requires 'IO::Async::SSL', 0; +requires 'IO::Socket::SSL', 0; +requires 'YAML', 0; +requires 'JSON', 0; +requires 'Getopt::Long', 0; + +on 'test' => sub { + requires 'Test::More', '>= 0.98'; +}; + diff --git a/contrib/vertobot/verto-example.json b/contrib/vertobot/verto-example.json new file mode 100644 index 000000000..e0230498a --- /dev/null +++ b/contrib/vertobot/verto-example.json @@ -0,0 +1,207 @@ +# JSON is shown in *reverse* chronological order. +# Send v. Receive is implicit. + +{ + "jsonrpc": "2.0", + "id": 7, + "result": { + "callID": "12795aa6-2a8d-84ee-ce63-2e82ffe825ef", + "message": "CALL ENDED", + "causeCode": 16, + "cause": "NORMAL_CLEARING", + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + } +} + +{ + "jsonrpc": "2.0", + "method": "verto.bye", + "params": { + "dialogParams": { + "useVideo": false, + "useStereo": true, + "tag": "webcam", + "login": "1008@webrtc.freeswitch.org", + "destination_number": "9664", + "caller_id_name": "FreeSWITCH User", + "caller_id_number": "1008", + "callID": "12795aa6-2a8d-84ee-ce63-2e82ffe825ef", + "remote_caller_id_name": "Outbound Call", + "remote_caller_id_number": "9664" + }, + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + }, + "id": 7 +} + +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "callID": "12795aa6-2a8d-84ee-ce63-2e82ffe825ef", + "action": "toggleHold", + "holdState": "active", + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + } +} + +{ + "jsonrpc": "2.0", + "method": "verto.modify", + "params": { + "action": "toggleHold", + "dialogParams": { + "useVideo": false, + "useStereo": true, + "tag": "webcam", + "login": "1008@webrtc.freeswitch.org", + "destination_number": "9664", + "caller_id_name": "FreeSWITCH User", + "caller_id_number": "1008", + "callID": "12795aa6-2a8d-84ee-ce63-2e82ffe825ef", + "remote_caller_id_name": "Outbound Call", + "remote_caller_id_number": "9664" + }, + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + }, + "id": 6 +} + +{ + "jsonrpc": "2.0", + "id": 5, + "result": { + "callID": "12795aa6-2a8d-84ee-ce63-2e82ffe825ef", + "action": "toggleHold", + "holdState": "held", + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + } +} + +{ + "jsonrpc": "2.0", + "method": "verto.modify", + "params": { + "action": "toggleHold", + "dialogParams": { + "useVideo": false, + "useStereo": true, + "tag": "webcam", + "login": "1008@webrtc.freeswitch.org", + "destination_number": "9664", + "caller_id_name": "FreeSWITCH User", + "caller_id_number": "1008", + "callID": "12795aa6-2a8d-84ee-ce63-2e82ffe825ef", + "remote_caller_id_name": "Outbound Call", + "remote_caller_id_number": "9664" + }, + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + }, + "id": 5 +} + +{ + "jsonrpc": "2.0", + "id": 349819, + "result": { + "method": "verto.answer" + } +} + +{ + "jsonrpc": "2.0", + "id": 349819, + "method": "verto.answer", + "params": { + "callID": "12795aa6-2a8d-84ee-ce63-2e82ffe825ef", + "sdp": "v=0\no=FreeSWITCH 1417101432 1417101433 IN IP4 209.105.235.10\ns=FreeSWITCH\nc=IN IP4 209.105.235.10\nt=0 0\na=msid-semantic: WMS jA3rmwLVwUq1iE6TYEYHeLk2YTUlh1Vq\nm=audio 30134 RTP/SAVPF 111 126\na=rtpmap:111 opus/48000/2\na=fmtp:111 minptime=10; stereo=1\na=rtpmap:126 telephone-event/8000\na=silenceSupp:off - - - -\na=ptime:20\na=sendrecv\na=fingerprint:sha-256 F8:72:18:E9:72:89:99:22:5B:F8:B6:C6:C6:0D:C5:9B:B2:FB:BC:CA:8D:AB:13:8A:66:E1:37:38:A0:16:AA:41\na=rtcp-mux\na=rtcp:30134 IN IP4 209.105.235.10\na=ssrc:210967934 cname:rOIEajpw4FocakWY\na=ssrc:210967934 msid:jA3rmwLVwUq1iE6TYEYHeLk2YTUlh1Vq a0\na=ssrc:210967934 mslabel:jA3rmwLVwUq1iE6TYEYHeLk2YTUlh1Vq\na=ssrc:210967934 label:jA3rmwLVwUq1iE6TYEYHeLk2YTUlh1Vqa0\na=ice-ufrag:OKwTmGLapwmxn7OF\na=ice-pwd:MmaMwq8rVmtWxfLbQ7U2Ew3T\na=candidate:2372654928 1 udp 659136 209.105.235.10 30134 typ host generation 0\n" + } +} + +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "message": "CALL CREATED", + "callID": "12795aa6-2a8d-84ee-ce63-2e82ffe825ef", + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + } +} + +{ + "jsonrpc": "2.0", + "method": "verto.invite", + "params": { + "sdp": "v=0\r\no=- 1381685806032722557 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE audio\r\na=msid-semantic: WMS 6OOMyGAyJakjwaOOBtV7WcBCCuIW6PpuXsNg\r\nm=audio 63088 RTP/SAVPF 111 103 104 0 8 106 105 13 126\r\nc=IN IP4 81.138.8.249\r\na=rtcp:63088 IN IP4 81.138.8.249\r\na=candidate:460398169 1 udp 2122260223 10.10.79.10 49945 typ host generation 0\r\na=candidate:460398169 2 udp 2122260223 10.10.79.10 49945 typ host generation 0\r\na=candidate:3460887983 1 udp 2122194687 192.168.1.64 63088 typ host generation 0\r\na=candidate:3460887983 2 udp 2122194687 192.168.1.64 63088 typ host generation 0\r\na=candidate:945327227 1 udp 1685987071 81.138.8.249 63088 typ srflx raddr 192.168.1.64 rport 63088 generation 0\r\na=candidate:945327227 2 udp 1685987071 81.138.8.249 63088 typ srflx raddr 192.168.1.64 rport 63088 generation 0\r\na=candidate:1441981097 1 tcp 1518280447 10.10.79.10 0 typ host tcptype active generation 0\r\na=candidate:1441981097 2 tcp 1518280447 10.10.79.10 0 typ host tcptype active generation 0\r\na=candidate:2160789855 1 tcp 1518214911 192.168.1.64 0 typ host tcptype active generation 0\r\na=candidate:2160789855 2 tcp 1518214911 192.168.1.64 0 typ host tcptype active generation 0\r\na=ice-ufrag:cP4qeRhn0LpcpA88\r\na=ice-pwd:fREmgSkXsDLGUUH1bwfrBQhW\r\na=ice-options:google-ice\r\na=fingerprint:sha-256 AF:35:64:1B:62:8A:EF:27:AE:2B:88:2E:FE:78:29:0B:08:DA:64:6C:DE:02:57:E3:EE:B1:D7:86:B8:36:8F:B0\r\na=setup:actpass\r\na=mid:audio\r\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=sendrecv\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 minptime=10; stereo=1\r\na=rtpmap:103 ISAC/16000\r\na=rtpmap:104 ISAC/32000\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:106 CN/32000\r\na=rtpmap:105 CN/16000\r\na=rtpmap:13 CN/8000\r\na=rtpmap:126 telephone-event/8000\r\na=maxptime:60\r\na=ssrc:558827154 cname:vdKHBNqa17t2gmE3\r\na=ssrc:558827154 msid:6OOMyGAyJakjwaOOBtV7WcBCCuIW6PpuXsNg bf1303fb-9833-4d7d-b9e4-b32cfe04acc3\r\na=ssrc:558827154 mslabel:6OOMyGAyJakjwaOOBtV7WcBCCuIW6PpuXsNg\r\na=ssrc:558827154 label:bf1303fb-9833-4d7d-b9e4-b32cfe04acc3\r\n", + "dialogParams": { + "useVideo": false, + "useStereo": true, + "tag": "webcam", + "login": "1008@webrtc.freeswitch.org", + "destination_number": "9664", + "caller_id_name": "FreeSWITCH User", + "caller_id_number": "1008", + "callID": "12795aa6-2a8d-84ee-ce63-2e82ffe825ef", + "remote_caller_id_name": "Outbound Call", + "remote_caller_id_number": "9664" + }, + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + }, + "id": 4 +} + +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "message": "logged in", + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + } +} + +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32000, + "message": "Authentication Required" + } +} + +{ + "jsonrpc": "2.0", + "method": "login", + "params": { + "login": "1008@webrtc.freeswitch.org", + "passwd": "1234", + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + }, + "id": 3 +} + +{ + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32000, + "message": "Authentication Required" + } +} + +{ + "jsonrpc": "2.0", + "method": "login", + "params": { + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + }, + "id": 1 +} + +{ + "jsonrpc": "2.0", + "method": "login", + "params": { + "sessid": "03a11060-3e14-23b6-c620-51b892c52983" + }, + "id": 2 +}