SMS-API-VoIP-MS-SMS-API-VoIP-MS/lib/SMS/API/VoIP/MS.pm
package SMS::API::VoIP::MS;
use 5.006;
use strict;
use warnings;
use Mo qw( build default required is );
use URI;
use URI::QueryParam;
use Date::Parse;
use Image::Magick;
use LWP::UserAgent;
use Cpanel::JSON::XS;
use Scalar::Util qw( openhandle );
use MIME::Base64 qw( encode_base64 );
use Unicode::LineBreak;
use POSIX qw( strftime );
=head1 NAME
SMS::API::VoIP::MS - VoIP.ms SMS client with MMS support.
=head1 VERSION
Version 0.01
=cut
our $VERSION = '0.01';
=head1 SYNOPSIS
A simple module that attempts to implement the SMS/MMS portion of VoIP.ms's
API.
use SMS::API::VoIP::MS;
my $sms = SMS::API::VoIP::MS->new(
# Required
username => 'john.doe@test.com',
api_key => 'abcd1234',
# Optional; you can set it as a 'from' parameter when sending
# messages. Or it'll use the only one you have if you only have 1 DID
# with SMS enabled.
did => '1231231234',
# Optional. Default is 0, which will split texts > 160 chars into
# multiples.
convert_long_sms_to_mms => 1,
);
$sms->send_sms(
did => 1231231234,
to => '1231231235',
'This is a message!',
);
$sms->send_mms(
to => '1231231235',
attachment => $io,
'This is also a message!',
);
=head1 SUBROUTINES/METHODS
=head2 new
Method that constructs your SMS object. This method will contact VoIP.ms and
verify the DID provided is in fact available to perform messaging. Failures
will die, so use try/catch if you want to fail gracefully.
=cut
has username => (
is => 'ro',
required => 1,
);
has did => ();
has api_key => (
is => 'ro',
required => 1,
);
has _available_dids => ();
has _lwp => (
default => sub {
LWP::UserAgent->new(
ssl_opts => { verify_hostname => 1 },
# Someone at VoIP.ms told Cloudflare to block libwww-perl, which
# is idiotic.
agent => q{},
)
},
);
has _json_parser => (
default => sub { Cpanel::JSON::XS->new }
);
has convert_long_sms_to_mms => (
is => 'ro',
default => sub { 0 },
);
=head2 BUILD
Gets the DIDs that have SMS enabled, verifies the one provided (if any) is
available.
=cut
sub BUILD {
my ( $self ) = @_;
$self->did( $self->_sanitise_number( $self->did ) ) if $self->did;
$self->_populate_sms_dids;
if ( my $num = $self->did ) {
$self->did( $num );
die $self->did
. " either isn't your DID, or doesn't have SMS available.\n"
if !$self->_available_dids->{ $self->did };
}
}
=head2 _sanitise_number
Just regexes to delete non digits, and leading 1s (yes, this makes it
NA-centric. I'm sorry.)
=cut
sub _sanitise_number {
my ( $self, $number ) = @_;
$number =~ tr/0-9//cd;
$number =~ s/^1//;
return $number;
}
=head2 _get_method
Wrapper for calling API methods with GET
=cut
sub _get_method {
my ( $self, $method, %parameters ) = @_;
return $self->_parse_result(
$self->_lwp->get( $self->_get_uri( $method, %parameters ) ),
$method,
);
}
=head2 _post_method
Wrapper for calling API methods with POST. We also specify form-data, because
VOIP.ms doesn't seem to like www encoded data in POSTs.
=cut
sub _post_method {
my ( $self, $method, %parameters ) = @_;
return $self->_parse_result(
$self->_lwp->post(
$self->_api_url,
Content_Type => 'form-data',
Content => [
$self->_default_params( $method ),
%parameters,
],
),
$method
);
}
=head2 _parse_result
Does error handling, decodes JSON.
=cut
sub _parse_result {
my ( $self, $res, $method ) = @_;
if ( !$res->is_success ) {
warn $res->request->as_string;
die "Failed calling $method: (" . $res->status_line . ") "
. $res->content;
}
my $json = $self->_json_parser->decode( $res->decoded_content );
if ( $json->{ status } ne 'success' ) {
warn $res->request->as_string;
}
die "$method call failed: " . $json->{ status }
if exists $json->{ status }
&& $json->{ status } ne 'success';
return $json;
}
=head2 _default_params
Just a quick utility to put the username, api_key, and method into every call.
=cut
sub _default_params {
my ( $self, $method ) = @_;
(
api_username => $self->username,
api_password => $self->api_key,
method => $method,
)
};
=head2 _api_url
Pretty self-explanatory.
=cut
sub _api_url { 'https://voip.ms/api/v1/rest.php' }
=head2 _get_uri
Assembles the URI for doing a GET call.
=cut
sub _get_uri {
my ( $self, $method, %params ) = @_;
my $uri = URI->new( $self->_api_url );
$uri->query_param( $_ => $params{ $_ } ) for keys %params;
my %default_params = $self->_default_params( $method );
$uri->query_param( $_ => $default_params{ $_ } ) for keys %default_params;
return $uri->canonical;
}
=head2 _populate_sms_dids
Parses out the available DIDs on this account with SMS enabled.
Dies if there aren't any because we can't really do much without one.
=cut
sub _populate_sms_dids {
my ( $self ) = @_;
my $result = $self->_get_method( 'getDIDsInfo' );
my @dids = grep { $_->{ sms_enabled } } @{ $result->{ dids } };
die "You don't have any DIDs with SMS enabled!\n" if !@dids;
$self->_available_dids({ map {( $_->{ did } => 1 )} @dids });
}
=head2 _get_sms_did
Gets an SMS DID by: verifying if you provided one that it has SMS enabled, or
returning the default if you only have one phone number with SMS enabled.
Otherwise, dies.
=cut
sub _get_sms_did {
my ( $self, $from ) = @_;
$from //= $self->did;
die "You have more than one SMS enabled DID and didn't specify a "
. "from address!\n" if !$from && scalar keys %{ $self->_available_dids } > 1;
$from //= @{ $self->_available_dids }[0];
die "Messages cannot be sent from $from!\n" if
!$self->_available_dids->{ $from };
return $from;
}
=head2 _wrap_text
I ran into some issues with line breaks, and character encoding, and text
message limits with voip.ms's systems because it looks like they may have some
encoding/escaping bugs, so this may still get punched in the mouth, but the
idea here is to do line breaks for messages that are too long in a sensible
way and understand other character encodings in the process.
YMMV for obvious reasons.
=cut
sub _wrap_text {
my ( $self, $text, $cols ) = @_;
return ( $text ) if length( $text ) <= $cols;
my $sep = chr( 29 );
$text =~ s/\r?\n/ $sep/g;
my $lb = Unicode::LineBreak->new(
CharMax => $cols,
ColMin => 1,
ColMax => $cols,
Format => 'TRIM',
Newline => "\0",
);
my $broken = $lb->break( $text );
$broken =~ s/ $sep/\n/g;
return map { s/\n$//; $_ } split m{\0}, $broken;
}
=head2 send_sms
=head2 send_mms
=head2 _send_text
Sends an SMS message to a number. Expects a hash of parameters, and then the
message.
Acceptable parameters are:
=over 3
=item did
This is the DID to send the message from. If one isn't specified, we'll use
the DID specified in ->new. If that wasn't specified, but you only have one
SMS enabled DID in your VOIP account, we'll use that. If you have multiple,
we'll die.
=item to
The number we're sending the message to.
=item media\d+
Any images being attached to the message. This will guarantee MMS instead of
SMS.
=back
=cut
sub send_sms { shift->_send_text( sms => @_ ) }
sub send_mms { shift->_send_text( mms => @_ ) }
sub _send_text {
my $self = shift;
my $type = shift;
my $method = 'send' . uc( $type );
die "Incorrect usage of $method!\n" if !( @_ % 2 );
my $message = pop;
my %params = @_;
if ( length( $message ) > 160 ) {
if ( $self->convert_long_sms_to_mms ) {
$method = 'sendMMS';
}
else {
die "Message is > 160 characters and automatic MMS conversion is disabled.";
}
}
$params{ did } = $self->_get_sms_did( delete $params{ did } );
die "You need to specify the from DID!\n" if !defined $params{ did };
$params{ dst } = delete $params{ to };
die "You didn't specify a to address!\n" if !defined $params{ dst };
my @msgs = $self->_wrap_text( $message, 160 );
foreach my $media_key ( grep { m{^media(\d+)} } keys %params ) {
$method = 'sendMMS';
my $file = $params{ $media_key };
if ( !openhandle( $file ) ) {
if ( -f $file && -r _ && -s _ ) {
open my $fh, '<', $file or die "can't open $file: $!";
$file = $fh;
}
else {
# TODO -- handle URLs?
die "I don't know what to do with $file.";
}
}
my $magick = Image::Magick->new;
binmode $file;
$magick->read( file => $file );
$magick->Strip();
$magick->set( magick => 'jpg' );
$magick->set( quality => 90 );
my $base_encoded = encode_base64(
join q{}, $magick->imagetoblob
);
$base_encoded =~ s/\r\n//g;
$params{ $media_key } = "data:image/jpeg;base64,$base_encoded";
}
$self->_post_method(
$method => (
%params,
message => $_,
)
) for @msgs;
}
=head2 get_sms
Just a goto to get_mms because get_mms can get SMSes too (and we tell it to).
=cut
sub get_sms { goto &get_mms }
=head2 get_mms
Gets MMS messages. You have to specify the did and the from date, but you can
optionally specify any other parameter voip.ms supports.
We by default ask for received (type 1), sms *and* mms (all_messages 1), and
(effectively) no limit. You can override these, though.
=cut
sub get_mms {
my ( $self, %params ) = @_;
$params{ did } = $self->_get_sms_did( delete $params{ did } );
die "You need to specify the DID to search!\n" if !defined $params{ did };
die "You need to specify the earliest search date!"
if !defined $params{ from };
my $date_parse = sub {
my $parsed = Date::Parse::str2time( $_[0] );
die "Couldn't parse a from date of $_[0]!" if !$parsed;
return strftime( '%Y-%m-%dT%H:%M:%S%z', localtime $parsed );
};
$params{ from } = $date_parse->( $params{ from } );
$params{ to } = $date_parse->( $params{ to } ) if $params{ to };
$self->_post_method( getMMS => (
type => 1,
all_messages => 1,
limit => 999999,
%params,
));
}
=head1 AUTHOR
Justin Wheeler, C<< <cpan at datademons.com> >>
=head1 BUGS
Please report any bugs or feature requests to C<bug-sms-voip-ms at rt.cpan.org>, or through
the web interface at L<https://rt.cpan.org/NoAuth/ReportBug.html?Queue=SMS-API-VoIP-MS>. I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.
=head1 SUPPORT
You can find documentation for this module with the perldoc command.
perldoc SMS::API::VoIP::MS
You can also look for information at:
=over 4
=item * RT: CPAN's request tracker (report bugs here)
L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=SMS-API-VoIP-MS>
=item * CPAN Ratings
L<https://cpanratings.perl.org/d/SMS-API-VoIP-MS>
=item * Search CPAN
L<https://metacpan.org/release/SMS-API-VoIP-MS>
=back
=head1 LICENSE AND COPYRIGHT
This software is Copyright (c) 2022 by Justin Wheeler.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)
=cut
1; # End of SMS::API::VoIP::MS