1 package FS::part_export::thinktel;
3 use base qw( FS::part_export );
12 use FS::Record qw( qsearch qsearchs );
14 our $me = '[Thinktel VoIP]';
16 our $base_url = 'https://api.thinktel.ca/rest.svc/';
18 # cache cities and provinces
20 our $cache_timeout = 60; # seconds
21 our $last_cache_update = 0;
25 tie my %locales, 'Tie::IxHash', (
32 SpanishLatinAmerica => 6
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' },
43 options => [ keys %locales ],
49 [ 'edm.trk.tprm.ca', 'tor.trk.tprm.ca' ],
52 label => 'SIP Trunk Type',
56 'Default SIP MG Model',
57 'Microsoft Lync Server 2010',
63 tie my %roles, 'Tie::IxHash',
64 'trunk' => { label => 'SIP trunk',
67 'did' => { label => 'DID',
71 'gateway' => { label => 'SIP gateway',
78 'svc' => [qw( svc_phone svc_pbx)],
80 'Provision trunks and DIDs to Thinktel VoIP',
81 'options' => \%options,
85 <P>Export to Thinktel SIP Trunking service.</P>
86 <P>This requires three service definitions to be configured:
88 <LI>A phone service for the SIP trunk. This should be attached to the
89 export in the "trunk" role. Usually there will be only one of these
90 per package. The <I>max_simultaneous</i> field of this service will set
91 the channel limit on the trunk. The <i>sip_password</i> will be used for
93 <LI>A phone service for a DID. This should be attached in the "did" role.
94 DIDs should have no properties other than the number and the E911
96 <LI>A PBX service for the customer's SIP gateway (Asterisk, OpenPBX, etc.
97 device). This should be attached in the "gateway" role. The <i>ip_addr</i>
98 field should be set to the static IP address that will receive calls.
99 There may be more than one of these on the trunk.</LI>
101 All three services must be within the same package. The "pbxsvc" field of
102 phone services will be ignored, as the DIDs do not belong to a specific
103 svc_pbx in a multi-gateway setup.
108 sub check_svc { # check the service for validity
109 my($self, $svc_x) = (shift, shift);
110 my $role = $self->svc_role($svc_x)
111 or return "No export role is assigned to this service type.";
112 if ( $role eq 'trunk' ) {
113 if (! $svc_x->isa('FS::svc_phone')) {
114 return "This is the wrong type of service (should be svc_phone).";
116 if (length($svc_x->sip_password) == 0
117 or length($svc_x->sip_password) > 14) {
118 return "SIP password must be 1 to 14 characters.";
120 } elsif ( $role eq 'did' ) {
121 # nothing really to check
122 } elsif ( $role eq 'gateway' ) {
123 if ($svc_x->max_simultaneous == 0) {
124 return "The maximum simultaneous calls field must be > 0."
126 if (!$svc_x->ip_addr) {
127 return "The gateway must have an IP address."
135 my($self, $svc_x) = (shift, shift);
137 my $error = $self->check_svc($svc_x);
138 return $error if $error;
139 my $role = $self->svc_role($svc_x);
140 $self->queue_action("insert_$role", $svc_x->svcnum);
145 my $action = shift; #'action_role' format: 'insert_did', 'delete_trunk', etc.
147 my @arg = ($self->exportnum, $svcnum, @_);
149 my $job = FS::queue->new({
150 job => 'FS::part_export::thinktel::'.$action,
158 my ($exportnum, $svcnum) = @_;
159 my $self = FS::part_export->by_key($exportnum);
160 my $svc_x = FS::svc_phone->by_key($svcnum);
162 my $phonenum = $svc_x->phonenum;
163 my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
164 or return; # non-fatal; just wait for the trunk to be created
166 my $trunknum = $trunk_svc->phonenum;
168 my $endpoint = "SipTrunks/$trunknum/Dids";
169 my $content = [ { Number => $phonenum } ];
171 my $result = $self->api_request('POST', $endpoint, $content);
173 # probably can only be one of these
174 my $error = join("\n",
175 map { $_->{Message} } grep { $_->{Reply} != 1 } @$result
179 warn "$me error provisioning $phonenum to $trunknum: $error\n";
183 # now insert the V911 record
185 $content = $self->e911_content($svc_x);
187 $result = $self->api_request('POST', $endpoint, $content);
188 if ( $result->{Reply} != 1 ) {
189 $error = "$me $result->{Message}";
190 # then delete the DID to keep things consistent
191 warn "$me error configuring e911 for $phonenum: $error\nReverting DID order.\n";
192 $endpoint = "SipTrunks/$trunknum/Dids/$phonenum";
193 $result = $self->api_request('DELETE', $endpoint);
194 if ( $result->{Reply} != 1 ) {
195 warn "Failed: $result->{Message}\n";
196 die "$error. E911 provisioning failed, but the DID could not be deleted: '" . $result->{Message} . "'. You may need to remove the DID manually.";
203 my ($exportnum, $svcnum) = @_;
204 my $self = FS::part_export->by_key($exportnum);
205 my $svc_x = FS::svc_pbx->by_key($svcnum);
207 my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
210 my $trunknum = $trunk_svc->phonenum;
211 # and $svc_x is a svc_pbx service
213 my $endpoint = "SipBindings";
215 ContactIPAddress => $svc_x->ip_addr,
217 IPMatchRequired => Cpanel::JSON::XS::true,
218 SipDomainName => $self->option('proxy'),
219 SipTrunkType => $self->option('trunktype'),
220 SipUsername => $trunknum,
221 SipPassword => $trunk_svc->sip_password,
223 my $result = $self->api_request('POST', $endpoint, $content);
225 if ( $result->{Reply} != 1 ) {
226 die "$me ".$result->{Message};
229 # store the binding ID in the service
230 my $binding_id = $result->{ID};
231 warn "$me created SIP binding with ID $binding_id\n" if $DEBUG;
232 local $FS::svc_Common::noexport_hack = 1;
233 $svc_x->set('uuid', $binding_id);
234 my $error = $svc_x->replace;
236 $error = "$me storing the SIP binding ID in the database: $error";
238 # link the main trunk record to the IP address binding
239 $endpoint = "SipTrunks/$trunknum/Lines";
241 'Channels' => $svc_x->max_simultaneous,
242 'SipBindingID' => $binding_id,
243 'TrunkNumber' => $trunknum,
245 $result = $self->api_request('POST', $endpoint, $content);
246 if ( $result->{Reply} != 1 ) {
247 $error = "$me attaching binding $binding_id to $trunknum: " .
254 $endpoint = "SipBindings/$binding_id";
255 $result = $self->api_request('DELETE', $endpoint);
256 if ( $result->{Reply} != 1 ) {
257 my $addl_error = $result->{Message};
258 warn "$error. The SIP binding could not be deleted: '$addl_error'.\n";
265 my ($exportnum, $svcnum) = @_;
266 my $self = FS::part_export->by_key($exportnum);
267 my $svc_x = FS::svc_phone->by_key($svcnum);
268 my $phonenum = $svc_x->phonenum;
270 my $endpoint = "SipTrunks";
272 Account => $self->option('username'),
273 Enabled => Cpanel::JSON::XS::true,
274 Label => $svc_x->phone_name_or_cust,
275 Locale => $locales{$self->option('locale')},
276 MaxChannels => $svc_x->max_simultaneous,
277 Number => { Number => $phonenum },
278 PlanID => $self->option('plan_id'),
279 ThirdPartyLabel => $svc_x->svcnum,
282 my $result = $self->api_request('POST', $endpoint, $content);
283 if ( $result->{Reply} != 1 ) {
284 die "$me ".$result->{Message};
287 my @gateways = $self->svc_with_role($svc_x, 'gateway');
288 my @dids = $self->svc_with_role($svc_x, 'did');
289 warn "$me inserting dependent services to trunk #$phonenum\n".
290 "gateways: ".@gateways."\nDIDs: ".@dids."\n";
292 foreach my $svc_x (@gateways, @dids) {
293 $self->export_insert($svc_x); # will generate additional queue jobs
297 sub _export_replace {
298 my ($self, $svc_new, $svc_old) = @_;
300 my $error = $self->check_svc($svc_new);
301 return $error if $error;
303 my $role = $self->svc_role($svc_new)
304 or return "No export role is assigned to this service type.";
306 if ( $role eq 'did' and $svc_new->phonenum ne $svc_old->phonenum ) {
307 my $pkgnum = $svc_new->cust_svc->pkgnum;
308 # not that the UI allows this...
309 return $self->queue_action("delete_did", $svc_old->svcnum,
310 $svc_old->phonenum, $pkgnum)
311 || $self->queue_action("insert_did", $svc_new->svcnum);
315 if ( $role eq 'trunk' and $svc_new->sip_password ne $svc_old->sip_password ) {
316 # then trigger a password change
317 %args = (password_change => 1);
320 $self->queue_action("replace_$role", $svc_new->svcnum, %args);
324 my ($exportnum, $svcnum, %args) = @_;
325 my $self = FS::part_export->by_key($exportnum);
326 my $svc_x = FS::svc_phone->by_key($svcnum);
328 my $enabled = Cpanel::JSON::XS::is_bool( $self->cust_svc->cust_pkg->susp == 0 );
330 my $phonenum = $svc_x->phonenum;
331 my $endpoint = "SipTrunks/$phonenum";
333 Account => $self->options('username'),
335 Label => $svc_x->phone_name_or_cust,
336 Locale => $self->option('locale'),
337 MaxChannels => $svc_x->max_simultaneous,
339 PlanID => $self->option('plan_id'),
340 ThirdPartyLabel => $svc_x->svcnum,
343 my $result = $self->api_request('PUT', $endpoint, $content);
344 if ( $result->{Reply} != 1 ) {
345 die "$me ".$result->{Message};
348 if ( $args{password_change} ) {
349 # then propagate the change to the bindings
350 my @bindings = $self->svc_with_role($svc_x->gateway);
351 foreach my $svc_pbx (@bindings) {
352 my $error = $self->export_replace($svc_pbx);
353 die "$me updating password on bindings: $error\n" if $error;
359 # we don't handle phonenum/trunk changes
360 my ($exportnum, $svcnum, %args) = @_;
361 my $self = FS::part_export->by_key($exportnum);
362 my $svc_x = FS::svc_phone->by_key($svcnum);
364 my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
366 my $phonenum = $svc_x->phonenum;
367 my $endpoint = "V911s/$phonenum";
368 my $content = $self->e911_content($svc_x);
370 my $result = $self->api_request('PUT', $endpoint, $content);
371 if ( $result->{Reply} != 1 ) {
372 die "$me ".$result->{Message};
376 sub replace_gateway {
377 my ($exportnum, $svcnum, %args) = @_;
378 my $self = FS::part_export->by_key($exportnum);
379 my $svc_x = FS::svc_pbx->by_key($svcnum);
381 my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
384 my $binding_id = $svc_x->uuid;
386 my $trunknum = $trunk_svc->phonenum;
388 my $endpoint = "SipBindings/$binding_id";
389 # get the canonical name of the binding
390 my $result = $self->api_request('GET', $endpoint);
391 if ( $result->{Message} ) {
392 # then assume the binding is not yet set up
393 return $self->export_insert($svc_x);
395 my $binding_name = $result->{Name};
398 ContactIPAddress => $svc_x->ip_addr,
401 IPMatchRequired => Cpanel::JSON::XS::true,
402 Name => $binding_name,
403 SipDomainName => $self->option('proxy'),
404 SipTrunkType => $self->option('trunktype'),
405 SipUsername => $trunknum,
406 SipPassword => $trunk_svc->sip_password,
408 $result = $self->api_request('PUT', $endpoint, $content);
410 if ( $result->{Reply} != 1 ) {
411 die "$me ".$result->{Message};
416 my ($self, $svc_x) = (shift, shift);
418 my $role = $self->svc_role($svc_x)
419 or return; # not really an error
420 my $pkgnum = $svc_x->cust_svc->pkgnum;
422 # delete_foo(svcnum, identifier, pkgnum)
423 # so that we can find the linked services later
425 if ( $role eq 'trunk' ) {
426 $self->queue_action("delete_trunk", $svc_x->svcnum, $svc_x->phonenum, $pkgnum);
427 } elsif ( $role eq 'did' ) {
428 $self->queue_action("delete_did", $svc_x->svcnum, $svc_x->phonenum, $pkgnum);
429 } elsif ( $role eq 'gateway' ) {
430 $self->queue_action("delete_gateway", $svc_x->svcnum, $svc_x->uuid, $pkgnum);
435 my ($exportnum, $svcnum, $phonenum, $pkgnum) = @_;
436 my $self = FS::part_export->by_key($exportnum);
438 my $endpoint = "SipTrunks/$phonenum";
440 my $result = $self->api_request('DELETE', $endpoint);
441 if ( $result->{Reply} != 1 ) {
442 die "$me ".$result->{Message};
445 # deleting this on the server side should remove all DIDs, but we still
446 # need to remove IP bindings
447 my @gateways = $self->svc_with_role($pkgnum, 'gateway');
448 foreach (@gateways) {
454 my ($exportnum, $svcnum, $phonenum, $pkgnum) = @_;
455 my $self = FS::part_export->by_key($exportnum);
457 my $endpoint = "V911s/$phonenum";
459 my $result = $self->api_request('DELETE', $endpoint);
460 if ( $result->{Reply} != 1 ) {
461 warn "$me ".$result->{Message}; # but continue removing the DID
464 my $trunk_svc = $self->svc_with_role($pkgnum, 'trunk')
465 or return ''; # then it's already been removed, most likely
467 my $trunknum = $trunk_svc->phonenum;
468 $endpoint = "SipTrunks/$trunknum/Dids/$phonenum";
470 $result = $self->api_request('DELETE', $endpoint);
471 if ( $result->{Reply} != 1 ) {
472 die "$me ".$result->{Message};
477 my ($exportnum, $svcnum, $binding_id, $pkgnum) = @_;
478 my $self = FS::part_export->by_key($exportnum);
480 my $trunk_svc = $self->svc_with_role($pkgnum, 'trunk');
482 # detach the address from the trunk
483 my $trunknum = $trunk_svc->phonenum;
484 my $endpoint = "SipTrunks/$trunknum/Lines/$binding_id";
485 my $result = $self->api_request('DELETE', $endpoint);
486 if ( $result->{Reply} != 1 ) {
487 die "$me ".$result->{Message};
491 # seems not to be necessary?
492 #my $endpoint = "SipBindings/$binding_id";
493 #my $result = $self->api_request('DELETE', $endpoint);
494 #if ( $result->{Reply} != 1 ) {
495 # die "$me ".$result->{Message};
500 my ($self, $svc_x) = @_;
502 my %location = $svc_x->location_hash;
503 my $cust_main = $svc_x->cust_main;
506 City => $location{'city'},
507 FirstName => $cust_main->first,
508 LastName => $cust_main->last,
509 Number => $svc_x->phonenum,
510 OtherInfo => ($svc_x->phone_name || ''),
511 PostalZip => $location{'zip'},
512 ProvinceState => $location{'state'},
513 SuiteNumber => $location{'address2'},
515 if ($location{address1} =~ /^(\w+) +(.*)$/) {
516 $content->{StreetNumber} = $1;
517 $content->{StreetName} = $2;
519 $content->{StreetNumber} = '';
520 $content->{StreetName} = $location{address1};
526 # select by province + ratecenter, not by NPA
527 sub get_dids_npa_select { 0 }
535 my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
537 if ( $opt{'region'} ) {
539 # return numbers (probably shouldn't cache this)
540 my $state = $self->ratecenter_cache->{city}{ $opt{'region'} };
541 my $ratecenter = $opt{'region'} . ', ' . $state;
542 my $endpoint = uri_escape("RateCenters/$ratecenter/Next10");
543 my $result = $self->api_request('GET', $endpoint);
544 if (ref($result) eq 'HASH') {
545 die "$me error fetching available DIDs in '$ratecenter': ".$result->{Message}."\n";
548 foreach my $row (@$result) {
549 push @return, $row->{Number};
555 if ( $opt{'state'} ) {
557 # ratecenter_cache will refresh the cache if necessary, and die on
558 # failure. default here is only in case someone gives us a state that
560 return $self->ratecenter_cache->{province}->{ $opt{'state'} } || [];
564 return $self->ratecenter_cache->{all_provinces};
570 sub ratecenter_cache {
571 # in-memory caching is probably sufficient...Thinktel's API is pretty fast
574 if (keys(%CACHE) == 0 or ($last_cache_update + $cache_timeout < time) ) {
575 %CACHE = ( province => {}, city => {} );
576 my $result = $self->api_request('GET', 'RateCenters');
577 if (ref($result) eq 'HASH') {
578 die "$me error fetching ratecenters: ".$result->{Message}."\n";
580 foreach my $row (@$result) {
581 my ($city, $province) = split(', ', $row->{Name});
582 $CACHE{province}->{$province} ||= [];
583 push @{ $CACHE{province}->{$province} }, $city;
584 $CACHE{city}{$city} = $province;
586 $CACHE{all_provinces} = [ sort keys %{ $CACHE{province} } ];
587 $last_cache_update = time;
593 =item queue_api_request METHOD, ENDPOINT, CONTENT, JOB
595 Adds a queue job to make a REST request.
597 =item api_request METHOD, ENDPOINT[, CONTENT ]
599 Makes a REST request using METHOD, to URL ENDPOINT (relative to the API
600 base). For POST or PUT requests, CONTENT is the content to submit, as a
601 hashref. Returns the decoded response; generally, on failure, this will
602 have a 'Message' element.
608 my ($method, $endpoint, $content) = @_;
609 my $json = Cpanel::JSON::XS->new->canonical(1); # hash keys are ordered
611 $DEBUG ||= 1 if $self->option('debug');
613 my $url = $base_url . $endpoint;
614 if ( ref($content) ) {
615 $content = $json->encode($content);
618 # PUT() == _simple_req('PUT'), etc.
619 my $request = HTTP::Request::Common::_simple_req(
622 'Accept' => 'text/json',
623 'Content-Type' => 'text/json',
624 'Content' => $content,
627 $request->authorization_basic(
628 $self->option('username'), $self->option('password')
631 my $stringify = 'content';
632 $stringify = 'as_string' if $DEBUG > 1; # includes HTTP headers
633 warn "$me $method $endpoint\n" . $request->$stringify ."\n" if $DEBUG;
634 my $ua = LWP::UserAgent->new;
635 my $response = $ua->request($request);
636 warn "$me received:\n" . $response->$stringify ."\n" if $DEBUG;
637 if ( ! $response->is_success ) {
639 return { Message => $response->content };
642 return $json->decode($response->content);