cd13a44d5943291d593ab495f0ad562f91000c4f
[freeside.git] / FS / FS / part_export / voipnow_did.pm
1 package FS::part_export::voipnow_did;
2
3 use vars qw(@ISA %info $DEBUG $CACHE);
4 use Tie::IxHash;
5 use FS::Record qw(qsearch qsearchs dbh);
6 use FS::part_export;
7 use FS::areacode;
8 use XML::Writer;
9 use XML::Simple 'XMLin';
10 use Net::SSLeay 'post_https';
11 use Cache::FileCache;
12
13 use strict;
14
15 $DEBUG = 0; # 1 = trace operations, 2 = dump XML
16 @ISA = qw(FS::part_export);
17
18 tie my %options, 'Tie::IxHash',
19   'login'         => { label=>'VoipNow client login' },
20   'password'      => { label=>'VoipNow client password' },
21   'country'       => { label=>'Country (two-letter code)' },
22 ;
23
24 %info = (
25   'svc'     => 'svc_phone',
26   'desc'    => 'Provision phone numbers to 4PSA VoipNow softswitch',
27   'options' => \%options,
28   'notes'   => <<'END'
29 Requires installation of
30 <a href="http://search.cpan.org/dist/XML-Writer">XML::Writer</a>
31 from CPAN.
32 END
33 );
34
35 sub rebless { shift; }
36
37 sub did_cache {
38   my $self = shift;
39   $CACHE ||= new Cache::FileCache( { 
40       'namespace' => __PACKAGE__,
41       'default_expires_in' => 300,
42     } );
43   return $CACHE->get($self->exportnum) || $self->reload_cache;
44 }
45  
46 sub get_dids {
47   my $self = shift;
48   my %opt = @_;
49
50   return [] if $opt{'tollfree'}; # currently not supported
51
52   my %search = ( 'exportnum' => $self->exportnum );
53
54   my $dids = $self->did_cache;
55
56   my ($state, $npa, $nxx) = @opt{'state', 'areacode', 'exchange'};
57   $state ||= (FS::areacode->locate($npa))[1];
58
59   if ($nxx) {
60     return [ sort keys %{ $dids->{$state}->{$npa}->{"$npa-$nxx"} } ];
61   }
62   elsif ($npa) {
63     return [ sort map { "($_-XXXX)" } keys %{ $dids->{$state}->{$npa} } ];
64   }
65   elsif ($state) {
66     return [ sort keys %{ $dids->{$state} } ];
67   }
68   else {
69     return []; # nothing really to do without state
70   }
71 }
72
73 sub reload_cache {
74   my $self = shift;
75   warn "updating DID cache\n" if $DEBUG;
76
77   my ($response, $error) = 
78     $self->voipnow_command('channel', 'GetPublicNoPoll', 
79       { 'userID' => $self->userID }
80   );
81
82   warn "error updating DID cache: $error\n" if $error;
83
84   my $dids = {};
85
86   my $avail = $response->{'publicNo'}{'available'}
87     or return []; # no available numbers
88   foreach ( ref($avail) eq 'ARRAY' ? @{ $avail } : $avail ) {
89     my $did = $_->{'externalNo'};
90     $did =~ /^(\d{3})(\d{3})(\d{4})/ or die "unparseable did $did\n";
91     my $state = (FS::areacode->locate($1))[1];
92     $dids->{$state}->{$1}->{"$1-$2"}->{"$1-$2-$3"} = $_->{'ID'};
93   }
94
95   $CACHE->set($self->exportnum, $dids);
96   return $dids;
97 }
98
99 sub _export_insert {
100   my( $self, $svc_phone ) = (shift, shift);
101
102   # find remote DID name
103   my $phonenum = $svc_phone->phonenum;
104   $phonenum =~ /^(\d{3})(\d{3})(\d{4})/
105     or die "unparseable phone number: $phonenum";
106
107   warn "checking DID $1-$2-$3\n" if $DEBUG;
108   my $state = (FS::areacode->locate($1))[1];
109
110   my $dids = $self->did_cache;
111   my $assign_did = $dids->{$state}->{$1}->{"$1-$2"}->{"$1-$2-$3"};
112   if ( !defined($assign_did) ) {
113     $self->reload_cache; # since it's clearly out of date
114     return "phone number $phonenum not available";
115   }
116
117   # need to check existence of parent objects?
118   my $cust_pkg = $svc_phone->cust_svc->cust_pkg;
119   my $cust_main = $cust_pkg->cust_main;
120
121   # this is subject to change
122   my %add_extension = (
123     namespace('client_data',
124       name      => $svc_phone->phone_name || $cust_main->contact_firstlast,
125       company   => $cust_main->company,
126 # to avoid collision with phone numbers, etc.--would be better to store the 
127 # remote identifier somewhere
128       login     => 'S'.$svc_phone->svcnum,
129       password  => $svc_phone->sip_password,
130       phone     => $cust_main->phone,
131       fax       => $cust_main->fax,
132       addresss  => $cust_main->address1,
133       city      => $cust_main->city,
134       pcode     => $cust_main->zip,
135       country   => $cust_main->country,
136     ),
137     parentID  => $self->userID,
138     #region--this is a problem
139     # Other options named in the documentation:
140     #
141     # passwordAuto passwordStrength forceUpdate
142     # timezone interfaceLang notes serverID chargingIdentifier
143     # phoneLang channelRuleId templateID extensionNo extensionType
144     # parentIdentifier parentLogin fromUser fromUserIdentifier
145     # chargingPlanID chargingPlanIdentifier verbose notifyOnly 
146     # scope dku accountFlag
147   );
148   my ($response, $error) = 
149     $self->voipnow_command('extension', 'AddExtension', \%add_extension);
150   return "[AddExtension] $error" if $error;
151
152   my $eid = $response->{'ID'};
153   warn "Extension created with id=$eid\n" if $DEBUG;
154
155   ($response, $error) = 
156     $self->voipnow_command('channel', 'AssignPublicNo', 
157       { didID => $assign_did, userID => $eid }
158   );
159   return "[AssignPublicNo] $error" if $error;
160   '';
161 }
162
163 sub _export_replace {
164   my( $self, $new, $old ) = (shift, shift, shift);
165
166   # this could be implemented later
167   '';
168 }
169
170 sub _export_delete {
171   my( $self, $svc_phone ) = (shift, shift);
172
173   my $eid = $self->extensionID($svc_phone);
174   my ($response, $error) = 
175     $self->voipnow_command('extension', 'DelExtension', { ID => $eid });
176   return "[DelExtension] $error" if $error;
177   # don't need to de-assign the DID separately.
178
179   '';
180 }
181
182 sub _export_suspend {
183   my( $self, $svc_phone ) = (shift, shift);
184   #nop for now
185   '';
186 }
187
188 sub _export_unsuspend {
189   my( $self, $svc_phone ) = (shift, shift);
190   #nop for now
191   '';
192 }
193
194 sub userID {
195   my $self = shift;
196   return $self->{'userID'} if $self->{'userID'};
197
198   my ($response, $error) = $self->voipnow_command('client', 'GetClients', {});
199   # GetClients run on a client's login returns only that client.
200   die "couldn't get userID: $error" if $error;
201   die "non-Client login specified: ".$self->option('login') if
202     ref($response->{'client'}) ne 'HASH' 
203       or $response->{'client'}->{'login'} ne $self->option('login');
204   return $self->{'userID'} = $response->{'client'}->{'ID'};
205 }
206
207 sub extensionID {
208   # technically this returns the "extension user ID" rather than 
209   # "extension ID".
210   my $self = shift;
211   my $svc_phone = shift;
212
213   my $login = 'S'.$svc_phone->svcnum;
214   my ($response, $error) = 
215     $self->voipnow_command('extension', 'GetExtensions', 
216       { 'filter'    => $login,
217         'parentID'  => $self->userID }
218   );
219   die "couldn't get extensionID for $login: $error" if $error;
220   my $extension = '';
221
222   if ( ref($response->{'extension'}) eq 'HASH' ) {
223     $extension = $response->{'extension'};
224   }
225   elsif ( ref($response->{'extension'}) eq 'ARRAY' ) {
226     ($extension) = grep { $_->{'login'} eq $login } 
227       @{ $response->{'extension'} };
228   }
229
230   die "extension $login not found" if !$extension;
231
232   warn "[extensionID] found ID ".$response->{'extension'}->{'ID'}."\n" 
233     if $DEBUG;
234   return $response->{'extension'}->{'ID'};
235 }
236
237 my $API_VERSION = '2.5.1';
238 my %namespaces = (
239   'envelope'    => 'http://schemas.xmlsoap.org/soap/envelope/',
240   'header'      => 'http://4psa.com/HeaderData.xsd/'.$API_VERSION,
241   'channel'     => 'http://4psa.com/ChannelMessages.xsd/'.$API_VERSION,
242   'extension'   => 'http://4psa.com/ExtensionMessages.xsd/'.$API_VERSION,
243   'client'      => 'http://4psa.com/ClientMessages.xsd/'.$API_VERSION,
244   'client_data' => 'http://4psa.com/ClientData.xsd/'.$API_VERSION,
245 );
246
247 # Infrastructure
248 # example: 
249 # ($result, $error) = 
250 #   $self->voipnow_command('endpoint', 'MethodFoo', { argument => 'value' });
251 # The third argument will be enclosed in a MethodFooRequest and serialized.
252 # $result is everything inside the MethodFooResponse element, as a tree.
253
254 sub voipnow_command {
255   my $self = shift;
256   my $endpoint = shift; # 'channel' or 'extension'
257   my $method = shift;
258   my $data = shift;
259   my $host = $self->machine;
260   my $path = "/soap2/${endpoint}_agent.php";
261
262   warn "[$method] constructing request\n" if $DEBUG;
263   my $soap_request;
264   my $writer = XML::Writer->new(
265     OUTPUT => \$soap_request,
266     NAMESPACES => 1,
267     PREFIX_MAP => { reverse %namespaces },
268     FORCED_NS_DECLS => [ values %namespaces ],
269     ENCODING => 'utf-8',
270   );
271
272   my $header = {
273     '#NS' => 'header',
274     'userCredentials' => {
275       'username' => $self->option('login'),
276       'password' => $self->option('password'),
277     }
278   };
279   my $body = {
280     '#NS' => $endpoint,
281     $method.'Request' => $data,
282   };
283
284   # build the request
285   descend( $writer,
286     { Envelope => { Header => $header, Body => $body } },
287     'envelope' #start in this namespace
288   );
289
290   warn "SENDING:\n$soap_request\n" if $DEBUG > 1;
291   my ($soap_response, $status) = 
292     post_https($host, 443, $path, '', $soap_request);
293   warn "STATUS: $status\nRECEIVED:\n$soap_response\n" if $DEBUG > 1;
294   if ( !length($soap_response) ) {
295     return undef, "No response ($status)";
296   }
297
298   my $response = eval { strip_ns(XMLin($soap_response)) };
299   # handle various errors
300   if ( $@ ) {
301     return undef, "Parse error: $@";
302   }
303   if ( !exists $response->{'Body'} ) {
304     return undef, "Bad response (missing Body section)";
305   }
306   $body = $response->{'Body'};
307   if ( exists $body->{'Fault'} ) {
308     return undef, $body->{'Fault'}->{'faultstring'};
309   }
310   if ( !exists $body->{"${method}Response"} ) {
311     return undef, "Bad response (missing ${method}Response section)";
312   }
313
314   return $body->{"${method}Response"};
315 }
316
317 # Infra-infrastructure
318
319 sub descend { # like XML::Simple, but more so
320   my $writer = shift;
321   my $tree = shift;
322   my $branch_ns = delete($tree->{'#NS'}) || shift;
323   while (my ($key, $val) = each %$tree) {
324     my ($name, $key_ns) = reverse split(':', $key);
325     $key_ns ||= $branch_ns;
326     $name = [ $namespaces{$key_ns}, $name ];
327     if ( ref($val) eq 'HASH' ) {
328       $writer->startTag($name);
329       descend($writer, $val, $key_ns);
330       $writer->endTag;
331     }
332     elsif ( defined($val) ) {
333       $writer->dataElement($name, $val);
334     }
335     else { #undef
336       $writer->emptyTag($name);
337     }
338   }
339 }
340
341 sub namespace {
342   my $ns = shift;
343   my %data = @_;
344   map { $ns.':'.$_ , $data{$_} } keys(%data);
345 }
346
347 sub strip_ns { # remove the namespace tags so that we can find stuff
348   my $tree = shift;
349   if ( ref $tree eq 'HASH' ) {
350     return +{ 
351       map {
352         my $name = $_;
353         $name =~ s/^.*://;
354         $name => strip_ns($tree->{$_});
355       } keys %$tree
356     }
357   }
358   elsif ( ref $tree eq 'ARRAY' ) {
359     return [
360       map { strip_ns($_) } @$tree
361     ]
362   }
363   else {
364     return $tree;
365   }
366 }
367
368 1;
369