Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
590 lines (396 sloc) 15.5 KB
% Copyright (C) 2013-2019 Olivier Boudeville
%
% This file is part of the Ceylan-Myriad library.
%
% This library is free software: you can redistribute it and/or modify
% it under the terms of the GNU Lesser General Public License or
% the GNU General Public License, as they are published by the Free Software
% Foundation, either version 3 of these Licenses, or (at your option)
% any later version.
% You can also redistribute it and/or modify it under the terms of the
% Mozilla Public License, version 1.1 or later.
%
% This library is distributed in the hope that it will be useful,
% but WITHOUT ANY WARRANTY; without even the implied warranty of
% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
% GNU Lesser General Public License and the GNU General Public License
% for more details.
%
% You should have received a copy of the GNU Lesser General Public
% License, of the GNU General Public License and of the Mozilla Public License
% along with this library.
% If not, see <http://www.gnu.org/licenses/> and
% <http://www.mozilla.org/MPL/>.
%
% Author: Olivier Boudeville [olivier (dot) boudeville (at) esperide (dot) com]
% Creation date: Friday, November 1, 2013.
% Gathering of various convenient SMS-related facilities.
%
% See sms_utils_test.erl for testing.
%
-module(sms_utils).
% Implementation notes.
%
% Note that, in any module not beginning with the UTF line below, with pre-R17
% Erlang versions, sent text might be garbled due to a wrong encoding.
%
%% -*- coding: utf-8 -*-
%
% Currently supported SMS providers (HTTP gateways):
%
% - VerySMS (http://www.verysms.fr)
% A typical returned header by the verysms service is:
%% [{"cache-control",
%% "no-store, no-cache, must-revalidate, post-check=0, pre-check=0"},
%% {"connection","Keep-Alive"},
%% {"date","Mon, 28 Oct 2013 15:18:24 GMT"},
%% {"pragma","no-cache"},
%% {"server",
%% "Apache/2.2.20 (Unix) mod_ssl/2.2.20 OpenSSL/0.9.8o"},
%% {"vary","Accept-Encoding,User-Agent"},
%% {"content-length","2"},
%% {"content-type","text/html"},
%% {"expires","Thu, 19 Nov 1981 08:52:00 GMT"},
%% {"x-powered-by","PHP/5.2.13-pl1-gentoo"},
%% {"set-cookie",
%% "PHPSESSID=9adc709f872cc44f1f4abc1c4931914b; path=/"},
%% {"keep-alive","timeout=15, max=100"}],
% Sending-related functions:
-export([ create_sms/3, create_sms/4, send/2, send/7,
update_credits/1,
account_to_string/1, sms_to_string/1 ]).
% Type declarations.
% Name of a SMS provider (HTTP gateway), as an atom:
-type provider() :: 'verysms'.
% Service class of a SMS being sent:
-type service_class() ::
% Only sensible for the 'verysms' provider:
'eco' | 'pro'.
% Message to be sent as SMS (up to 160 bytes):
-type message() :: string().
% International mobile phone number of the recipient (ex: "+330616XXXXXX").
-type recipient() :: string().
% Any (short) alphanumerical string describing the sender, if this service is
% provided:
%
% (with verysms, supposedly only taken into account in the 'pro' class; but
% actually SMS always shown as sent by the number "38200")
%
-type sender_description() :: string().
% Many steps may fo wrong:
-type failure_reason() ::
{ 'invalid_content', string() }
| { 'invalid_phone_number', string() }
| 'credits_exhausted'
| 'insufficient_credits'
| { 'invalid_password', string() }
| { 'invalid_user', string() }
| { 'invalid_content', string() }
| { 'invalid_request', string() }
| { 'error', string() }
| { 'request_failed', string() }.
-type diagnosis() :: any().
% Describes the result of the sending, as reported by the gateway:
-type sending_outcome() :: 'success' | { failure_reason(), diagnosis() }.
% Number of credits left (if applicable):
-type credits() :: maybe( basic_utils:count() ).
% Describes a SMS account at a provider.
-record( sms_account, {
provider :: provider(),
user_name :: system_utils:user_name(),
password :: system_utils:password(),
default_class :: service_class(),
credits :: credits(),
sent_count :: basic_utils:count(),
sent_success_count :: basic_utils:count() } ).
-type sms_account() :: #sms_account{}.
% Describes a SMS.
-record( sms, {
message :: message(),
recipient :: recipient(),
sender_description :: sender_description(),
% Default account service class to be used, if not specified here:
service_class :: maybe( service_class() ) }).
-type sms() :: #sms{}.
-export_type([ provider/0, credits/0, sms_account/0, message/0, recipient/0,
sender_description/0, service_class/0, sms/0,
sending_outcome/0 ]).
% Creates a SMS record instance from specified information, the service class
% being not defined, so that the default class of the account will prevail.
%
-spec create_sms( message(), recipient(), sender_description() ) -> sms().
create_sms( Message, Recipient, SenderDescription ) when is_list( Message )
andalso is_list(Recipient) andalso is_list(SenderDescription) ->
create_sms( Message, Recipient, SenderDescription,
_ServiceClass=undefined ).
% Creates a SMS record instance from specified information.
-spec create_sms( message(), recipient(), sender_description(),
maybe( service_class() ) ) -> sms().
create_sms( Message, Recipient, SenderDescription, ServiceClass )
when is_list( Message ) andalso is_list( Recipient )
andalso is_list( SenderDescription ) andalso is_atom( ServiceClass )->
%trace_utils:debug_fmt( "created '~s' ~B.",
% [ Message, length( Message ) ] ),
% More checking should be done:
% Disabled, as there are 3 sizes: the number of Unicode characters, the
% number of bytes once encoded in an URL, and the number of bytes once
% encoded for SMS. Not sure which rule to apply.
% case length( text_utils:encode_element_as_url( Message
% ) ) of
%% L when L > 160 ->
%% % In bytes, not characters:
%% throw( { message_too_long, L, Message } );
%% _ ->
%% ok
%% end,
#sms{ message=Message,
recipient=Recipient,
sender_description=SenderDescription,
service_class=ServiceClass }.
% Sending-related functions.
% Sends specified SMS, using specified account.
-spec send( sms(), sms_account() ) -> { sending_outcome(), sms_account() }.
send( #sms{ message=Message, recipient=Recipient,
sender_description=SenderDescription, service_class=ServiceClass },
Account=#sms_account{ provider=Provider,
user_name=UserName, password=Password,
default_class=DefaultClass, credits=Credits,
sent_count=SentCount, sent_success_count=Successes
} ) ->
% From a user perspective, to a technical one:
% If the SMS does not specify a class, use the default from account:
ActualServiceClass = case ServiceClass of
undefined ->
DefaultClass;
C ->
C
end,
Outcome = send( Provider, ActualServiceClass, UserName, Password, Message,
Recipient, SenderDescription ),
{ NewCredits, NewSuccesses } = case Outcome of
success ->
NewC = Credits - get_credit_cost( Provider,
ActualServiceClass ),
NewS = Successes + 1,
{ NewC, NewS };
_Failure ->
{ Credits, Successes }
end,
{ Outcome, Account#sms_account{ credits=NewCredits,
sent_count=SentCount + 1,
sent_success_count=NewSuccesses } }.
% Sends specified SMS, using most detailed (explicit) settings.
%
% (base, only actual sending function)
%
-spec send( provider(), service_class(), system_utils:user_name(),
system_utils:password(), message(), recipient(),
sender_description() ) -> sending_outcome().
send( _Provider=verysms, _ServiceClass=eco, Username, Password, Message,
Recipient, SenderDescription ) ->
% In eco mode, no specific sender can be specified (will be "random" mobile
% numbers).
check_recipient( Recipient ),
% Might be started multiple times:
inets:start(),
EcoURL = "http://www.verysms.fr/api_sendsms.php",
FullData = [ { 'user', Username },
{ 'pass', Password },
{ 'dest', Recipient },
{ 'flash', "" },
{ 'type', "" },
{ 'url', "" },
{ 'msg', Message },
{ 'origine', SenderDescription } ],
% For this provider, text_utils:escape/1 must be used instead of
% text_utils:encode_as_url/1:
%
Request = { EcoURL, _Headers=[], _ContentType=get_mime_type(),
_Body=text_utils:escape( FullData ) },
execute_request( Request, Username, Password, Recipient );
%% send( Provider=verysms, ServiceClass=eco, Username, Password, Message,
%% Recipient, SenderDescription ) ->
%% % Not allowed in eco mode:
%% throw( { no_sender_description_supported, { Provider, ServiceClass } } );
send( _Provider=verysms, _ServiceClass=pro, Username, Password, Message,
Recipient, SenderDescription ) ->
check_recipient( Recipient ),
% Might be started multiple times:
inets:start(),
ProURL = "http://www.verysms.fr/api_sendsmspro.php",
FullData = [ { 'user', Username },
{ 'pass', Password },
{ 'dest', Recipient },
{ 'flash', "" },
{ 'type', "" },
{ 'url', "" },
{ 'msg', Message },
{ 'senderID', SenderDescription },
{ 'origine', "ceylan" },
{ 'idSending', "1" } ],
% For this provider, text_utils:escape/1 must be used instead of
% text_utils:encode_as_url/1:
%
Request = { ProURL, _Headers=[], _ContentType=get_mime_type(),
_Body=text_utils:escape( FullData ) },
% In pro mode, if no sender description is specified, will be "38200":
%% FullData = case SenderDescription of
%% [] ->
%% List;
%% Desc ->
%% [ { "senderID", Desc } | List ]
%% end;
execute_request( Request, Username, Password, Recipient );
send( Provider=verysms, ServiceClass, _Username, _Password, _Message,
_Recipient, _SenderDescription ) ->
throw( { invalid_service_class, ServiceClass, Provider } );
send( Provider, _ServiceClass, _Username, _Password, _Message, _Recipient,
_SenderDescription ) ->
throw( { unsupported_sms_provider, Provider } ).
% Returns the specified SMS account, whose credit count has been updated,
% telling whether the actual, provider-obtained count corresponds to the
% recorded one ('matching') or not ('overwritten'), in which case the actual one
% replaces the recorded one.
%
% If the operation failed, returns 'failed' with an unchanged account.
%
-spec update_credits( sms_account() ) ->
{ 'matching' | 'overwritten' | 'failed', sms_account() }.
update_credits( Account=#sms_account{ credits=Credits } ) ->
case get_credits_for( Account ) of
undefined ->
{ failed, Account };
Credits ->
{ matching, Account };
ActualCredits ->
{ overwritten, Account#sms_account{ credits=ActualCredits } }
end.
% Returns a textual description of the specified SMS account.
-spec account_to_string( sms_account() ) -> string().
account_to_string( #sms_account{ provider=Provider, user_name=Username,
password=Password, default_class=DefaultClass,
credits=Credits, sent_count=SentCount,
sent_success_count=SentSuccessCount } ) ->
text_utils:format( "SMS account on provider '~s': user name is '~s', "
"password is '~s', relying on default sending class '~s', "
"with stored credits: ~p; ~B success sendings over a total "
"of ~B",
[ Provider, Username, Password, DefaultClass, Credits,
SentSuccessCount, SentCount ] ).
% Returns a textual description of the specified SMS.
-spec sms_to_string( sms() ) -> string().
sms_to_string( #sms{ message=Message, recipient=Recipient,
sender_description=SenderDesc,
service_class=ServiceClass } ) ->
% Encoding (ex: of accentuated characters) changes the byte size:
Len = length( Message ),
text_utils:format( "SMS whose message is '~s' (character length: ~B bytes), "
"sent to recipient number '~s' from sender '~p' "
"with service class ~p",
[ Message, Len, Recipient, SenderDesc, ServiceClass ] ).
% Helper functions.
% A regex with the re module could be used:
check_recipient( Recipient ) ->
check_recipient( Recipient, Recipient ).
check_recipient( _Remaining=[], _Recipient ) ->
ok;
check_recipient( _Remaining=[ C | T ], Recipient ) ->
case text_utils:is_figure( C ) of
true ->
check_recipient( T, Recipient );
false ->
throw( { invalid_recipient, Recipient, C } )
end.
% Returns the MIME type to be used here.
get_mime_type() ->
"application/x-www-form-urlencoded".
% Returns the current number of credits for the specified account, as reported
% by the provider, or 'undefined' if the operation failed.
%
-spec get_credits_for( sms_account() ) -> credits().
get_credits_for( _Account=#sms_account{ provider=verysms, user_name=Username,
password=Password } ) ->
% Might be started multiple times:
inets:start(),
CreditURL = "http://www.verysms.fr/credit.php",
FullData = [ { 'user', Username },
{ 'pass', Password } ],
% For this provider, text_utils:escape/1 must be used instead of
% text_utils:encode_as_url/1:
%
Request = { CreditURL, _ReqHeaders=[], _ContentType=get_mime_type(),
_ReqBody=text_utils:escape( FullData ) },
case httpc:request( _Method=post, Request, _HTTPOptions=[], _Options=[] ) of
% HTTPVersion below: typically equal to "HTTP/1.1";
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="CREDIT OUT" } } ->
0;
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="CREDIT " ++ RemainingCredit } } ->
try
text_utils:string_to_integer( RemainingCredit )
catch
throw:_ ->
undefined
end;
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="KO" } } ->
undefined;
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="USER INVALID" } } ->
undefined;
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="PASS INVALID" } } ->
undefined;
{ ok, { _StatusLine, _Headers, _Body } } ->
undefined;
{ error, _Reason } ->
undefined
end.
% Executes the specified HTTP request.
%
% (helper)
%
execute_request( Request, Username, Password, Recipient ) ->
%trace_utils:debug_fmt( "Request: '~p'.", [ Request ] ),
% We strongly prefer POST over GET (safer, stricter):
case httpc:request( _Method=post, Request, _HTTPOptions=[], _Options=[] ) of
% HTTPVersion below: typically equal to "HTTP/1.1";
% Body: "OK 5" if 5 credits are remaining (we do not care here);
%
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="OK " ++ _RemainingCredit } } ->
success;
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="KO" } } ->
{ invalid_content, "Invalid SMS body, not sent" };
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="BAD NUMBER" } } ->
{ invalid_phone_number, Recipient };
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="CREDIT OUT" } } ->
credits_exhausted;
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="CREDIT NOT ENOUGH" } } ->
insufficient_credits;
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="USER INVALID" } } ->
{ invalid_user, Username };
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
_Body="PASS INVALID" } } ->
{ invalid_password, Password };
{ ok, { _StatusLine={ _HTTPVersion, 200, "OK" }, _Headers,
Body } } ->
{ invalid_content, Body };
{ ok, Res={ _StatusLine, _Headers, _Body } } ->
{ invalid_request, Res };
{ error, Reason } ->
{ request_failed, Reason }
end.
% Returns the cost in credits of sending one SMS of the specified service class
% from specified provider.
%
get_credit_cost( _Provider=verysms, _ServiceClass=eco ) ->
5;
get_credit_cost( _Provider=verysms, _ServiceClass=pro ) ->
10.
You can’t perform that action at this time.