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',
70 'gateway' => { label => 'SIP gateway',
76 'svc' => [qw( svc_phone svc_pbx)],
78 'Provision trunks and DIDs to Thinktel VoIP',
79 'options' => \%options,
83 <P>Export to Thinktel SIP Trunking service.</P>
84 <P>This requires three service definitions to be configured:
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
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
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>
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.
106 =item svc_with_role { SVC | PKGNUM }, ROLE
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).
115 my $svc_or_pkgnum = shift;
118 if ( ref $svc_or_pkgnum ) {
119 $pkgnum = $svc_or_pkgnum->cust_svc->pkgnum or return '';
121 $pkgnum = $svc_or_pkgnum;
123 my $svcdb = ($role eq 'gateway' ? 'svc_pbx' : 'svc_phone');
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'",
132 if ( $role eq 'trunk' ) {
133 warn "$me more than one trunk service in pkgnum $pkgnum.\n" if @svcs > 1;
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).";
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.";
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."
158 if (!$svc_x->ip_addr) {
159 return "The gateway must have an IP address."
167 my($self, $svc_x) = (shift, shift);
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);
177 my $action = shift; #'action_role' format: 'insert_did', 'delete_trunk', etc.
179 my @arg = ($self->exportnum, $svcnum, @_);
181 my $job = FS::queue->new({
182 job => 'FS::part_export::thinktel::'.$action,
190 my ($exportnum, $svcnum) = @_;
191 my $self = FS::part_export->by_key($exportnum);
192 my $svc_x = FS::svc_phone->by_key($svcnum);
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
198 my $trunknum = $trunk_svc->phonenum;
200 my $endpoint = "SipTrunks/$trunknum/Dids";
201 my $content = [ { Number => $phonenum } ];
203 my $result = $self->api_request('POST', $endpoint, $content);
205 # probably can only be one of these
206 my $error = join("\n",
207 map { $_->{Message} } grep { $_->{Reply} != 1 } @$result
211 warn "$me error provisioning $phonenum to $trunknum: $error\n";
215 # now insert the V911 record
217 $content = $self->e911_content($svc_x);
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.";
235 my ($exportnum, $svcnum) = @_;
236 my $self = FS::part_export->by_key($exportnum);
237 my $svc_x = FS::svc_pbx->by_key($svcnum);
239 my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
242 my $trunknum = $trunk_svc->phonenum;
243 # and $svc_x is a svc_pbx service
245 my $endpoint = "SipBindings";
247 ContactIPAddress => $svc_x->ip_addr,
249 IPMatchRequired => JSON::true,
250 SipDomainName => $self->option('proxy'),
251 SipTrunkType => $self->option('trunktype'),
252 SipUsername => $trunknum,
253 SipPassword => $trunk_svc->sip_password,
255 my $result = $self->api_request('POST', $endpoint, $content);
257 if ( $result->{Reply} != 1 ) {
258 die "$me ".$result->{Message};
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;
268 $error = "$me storing the SIP binding ID in the database: $error";
270 # link the main trunk record to the IP address binding
271 $endpoint = "SipTrunks/$trunknum/Lines";
273 'Channels' => $svc_x->max_simultaneous,
274 'SipBindingID' => $binding_id,
275 'TrunkNumber' => $trunknum,
277 $result = $self->api_request('POST', $endpoint, $content);
278 if ( $result->{Reply} != 1 ) {
279 $error = "$me attaching binding $binding_id to $trunknum: " .
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";
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;
302 my $endpoint = "SipTrunks";
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,
314 my $result = $self->api_request('POST', $endpoint, $content);
315 if ( $result->{Reply} != 1 ) {
316 die "$me ".$result->{Message};
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";
324 foreach my $svc_x (@gateways, @dids) {
325 $self->export_insert($svc_x); # will generate additional queue jobs
330 my ($self, $svc_new, $svc_old) = @_;
332 my $error = $self->check_svc($svc_new);
333 return $error if $error;
335 my $role = $self->svc_role($svc_new)
336 or return "No export role is assigned to this service type.";
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);
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);
352 $self->queue_action("replace_$role", $svc_new->svcnum, %args);
356 my ($exportnum, $svcnum, %args) = @_;
357 my $self = FS::part_export->by_key($exportnum);
358 my $svc_x = FS::svc_phone->by_key($svcnum);
360 my $enabled = JSON::is_bool( $self->cust_svc->cust_pkg->susp == 0 );
362 my $phonenum = $svc_x->phonenum;
363 my $endpoint = "SipTrunks/$phonenum";
365 Account => $self->options('username'),
367 Label => $svc_x->phone_name_or_cust,
368 Locale => $self->option('locale'),
369 MaxChannels => $svc_x->max_simultaneous,
371 PlanID => $self->option('plan_id'),
372 ThirdPartyLabel => $svc_x->svcnum,
375 my $result = $self->api_request('PUT', $endpoint, $content);
376 if ( $result->{Reply} != 1 ) {
377 die "$me ".$result->{Message};
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;
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);
396 my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
398 my $phonenum = $svc_x->phonenum;
399 my $endpoint = "V911s/$phonenum";
400 my $content = $self->e911_content($svc_x);
402 my $result = $self->api_request('PUT', $endpoint, $content);
403 if ( $result->{Reply} != 1 ) {
404 die "$me ".$result->{Message};
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);
413 my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
416 my $binding_id = $svc_x->uuid;
418 my $trunknum = $trunk_svc->phonenum;
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);
427 my $binding_name = $result->{Name};
430 ContactIPAddress => $svc_x->ip_addr,
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,
440 $result = $self->api_request('PUT', $endpoint, $content);
442 if ( $result->{Reply} != 1 ) {
443 die "$me ".$result->{Message};
448 my ($self, $svc_x) = (shift, shift);
450 my $role = $self->svc_role($svc_x)
451 or return; # not really an error
452 my $pkgnum = $svc_x->cust_svc->pkgnum;
454 # delete_foo(svcnum, identifier, pkgnum)
455 # so that we can find the linked services later
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);
467 my ($exportnum, $svcnum, $phonenum, $pkgnum) = @_;
468 my $self = FS::part_export->by_key($exportnum);
470 my $endpoint = "SipTrunks/$phonenum";
472 my $result = $self->api_request('DELETE', $endpoint);
473 if ( $result->{Reply} != 1 ) {
474 die "$me ".$result->{Message};
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) {
486 my ($exportnum, $svcnum, $phonenum, $pkgnum) = @_;
487 my $self = FS::part_export->by_key($exportnum);
489 my $endpoint = "V911s/$phonenum";
491 my $result = $self->api_request('DELETE', $endpoint);
492 if ( $result->{Reply} != 1 ) {
493 warn "$me ".$result->{Message}; # but continue removing the DID
496 my $trunk_svc = $self->svc_with_role($pkgnum, 'trunk')
497 or return ''; # then it's already been removed, most likely
499 my $trunknum = $trunk_svc->phonenum;
500 $endpoint = "SipTrunks/$trunknum/Dids/$phonenum";
502 $result = $self->api_request('DELETE', $endpoint);
503 if ( $result->{Reply} != 1 ) {
504 die "$me ".$result->{Message};
509 my ($exportnum, $svcnum, $binding_id, $pkgnum) = @_;
510 my $self = FS::part_export->by_key($exportnum);
512 my $trunk_svc = $self->svc_with_role($pkgnum, 'trunk');
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};
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};
532 my ($self, $svc_x) = @_;
534 my %location = $svc_x->location_hash;
535 my $cust_main = $svc_x->cust_main;
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'},
547 if ($location{address1} =~ /^(\w+) +(.*)$/) {
548 $content->{StreetNumber} = $1;
549 $content->{StreetName} = $2;
551 $content->{StreetNumber} = '';
552 $content->{StreetName} = $location{address1};
558 # select by province + ratecenter, not by NPA
559 sub get_dids_npa_select { 0 }
567 my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
569 if ( $opt{'region'} ) {
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";
580 foreach my $row (@$result) {
581 push @return, $row->{Number};
587 if ( $opt{'state'} ) {
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
592 return $self->ratecenter_cache->{province}->{ $opt{'state'} } || [];
596 return $self->ratecenter_cache->{all_provinces};
602 sub ratecenter_cache {
603 # in-memory caching is probably sufficient...Thinktel's API is pretty fast
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";
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;
618 $CACHE{all_provinces} = [ sort keys %{ $CACHE{province} } ];
619 $last_cache_update = time;
625 =item queue_api_request METHOD, ENDPOINT, CONTENT, JOB
627 Adds a queue job to make a REST request.
629 =item api_request METHOD, ENDPOINT[, CONTENT ]
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.
640 my ($method, $endpoint, $content) = @_;
641 my $json = JSON->new->canonical(1); # hash keys are ordered
643 $DEBUG ||= 1 if $self->option('debug');
645 my $url = $base_url . $endpoint;
646 if ( ref($content) ) {
647 $content = $json->encode($content);
650 # PUT() == _simple_req('PUT'), etc.
651 my $request = HTTP::Request::Common::_simple_req(
654 'Accept' => 'text/json',
655 'Content-Type' => 'text/json',
656 'Content' => $content,
659 $request->authorization_basic(
660 $self->option('username'), $self->option('password')
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 ) {
671 return { Message => $response->content };
674 return $json->decode($response->content);