4a28649a3919d7ea1d1a703b6c0a5fe57ef442c1
[freeside.git] / FS / FS / part_export / thinktel.pm
1 package FS::part_export::thinktel;
2
3 use base qw( FS::part_export );
4 use strict;
5
6 use Tie::IxHash;
7 use URI::Escape;
8 use LWP::UserAgent;
9 use URI::Escape;
10 use JSON;
11
12 use FS::Record qw( qsearch qsearchs );
13
14 our $me = '[Thinktel VoIP]';
15 our $DEBUG = 1;
16 our $base_url = 'https://api.thinktel.ca/rest.svc/';
17
18 # cache cities and provinces
19 our %CACHE;
20 our $cache_timeout = 60; # seconds
21 our $last_cache_update = 0;
22
23 # static data
24
25 tie my %locales, 'Tie::IxHash', (
26   EnglishUS => 0,
27   EnglishUK => 1,
28   EnglishCA => 2,
29   UserDefined1 => 3,
30   UserDefined2 => 4,
31   FrenchCA  => 5,
32   SpanishLatinAmerica => 6
33 );
34
35 tie my %options, 'Tie::IxHash',
36   'username'        => { label => 'Thinktel username', },
37   'password'        => { label => 'Thinktel password', },
38   'debug'           => { label => 'Enable debugging', type => 'checkbox', value => 1 },
39   'plan_id'         => { label => 'Trunk plan ID' },
40   'locale'          => {
41     label => 'Locale',
42     type => 'select',
43     options => [ keys %locales ],
44   },
45   'proxy'           => {
46     label => 'SIP Proxy',
47     type => 'select',
48     options =>
49       [ 'edm.trk.tprm.ca', 'tor.trk.tprm.ca' ],
50   },
51   'trunktype'       => {
52     label => 'SIP Trunk Type',
53     type => 'select',
54     options => [
55       'Avaya CM/SM',
56       'Default SIP MG Model',
57       'Microsoft Lync Server 2010',
58     ],
59   },
60
61 ;
62
63 tie my %roles, 'Tie::IxHash',
64   'trunk'   => {  label     => 'SIP trunk',
65                   svcdb     => 'svc_phone',
66                },
67   'did'     => {  label     => 'DID',
68                   svcdb     => 'svc_phone',
69                },
70   'gateway' => {  label     => 'SIP gateway',
71                   svcdb     => 'svc_pbx'
72                },
73 ;
74
75 our %info = (
76   'svc'         => [qw( svc_phone svc_pbx)],
77   'desc'        =>
78     'Provision trunks and DIDs to Thinktel VoIP',
79   'options'     => \%options,
80   'roles'       => \%roles,
81   'no_machine'  => 1,
82   'notes'       => <<'END'
83 <P>Export to Thinktel SIP Trunking service.</P>
84 <P>This requires three service definitions to be configured:
85   <OL>
86     <LI>A phone service for the SIP trunk. This should be attached to the 
87     export in the "trunk" role. Usually there will be only one of these
88     per package. The <I>max_simultaneous</i> field of this service will set 
89     the channel limit on the trunk. The I<sip_password> will be used for
90     all gateways.</LI>
91     <LI>A phone service for a DID. This should be attached in the "did" role.
92     DIDs should have no properties other than the number and the E911 
93     location.</LI>
94     <LI>A PBX service for the customer's SIP gateway (Asterisk, OpenPBX, etc. 
95     device). This should be attached in the "gateway" role. The <i>ip_addr</i> 
96     field should be set to the static IP address that will receive calls. 
97     There may be more than one of these on the trunk.</LI>
98   </OL>
99   All three services must be within the same package. The "pbxsvc" field of
100   phone services will be ignored, as the DIDs do not belong to a specific 
101   svc_pbx in a multi-gateway setup.
102 </P>
103 END
104 );
105
106 =item svc_with_role { SVC | PKGNUM }, ROLE
107
108 Finds the service(s) in the same package as SVC (or the package PKGNUM) that 
109 are linked to the export in ROLE (trunk, gateway, or did).
110
111 =cut
112
113 sub svc_with_role {
114   my $self = shift;
115   my $svc_or_pkgnum = shift;
116   my $role = shift;
117   my $pkgnum;
118   if ( ref $svc_or_pkgnum ) {
119     $pkgnum = $svc_or_pkgnum->cust_svc->pkgnum or return '';
120   } else {
121     $pkgnum = $svc_or_pkgnum;
122   }
123   my $svcdb = ($role eq 'gateway' ? 'svc_pbx' : 'svc_phone');
124   my @svcs = qsearch({
125     'table'     =>  $svcdb,
126     'addl_from' =>  ' JOIN cust_svc USING (svcnum)' .
127                     ' JOIN export_svc USING (svcpart)',
128     'extra_sql' =>  " WHERE cust_svc.pkgnum = $pkgnum" .
129                     " AND export_svc.exportnum = ".$self->exportnum .
130                     " AND export_svc.role = '$role'",
131   });
132   if ( $role eq 'trunk' ) {
133     warn "$me more than one trunk service in pkgnum $pkgnum.\n" if @svcs > 1;
134     return $svcs[0];
135   } else {
136     return @svcs;
137   }
138 }
139
140 sub check_svc { # check the service for validity
141   my($self, $svc_x) = (shift, shift);
142   my $role = $self->svc_role($svc_x)
143     or return "No export role is assigned to this service type.";
144   if ( $role eq 'trunk' ) {
145     if (! $svc_x->isa('FS::svc_phone')) {
146       return "This is the wrong type of service (should be svc_phone).";
147     }
148     if (length($svc_x->sip_password) == 0
149         or length($svc_x->sip_password) > 14) {
150       return "SIP password must be 1 to 14 characters.";
151     }
152   } elsif ( $role eq 'did' ) {
153     # nothing really to check
154   } elsif ( $role eq 'gateway' ) {
155     if ($svc_x->max_simultaneous == 0) {
156       return "The maximum simultaneous calls field must be > 0."
157     }
158     if (!$svc_x->ip_addr) {
159       return "The gateway must have an IP address."
160     }
161   }
162
163   '';
164 }
165
166 sub export_insert {
167   my($self, $svc_x) = (shift, shift);
168
169   my $error = $self->check_svc($svc_x);
170   return $error if $error;
171   my $role = $self->svc_role($svc_x);
172   $self->queue_action("insert_$role", $svc_x->svcnum);
173 }
174
175 sub queue_action {
176   my $self = shift;
177   my $action = shift; #'action_role' format: 'insert_did', 'delete_trunk', etc.
178   my $svcnum = shift;
179   my @arg = ($self->exportnum, $svcnum, @_);
180
181   my $job = FS::queue->new({
182       job => 'FS::part_export::thinktel::'.$action,
183       svcnum => $svcnum,
184   });
185
186   $job->insert(@arg);
187 }
188
189 sub insert_did {
190   my ($exportnum, $svcnum) = @_;
191   my $self = FS::part_export->by_key($exportnum);
192   my $svc_x = FS::svc_phone->by_key($svcnum);
193
194   my $phonenum = $svc_x->phonenum;
195   my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
196     or return; # non-fatal; just wait for the trunk to be created
197
198   my $trunknum = $trunk_svc->phonenum;
199
200   my $endpoint = "SipTrunks/$trunknum/Dids";
201   my $content = [ { Number  => $phonenum } ];
202
203   my $result = $self->api_request('POST', $endpoint, $content);
204
205   # probably can only be one of these
206   my $error = join("\n",
207     map { $_->{Message} } grep { $_->{Reply} != 1 } @$result
208   );
209
210   if ( $error ) {
211     warn "$me error provisioning $phonenum to $trunknum: $error\n";
212     die "$me $error";
213   }
214
215   # now insert the V911 record
216   $endpoint = "V911s";
217   $content = $self->e911_content($svc_x);
218
219   $result = $self->api_request('POST', $endpoint, $content);
220   if ( $result->{Reply} != 1 ) {
221     $error = "$me $result->{Message}";
222     # then delete the DID to keep things consistent
223     warn "$me error configuring e911 for $phonenum: $error\nReverting DID order.\n";
224     $endpoint = "SipTrunks/$trunknum/Dids/$phonenum";
225     $result = $self->api_request('DELETE', $endpoint);
226     if ( $result->{Reply} != 1 ) {
227       warn "Failed: $result->{Message}\n";
228       die "$error. E911 provisioning failed, but the DID could not be deleted: '" . $result->{Message} . "'. You may need to remove the DID manually.";
229     }
230     die $error;
231   }
232 }
233
234 sub insert_gateway {
235   my ($exportnum, $svcnum) = @_;
236   my $self = FS::part_export->by_key($exportnum);
237   my $svc_x = FS::svc_pbx->by_key($svcnum);
238
239   my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
240     or return;
241
242   my $trunknum = $trunk_svc->phonenum;
243   # and $svc_x is a svc_pbx service
244
245   my $endpoint = "SipBindings";
246   my $content = {
247     ContactIPAddress  => $svc_x->ip_addr,
248     ContactPort       => 5060,
249     IPMatchRequired   => JSON::true,
250     SipDomainName     => $self->option('proxy'),
251     SipTrunkType      => $self->option('trunktype'),
252     SipUsername       => $trunknum,
253     SipPassword       => $trunk_svc->sip_password,
254   };
255   my $result = $self->api_request('POST', $endpoint, $content);
256
257   if ( $result->{Reply} != 1 ) {
258     die "$me ".$result->{Message};
259   }
260
261   # store the binding ID in the service
262   my $binding_id = $result->{ID};
263   warn "$me created SIP binding with ID $binding_id\n" if $DEBUG;
264   local $FS::svc_Common::noexport_hack = 1;
265   $svc_x->set('uuid', $binding_id);
266   my $error = $svc_x->replace;
267   if ( $error ) {
268     $error = "$me storing the SIP binding ID in the database: $error";
269   } else {
270     # link the main trunk record to the IP address binding
271     $endpoint = "SipTrunks/$trunknum/Lines";
272     $content = {
273       'Channels'     => $svc_x->max_simultaneous,
274       'SipBindingID' => $binding_id,
275       'TrunkNumber'  => $trunknum,
276     };
277     $result = $self->api_request('POST', $endpoint, $content);
278     if ( $result->{Reply} != 1 ) {
279       $error = "$me attaching binding $binding_id to $trunknum: " .
280         $result->{Message};
281     }
282   }
283
284   if ( $error ) {
285     # delete the binding
286     $endpoint = "SipBindings/$binding_id";
287     $result = $self->api_request('DELETE', $endpoint);
288     if ( $result->{Reply} != 1 ) {
289       my $addl_error = $result->{Message};
290       warn "$error. The SIP binding could not be deleted: '$addl_error'.\n";
291     }
292     die $error;
293   }
294 }
295
296 sub insert_trunk {
297   my ($exportnum, $svcnum) = @_;
298   my $self = FS::part_export->by_key($exportnum);
299   my $svc_x = FS::svc_phone->by_key($svcnum);
300   my $phonenum = $svc_x->phonenum;
301
302   my $endpoint = "SipTrunks";
303   my $content = {
304     Account           => $self->option('username'),
305     Enabled           => JSON::true,
306     Label             => $svc_x->phone_name_or_cust,
307     Locale            => $locales{$self->option('locale')},
308     MaxChannels       => $svc_x->max_simultaneous,
309     Number            => { Number => $phonenum },
310     PlanID            => $self->option('plan_id'),
311     ThirdPartyLabel   => $svc_x->svcnum,
312   };
313
314   my $result = $self->api_request('POST', $endpoint, $content);
315   if ( $result->{Reply} != 1 ) {
316     die "$me ".$result->{Message};
317   }
318
319   my @gateways = $self->svc_with_role($svc_x, 'gateway');
320   my @dids = $self->svc_with_role($svc_x, 'did');
321   warn "$me inserting dependent services to trunk #$phonenum\n".
322        "gateways: ".@gateways."\nDIDs: ".@dids."\n";
323
324   foreach my $svc_x (@gateways, @dids) {
325     $self->export_insert($svc_x); # will generate additional queue jobs
326   }
327 }
328
329 sub export_replace {
330   my ($self, $svc_new, $svc_old) = @_;
331
332   my $error = $self->check_svc($svc_new);
333   return $error if $error;
334
335   my $role = $self->svc_role($svc_new)
336     or return "No export role is assigned to this service type.";
337
338   if ( $role eq 'did' and $svc_new->phonenum ne $svc_old->phonenum ) {
339     my $pkgnum = $svc_new->cust_svc->pkgnum;
340     # not that the UI allows this...
341     return $self->queue_action("delete_did", $svc_old->svcnum, 
342                                $svc_old->phonenum, $pkgnum)
343         || $self->queue_action("insert_did", $svc_new->svcnum);
344   }
345
346   my %args;
347   if ( $role eq 'trunk' and $svc_new->sip_password ne $svc_old->sip_password ) {
348     # then trigger a password change
349     %args = (password_change => 1);
350   }
351     
352   $self->queue_action("replace_$role", $svc_new->svcnum, %args);
353 }
354
355 sub replace_trunk {
356   my ($exportnum, $svcnum, %args) = @_;
357   my $self = FS::part_export->by_key($exportnum);
358   my $svc_x = FS::svc_phone->by_key($svcnum);
359
360   my $enabled = JSON::is_bool( $self->cust_svc->cust_pkg->susp == 0 );
361
362   my $phonenum = $svc_x->phonenum;
363   my $endpoint = "SipTrunks/$phonenum";
364   my $content = {
365     Account           => $self->options('username'),
366     Enabled           => $enabled,
367     Label             => $svc_x->phone_name_or_cust,
368     Locale            => $self->option('locale'),
369     MaxChannels       => $svc_x->max_simultaneous,
370     Number            => $phonenum,
371     PlanID            => $self->option('plan_id'),
372     ThirdPartyLabel   => $svc_x->svcnum,
373   };
374
375   my $result = $self->api_request('PUT', $endpoint, $content);
376   if ( $result->{Reply} != 1 ) {
377     die "$me ".$result->{Message};
378   }
379
380   if ( $args{password_change} ) {
381     # then propagate the change to the bindings
382     my @bindings = $self->svc_with_role($svc_x->gateway);
383     foreach my $svc_pbx (@bindings) {
384       my $error = $self->export_replace($svc_pbx);
385       die "$me updating password on bindings: $error\n" if $error;
386     }
387   }
388 }
389
390 sub replace_did {
391   # we don't handle phonenum/trunk changes
392   my ($exportnum, $svcnum, %args) = @_;
393   my $self = FS::part_export->by_key($exportnum);
394   my $svc_x = FS::svc_phone->by_key($svcnum);
395
396   my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
397     or return;
398   my $phonenum = $svc_x->phonenum;
399   my $endpoint = "V911s/$phonenum";
400   my $content = $self->e911_content($svc_x);
401
402   my $result = $self->api_request('PUT', $endpoint, $content);
403   if ( $result->{Reply} != 1 ) {
404     die "$me ".$result->{Message};
405   }
406 }
407
408 sub replace_gateway {
409   my ($exportnum, $svcnum, %args) = @_;
410   my $self = FS::part_export->by_key($exportnum);
411   my $svc_x = FS::svc_pbx->by_key($svcnum);
412
413   my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
414     or return;
415
416   my $binding_id = $svc_x->uuid;
417
418   my $trunknum = $trunk_svc->phonenum;
419
420   my $endpoint = "SipBindings/$binding_id";
421   # get the canonical name of the binding
422   my $result = $self->api_request('GET', $endpoint);
423   if ( $result->{Message} ) {
424     # then assume the binding is not yet set up
425     return $self->export_insert($svc_x);
426   }
427   my $binding_name = $result->{Name};
428  
429   my $content = {
430     ContactIPAddress  => $svc_x->ip_addr,
431     ContactPort       => 5060,
432     ID                => $binding_id,
433     IPMatchRequired   => JSON::true,
434     Name              => $binding_name,
435     SipDomainName     => $self->option('proxy'),
436     SipTrunkType      => $self->option('trunktype'),
437     SipUsername       => $trunknum,
438     SipPassword       => $trunk_svc->sip_password,
439   };
440   $result = $self->api_request('PUT', $endpoint, $content);
441
442   if ( $result->{Reply} != 1 ) {
443     die "$me ".$result->{Message};
444   }
445 }
446
447 sub export_delete {
448   my ($self, $svc_x) = (shift, shift);
449
450   my $role = $self->svc_role($svc_x)
451     or return; # not really an error
452   my $pkgnum = $svc_x->cust_svc->pkgnum;
453
454   # delete_foo(svcnum, identifier, pkgnum)
455   # so that we can find the linked services later
456
457   if ( $role eq 'trunk' ) {
458     $self->queue_action("delete_trunk", $svc_x->svcnum, $svc_x->phonenum, $pkgnum);
459   } elsif ( $role eq 'did' ) {
460     $self->queue_action("delete_did", $svc_x->svcnum, $svc_x->phonenum, $pkgnum);
461   } elsif ( $role eq 'gateway' ) {
462     $self->queue_action("delete_gateway", $svc_x->svcnum, $svc_x->uuid, $pkgnum);
463   }
464 }
465
466 sub delete_trunk {
467   my ($exportnum, $svcnum, $phonenum, $pkgnum) = @_;
468   my $self = FS::part_export->by_key($exportnum);
469
470   my $endpoint = "SipTrunks/$phonenum";
471
472   my $result = $self->api_request('DELETE', $endpoint);
473   if ( $result->{Reply} != 1 ) {
474     die "$me ".$result->{Message};
475   }
476
477   # deleting this on the server side should remove all DIDs, but we still
478   # need to remove IP bindings
479   my @gateways = $self->svc_with_role($pkgnum, 'gateway');
480   foreach (@gateways) {
481     $_->export_delete;
482   }
483 }
484
485 sub delete_did {
486   my ($exportnum, $svcnum, $phonenum, $pkgnum) = @_;
487   my $self = FS::part_export->by_key($exportnum);
488
489   my $endpoint = "V911s/$phonenum";
490
491   my $result = $self->api_request('DELETE', $endpoint);
492   if ( $result->{Reply} != 1 ) {
493     warn "$me ".$result->{Message}; # but continue removing the DID
494   }
495
496   my $trunk_svc = $self->svc_with_role($pkgnum, 'trunk')
497     or return ''; # then it's already been removed, most likely
498
499   my $trunknum = $trunk_svc->phonenum;
500   $endpoint = "SipTrunks/$trunknum/Dids/$phonenum";
501
502   $result = $self->api_request('DELETE', $endpoint);
503   if ( $result->{Reply} != 1 ) {
504     die "$me ".$result->{Message};
505   }
506 }
507
508 sub delete_gateway {
509   my ($exportnum, $svcnum, $binding_id, $pkgnum) = @_;
510   my $self = FS::part_export->by_key($exportnum);
511
512   my $trunk_svc = $self->svc_with_role($pkgnum, 'trunk');
513   if ( $trunk_svc ) {
514     # detach the address from the trunk
515     my $trunknum = $trunk_svc->phonenum;
516     my $endpoint = "SipTrunks/$trunknum/Lines/$binding_id";
517     my $result = $self->api_request('DELETE', $endpoint);
518     if ( $result->{Reply} != 1 ) {
519       die "$me ".$result->{Message};
520     }
521   }
522
523   # seems not to be necessary?
524   #my $endpoint = "SipBindings/$binding_id";
525   #my $result = $self->api_request('DELETE', $endpoint);
526   #if ( $result->{Reply} != 1 ) {
527   #  die "$me ".$result->{Message};
528   #}
529 }
530
531 sub e911_content {
532   my ($self, $svc_x) = @_;
533
534   my %location = $svc_x->location_hash;
535   my $cust_main = $svc_x->cust_main;
536
537   my $content = {
538     City            => $location{'city'},
539     FirstName       => $cust_main->first,
540     LastName        => $cust_main->last,
541     Number          => $svc_x->phonenum,
542     OtherInfo       => ($svc_x->phone_name || ''),
543     PostalZip       => $location{'zip'},
544     ProvinceState   => $location{'state'},
545     SuiteNumber     => $location{'address2'},
546   };
547   if ($location{address1} =~ /^(\w+) +(.*)$/) {
548     $content->{StreetNumber} = $1;
549     $content->{StreetName} = $2;
550   } else {
551     $content->{StreetNumber} = '';
552     $content->{StreetName} = $location{address1};
553   }
554
555   return $content;
556 }
557
558 # select by province + ratecenter, not by NPA
559 sub get_dids_npa_select { 0 }
560
561 sub get_dids {
562   my $self = shift;
563   local $DEBUG = 0;
564
565   my %opt = @_;
566
567   my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
568
569   if ( $opt{'region'} ) {
570
571     # return numbers (probably shouldn't cache this)
572     my $state = $self->ratecenter_cache->{city}{ $opt{'region'} };
573     my $ratecenter = $opt{'region'} . ', ' . $state;
574     my $endpoint = uri_escape("RateCenters/$ratecenter/Next10");
575     my $result = $self->api_request('GET', $endpoint);
576     if (ref($result) eq 'HASH') {
577       die "$me error fetching available DIDs in '$ratecenter': ".$result->{Message}."\n";
578     }
579     my @return;
580     foreach my $row (@$result) {
581       push @return, $row->{Number};
582     }
583     return \@return;
584
585   } else {
586
587     if ( $opt{'state'} ) {
588
589       # ratecenter_cache will refresh the cache if necessary, and die on 
590       # failure. default here is only in case someone gives us a state that
591       # doesn't exist.
592       return $self->ratecenter_cache->{province}->{ $opt{'state'} } || [];
593
594     } else {
595
596       return $self->ratecenter_cache->{all_provinces};
597
598     }
599   }
600 }
601
602 sub ratecenter_cache {
603   # in-memory caching is probably sufficient...Thinktel's API is pretty fast
604   my $self = shift;
605
606   if (keys(%CACHE) == 0 or ($last_cache_update + $cache_timeout < time) ) {
607     %CACHE = ( province => {}, city => {} );
608     my $result = $self->api_request('GET', 'RateCenters');
609     if (ref($result) eq 'HASH') {
610       die "$me error fetching ratecenters: ".$result->{Message}."\n";
611     }
612     foreach my $row (@$result) {
613       my ($city, $province) = split(', ', $row->{Name});
614       $CACHE{province}->{$province} ||= [];
615       push @{ $CACHE{province}->{$province} }, $city;
616       $CACHE{city}{$city} = $province;
617     }
618     $CACHE{all_provinces} = [ sort keys %{ $CACHE{province} } ];
619     $last_cache_update = time;
620   }
621   
622   return \%CACHE;
623 }
624
625 =item queue_api_request METHOD, ENDPOINT, CONTENT, JOB
626
627 Adds a queue job to make a REST request.
628
629 =item api_request METHOD, ENDPOINT[, CONTENT ]
630
631 Makes a REST request using METHOD, to URL ENDPOINT (relative to the API
632 base). For POST or PUT requests, CONTENT is the content to submit, as a
633 hashref. Returns the decoded response; generally, on failure, this will
634 have a 'Message' element.
635
636 =cut
637
638 sub api_request {
639   my $self = shift;
640   my ($method, $endpoint, $content) = @_;
641   my $json = JSON->new->canonical(1); # hash keys are ordered
642
643   $DEBUG ||= 1 if $self->option('debug');
644
645   my $url = $base_url . $endpoint;
646   if ( ref($content) ) {
647     $content = $json->encode($content);
648   }
649
650   # PUT() == _simple_req('PUT'), etc.
651   my $request = HTTP::Request::Common::_simple_req(
652     $method,
653     $url,
654     'Accept'        => 'text/json',
655     'Content-Type'  => 'text/json',
656     'Content'       => $content,
657   );
658
659   $request->authorization_basic(
660     $self->option('username'), $self->option('password')
661   );
662
663   my $stringify = 'content';
664   $stringify = 'as_string' if $DEBUG > 1; # includes HTTP headers
665   warn "$me $method $endpoint\n" . $request->$stringify ."\n" if $DEBUG;
666   my $ua = LWP::UserAgent->new;
667   my $response = $ua->request($request);
668   warn "$me received:\n" . $response->$stringify ."\n" if $DEBUG;
669   if ( ! $response->is_success ) {
670     # fake up a response
671     return { Message => $response->content };
672   }
673
674   return $json->decode($response->content);
675 }
676
677 1;