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