From 0aa5e2e2028e3d4b717302173d397daa91037683 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Fri, 4 Mar 2016 16:50:40 -0800 Subject: [PATCH] Bandwidth.com provisioning, #39914 --- FS/FS/part_export.pm | 8 +- FS/FS/part_export/bandwidth_com.pm | 448 +++++++++++++++++++++++++++++++++++++ FS/FS/svc_phone.pm | 2 +- 3 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 FS/FS/part_export/bandwidth_com.pm diff --git a/FS/FS/part_export.pm b/FS/FS/part_export.pm index d6357fd73..182f47608 100644 --- a/FS/FS/part_export.pm +++ b/FS/FS/part_export.pm @@ -704,8 +704,12 @@ actual DIDs except at the lowest level. Generally, 'state' alone will return an array of area codes or region names in the state. -'state' and 'areacode' together will return an array of exchanges (NXX -prefixes), or for some exports, an array of ratecenter names. +'state' and 'areacode' together will return an array of either: +- exchange strings of the form "New York (212-555-XXXX)" +- ratecenter names of the form "New York, NY" + +These strings are sent back to the UI and offered as options so that the user +can choose the local calling area they like. 'areacode' and 'exchange', or 'state' and 'ratecenter', or 'region' by itself will return an array of actual DID numbers. diff --git a/FS/FS/part_export/bandwidth_com.pm b/FS/FS/part_export/bandwidth_com.pm new file mode 100644 index 000000000..7bb26e08d --- /dev/null +++ b/FS/FS/part_export/bandwidth_com.pm @@ -0,0 +1,448 @@ +package FS::part_export::bandwidth_com; + +use base qw( FS::part_export ); +use strict; + +use Tie::IxHash; +use LWP::UserAgent; +use URI; +use HTTP::Request::Common; +use Cache::FileCache; +use FS::Record qw(dbh qsearch); +use FS::queue; +use XML::LibXML::Simple qw(XMLin); +use XML::Writer; +use Try::Tiny; + +our $me = '[bandwidth.com]'; + +# cache NPA/NXX records, peer IDs, etc. +our %CACHE; # exportnum => cache +our $cache_timeout = 86400; # seconds + +our $API_VERSION = 'v1.0'; + +tie my %options, 'Tie::IxHash', + 'accountId' => { label => 'Account ID' }, + 'username' => { label => 'API username', }, + 'password' => { label => 'API password', }, + 'siteId' => { label => 'Site ID' }, + 'num_dids' => { label => 'Maximum available phone numbers to show', + default => '20' + }, + 'debug' => { label => 'Debugging', + type => 'select', + options => [ 0, 1, 2 ], + option_labels => { + 0 => 'none', + 1 => 'terse', + 2 => 'verbose', + } + }, + 'test' => { label => 'Use test server', type => 'checkbox', value => 1 }, +; + +our %info = ( + 'svc' => [qw( svc_phone )], + 'desc' => 'Provision DIDs to Bandwidth.com', + 'options' => \%options, + 'no_machine' => 1, + 'notes' => <<'END' +

Export to bandwidth.com interconnected VoIP service.

+

Bandwidth.com uses a SIP peering architecture. Each phone number is routed +to a specific peer, which comprises one or more IP addresses. The IP address +will be taken from the "sip_server" field of the phone service. If no peer +with this IP address exists, one will be created.

+

If you are operating a central SIP gateway to receive traffic for all (or +a subset of) customers, you should configure a phone service with a fixed +value, or a list of fixed values, for the sip_server field.

+END +); + +sub export_insert { + my($self, $svc_phone) = (shift, shift); + local $SIG{__DIE__}; + try { + my $account_id = $self->option('accountId'); + my $peer = $self->find_peer($svc_phone) + or die "couldn't find SIP peer for ".$svc_phone->sip_server.".\n"; + my $phonenum = $svc_phone->phonenum; + # future: reserve numbers before activating? + # and an option to order first available number instead of selecting DID? + my $order = { + Order => { + Name => "Order svc#".$svc_phone->svcnum." - $phonenum", + SiteId => $peer->{SiteId}, + PeerId => $peer->{PeerId}, + Quantity => 1, + ExistingTelephoneNumberOrderType => { + TelephoneNumberList => { + TelephoneNumber => $phonenum + } + } + } + }; + my $result = $self->api_post("orders", $order); + # future: add a queue job here to poll the order completion status. + ''; + } catch { + "$me $_"; + }; +} + +sub export_replace { + my ($self, $new, $old) = @_; + # we only export the IP address and the phone number, + # neither of which we can change in place. + if ( $new->phonenum ne $old->phonenum + or $new->sip_server ne $old->sip_server ) { + return $self->export_delete($old) || $self->export_insert($new); + } + ''; +} + +sub export_delete { + my ($self, $svc_phone) = (shift, shift); + local $SIG{__DIE__}; + try { + my $phonenum = $svc_phone->phonenum; + my $disconnect = { + DisconnectTelephoneNumberOrder => { + Name => "Disconnect svc#".$svc_phone->svcnum." - $phonenum", + DisconnectTelephoneNumberOrderType => { + TelephoneNumberList => [ + { TelephoneNumber => $phonenum }, + ], + }, + } + }; + my $result = $self->api_post("disconnects", $disconnect); + # this is also an order, and we could poll its status also + ''; + } catch { + "$me $_"; + }; +} + +sub find_peer { + my $self = shift; + my $svc_phone = shift; + my $ip = $svc_phone->sip_server; # future: support svc_pbx for this + die "SIP server address required.\n" if !$ip; + + my $peers = $self->peer_cache; + if ( $peers->{hostname}{$ip} ) { + return $peers->{hostname}{$ip}; + } + # refresh the cache and try again + $self->cache->remove('peers'); + $peers = $self->peer_cache; + return $peers->{hostname}{$ip} || undef; +} + +################# +# DID SELECTION # +################# + +sub can_get_dids { 1 } + +# we don't yet have tollfree support + +sub get_dids_npa_select { 1 } + +sub get_dids { + local $SIG{__DIE__}; + + my $self = shift; + my %opt = @_; + + my ($exportnum) = $self->exportnum =~ /^(\d+)$/; + + return [] if $opt{'tollfree'}; # we'll come back to this + + my ($state, $npa, $nxx) = @opt{'state', 'areacode', 'exchange'}; + + if ( $nxx ) { + + die "areacode required\n" unless $npa; + my $limit = $self->option('num_dids') || 20; + my $result = $self->api_get('availableNumbers', [ + 'npaNxx' => $npa.$nxx, + 'quantity' => $limit, + 'LCA' => 'false', + # find only those that match the NPA-NXX, not those thought to be in + # the same local calling area. though that might be useful. + ]); + return [ $result->findnodes('//TelephoneNumber')->to_literal_list ]; + + } elsif ( $npa ) { + + return $self->npanxx_cache($npa); + + } elsif ( $state ) { + + return $self->npa_cache($state); + + } else { # something's wrong + + warn "get_dids called with no arguments"; + return []; + + } + +} + +######### +# CACHE # +######### + +=item peer_cache + +Returns a hashref of information on peer addresses. Currently has one key, +'hostname', pointing to a hash of (IP address => peer ID). + +=cut + +sub peer_cache { + my $self = shift; + my $peer_table = $self->cache->get('peers'); + if (!$peer_table) { + $peer_table = { hostname => {} }; + my $result = $self->api_get('sites'); + my @site_ids = $result->findnodes('//Site/Id')->to_literal_list; + foreach my $site_id (@site_ids) { + $result = $self->api_get("sites/$site_id/sippeers"); + my @peers = $result->findnodes('//SipPeer'); + foreach my $peer (@peers) { + my $peer_id = $peer->findvalue('PeerId'); + my @hosts = $peer->findnodes('VoiceHosts/Host/HostName')->to_literal_list; + foreach my $host (@hosts) { + $peer_table->{hostname}->{ $host } = { + PeerId => $peer_id, + SiteId => $site_id, + }; + } + # any other peer info we need? I don't think so. + } # foreach $peer + } # foreach $site_id + $self->cache->set('peers', $peer_table, $cache_timeout); + } + $peer_table; +} + +=item npanxx_cache NPA + +Returns an arrayref of exchange prefixes in the areacode NPA. This will +only work if the available prefixes in that areacode's state have already +been loaded. + +=cut + +sub npanxx_cache { + my $self = shift; + my $npa = shift; + my $exchanges = $self->cache->get("npanxx_$npa"); + if (!$exchanges) { + warn "NPA $npa not yet loaded; returning nothing"; + return []; + } + $exchanges; +} + +=item npa_cache STATE + +Returns an arrayref of area codes in the state. This will refresh the cache +if necessary. + +=cut + +sub npa_cache { + my $self = shift; + my $state = shift; + + my $npas = $self->cache->get("npa_$state"); + if (!$npas) { + my $data = {}; # NPA => [ NPANXX, ... ] + my $result = $self->api_get('availableNpaNxx', [ 'state' => $state ]); + foreach my $entry ($result->findnodes('//AvailableNpaNxx')) { + my $npa = $entry->findvalue('Npa'); + my $nxx = $entry->findvalue('Nxx'); + my $city = $entry->findvalue('City'); + push @{ $data->{$npa} ||= [] }, "$city ($npa-$nxx-XXXX)"; + } + $npas = [ sort keys %$data ]; + $self->cache->set("npa_$state", $npas); + foreach (@$npas) { + # sort by city, then NXX + $data->{$_} = [ sort @{ $data->{$_} } ]; + $self->cache->set("npanxx_$_", $data->{$_}); + } + } + return $npas; +} + +=item cache + +Returns the Cache::FileCache object for this export. Each instance of the +export gets a separate cache. + +=cut + +sub cache { + my $self = shift; + + my $exportnum = $self->get('exportnum'); + $CACHE{$exportnum} ||= Cache::FileCache->new({ + 'cache_root' => $FS::UID::cache_dir.'/cache.'.$FS::UID::datasrc, + 'namespace' => __PACKAGE__ . '_' . $exportnum, + 'default_expires_in' => $cache_timeout, + }); + +} + +############## +# API ACCESS # +############## + +sub debug { + shift->option('debug') || 0; +} + +sub api_get { + my ($self, $path, $content) = @_; + warn "$me GET $path\n" if $self->debug; + my $url = URI->new( 'https://' . + join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path) + ); + $url->query_form($content); + my $request = GET($url); + $self->_request($request); +} + +sub api_post { + my ($self, $path, $content) = @_; + warn "$me POST $path\n" if $self->debug; + my $url = URI->new( 'https://' . + join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path) + ); + my $request = POST($url, 'Content-Type' => 'application/xml', + 'Content' => $self->xmlout($content)); + $self->_request($request); +} + +sub api_put { + my ($self, $path, $content) = @_; + warn "$me PUT $path\n" if $self->debug; + my $url = URI->new( 'https://' . + join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path) + ); + my $request = PUT ($url, 'Content-Type' => 'application/xml', + 'Content' => $self->xmlout($content)); + $self->_request($request); +} + +sub api_delete { + my ($self, $path) = @_; + warn "$me DELETE $path\n" if $self->debug; + my $url = URI->new( 'https://' . + join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path) + ); + my $request = DELETE($url); + $self->_request($request); +} + +sub xmlout { + my ($self, $content) = @_; + my $output; + my $writer = XML::Writer->new( OUTPUT => \$output, ENCODING => 'utf-8' ); + my @queue = ($content); + while ( @queue ) { + my $obj = shift @queue; + if (ref($obj) eq 'HASH') { + foreach my $k (keys %$obj) { + unshift @queue, "endTag $k"; + unshift @queue, $obj->{$k}; + unshift @queue, "startTag $k"; + } + } elsif ( ref($obj) eq 'ARRAY' ) { + unshift @queue, @$obj; + } elsif ( $obj =~ /^startTag (.*)$/ ) { + $writer->startTag($1); + } elsif ( $obj =~ /^endTag (.*)$/ ) { + $writer->endTag($1); + } elsif ( defined($obj) ) { + $writer->characters($obj); + } + } + return $output; +} + +sub xmlin { + # wrapper for XML::LibXML::Simple's XMLin, with auto-flattening of NodeLists + my $self = shift; + my @out; + foreach my $node (@_) { + if ($node->can('get_nodelist')) { + push @out, map { XMLin($_, KeepRoot => 1) } $node->get_nodelist; + } else { + push @out, XMLin($node); + } + } + @out; +} + +sub _request { # even lower level + my ($self, $request) = @_; + warn $request->as_string . "\n" if $self->debug > 1; + my $response = $self->ua->request( $request ); + warn "$me received\n" . $response->as_string . "\n" if $self->debug > 1; + + if ($response->content) { + + my $xmldoc = XML::LibXML->load_xml(string => $response->content); + # errors are found in at least two places: ResponseStatus/ErrorCode + my $error; + my ($ec) = $xmldoc->findnodes('//ErrorCode'); + if ($ec) { + $error = $ec->parentNode->findvalue('Description'); + } + # and ErrorList/Error + $error ||= join("; ", $xmldoc->findnodes('//Error/Description')->to_literal_list); + die "$error\n" if $error; + return $xmldoc; + + } elsif ($response->code eq '201') { # Created, response to a POST + + return $response->header('Location'); + + } else { + + die $response->status_line."\n"; + + } +} + +sub host { + my $self = shift; + $self->{_host} ||= do { + my $host = 'dashboard.bandwidth.com'; + $host = "test.$host" if $self->option('test'); + }; +} + +sub ua { + my $self = shift; + $self->{_ua} ||= do { + my $ua = LWP::UserAgent->new; + $ua->credentials( + $self->host . ':443', + 'Bandwidth API', + $self->option('username'), + $self->option('password') + ); + $ua; + } +} + + +1; diff --git a/FS/FS/svc_phone.pm b/FS/FS/svc_phone.pm index 3a58b465c..f2be7d348 100644 --- a/FS/FS/svc_phone.pm +++ b/FS/FS/svc_phone.pm @@ -274,7 +274,7 @@ sub table_info { }, 'sip_server' => { label => 'SIP Host', - %dis2, + disable_inventory => 1, }, }, }; -- 2.11.0