package FS::part_export::voipnow_did; use vars qw(@ISA %info $DEBUG $CACHE); use Tie::IxHash; use FS::Record qw(qsearch qsearchs dbh); use FS::part_export; use FS::areacode; use XML::Simple 'XMLin'; use Net::SSLeay 'post_https'; use Cache::FileCache; use strict; $DEBUG = 0; # 1 = trace operations, 2 = dump XML @ISA = qw(FS::part_export); tie my %options, 'Tie::IxHash', 'login' => { label=>'VoipNow client login' }, 'password' => { label=>'VoipNow client password' }, 'country' => { label=>'Country (two-letter code)' }, 'cache_time' => { label=>'Cache lifetime (seconds)' }, ; %info = ( 'svc' => 'svc_phone', 'desc' => 'Provision phone numbers to 4PSA VoipNow softswitch', 'options' => \%options, 'notes' => <<'END' Requires installation of XML::Writer from CPAN. END ); sub rebless { shift; } sub did_cache { my $self = shift; $CACHE ||= new Cache::FileCache( { 'namespace' => __PACKAGE__, 'default_expires_in' => $self->option('cache_time') || 300, 'cache_root' => $FS::UID::cache_dir.'/cache'.$FS::UID::datasrc, } ); return $CACHE->get($self->exportnum) || $self->reload_cache; } sub can_get_dids { 1; } sub get_dids { my $self = shift; my %opt = @_; return [] if $opt{'tollfree'}; # currently not supported my %search = ( 'exportnum' => $self->exportnum ); my $dids = $self->did_cache; my ($state, $npa, $nxx) = @opt{'state', 'areacode', 'exchange'}; $state ||= (FS::areacode->locate($npa))[1]; if ($nxx) { return [ sort keys %{ $dids->{$state}->{$npa}->{"$npa-$nxx"} } ]; } elsif ($npa) { return [ sort map { "($_-XXXX)" } keys %{ $dids->{$state}->{$npa} } ]; } elsif ($state) { return [ sort keys %{ $dids->{$state} } ]; } else { return []; # nothing really to do without state } } sub reload_cache { my $self = shift; warn "updating DID cache\n" if $DEBUG; my ($response, $error) = $self->voipnow_command('channel', 'GetPublicNoPoll', { 'userID' => $self->userID } ); warn "error updating DID cache: $error\n" if $error; my $dids = {}; my $avail = $response->{'publicNo'}{'available'} or return []; # no available numbers foreach ( ref($avail) eq 'ARRAY' ? @{ $avail } : $avail ) { my $did = $_->{'externalNo'}; $did =~ /^(\d{3})(\d{3})(\d{4})/ or die "unparseable did $did\n"; my $state = (FS::areacode->locate($1))[1]; $dids->{$state}->{$1}->{"$1-$2"}->{"$1-$2-$3"} = $_->{'ID'}; } $CACHE->set($self->exportnum, $dids); return $dids; } sub _export_insert { my( $self, $svc_phone ) = (shift, shift); # find remote DID name my $phonenum = $svc_phone->phonenum; $phonenum =~ /^(\d{3})(\d{3})(\d{4})/ or die "unparseable phone number: $phonenum"; warn "checking DID $1-$2-$3\n" if $DEBUG; my $state = (FS::areacode->locate($1))[1]; my $dids = $self->did_cache; my $assign_did = $dids->{$state}->{$1}->{"$1-$2"}->{"$1-$2-$3"}; if ( !defined($assign_did) ) { $self->reload_cache; # since it's clearly out of date return "phone number $phonenum not available"; } # need to check existence of parent objects? my $cust_pkg = $svc_phone->cust_svc->cust_pkg; my $cust_main = $cust_pkg->cust_main; # this is subject to change my %add_extension = ( namespace('client_data', name => $svc_phone->phone_name || $cust_main->contact_firstlast, company => $cust_main->company, # to avoid collision with phone numbers, etc.--would be better to store the # remote identifier somewhere login => 'S'.$svc_phone->svcnum, password => $svc_phone->sip_password, phone => $cust_main->phone, fax => $cust_main->fax, addresss => $cust_main->address1, city => $cust_main->city, pcode => $cust_main->zip, country => $cust_main->country, ), parentID => $self->userID, #region--this is a problem # Other options named in the documentation: # # passwordAuto passwordStrength forceUpdate # timezone interfaceLang notes serverID chargingIdentifier # phoneLang channelRuleId templateID extensionNo extensionType # parentIdentifier parentLogin fromUser fromUserIdentifier # chargingPlanID chargingPlanIdentifier verbose notifyOnly # scope dku accountFlag ); my ($response, $error) = $self->voipnow_command('extension', 'AddExtension', \%add_extension); return "[AddExtension] $error" if $error; my $eid = $response->{'ID'}; warn "Extension created with id=$eid\n" if $DEBUG; ($response, $error) = $self->voipnow_command('channel', 'AssignPublicNo', { didID => $assign_did, userID => $eid } ); return "[AssignPublicNo] $error" if $error; ''; } sub _export_replace { my( $self, $new, $old ) = (shift, shift, shift); # this could be implemented later ''; } sub _export_delete { my( $self, $svc_phone ) = (shift, shift); my $eid = $self->extensionID($svc_phone); my ($response, $error) = $self->voipnow_command('extension', 'DelExtension', { ID => $eid }); return "[DelExtension] $error" if $error; # don't need to de-assign the DID separately. ''; } sub _export_suspend { my( $self, $svc_phone ) = (shift, shift); #nop for now ''; } sub _export_unsuspend { my( $self, $svc_phone ) = (shift, shift); #nop for now ''; } sub userID { my $self = shift; return $self->{'userID'} if $self->{'userID'}; my ($response, $error) = $self->voipnow_command('client', 'GetClients', {}); # GetClients run on a client's login returns only that client. die "couldn't get userID: $error" if $error; die "non-Client login specified: ".$self->option('login') if ref($response->{'client'}) ne 'HASH' or $response->{'client'}->{'login'} ne $self->option('login'); return $self->{'userID'} = $response->{'client'}->{'ID'}; } sub extensionID { # technically this returns the "extension user ID" rather than # "extension ID". my $self = shift; my $svc_phone = shift; my $login = 'S'.$svc_phone->svcnum; my ($response, $error) = $self->voipnow_command('extension', 'GetExtensions', { 'filter' => $login, 'parentID' => $self->userID } ); die "couldn't get extensionID for $login: $error" if $error; my $extension = ''; if ( ref($response->{'extension'}) eq 'HASH' ) { $extension = $response->{'extension'}; } elsif ( ref($response->{'extension'}) eq 'ARRAY' ) { ($extension) = grep { $_->{'login'} eq $login } @{ $response->{'extension'} }; } die "extension $login not found" if !$extension; warn "[extensionID] found ID ".$response->{'extension'}->{'ID'}."\n" if $DEBUG; return $response->{'extension'}->{'ID'}; } my $API_VERSION = '2.5.1'; my %namespaces = ( 'envelope' => 'http://schemas.xmlsoap.org/soap/envelope/', 'header' => 'http://4psa.com/HeaderData.xsd/'.$API_VERSION, 'channel' => 'http://4psa.com/ChannelMessages.xsd/'.$API_VERSION, 'extension' => 'http://4psa.com/ExtensionMessages.xsd/'.$API_VERSION, 'client' => 'http://4psa.com/ClientMessages.xsd/'.$API_VERSION, 'client_data' => 'http://4psa.com/ClientData.xsd/'.$API_VERSION, ); # Infrastructure # example: # ($result, $error) = # $self->voipnow_command('endpoint', 'MethodFoo', { argument => 'value' }); # The third argument will be enclosed in a MethodFooRequest and serialized. # $result is everything inside the MethodFooResponse element, as a tree. sub voipnow_command { my $self = shift; my $endpoint = shift; # 'channel' or 'extension' my $method = shift; my $data = shift; my $host = $self->machine; my $path = "/soap2/${endpoint}_agent.php"; eval "use XML::Writer"; die $@ if $@; warn "[$method] constructing request\n" if $DEBUG; my $soap_request; my $writer = XML::Writer->new( OUTPUT => \$soap_request, NAMESPACES => 1, PREFIX_MAP => { reverse %namespaces }, FORCED_NS_DECLS => [ values %namespaces ], ENCODING => 'utf-8', ); my $header = { '#NS' => 'header', 'userCredentials' => { 'username' => $self->option('login'), 'password' => $self->option('password'), } }; my $body = { '#NS' => $endpoint, $method.'Request' => $data, }; # build the request descend( $writer, { Envelope => { Header => $header, Body => $body } }, 'envelope' #start in this namespace ); warn "SENDING:\n$soap_request\n" if $DEBUG > 1; my ($soap_response, $status) = post_https($host, 443, $path, '', $soap_request); warn "STATUS: $status\nRECEIVED:\n$soap_response\n" if $DEBUG > 1; if ( !length($soap_response) ) { return undef, "No response ($status)"; } my $response = eval { strip_ns(XMLin($soap_response)) }; # handle various errors if ( $@ ) { return undef, "Parse error: $@"; } if ( !exists $response->{'Body'} ) { return undef, "Bad response (missing Body section)"; } $body = $response->{'Body'}; if ( exists $body->{'Fault'} ) { return undef, $body->{'Fault'}->{'faultstring'}; } if ( !exists $body->{"${method}Response"} ) { return undef, "Bad response (missing ${method}Response section)"; } return $body->{"${method}Response"}; } # Infra-infrastructure sub descend { # like XML::Simple, but more so my $writer = shift; my $tree = shift; my $branch_ns = delete($tree->{'#NS'}) || shift; while (my ($key, $val) = each %$tree) { my ($name, $key_ns) = reverse split(':', $key); $key_ns ||= $branch_ns; $name = [ $namespaces{$key_ns}, $name ]; if ( ref($val) eq 'HASH' ) { $writer->startTag($name); descend($writer, $val, $key_ns); $writer->endTag; } elsif ( defined($val) ) { $writer->dataElement($name, $val); } else { #undef $writer->emptyTag($name); } } } sub namespace { my $ns = shift; my %data = @_; map { $ns.':'.$_ , $data{$_} } keys(%data); } sub strip_ns { # remove the namespace tags so that we can find stuff my $tree = shift; if ( ref $tree eq 'HASH' ) { return +{ map { my $name = $_; $name =~ s/^.*://; $name => strip_ns($tree->{$_}); } keys %$tree } } elsif ( ref $tree eq 'ARRAY' ) { return [ map { strip_ns($_) } @$tree ] } else { return $tree; } } 1;