209 lines
8.8 KiB
Python
Executable File
209 lines
8.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
""" Script to send SMSes using the Free mobile API
|
|
"""
|
|
import sys
|
|
import time
|
|
import logging
|
|
import argparse
|
|
import readline
|
|
import configparser
|
|
from itertools import count
|
|
from typing import List
|
|
|
|
import requests
|
|
|
|
|
|
class SMSApiManager:
|
|
""" Class to manage ending SMS notification using Free Mobile API.
|
|
"""
|
|
FREE_SMSAPI_URL = "https://smsapi.free-mobile.fr/sendmsg"
|
|
MAX_LENGTH = 999
|
|
RETURN_CODE = {200: "Message sent",
|
|
400: "Mandatory parameter is missing",
|
|
402: "Too many SMS sent in to few time",
|
|
403: "Service disabled",
|
|
500: "Server error, please try again later"}
|
|
def __init__(self, conf_file_path: str):
|
|
self._pass = ""
|
|
self.user_id = ""
|
|
self._read_config(conf_file_path)
|
|
|
|
def send_sms(self, msg: str) -> int:
|
|
""" Send a SMS to a user using its passphrase and user ID
|
|
Return the response object from requests.
|
|
|
|
If the message is longer than the maximum amount of characters allowed,
|
|
it is truncated silently (better get a truncated notification than no notification).
|
|
If you want to keep all of the message, use send_paginated_sms().
|
|
"""
|
|
# Make sure we do not send more than MAX_LENGTH characters
|
|
# or it will fail.
|
|
msg = msg[:self.MAX_LENGTH]
|
|
data = {"user": self.user_id,
|
|
"pass": self._pass,
|
|
"msg": msg}
|
|
res = requests.get(self.FREE_SMSAPI_URL, params=data)
|
|
logging.info(self.RETURN_CODE[res.status_code])
|
|
return res.status_code
|
|
|
|
def send_paginated_sms(self, msg: str, max_message:int=-1) -> List[int]:
|
|
""" Paginate a message that may be too long for the SMS API.
|
|
Try to cut the message either at line break, if not possible at
|
|
sentence boundary, and if not possible at word boundary, to avoid
|
|
cutting a message mid-word.
|
|
|
|
If max_message is set and > 0, send at most max_message messages.
|
|
Otherwise, there is not limit.
|
|
"""
|
|
paginated_message = self.paginate_message(msg)
|
|
return_values = []
|
|
for i, chunk in enumerate(paginated_message, start=1):
|
|
if i > 0 and i > max_message:
|
|
# pylint: disable=logging-fstring-interpolation
|
|
logging.info(f"Hitting chunk number limit, {len(paginated_message)-i+1} out "
|
|
f"of {len(paginated_message)} not sent.")
|
|
break
|
|
message = chunk.strip() + f"\n{i}/{len(paginated_message)}"
|
|
# pylint: disable=logging-fstring-interpolation
|
|
logging.info(f"Sending chunk {i} out of {len(paginated_message)}: len {len(message)}")
|
|
return_values.append(self.send_sms(message))
|
|
# Little pause to avoid trigerring rate limiting
|
|
time.sleep(0.1)
|
|
return return_values
|
|
|
|
def paginate_message(self, msg: str) -> List[str]:
|
|
""" Paginate a message that may be too long for the SMS API.
|
|
Try to cut the message either at line break, if not possible at
|
|
sentence boundary, and if not possible at word boundary, to avoid
|
|
cutting a message mid-word.
|
|
"""
|
|
## Calculation of the place to leave for the page counter ##
|
|
# Worst case scenario, that should not happen a lot:
|
|
# Smallest line is self.MAX_LENGTH/2 + 1 character
|
|
# Pagination algorithm generates twice the amount of chunk that would be needed
|
|
# if we were to cut naively.
|
|
# So we need to leave enough place to write "\n<max_nb>/<max_nb>" at the end of the
|
|
# message
|
|
pagination_mark_max_length = len(str((len(msg)/self.MAX_LENGTH)*2)) * 2 + 2
|
|
|
|
# Check if message is short enough. There is no way to know beforehand the
|
|
# number of chunks, so we leave enough space for the worst case scenario:
|
|
if len(msg) <= self.MAX_LENGTH - pagination_mark_max_length:
|
|
return [msg]
|
|
|
|
messages = []
|
|
# Cut at each line
|
|
msg_lines = msg.splitlines(keepends=True)
|
|
if len(msg_lines) == 1:
|
|
# If there is only one line and it is still too long, cut at sentence end.
|
|
msg_sentences = [sentence + '. ' for sentence in msg.split('. ')
|
|
if sentence.strip()]
|
|
msg_sentences[-1] = msg_sentences[-1][:-len('. ')]
|
|
if len(msg_sentences) == 1:
|
|
# If there is only one sentence and it is still too long,
|
|
# cut at word boundaries.
|
|
msg_words = [word + ' ' for word in msg.split() if word.strip()]
|
|
msg_words[-1] = msg_words[-1][:-len(' ')]
|
|
messages.extend(msg_words)
|
|
else:
|
|
for sentence in msg_sentences:
|
|
messages.extend(self.paginate_message(sentence))
|
|
return messages
|
|
|
|
for line in msg_lines:
|
|
messages.extend(self.paginate_message(line))
|
|
# Rationalize the "messages" list.
|
|
# Message now contains chunks that all fit in a SMS,
|
|
# but multiple chunk could fit in a SMS as well.
|
|
# We concatenate those chunks.
|
|
final_message = []
|
|
chunk = ""
|
|
i = 0
|
|
while i < len(messages):
|
|
while (i < len(messages) and
|
|
len(chunk+messages[i]) <= self.MAX_LENGTH - pagination_mark_max_length):
|
|
chunk += messages[i]
|
|
i += 1
|
|
final_message.append(chunk)
|
|
chunk = ""
|
|
return final_message
|
|
|
|
|
|
def _read_config(self, conf_file_path: str) -> None:
|
|
""" Read and apply the configuration from a config file.
|
|
That config file must have a 'creds' section, and the following options:
|
|
- 'user_id', that contains the user identifier for the API
|
|
- 'passphrase', that contains this user's API key
|
|
"""
|
|
conf_parser = configparser.ConfigParser()
|
|
conf_parser.read(conf_file_path)
|
|
self.user_id = conf_parser["creds"]["user_id"]
|
|
self._pass = conf_parser["creds"]["passphrase"]
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
""" Parse the script's command line argument, and return the args object.
|
|
"""
|
|
my_parser = argparse.ArgumentParser(description="Script to send a SMS to a person. "
|
|
"If the message is longer than "
|
|
f"{SMSApiManager.MAX_LENGTH} characters, "
|
|
"it will be split in multiple SMS.")
|
|
my_parser.add_argument("-c", "--config", help="Path to the config file.",
|
|
default="secrets.ini")
|
|
my_parser.add_argument("message", nargs="?",
|
|
help="Message to send to the recipient. If no message is given, "
|
|
f"read up to {SMSApiManager.MAX_LENGTH} characters from the "
|
|
"standard input")
|
|
my_parser.add_argument("-i", "--interactive", action="store_true",
|
|
help="Send messages in interactive mode "
|
|
"instead of from the command line.")
|
|
return my_parser.parse_args()
|
|
|
|
|
|
def rlinput(prompt: str, prefill: str='') -> str:
|
|
""" Prefill the input content with the value of `prefill`
|
|
All credits go to https://stackoverflow.com/a/2533142
|
|
"""
|
|
readline.set_startup_hook(lambda: readline.insert_text(prefill))
|
|
try:
|
|
return input(prompt)
|
|
finally:
|
|
readline.set_startup_hook()
|
|
|
|
def main() -> None:
|
|
""" Instantiate an sms_manager, and send SMS
|
|
"""
|
|
args = parse_args()
|
|
sms_manager = SMSApiManager(args.config)
|
|
while args.interactive:
|
|
# Interactive mode is enabled, loop indefinetely until user hits Ctrl+C or Ctrl+D
|
|
print("Type your sms, and submit an empty line to send it.")
|
|
try:
|
|
message=[]
|
|
for i in count():
|
|
message.append(rlinput(f"What is your message (line {i+1})?: ",
|
|
prefill=args.message))
|
|
args.message=""
|
|
if not message[i]:
|
|
break
|
|
res = sms_manager.send_sms(msg='\n'.join(message))
|
|
if res != 200:
|
|
print("Error while sending the message:")
|
|
print(f"{sms_manager.RETURN_CODE[res]}")
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nBye !")
|
|
break
|
|
else:
|
|
# Else either get message from command line or stdin, and send it paginated.
|
|
logging.basicConfig(level=logging.INFO)
|
|
if args.message:
|
|
# Send the requested message
|
|
sms_manager.send_paginated_sms(msg=args.message)
|
|
else:
|
|
# Read the message from stdin
|
|
msg = sys.stdin.read()
|
|
sms_manager.send_paginated_sms(msg=msg, max_message=5)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|