1 package FS::part_export::bandwidth_com;
3 use base qw( FS::part_export );
9 use HTTP::Request::Common;
11 use FS::Record qw(dbh qsearch);
13 use XML::LibXML::Simple qw(XMLin);
17 our $me = '[bandwidth.com]';
19 # cache NPA/NXX records, peer IDs, etc.
20 our %CACHE; # exportnum => cache
21 our $cache_timeout = 86400; # seconds
23 our $API_VERSION = 'v1.0';
25 tie my %options, 'Tie::IxHash',
26 'accountId' => { label => 'Account ID' },
27 'username' => { label => 'API username', },
28 'password' => { label => 'API password', },
29 'siteId' => { label => 'Site ID' },
30 'num_dids' => { label => 'Maximum available phone numbers to show',
33 'debug' => { label => 'Debugging',
35 options => [ 0, 1, 2 ],
42 'test' => { label => 'Use test server', type => 'checkbox', value => 1 },
46 'svc' => [qw( svc_phone )],
47 'desc' => 'Provision DIDs to Bandwidth.com',
48 'options' => \%options,
51 <P>Export to <b>bandwidth.com</b> interconnected VoIP service.</P>
52 <P>Bandwidth.com uses a SIP peering architecture. Each phone number is routed
53 to a specific peer, which comprises one or more IP addresses. The IP address
54 will be taken from the "sip_server" field of the phone service. If no peer
55 with this IP address exists, one will be created.</P>
56 <P>If you are operating a central SIP gateway to receive traffic for all (or
57 a subset of) customers, you should configure a phone service with a fixed
58 value, or a list of fixed values, for the sip_server field.</P>
59 <P>To find your account ID and site ID:
61 <LI>Login to <a target="_blank" href="https://dashboard.bandwidth.com">the Dashboard.
63 <LI>Under "Your subaccounts", find the subaccount (site) that you want to use
64 for exported DIDs. Click the "manage sub-account" link.</LI>
65 <LI>Look at the URL. It will end in <i>{"a":xxxxxxx,"s":yyyy}</i>.</LI>
66 <LI>Your account ID is <i>xxxxxxx</i>, and the site ID is <i>yyyy</i>.</LI>
73 my($self, $svc_phone) = (shift, shift);
76 my $account_id = $self->option('accountId');
77 my $peer = $self->find_peer($svc_phone)
78 or die "couldn't find SIP peer for ".$svc_phone->sip_server.".\n";
79 my $phonenum = $svc_phone->phonenum;
80 # future: reserve numbers before activating?
81 # and an option to order first available number instead of selecting DID?
84 Name => "Order svc#".$svc_phone->svcnum." - $phonenum",
85 SiteId => $peer->{SiteId},
86 PeerId => $peer->{PeerId},
88 ExistingTelephoneNumberOrderType => {
89 TelephoneNumberList => {
90 TelephoneNumber => $phonenum
95 my $result = $self->api_post("orders", $order);
96 # future: add a queue job here to poll the order completion status.
104 my ($self, $new, $old) = @_;
105 # we only export the IP address and the phone number,
106 # neither of which we can change in place.
107 if ( $new->phonenum ne $old->phonenum
108 or $new->sip_server ne $old->sip_server ) {
109 return $self->export_delete($old) || $self->export_insert($new);
115 my ($self, $svc_phone) = (shift, shift);
118 my $phonenum = $svc_phone->phonenum;
120 DisconnectTelephoneNumberOrder => {
121 Name => "Disconnect svc#".$svc_phone->svcnum." - $phonenum",
122 DisconnectTelephoneNumberOrderType => {
123 TelephoneNumberList => [
124 { TelephoneNumber => $phonenum },
129 my $result = $self->api_post("disconnects", $disconnect);
130 # this is also an order, and we could poll its status also
139 my $svc_phone = shift;
140 my $ip = $svc_phone->sip_server; # future: support svc_pbx for this
141 die "SIP server address required.\n" if !$ip;
143 my $peers = $self->peer_cache;
144 if ( $peers->{hostname}{$ip} ) {
145 return $peers->{hostname}{$ip};
147 # refresh the cache and try again
148 $self->cache->remove('peers');
149 $peers = $self->peer_cache;
150 return $peers->{hostname}{$ip} || undef;
157 sub can_get_dids { 1 }
159 # we don't yet have tollfree support
161 sub get_dids_npa_select { 1 }
168 my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
171 return [] if $opt{'tollfree'}; # we'll come back to this
173 my ($state, $npa, $nxx) = @opt{'state', 'areacode', 'exchange'};
177 die "areacode required\n" unless $npa;
178 my $limit = $self->option('num_dids') || 20;
179 my $result = $self->api_get('availableNumbers', [
180 'npaNxx' => $npa.$nxx,
181 'quantity' => $limit,
183 # find only those that match the NPA-NXX, not those thought to be in
184 # the same local calling area. though that might be useful.
186 return [ $result->findnodes('//TelephoneNumber')->to_literal_list ];
190 return $self->npanxx_cache($npa);
194 return $self->npa_cache($state);
196 } else { # something's wrong
198 warn "get_dids called with no arguments";
214 Returns a hashref of information on peer addresses. Currently has one key,
215 'hostname', pointing to a hash of (IP address => peer ID).
221 my $peer_table = $self->cache->get('peers');
223 $peer_table = { hostname => {} };
224 my $result = $self->api_get('sites');
225 my @site_ids = $result->findnodes('//Site/Id')->to_literal_list;
226 foreach my $site_id (@site_ids) {
227 $result = $self->api_get("sites/$site_id/sippeers");
228 my @peers = $result->findnodes('//SipPeer');
229 foreach my $peer (@peers) {
230 my $peer_id = $peer->findvalue('PeerId');
231 my @hosts = $peer->findnodes('VoiceHosts/Host/HostName')->to_literal_list;
232 foreach my $host (@hosts) {
233 $peer_table->{hostname}->{ $host } = {
238 # any other peer info we need? I don't think so.
241 $self->cache->set('peers', $peer_table, $cache_timeout);
246 =item npanxx_cache NPA
248 Returns an arrayref of exchange prefixes in the areacode NPA. This will
249 only work if the available prefixes in that areacode's state have already
257 my $exchanges = $self->cache->get("npanxx_$npa");
259 warn "NPA $npa not yet loaded; returning nothing";
265 =item npa_cache STATE
267 Returns an arrayref of area codes in the state. This will refresh the cache
276 my $npas = $self->cache->get("npa_$state");
278 my $data = {}; # NPA => [ NPANXX, ... ]
279 my $result = $self->api_get('availableNpaNxx', [ 'state' => $state ]);
280 foreach my $entry ($result->findnodes('//AvailableNpaNxx')) {
281 my $npa = $entry->findvalue('Npa');
282 my $nxx = $entry->findvalue('Nxx');
283 my $city = $entry->findvalue('City');
284 push @{ $data->{$npa} ||= [] }, "$city ($npa-$nxx-XXXX)";
286 $npas = [ sort keys %$data ];
287 $self->cache->set("npa_$state", $npas);
289 # sort by city, then NXX
290 $data->{$_} = [ sort @{ $data->{$_} } ];
291 $self->cache->set("npanxx_$_", $data->{$_});
299 Returns the Cache::FileCache object for this export. Each instance of the
300 export gets a separate cache.
307 my $exportnum = $self->get('exportnum');
308 $CACHE{$exportnum} ||= Cache::FileCache->new({
309 'cache_root' => $FS::UID::cache_dir.'/cache.'.$FS::UID::datasrc,
310 'namespace' => __PACKAGE__ . '_' . $exportnum,
311 'default_expires_in' => $cache_timeout,
321 shift->option('debug') || 0;
325 my ($self, $path, $content) = @_;
326 warn "$me GET $path\n" if $self->debug;
327 my $url = URI->new( 'https://' .
328 join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path)
330 $url->query_form($content);
331 my $request = GET($url);
332 $self->_request($request);
336 my ($self, $path, $content) = @_;
337 warn "$me POST $path\n" if $self->debug;
338 my $url = URI->new( 'https://' .
339 join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path)
341 my $request = POST($url, 'Content-Type' => 'application/xml',
342 'Content' => $self->xmlout($content));
343 $self->_request($request);
347 my ($self, $path, $content) = @_;
348 warn "$me PUT $path\n" if $self->debug;
349 my $url = URI->new( 'https://' .
350 join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path)
352 my $request = PUT ($url, 'Content-Type' => 'application/xml',
353 'Content' => $self->xmlout($content));
354 $self->_request($request);
358 my ($self, $path) = @_;
359 warn "$me DELETE $path\n" if $self->debug;
360 my $url = URI->new( 'https://' .
361 join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path)
363 my $request = DELETE($url);
364 $self->_request($request);
368 my ($self, $content) = @_;
370 my $writer = XML::Writer->new( OUTPUT => \$output, ENCODING => 'utf-8' );
371 my @queue = ($content);
373 my $obj = shift @queue;
374 if (ref($obj) eq 'HASH') {
375 foreach my $k (keys %$obj) {
376 unshift @queue, "endTag $k";
377 unshift @queue, $obj->{$k};
378 unshift @queue, "startTag $k";
380 } elsif ( ref($obj) eq 'ARRAY' ) {
381 unshift @queue, @$obj;
382 } elsif ( $obj =~ /^startTag (.*)$/ ) {
383 $writer->startTag($1);
384 } elsif ( $obj =~ /^endTag (.*)$/ ) {
386 } elsif ( defined($obj) ) {
387 $writer->characters($obj);
394 # wrapper for XML::LibXML::Simple's XMLin, with auto-flattening of NodeLists
397 foreach my $node (@_) {
398 if ($node->can('get_nodelist')) {
399 push @out, map { XMLin($_, KeepRoot => 1) } $node->get_nodelist;
401 push @out, XMLin($node);
407 sub _request { # even lower level
408 my ($self, $request) = @_;
409 warn $request->as_string . "\n" if $self->debug > 1;
410 my $response = $self->ua->request( $request );
411 warn "$me received\n" . $response->as_string . "\n" if $self->debug > 1;
413 if ($response->content) {
415 my $xmldoc = XML::LibXML->load_xml(string => $response->content);
416 # errors are found in at least two places: ResponseStatus/ErrorCode
418 my ($ec) = $xmldoc->findnodes('//ErrorCode');
420 $error = $ec->parentNode->findvalue('Description');
422 # and ErrorList/Error
423 $error ||= join("; ", $xmldoc->findnodes('//Error/Description')->to_literal_list);
424 die "$error\n" if $error;
427 } elsif ($response->code eq '201') { # Created, response to a POST
429 return $response->header('Location');
433 die $response->status_line."\n";
440 $self->{_host} ||= do {
441 my $host = 'dashboard.bandwidth.com';
442 $host = "test.$host" if $self->option('test');
449 $self->{_ua} ||= do {
450 my $ua = LWP::UserAgent->new;
452 $self->host . ':443',
454 $self->option('username'),
455 $self->option('password')