1 package FS::part_export::voipnow_did;
3 use vars qw(@ISA %info $DEBUG $CACHE);
5 use FS::Record qw(qsearch qsearchs dbh);
8 use XML::Simple 'XMLin';
9 use Net::SSLeay 'post_https';
14 $DEBUG = 0; # 1 = trace operations, 2 = dump XML
15 @ISA = qw(FS::part_export);
17 tie my %options, 'Tie::IxHash',
18 'login' => { label=>'VoipNow client login' },
19 'password' => { label=>'VoipNow client password' },
20 'country' => { label=>'Country (two-letter code)' },
21 'cache_time' => { label=>'Cache lifetime (seconds)' },
26 'desc' => 'Provision phone numbers to 4PSA VoipNow softswitch',
27 'options' => \%options,
29 Requires installation of
30 <a href="http://search.cpan.org/dist/XML-Writer">XML::Writer</a>
35 sub rebless { shift; }
39 $CACHE ||= new Cache::FileCache( {
40 'namespace' => __PACKAGE__,
41 'default_expires_in' => $self->option('cache_time') || 300,
42 'cache_root' => $FS::UID::cache_dir.'/cache'.$FS::UID::datasrc,
44 return $CACHE->get($self->exportnum) || $self->reload_cache;
47 sub can_get_dids { 1; }
53 return [] if $opt{'tollfree'}; # currently not supported
55 my %search = ( 'exportnum' => $self->exportnum );
57 my $dids = $self->did_cache;
59 my ($state, $npa, $nxx) = @opt{'state', 'areacode', 'exchange'};
60 $state ||= (FS::areacode->locate($npa))[1];
63 return [ sort keys %{ $dids->{$state}->{$npa}->{"$npa-$nxx"} } ];
66 return [ sort map { "($_-XXXX)" } keys %{ $dids->{$state}->{$npa} } ];
69 return [ sort keys %{ $dids->{$state} } ];
72 return []; # nothing really to do without state
78 warn "updating DID cache\n" if $DEBUG;
80 my ($response, $error) =
81 $self->voipnow_command('channel', 'GetPublicNoPoll',
82 { 'userID' => $self->userID }
85 warn "error updating DID cache: $error\n" if $error;
89 my $avail = $response->{'publicNo'}{'available'}
90 or return []; # no available numbers
91 foreach ( ref($avail) eq 'ARRAY' ? @{ $avail } : $avail ) {
92 my $did = $_->{'externalNo'};
93 $did =~ /^(\d{3})(\d{3})(\d{4})/ or die "unparseable did $did\n";
94 my $state = (FS::areacode->locate($1))[1];
95 $dids->{$state}->{$1}->{"$1-$2"}->{"$1-$2-$3"} = $_->{'ID'};
98 $CACHE->set($self->exportnum, $dids);
103 my( $self, $svc_phone ) = (shift, shift);
105 # find remote DID name
106 my $phonenum = $svc_phone->phonenum;
107 $phonenum =~ /^(\d{3})(\d{3})(\d{4})/
108 or die "unparseable phone number: $phonenum";
110 warn "checking DID $1-$2-$3\n" if $DEBUG;
111 my $state = (FS::areacode->locate($1))[1];
113 my $dids = $self->did_cache;
114 my $assign_did = $dids->{$state}->{$1}->{"$1-$2"}->{"$1-$2-$3"};
115 if ( !defined($assign_did) ) {
116 $self->reload_cache; # since it's clearly out of date
117 return "phone number $phonenum not available";
120 # need to check existence of parent objects?
121 my $cust_pkg = $svc_phone->cust_svc->cust_pkg;
122 my $cust_main = $cust_pkg->cust_main;
124 # this is subject to change
125 my %add_extension = (
126 namespace('client_data',
127 name => $svc_phone->phone_name || $cust_main->contact_firstlast,
128 company => $cust_main->company,
129 # to avoid collision with phone numbers, etc.--would be better to store the
130 # remote identifier somewhere
131 login => 'S'.$svc_phone->svcnum,
132 password => $svc_phone->sip_password,
133 phone => $cust_main->phone,
134 fax => $cust_main->fax,
135 addresss => $cust_main->address1,
136 city => $cust_main->city,
137 pcode => $cust_main->zip,
138 country => $cust_main->country,
140 parentID => $self->userID,
141 #region--this is a problem
142 # Other options named in the documentation:
144 # passwordAuto passwordStrength forceUpdate
145 # timezone interfaceLang notes serverID chargingIdentifier
146 # phoneLang channelRuleId templateID extensionNo extensionType
147 # parentIdentifier parentLogin fromUser fromUserIdentifier
148 # chargingPlanID chargingPlanIdentifier verbose notifyOnly
149 # scope dku accountFlag
151 my ($response, $error) =
152 $self->voipnow_command('extension', 'AddExtension', \%add_extension);
153 return "[AddExtension] $error" if $error;
155 my $eid = $response->{'ID'};
156 warn "Extension created with id=$eid\n" if $DEBUG;
158 ($response, $error) =
159 $self->voipnow_command('channel', 'AssignPublicNo',
160 { didID => $assign_did, userID => $eid }
162 return "[AssignPublicNo] $error" if $error;
166 sub _export_replace {
167 my( $self, $new, $old ) = (shift, shift, shift);
169 # this could be implemented later
174 my( $self, $svc_phone ) = (shift, shift);
176 my $eid = $self->extensionID($svc_phone);
177 my ($response, $error) =
178 $self->voipnow_command('extension', 'DelExtension', { ID => $eid });
179 return "[DelExtension] $error" if $error;
180 # don't need to de-assign the DID separately.
185 sub _export_suspend {
186 my( $self, $svc_phone ) = (shift, shift);
191 sub _export_unsuspend {
192 my( $self, $svc_phone ) = (shift, shift);
199 return $self->{'userID'} if $self->{'userID'};
201 my ($response, $error) = $self->voipnow_command('client', 'GetClients', {});
202 # GetClients run on a client's login returns only that client.
203 die "couldn't get userID: $error" if $error;
204 die "non-Client login specified: ".$self->option('login') if
205 ref($response->{'client'}) ne 'HASH'
206 or $response->{'client'}->{'login'} ne $self->option('login');
207 return $self->{'userID'} = $response->{'client'}->{'ID'};
211 # technically this returns the "extension user ID" rather than
214 my $svc_phone = shift;
216 my $login = 'S'.$svc_phone->svcnum;
217 my ($response, $error) =
218 $self->voipnow_command('extension', 'GetExtensions',
219 { 'filter' => $login,
220 'parentID' => $self->userID }
222 die "couldn't get extensionID for $login: $error" if $error;
225 if ( ref($response->{'extension'}) eq 'HASH' ) {
226 $extension = $response->{'extension'};
228 elsif ( ref($response->{'extension'}) eq 'ARRAY' ) {
229 ($extension) = grep { $_->{'login'} eq $login }
230 @{ $response->{'extension'} };
233 die "extension $login not found" if !$extension;
235 warn "[extensionID] found ID ".$response->{'extension'}->{'ID'}."\n"
237 return $response->{'extension'}->{'ID'};
240 my $API_VERSION = '2.5.1';
242 'envelope' => 'http://schemas.xmlsoap.org/soap/envelope/',
243 'header' => 'http://4psa.com/HeaderData.xsd/'.$API_VERSION,
244 'channel' => 'http://4psa.com/ChannelMessages.xsd/'.$API_VERSION,
245 'extension' => 'http://4psa.com/ExtensionMessages.xsd/'.$API_VERSION,
246 'client' => 'http://4psa.com/ClientMessages.xsd/'.$API_VERSION,
247 'client_data' => 'http://4psa.com/ClientData.xsd/'.$API_VERSION,
252 # ($result, $error) =
253 # $self->voipnow_command('endpoint', 'MethodFoo', { argument => 'value' });
254 # The third argument will be enclosed in a MethodFooRequest and serialized.
255 # $result is everything inside the MethodFooResponse element, as a tree.
257 sub voipnow_command {
259 my $endpoint = shift; # 'channel' or 'extension'
262 my $host = $self->machine;
263 my $path = "/soap2/${endpoint}_agent.php";
265 eval "use XML::Writer";
268 warn "[$method] constructing request\n" if $DEBUG;
270 my $writer = XML::Writer->new(
271 OUTPUT => \$soap_request,
273 PREFIX_MAP => { reverse %namespaces },
274 FORCED_NS_DECLS => [ values %namespaces ],
280 'userCredentials' => {
281 'username' => $self->option('login'),
282 'password' => $self->option('password'),
287 $method.'Request' => $data,
292 { Envelope => { Header => $header, Body => $body } },
293 'envelope' #start in this namespace
296 warn "SENDING:\n$soap_request\n" if $DEBUG > 1;
297 my ($soap_response, $status) =
298 post_https($host, 443, $path, '', $soap_request);
299 warn "STATUS: $status\nRECEIVED:\n$soap_response\n" if $DEBUG > 1;
300 if ( !length($soap_response) ) {
301 return undef, "No response ($status)";
304 my $response = eval { strip_ns(XMLin($soap_response)) };
305 # handle various errors
307 return undef, "Parse error: $@";
309 if ( !exists $response->{'Body'} ) {
310 return undef, "Bad response (missing Body section)";
312 $body = $response->{'Body'};
313 if ( exists $body->{'Fault'} ) {
314 return undef, $body->{'Fault'}->{'faultstring'};
316 if ( !exists $body->{"${method}Response"} ) {
317 return undef, "Bad response (missing ${method}Response section)";
320 return $body->{"${method}Response"};
323 # Infra-infrastructure
325 sub descend { # like XML::Simple, but more so
328 my $branch_ns = delete($tree->{'#NS'}) || shift;
329 while (my ($key, $val) = each %$tree) {
330 my ($name, $key_ns) = reverse split(':', $key);
331 $key_ns ||= $branch_ns;
332 $name = [ $namespaces{$key_ns}, $name ];
333 if ( ref($val) eq 'HASH' ) {
334 $writer->startTag($name);
335 descend($writer, $val, $key_ns);
338 elsif ( defined($val) ) {
339 $writer->dataElement($name, $val);
342 $writer->emptyTag($name);
350 map { $ns.':'.$_ , $data{$_} } keys(%data);
353 sub strip_ns { # remove the namespace tags so that we can find stuff
355 if ( ref $tree eq 'HASH' ) {
360 $name => strip_ns($tree->{$_});
364 elsif ( ref $tree eq 'ARRAY' ) {
366 map { strip_ns($_) } @$tree