From 08c73a17d15bf5902f9581b26015b6c50c6c16ad Mon Sep 17 00:00:00 2001 From: Hemna Date: Thu, 17 Dec 2020 10:00:47 -0500 Subject: [PATCH 1/2] Major refactor This branch refactors the majority of main.py out into individual modules to compartmentalize the code. Migrated all email related features unti email.py, sending of messages into messaging.py Also refactored all of the socket code to use aprslib for all APRS-IS communication as well as message/packet processing. Moved the email command into it's own Plugin. --- .github/workflows/python.yml | 2 +- aprsd/client.py | 63 +++ aprsd/email.py | 356 +++++++++++++++ aprsd/main.py | 850 +++++------------------------------ aprsd/messaging.py | 149 ++++++ aprsd/plugin.py | 291 ++++++++---- aprsd/utils.py | 6 +- dev-requirements.in | 1 - dev-requirements.txt | 4 +- requirements.in | 1 + requirements.txt | 5 +- test-requirements-py2.txt | 3 - test-requirements.txt | 2 - tests/test_main.py | 8 +- tox.ini | 23 +- 15 files changed, 916 insertions(+), 848 deletions(-) create mode 100644 aprsd/client.py create mode 100644 aprsd/email.py create mode 100644 aprsd/messaging.py delete mode 100644 test-requirements-py2.txt delete mode 100644 test-requirements.txt diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3575be2..369a1a2 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [2.7, 3.6, 3.7, 3.8, 3.9] + python-version: [3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/aprsd/client.py b/aprsd/client.py new file mode 100644 index 0000000..cba9dd8 --- /dev/null +++ b/aprsd/client.py @@ -0,0 +1,63 @@ +import logging +import time + +import aprslib + +LOG = logging.getLogger("APRSD") + + +class Client(object): + """Singleton client class that constructs the aprslib connection.""" + + _instance = None + aprs_client = None + config = None + + def __new__(cls, *args, **kwargs): + """This magic turns this into a singleton.""" + if cls._instance is None: + cls._instance = super(Client, cls).__new__(cls) + # Put any initialization here. + return cls._instance + + def __init__(self, config=None): + """Initialize the object instance.""" + if config: + self.config = config + + @property + def client(self): + if not self.aprs_client: + self.aprs_client = self.setup_connection() + return self.aprs_client + + def reset(self): + """Call this to force a rebuild/reconnect.""" + del self.aprs_client + + def setup_connection(self): + user = self.config["aprs"]["login"] + password = self.config["aprs"]["password"] + host = self.config["aprs"].get("host", "rotate.aprs.net") + port = self.config["aprs"].get("port", 14580) + connected = False + while not connected: + try: + LOG.info("Creating aprslib client") + aprs_client = aprslib.IS(user, passwd=password, host=host, port=port) + # Force the logging to be the same + aprs_client.logger = LOG + aprs_client.connect() + connected = True + except Exception as e: + LOG.error("Unable to connect to APRS-IS server.\n") + print(str(e)) + time.sleep(5) + continue + LOG.debug("Logging in to APRS-IS with user '%s'" % user) + return aprs_client + + +def get_client(): + cl = Client() + return cl.client diff --git a/aprsd/email.py b/aprsd/email.py new file mode 100644 index 0000000..5f27f86 --- /dev/null +++ b/aprsd/email.py @@ -0,0 +1,356 @@ +import datetime +import email +import imaplib +import logging +import re +import smtplib +import threading +import time +from email.mime.text import MIMEText + +import imapclient +import six + +from aprsd import messaging + +LOG = logging.getLogger("APRSD") + +# This gets forced set from main.py prior to being used internally +CONFIG = None + + +def start_thread(): + checkemailthread = threading.Thread( + target=check_email_thread, name="check_email", args=() + ) # args must be tuple + checkemailthread.start() + + +def _imap_connect(): + imap_port = CONFIG["imap"].get("port", 143) + use_ssl = CONFIG["imap"].get("use_ssl", False) + host = CONFIG["imap"]["host"] + msg = "{}{}:{}".format("TLS " if use_ssl else "", host, imap_port) + # LOG.debug("Connect to IMAP host {} with user '{}'". + # format(msg, CONFIG['imap']['login'])) + + try: + server = imapclient.IMAPClient( + CONFIG["imap"]["host"], port=imap_port, use_uid=True, ssl=use_ssl + ) + except Exception: + LOG.error("Failed to connect IMAP server") + return + + try: + server.login(CONFIG["imap"]["login"], CONFIG["imap"]["password"]) + except (imaplib.IMAP4.error, Exception) as e: + msg = getattr(e, "message", repr(e)) + LOG.error("Failed to login {}".format(msg)) + return + + server.select_folder("INBOX") + return server + + +def _smtp_connect(): + host = CONFIG["smtp"]["host"] + smtp_port = CONFIG["smtp"]["port"] + use_ssl = CONFIG["smtp"].get("use_ssl", False) + msg = "{}{}:{}".format("SSL " if use_ssl else "", host, smtp_port) + LOG.debug( + "Connect to SMTP host {} with user '{}'".format(msg, CONFIG["imap"]["login"]) + ) + + try: + if use_ssl: + server = smtplib.SMTP_SSL(host=host, port=smtp_port) + else: + server = smtplib.SMTP(host=host, port=smtp_port) + except Exception: + LOG.error("Couldn't connect to SMTP Server") + return + + LOG.debug("Connected to smtp host {}".format(msg)) + + try: + server.login(CONFIG["smtp"]["login"], CONFIG["smtp"]["password"]) + except Exception: + LOG.error("Couldn't connect to SMTP Server") + return + + LOG.debug("Logged into SMTP server {}".format(msg)) + return server + + +def validate_email(): + """function to simply ensure we can connect to email services. + + This helps with failing early during startup. + """ + LOG.info("Checking IMAP configuration") + imap_server = _imap_connect() + LOG.info("Checking SMTP configuration") + smtp_server = _smtp_connect() + + if imap_server and smtp_server: + return True + else: + return False + + +def parse_email(msgid, data, server): + envelope = data[b"ENVELOPE"] + # email address match + # use raw string to avoid invalid escape secquence errors r"string here" + f = re.search(r"([\.\w_-]+@[\.\w_-]+)", str(envelope.from_[0])) + if f is not None: + from_addr = f.group(1) + else: + from_addr = "noaddr" + LOG.debug("Got a message from '{}'".format(from_addr)) + m = server.fetch([msgid], ["RFC822"]) + msg = email.message_from_string(m[msgid][b"RFC822"].decode(errors="ignore")) + if msg.is_multipart(): + text = "" + html = None + # default in case body somehow isn't set below - happened once + body = b"* unreadable msg received" + # this uses the last text or html part in the email, phone companies often put content in an attachment + for part in msg.get_payload(): + if part.get_content_charset() is None: + # or BREAK when we hit a text or html? + # We cannot know the character set, + # so return decoded "something" + LOG.debug("Email got unknown content type") + text = part.get_payload(decode=True) + continue + + charset = part.get_content_charset() + + if part.get_content_type() == "text/plain": + LOG.debug("Email got text/plain") + text = six.text_type( + part.get_payload(decode=True), str(charset), "ignore" + ).encode("utf8", "replace") + + if part.get_content_type() == "text/html": + LOG.debug("Email got text/html") + html = six.text_type( + part.get_payload(decode=True), str(charset), "ignore" + ).encode("utf8", "replace") + + if text is not None: + # strip removes white space fore and aft of string + body = text.strip() + else: + body = html.strip() + else: # message is not multipart + # email.uscc.net sends no charset, blows up unicode function below + LOG.debug("Email is not multipart") + if msg.get_content_charset() is None: + text = six.text_type( + msg.get_payload(decode=True), "US-ASCII", "ignore" + ).encode("utf8", "replace") + else: + text = six.text_type( + msg.get_payload(decode=True), msg.get_content_charset(), "ignore" + ).encode("utf8", "replace") + body = text.strip() + + # FIXED: UnicodeDecodeError: 'ascii' codec can't decode byte 0xf0 in position 6: ordinal not in range(128) + # decode with errors='ignore'. be sure to encode it before we return it below, also with errors='ignore' + try: + body = body.decode(errors="ignore") + except Exception as e: + LOG.error("Unicode decode failure: " + str(e)) + LOG.error("Unidoce decode failed: " + str(body)) + body = "Unreadable unicode msg" + # strip all html tags + body = re.sub("<[^<]+?>", "", body) + # strip CR/LF, make it one line, .rstrip fails at this + body = body.replace("\n", " ").replace("\r", " ") + # ascii might be out of range, so encode it, removing any error characters + body = body.encode(errors="ignore") + return (body, from_addr) + + +# end parse_email + + +def resend_email(count, fromcall): + global check_email_delay + date = datetime.datetime.now() + month = date.strftime("%B")[:3] # Nov, Mar, Apr + day = date.day + year = date.year + today = "%s-%s-%s" % (day, month, year) + + shortcuts = CONFIG["shortcuts"] + # swap key/value + shortcuts_inverted = dict([[v, k] for k, v in shortcuts.items()]) + + try: + server = _imap_connect() + except Exception as e: + LOG.exception("Failed to Connect to IMAP. Cannot resend email ", e) + return + + messages = server.search(["SINCE", today]) + # LOG.debug("%d messages received today" % len(messages)) + + msgexists = False + + messages.sort(reverse=True) + del messages[int(count) :] # only the latest "count" messages + for message in messages: + for msgid, data in list(server.fetch(message, ["ENVELOPE"]).items()): + # one at a time, otherwise order is random + (body, from_addr) = parse_email(msgid, data, server) + # unset seen flag, will stay bold in email client + server.remove_flags(msgid, [imapclient.SEEN]) + if from_addr in shortcuts_inverted: + # reverse lookup of a shortcut + from_addr = shortcuts_inverted[from_addr] + # asterisk indicates a resend + reply = "-" + from_addr + " * " + body.decode(errors="ignore") + messaging.send_message(fromcall, reply) + msgexists = True + + if msgexists is not True: + stm = time.localtime() + h = stm.tm_hour + m = stm.tm_min + s = stm.tm_sec + # append time as a kind of serial number to prevent FT1XDR from + # thinking this is a duplicate message. + # The FT1XDR pretty much ignores the aprs message number in this + # regard. The FTM400 gets it right. + reply = "No new msg %s:%s:%s" % ( + str(h).zfill(2), + str(m).zfill(2), + str(s).zfill(2), + ) + messaging.send_message(fromcall, reply) + + # check email more often since we're resending one now + check_email_delay = 60 + + server.logout() + # end resend_email() + + +def check_email_thread(): + global check_email_delay + + # LOG.debug("FIXME initial email delay is 10 seconds") + check_email_delay = 60 + while True: + # LOG.debug("Top of check_email_thread.") + + time.sleep(check_email_delay) + + # slowly increase delay every iteration, max out at 300 seconds + # any send/receive/resend activity will reset this to 60 seconds + if check_email_delay < 300: + check_email_delay += 1 + LOG.debug("check_email_delay is " + str(check_email_delay) + " seconds") + + shortcuts = CONFIG["shortcuts"] + # swap key/value + shortcuts_inverted = dict([[v, k] for k, v in shortcuts.items()]) + + date = datetime.datetime.now() + month = date.strftime("%B")[:3] # Nov, Mar, Apr + day = date.day + year = date.year + today = "%s-%s-%s" % (day, month, year) + + server = None + try: + server = _imap_connect() + except Exception as e: + LOG.exception("Failed to get IMAP server Can't check email.", e) + + if not server: + continue + + messages = server.search(["SINCE", today]) + # LOG.debug("{} messages received today".format(len(messages))) + + for msgid, data in server.fetch(messages, ["ENVELOPE"]).items(): + envelope = data[b"ENVELOPE"] + # LOG.debug('ID:%d "%s" (%s)' % (msgid, envelope.subject.decode(), envelope.date)) + f = re.search( + r"'([[A-a][0-9]_-]+@[[A-a][0-9]_-\.]+)", str(envelope.from_[0]) + ) + if f is not None: + from_addr = f.group(1) + else: + from_addr = "noaddr" + + # LOG.debug("Message flags/tags: " + str(server.get_flags(msgid)[msgid])) + # if "APRS" not in server.get_flags(msgid)[msgid]: + # in python3, imap tags are unicode. in py2 they're strings. so .decode them to handle both + taglist = [ + x.decode(errors="ignore") for x in server.get_flags(msgid)[msgid] + ] + if "APRS" not in taglist: + # if msg not flagged as sent via aprs + server.fetch([msgid], ["RFC822"]) + (body, from_addr) = parse_email(msgid, data, server) + # unset seen flag, will stay bold in email client + server.remove_flags(msgid, [imapclient.SEEN]) + + if from_addr in shortcuts_inverted: + # reverse lookup of a shortcut + from_addr = shortcuts_inverted[from_addr] + + reply = "-" + from_addr + " " + body.decode(errors="ignore") + messaging.send_message(CONFIG["ham"]["callsign"], reply) + # flag message as sent via aprs + server.add_flags(msgid, ["APRS"]) + # unset seen flag, will stay bold in email client + server.remove_flags(msgid, [imapclient.SEEN]) + # check email more often since we just received an email + check_email_delay = 60 + + server.logout() + + +# end check_email() + + +def send_email(to_addr, content): + global check_email_delay + + LOG.info("Sending Email_________________") + shortcuts = CONFIG["shortcuts"] + if to_addr in shortcuts: + LOG.info("To : " + to_addr) + to_addr = shortcuts[to_addr] + LOG.info(" (" + to_addr + ")") + subject = CONFIG["ham"]["callsign"] + # content = content + "\n\n(NOTE: reply with one line)" + LOG.info("Subject : " + subject) + LOG.info("Body : " + content) + + # check email more often since there's activity right now + check_email_delay = 60 + + msg = MIMEText(content) + msg["Subject"] = subject + msg["From"] = CONFIG["smtp"]["login"] + msg["To"] = to_addr + server = _smtp_connect() + if server: + try: + server.sendmail(CONFIG["smtp"]["login"], [to_addr], msg.as_string()) + except Exception as e: + msg = getattr(e, "message", repr(e)) + LOG.error("Sendmail Error!!!! '{}'", msg) + server.quit() + return -1 + server.quit() + return 0 + # end send_email diff --git a/aprsd/main.py b/aprsd/main.py index ec05d59..51a40bc 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -21,38 +21,34 @@ # # python included libs -import datetime -import email -import imaplib import logging import os -import pprint -import re -import select import signal -import smtplib -import socket import sys -import threading import time -from email.mime.text import MIMEText +from logging import NullHandler from logging.handlers import RotatingFileHandler +import aprslib import click import click_completion -import imapclient -import six import yaml # local imports here import aprsd -from aprsd import plugin, utils +from aprsd import client, email, messaging, plugin, utils # setup the global logger +# logging.basicConfig(level=logging.DEBUG) # level=10 LOG = logging.getLogger("APRSD") -# global for the config yaml -CONFIG = None +LOG_LEVELS = { + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, +} # localization, please edit: # HOST = "noam.aprs2.net" # north america tier2 servers round robin @@ -65,33 +61,6 @@ CONFIG = None # "wb" : "5553909472@vtext.com" # } -# globals - tell me a better way to update data being used by threads - -# message_number:time combos so we don't resend the same email in -# five mins {int:int} -email_sent_dict = {} - -# message_nubmer:ack combos so we stop sending a message after an -# ack from radio {int:int} -ack_dict = {} - -# current aprs radio message number, increments for each message we -# send over rf {int} -message_number = 0 - -# global telnet connection object -- not needed anymore -# tn = None - -# ## set default encoding for python, so body.decode doesn't blow up in email thread -# reload(sys) -# sys.setdefaultencoding('utf8') - -# import locale -# def getpreferredencoding(do_setlocale = True): -# return "utf-8" -# locale.getpreferredencoding = getpreferredencoding -# ## default encoding failed attempts.... - def custom_startswith(string, incomplete): """A custom completion match that supports case insensitive matching.""" @@ -167,34 +136,6 @@ def install(append, case_insensitive, shell, path): click.echo("%s completion installed in %s" % (shell, path)) -def setup_connection(): - global sock - connected = False - while not connected: - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(300) - sock.connect((CONFIG["aprs"]["host"], 14580)) - connected = True - LOG.debug("Connected to server: " + CONFIG["aprs"]["host"]) - # sock_file = sock.makefile(mode="r") - # sock_file = sock.makefile(mode='r', encoding=None, errors=None, newline=None) - # sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # disable nagle algorithm - # sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 512) # buffer size - except Exception as e: - LOG.error("Unable to connect to APRS-IS server.\n") - print(str(e)) - time.sleep(5) - continue - # os._exit(1) - user = CONFIG["aprs"]["login"] - password = CONFIG["aprs"]["password"] - LOG.debug("Logging in to APRS-IS with user '%s'" % user) - msg = "user {} pass {} vers aprsd {}\n".format(user, password, aprsd.__version__) - sock.send(msg.encode()) - return sock - - def signal_handler(signal, frame): LOG.info("Ctrl+C, exiting.") # sys.exit(0) # thread ignores this @@ -204,487 +145,20 @@ def signal_handler(signal, frame): # end signal_handler -def parse_email(msgid, data, server): - envelope = data[b"ENVELOPE"] - # email address match - # use raw string to avoid invalid escape secquence errors r"string here" - f = re.search(r"([\.\w_-]+@[\.\w_-]+)", str(envelope.from_[0])) - if f is not None: - from_addr = f.group(1) - else: - from_addr = "noaddr" - LOG.debug("Got a message from '{}'".format(from_addr)) - m = server.fetch([msgid], ["RFC822"]) - msg = email.message_from_string(m[msgid][b"RFC822"].decode(errors="ignore")) - if msg.is_multipart(): - text = "" - html = None - # default in case body somehow isn't set below - happened once - body = b"* unreadable msg received" - # this uses the last text or html part in the email, phone companies often put content in an attachment - for part in msg.get_payload(): - if part.get_content_charset() is None: - # or BREAK when we hit a text or html? - # We cannot know the character set, - # so return decoded "something" - LOG.debug("Email got unknown content type") - text = part.get_payload(decode=True) - continue - - charset = part.get_content_charset() - - if part.get_content_type() == "text/plain": - LOG.debug("Email got text/plain") - text = six.text_type( - part.get_payload(decode=True), str(charset), "ignore" - ).encode("utf8", "replace") - - if part.get_content_type() == "text/html": - LOG.debug("Email got text/html") - html = six.text_type( - part.get_payload(decode=True), str(charset), "ignore" - ).encode("utf8", "replace") - - if text is not None: - # strip removes white space fore and aft of string - body = text.strip() - else: - body = html.strip() - else: # message is not multipart - # email.uscc.net sends no charset, blows up unicode function below - LOG.debug("Email is not multipart") - if msg.get_content_charset() is None: - text = six.text_type( - msg.get_payload(decode=True), "US-ASCII", "ignore" - ).encode("utf8", "replace") - else: - text = six.text_type( - msg.get_payload(decode=True), msg.get_content_charset(), "ignore" - ).encode("utf8", "replace") - body = text.strip() - - # FIXED: UnicodeDecodeError: 'ascii' codec can't decode byte 0xf0 in position 6: ordinal not in range(128) - # decode with errors='ignore'. be sure to encode it before we return it below, also with errors='ignore' - try: - body = body.decode(errors="ignore") - except Exception as e: - LOG.error("Unicode decode failure: " + str(e)) - LOG.error("Unidoce decode failed: " + str(body)) - body = "Unreadable unicode msg" - # strip all html tags - body = re.sub("<[^<]+?>", "", body) - # strip CR/LF, make it one line, .rstrip fails at this - body = body.replace("\n", " ").replace("\r", " ") - # ascii might be out of range, so encode it, removing any error characters - body = body.encode(errors="ignore") - return (body, from_addr) - - -# end parse_email - - -def _imap_connect(): - imap_port = CONFIG["imap"].get("port", 143) - use_ssl = CONFIG["imap"].get("use_ssl", False) - host = CONFIG["imap"]["host"] - msg = "{}{}:{}".format("TLS " if use_ssl else "", host, imap_port) - # LOG.debug("Connect to IMAP host {} with user '{}'". - # format(msg, CONFIG['imap']['login'])) - - try: - server = imapclient.IMAPClient( - CONFIG["imap"]["host"], port=imap_port, use_uid=True, ssl=use_ssl - ) - except Exception: - LOG.error("Failed to connect IMAP server") - return - - # LOG.debug("Connected to IMAP host {}".format(msg)) - - try: - server.login(CONFIG["imap"]["login"], CONFIG["imap"]["password"]) - except (imaplib.IMAP4.error, Exception) as e: - msg = getattr(e, "message", repr(e)) - LOG.error("Failed to login {}".format(msg)) - return - - # LOG.debug("Logged in to IMAP, selecting INBOX") - server.select_folder("INBOX") - return server - - -def _smtp_connect(): - host = CONFIG["smtp"]["host"] - smtp_port = CONFIG["smtp"]["port"] - use_ssl = CONFIG["smtp"].get("use_ssl", False) - msg = "{}{}:{}".format("SSL " if use_ssl else "", host, smtp_port) - LOG.debug( - "Connect to SMTP host {} with user '{}'".format(msg, CONFIG["imap"]["login"]) - ) - - try: - if use_ssl: - server = smtplib.SMTP_SSL(host=host, port=smtp_port) - else: - server = smtplib.SMTP(host=host, port=smtp_port) - except Exception: - LOG.error("Couldn't connect to SMTP Server") - return - - LOG.debug("Connected to smtp host {}".format(msg)) - - try: - server.login(CONFIG["smtp"]["login"], CONFIG["smtp"]["password"]) - except Exception: - LOG.error("Couldn't connect to SMTP Server") - return - - LOG.debug("Logged into SMTP server {}".format(msg)) - return server - - -def validate_email(): - """function to simply ensure we can connect to email services. - - This helps with failing early during startup. - """ - LOG.info("Checking IMAP configuration") - imap_server = _imap_connect() - LOG.info("Checking SMTP configuration") - smtp_server = _smtp_connect() - - if imap_server and smtp_server: - return True - else: - return False - - -def resend_email(count, fromcall): - global check_email_delay - date = datetime.datetime.now() - month = date.strftime("%B")[:3] # Nov, Mar, Apr - day = date.day - year = date.year - today = "%s-%s-%s" % (day, month, year) - - shortcuts = CONFIG["shortcuts"] - # swap key/value - shortcuts_inverted = dict([[v, k] for k, v in shortcuts.items()]) - - try: - server = _imap_connect() - except Exception as e: - LOG.exception("Failed to Connect to IMAP. Cannot resend email ", e) - return - - messages = server.search(["SINCE", today]) - # LOG.debug("%d messages received today" % len(messages)) - - msgexists = False - - messages.sort(reverse=True) - del messages[int(count) :] # only the latest "count" messages - for message in messages: - for msgid, data in list(server.fetch(message, ["ENVELOPE"]).items()): - # one at a time, otherwise order is random - (body, from_addr) = parse_email(msgid, data, server) - # unset seen flag, will stay bold in email client - server.remove_flags(msgid, [imapclient.SEEN]) - if from_addr in shortcuts_inverted: - # reverse lookup of a shortcut - from_addr = shortcuts_inverted[from_addr] - # asterisk indicates a resend - reply = "-" + from_addr + " * " + body.decode(errors="ignore") - send_message(fromcall, reply) - msgexists = True - - if msgexists is not True: - stm = time.localtime() - h = stm.tm_hour - m = stm.tm_min - s = stm.tm_sec - # append time as a kind of serial number to prevent FT1XDR from - # thinking this is a duplicate message. - # The FT1XDR pretty much ignores the aprs message number in this - # regard. The FTM400 gets it right. - reply = "No new msg %s:%s:%s" % ( - str(h).zfill(2), - str(m).zfill(2), - str(s).zfill(2), - ) - send_message(fromcall, reply) - - # check email more often since we're resending one now - check_email_delay = 60 - - server.logout() - # end resend_email() - - -def check_email_thread(): - global check_email_delay - - # LOG.debug("FIXME initial email delay is 10 seconds") - check_email_delay = 60 - while True: - # LOG.debug("Top of check_email_thread.") - - time.sleep(check_email_delay) - - # slowly increase delay every iteration, max out at 300 seconds - # any send/receive/resend activity will reset this to 60 seconds - if check_email_delay < 300: - check_email_delay += 1 - LOG.debug("check_email_delay is " + str(check_email_delay) + " seconds") - - shortcuts = CONFIG["shortcuts"] - # swap key/value - shortcuts_inverted = dict([[v, k] for k, v in shortcuts.items()]) - - date = datetime.datetime.now() - month = date.strftime("%B")[:3] # Nov, Mar, Apr - day = date.day - year = date.year - today = "%s-%s-%s" % (day, month, year) - - server = None - try: - server = _imap_connect() - except Exception as e: - LOG.exception("Failed to get IMAP server Can't check email.", e) - - if not server: - continue - - messages = server.search(["SINCE", today]) - # LOG.debug("{} messages received today".format(len(messages))) - - for msgid, data in server.fetch(messages, ["ENVELOPE"]).items(): - envelope = data[b"ENVELOPE"] - # LOG.debug('ID:%d "%s" (%s)' % (msgid, envelope.subject.decode(), envelope.date)) - f = re.search( - r"'([[A-a][0-9]_-]+@[[A-a][0-9]_-\.]+)", str(envelope.from_[0]) - ) - if f is not None: - from_addr = f.group(1) - else: - from_addr = "noaddr" - - # LOG.debug("Message flags/tags: " + str(server.get_flags(msgid)[msgid])) - # if "APRS" not in server.get_flags(msgid)[msgid]: - # in python3, imap tags are unicode. in py2 they're strings. so .decode them to handle both - taglist = [ - x.decode(errors="ignore") for x in server.get_flags(msgid)[msgid] - ] - if "APRS" not in taglist: - # if msg not flagged as sent via aprs - server.fetch([msgid], ["RFC822"]) - (body, from_addr) = parse_email(msgid, data, server) - # unset seen flag, will stay bold in email client - server.remove_flags(msgid, [imapclient.SEEN]) - - if from_addr in shortcuts_inverted: - # reverse lookup of a shortcut - from_addr = shortcuts_inverted[from_addr] - - reply = "-" + from_addr + " " + body.decode(errors="ignore") - send_message(CONFIG["ham"]["callsign"], reply) - # flag message as sent via aprs - server.add_flags(msgid, ["APRS"]) - # unset seen flag, will stay bold in email client - server.remove_flags(msgid, [imapclient.SEEN]) - # check email more often since we just received an email - check_email_delay = 60 - - server.logout() - - -# end check_email() - - -def send_ack_thread(tocall, ack, retry_count): - tocall = tocall.ljust(9) # pad to nine chars - line = "{}>APRS::{}:ack{}\n".format(CONFIG["aprs"]["login"], tocall, ack) - for i in range(retry_count, 0, -1): - LOG.info("Sending ack __________________ Tx({})".format(i)) - LOG.info("Raw : {}".format(line.rstrip("\n"))) - LOG.info("To : {}".format(tocall)) - LOG.info("Ack number : {}".format(ack)) - sock.send(line.encode()) - # aprs duplicate detection is 30 secs? - # (21 only sends first, 28 skips middle) - time.sleep(31) - # end_send_ack_thread - - -def send_ack(tocall, ack): - LOG.debug("Send ACK({}:{}) to radio.".format(tocall, ack)) - retry_count = 3 - thread = threading.Thread( - target=send_ack_thread, name="send_ack", args=(tocall, ack, retry_count) - ) - thread.start() - # end send_ack() - - -def send_message_thread(tocall, message, this_message_number, retry_count): - global ack_dict - # line = (CONFIG['aprs']['login'] + ">APRS::" + tocall + ":" + message - # + "{" + str(this_message_number) + "\n") - # line = ("{}>APRS::{}:{}{{{}\n".format( CONFIG['aprs']['login'], tocall, message.encode(errors='ignore'), str(this_message_number),)) - line = "{}>APRS::{}:{}{{{}\n".format( - CONFIG["aprs"]["login"], - tocall, - message, - str(this_message_number), - ) - for i in range(retry_count, 0, -1): - LOG.debug("DEBUG: send_message_thread msg:ack combos are: ") - LOG.debug(pprint.pformat(ack_dict)) - if ack_dict[this_message_number] != 1: - LOG.info( - "Sending message_______________ {}(Tx{})".format( - str(this_message_number), str(i) - ) - ) - LOG.info("Raw : {}".format(line.rstrip("\n"))) - LOG.info("To : {}".format(tocall)) - # LOG.info("Message : {}".format(message.encode(errors='ignore'))) - LOG.info("Message : {}".format(message)) - # tn.write(line) - sock.send(line.encode()) - # decaying repeats, 31 to 93 second intervals - sleeptime = (retry_count - i + 1) * 31 - time.sleep(sleeptime) - else: - break - return - # end send_message_thread - - -def send_message(tocall, message): - global message_number - global ack_dict - retry_count = 3 - if message_number > 98: # global - message_number = 0 - message_number += 1 - if len(ack_dict) > 90: - # empty ack dict if it's really big, could result in key error later - LOG.debug( - "DEBUG: Length of ack dictionary is big at %s clearing." % len(ack_dict) - ) - ack_dict.clear() - LOG.debug(pprint.pformat(ack_dict)) - LOG.debug( - "DEBUG: Cleared ack dictionary, ack_dict length is now %s." % len(ack_dict) - ) - ack_dict[message_number] = 0 # clear ack for this message number - tocall = tocall.ljust(9) # pad to nine chars - - # max? ftm400 displays 64, raw msg shows 74 - # and ftm400-send is max 64. setting this to - # 67 displays 64 on the ftm400. (+3 {01 suffix) - # feature req: break long ones into two msgs - message = message[:67] - # We all miss George Carlin - message = re.sub("fuck|shit|cunt|piss|cock|bitch", "****", message) - thread = threading.Thread( - target=send_message_thread, - name="send_message", - args=(tocall, message, message_number, retry_count), - ) - thread.start() - return () - # end send_message() - - -def process_message(line): - f = re.search("^(.*)>", line) - fromcall = f.group(1) - searchstring = "::%s[ ]*:(.*)" % CONFIG["aprs"]["login"] - # verify this, callsign is padded out with spaces to colon - m = re.search(searchstring, line) - fullmessage = m.group(1) - - ack_attached = re.search("(.*){([0-9A-Z]+)", fullmessage) - # ack formats include: {1, {AB}, {12 - if ack_attached: - # "{##" suffix means radio wants an ack back - # message content - message = ack_attached.group(1) - # suffix number to use in ack - ack_num = ack_attached.group(2) - else: - message = fullmessage - # ack not requested, but lets send one as 0 - ack_num = "0" - - LOG.info("Received message______________") - LOG.info("Raw : " + line) - LOG.info("From : " + fromcall) - LOG.info("Message : " + message) - LOG.info("Msg number : " + str(ack_num)) - - return (fromcall, message, ack_num) - # end process_message() - - -def send_email(to_addr, content): - global check_email_delay - - LOG.info("Sending Email_________________") - shortcuts = CONFIG["shortcuts"] - if to_addr in shortcuts: - LOG.info("To : " + to_addr) - to_addr = shortcuts[to_addr] - LOG.info(" (" + to_addr + ")") - subject = CONFIG["ham"]["callsign"] - # content = content + "\n\n(NOTE: reply with one line)" - LOG.info("Subject : " + subject) - LOG.info("Body : " + content) - - # check email more often since there's activity right now - check_email_delay = 60 - - msg = MIMEText(content) - msg["Subject"] = subject - msg["From"] = CONFIG["smtp"]["login"] - msg["To"] = to_addr - server = _smtp_connect() - if server: - try: - server.sendmail(CONFIG["smtp"]["login"], [to_addr], msg.as_string()) - except Exception as e: - msg = getattr(e, "message", repr(e)) - LOG.error("Sendmail Error!!!! '{}'", msg) - server.quit() - return -1 - server.quit() - return 0 - # end send_email - - # Setup the logging faciility # to disable logging to stdout, but still log to file # use the --quiet option on the cmdln -def setup_logging(loglevel, quiet): - levels = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "WARNING": logging.WARNING, - "INFO": logging.INFO, - "DEBUG": logging.DEBUG, - } - log_level = levels[loglevel] - +def setup_logging(config, loglevel, quiet): + log_level = LOG_LEVELS[loglevel] LOG.setLevel(log_level) log_format = "[%(asctime)s] [%(threadName)-12s] [%(levelname)-5.5s]" " %(message)s" date_format = "%m/%d/%Y %I:%M:%S %p" log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) - fh = RotatingFileHandler( - CONFIG["aprs"]["logfile"], maxBytes=(10248576 * 5), backupCount=4 - ) + log_file = config["aprs"].get("logfile", None) + if log_file: + fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4) + else: + fh = NullHandler() fh.setFormatter(log_formatter) LOG.addHandler(fh) @@ -694,74 +168,77 @@ def setup_logging(loglevel, quiet): LOG.addHandler(sh) +def process_packet(packet): + """Process a packet recieved from aprs-is server.""" + + LOG.debug("Process packet!") + try: + LOG.debug("Got message: {}".format(packet)) + + fromcall = packet["from"] + message = packet.get("message_text", None) + if not message: + LOG.debug("Didn't get a message, could be an ack?") + if packet.get("response", None) == "ack": + # looks like an ACK + ack_num = packet.get("msgNo") + messaging.ack_dict.update({ack_num: 1}) + + msg_number = packet.get("msgNo", None) + if msg_number: + ack = msg_number + else: + ack = "0" + + LOG.debug(" Received message______________") + LOG.debug(" Raw : {}".format(packet["raw"])) + LOG.debug(" From : {}".format(fromcall)) + LOG.debug(" Message : {}".format(message)) + LOG.debug(" Msg number : {}".format(str(ack))) + + found_command = False + # Get singleton of the PM + pm = plugin.PluginManager() + try: + results = pm.run(fromcall=fromcall, message=message, ack=ack) + for reply in results: + found_command = True + # A plugin can return a null message flag which signals + # us that they processed the message correctly, but have + # nothing to reply with, so we avoid replying with a usage string + if reply is not messaging.NULL_MESSAGE: + LOG.debug("Sending '{}'".format(reply)) + messaging.send_message(fromcall, reply) + else: + LOG.debug("Got NULL MESSAGE from plugin") + + if not found_command: + plugins = pm.get_plugins() + names = [x.command_name for x in plugins] + names.sort() + + reply = "Usage: {}".format(", ".join(names)) + messaging.send_message(fromcall, reply) + except Exception as ex: + LOG.exception("Plugin failed!!!", ex) + reply = "A Plugin failed! try again?" + messaging.send_message(fromcall, reply) + + # let any threads do their thing, then ack + # send an ack last + messaging.send_ack(fromcall, ack) + LOG.debug("Packet processing complete") + + except (aprslib.ParseError, aprslib.UnknownFormat) as exp: + LOG.exception("Failed to parse packet from aprs-is", exp) + + @main.command() def sample_config(): """This dumps the config to stdout.""" click.echo(yaml.dump(utils.DEFAULT_CONFIG_DICT)) -COMMAND_ENVELOPE = { - "email": {"command": "^-.*", "function": "command_email"}, -} - - -def command_email(fromcall, message, ack): - LOG.info("Email COMMAND") - - searchstring = "^" + CONFIG["ham"]["callsign"] + ".*" - # only I can do email - if re.search(searchstring, fromcall): - # digits only, first one is number of emails to resend - r = re.search("^-([0-9])[0-9]*$", message) - if r is not None: - resend_email(r.group(1), fromcall) - # -user@address.com body of email - elif re.search(r"^-([A-Za-z0-9_\-\.@]+) (.*)", message): - # (same search again) - a = re.search(r"^-([A-Za-z0-9_\-\.@]+) (.*)", message) - if a is not None: - to_addr = a.group(1) - content = a.group(2) - # send recipient link to aprs.fi map - if content == "mapme": - content = "Click for my location: http://aprs.fi/{}".format( - CONFIG["ham"]["callsign"] - ) - too_soon = 0 - now = time.time() - # see if we sent this msg number recently - if ack in email_sent_dict: - timedelta = now - email_sent_dict[ack] - if timedelta < 300: # five minutes - too_soon = 1 - if not too_soon or ack == 0: - send_result = send_email(to_addr, content) - if send_result != 0: - send_message(fromcall, "-" + to_addr + " failed") - else: - # send_message(fromcall, "-" + to_addr + " sent") - if ( - len(email_sent_dict) > 98 - ): # clear email sent dictionary if somehow goes over 100 - LOG.debug( - "DEBUG: email_sent_dict is big (" - + str(len(email_sent_dict)) - + ") clearing out." - ) - email_sent_dict.clear() - email_sent_dict[ack] = now - else: - LOG.info( - "Email for message number " - + ack - + " recently sent, not sending again." - ) - else: - send_message(fromcall, "Bad email address") - - return (fromcall, message, ack) - - # main() ### @main.command() @click.option( @@ -785,144 +262,55 @@ def command_email(fromcall, message, ack): ) def server(loglevel, quiet, config_file): """Start the aprsd server process.""" - global CONFIG - CONFIG = utils.parse_config(config_file) signal.signal(signal.SIGINT, signal_handler) - setup_logging(loglevel, quiet) + + click.echo("Load config") + config = utils.parse_config(config_file) + + # Force setting the config to the modules that need it + # TODO(Walt): convert these modules to classes that can + # Accept the config as a constructor param, instead of this + # hacky global setting + email.CONFIG = config + messaging.CONFIG = config + + setup_logging(config, loglevel, quiet) LOG.info("APRSD Started version: {}".format(aprsd.__version__)) - time.sleep(2) - client_sock = setup_connection() - valid = validate_email() + # TODO(walt): Make email processing/checking optional? + # Maybe someone only wants this to process messages with plugins only. + valid = email.validate_email() if not valid: LOG.error("Failed to validate email config options") sys.exit(-1) - user = CONFIG["aprs"]["login"] - LOG.debug("Looking for messages for user '{}'".format(user)) - # password = CONFIG["aprs"]["password"] - # LOG.debug("LOGIN to APRSD with user '%s'" % user) - # msg = ("user {} pass {} vers aprsd {}\n".format(user, password, aprsd.__version__)) - # sock.send(msg.encode()) + # start the email thread + email.start_thread() - time.sleep(2) + # Create the initial PM singleton and Register plugins + plugin_manager = plugin.PluginManager(config) + plugin_manager.setup_plugins() + cl = client.Client(config) - checkemailthread = threading.Thread( - target=check_email_thread, name="check_email", args=() - ) # args must be tuple - checkemailthread.start() - - read_sockets = [client_sock] - - # Register plugins - pm = plugin.setup_plugins(CONFIG) - - fromcall = message = ack = None + # setup and run the main blocking loop while True: - LOG.debug("Main loop start") - reconnect = False - message = None + # Now use the helper which uses the singleton + aprs_client = client.get_client() + + # setup the consumer of messages and block until a messages try: - readable, writable, exceptional = select.select(read_sockets, [], []) - - for s in readable: - data = s.recv(10240).decode().strip() - if data: - LOG.info("APRS-IS({}): {}".format(len(data), data)) - searchstring = "::%s" % user - if re.search(searchstring, data): - LOG.debug( - "main: found message addressed to us begin process_message" - ) - (fromcall, message, ack) = process_message(data) - else: - LOG.error("Connection Failed. retrying to connect") - read_sockets.remove(s) - s.close() - time.sleep(2) - client_sock = setup_connection() - read_sockets.append(client_sock) - reconnect = True - - for s in exceptional: - LOG.error("Connection Failed. retrying to connect") - read_sockets.remove(s) - s.close() - time.sleep(2) - client_sock = setup_connection() - read_sockets.append(client_sock) - reconnect = True - - if reconnect: - # start the loop over - LOG.warning("Starting Main loop over.") - continue - - except Exception as e: - LOG.exception(e) - LOG.error("%s" % str(e)) - if ( - str(e) == "closed_socket" - or str(e) == "timed out" - or str(e) == "Temporary failure in name resolution" - or str(e) == "Network is unreachable" - ): - LOG.error("Attempting to reconnect.") - sock.shutdown(0) - sock.close() - client_sock = setup_connection() - continue - LOG.error("Unexpected error: " + str(e)) - LOG.error("Continuing anyway.") + # This will register a packet consumer with aprslib + # When new packets come in the consumer will process + # the packet + aprs_client.consumer(process_packet, raw=False) + except aprslib.exceptions.ConnectionDrop: + LOG.error("Connection dropped, reconnecting") time.sleep(5) - continue # don't know what failed, so wait and then continue main loop again - - if not message: - continue - - LOG.debug("Process the command. '{}'".format(message)) - - # ACK (ack##) - # Custom command due to needing to avoid send_ack - if re.search("^ack[0-9]+", message): - LOG.debug("ACK") - # put message_number:1 in dict to record the ack - a = re.search("^ack([0-9]+)", message) - ack_dict.update({int(a.group(1)): 1}) - continue # break out of this so we don't ack an ack at the end - - # call our `myhook` hook - found_command = False - results = pm.hook.run(fromcall=fromcall, message=message, ack=ack) - for reply in results: - found_command = True - send_message(fromcall, reply) - - # it's not an ack, so try and process user input - for key in COMMAND_ENVELOPE: - if re.search(COMMAND_ENVELOPE[key]["command"], message): - # now call the registered function - funct = COMMAND_ENVELOPE[key]["function"] - (fromcall, message, ack) = globals()[funct](fromcall, message, ack) - found_command = True - - if not found_command: - plugins = pm.get_plugins() - names = [x.command_name for x in plugins] - for k in COMMAND_ENVELOPE.keys(): - names.append(k) - names.sort() - - reply = "Usage: {}".format(", ".join(names)) - send_message(fromcall, reply) - - # let any threads do their thing, then ack - time.sleep(1) - # send an ack last - send_ack(fromcall, ack) - LOG.debug("Main loop end") - # end while True + # Force the deletion of the client object connected to aprs + # This will cause a reconnect, next time client.get_client() + # is called + cl.reset() if __name__ == "__main__": diff --git a/aprsd/messaging.py b/aprsd/messaging.py new file mode 100644 index 0000000..24d3d55 --- /dev/null +++ b/aprsd/messaging.py @@ -0,0 +1,149 @@ +import logging +import pprint +import re +import threading +import time + +from aprsd import client + +LOG = logging.getLogger("APRSD") +CONFIG = None + +# current aprs radio message number, increments for each message we +# send over rf {int} +message_number = 0 + +# message_nubmer:ack combos so we stop sending a message after an +# ack from radio {int:int} +ack_dict = {} + +# What to return from a plugin if we have processed the message +# and it's ok, but don't send a usage string back +NULL_MESSAGE = -1 + + +def send_ack_thread(tocall, ack, retry_count): + cl = client.get_client() + tocall = tocall.ljust(9) # pad to nine chars + line = "{}>APRS::{}:ack{}\n".format(CONFIG["aprs"]["login"], tocall, ack) + for i in range(retry_count, 0, -1): + LOG.info("Sending ack __________________ Tx({})".format(i)) + LOG.info("Raw : {}".format(line.rstrip("\n"))) + LOG.info("To : {}".format(tocall)) + LOG.info("Ack number : {}".format(ack)) + cl.sendall(line) + # aprs duplicate detection is 30 secs? + # (21 only sends first, 28 skips middle) + time.sleep(31) + # end_send_ack_thread + + +def send_ack(tocall, ack): + LOG.debug("Send ACK({}:{}) to radio.".format(tocall, ack)) + retry_count = 3 + thread = threading.Thread( + target=send_ack_thread, name="send_ack", args=(tocall, ack, retry_count) + ) + thread.start() + # end send_ack() + + +def send_message_thread(tocall, message, this_message_number, retry_count): + cl = client.get_client() + line = "{}>APRS::{}:{}{{{}\n".format( + CONFIG["aprs"]["login"], + tocall, + message, + str(this_message_number), + ) + for i in range(retry_count, 0, -1): + LOG.debug("DEBUG: send_message_thread msg:ack combos are: ") + LOG.debug(pprint.pformat(ack_dict)) + if ack_dict[this_message_number] != 1: + LOG.info( + "Sending message_______________ {}(Tx{})".format( + str(this_message_number), str(i) + ) + ) + LOG.info("Raw : {}".format(line.rstrip("\n"))) + LOG.info("To : {}".format(tocall)) + # LOG.info("Message : {}".format(message.encode(errors='ignore'))) + LOG.info("Message : {}".format(message)) + # tn.write(line) + cl.sendall(line) + # decaying repeats, 31 to 93 second intervals + sleeptime = (retry_count - i + 1) * 31 + time.sleep(sleeptime) + else: + break + return + # end send_message_thread + + +def send_message(tocall, message): + global message_number + global ack_dict + + retry_count = 3 + if message_number > 98: # global + message_number = 0 + message_number += 1 + if len(ack_dict) > 90: + # empty ack dict if it's really big, could result in key error later + LOG.debug( + "DEBUG: Length of ack dictionary is big at %s clearing." % len(ack_dict) + ) + ack_dict.clear() + LOG.debug(pprint.pformat(ack_dict)) + LOG.debug( + "DEBUG: Cleared ack dictionary, ack_dict length is now %s." % len(ack_dict) + ) + ack_dict[message_number] = 0 # clear ack for this message number + tocall = tocall.ljust(9) # pad to nine chars + + # max? ftm400 displays 64, raw msg shows 74 + # and ftm400-send is max 64. setting this to + # 67 displays 64 on the ftm400. (+3 {01 suffix) + # feature req: break long ones into two msgs + message = message[:67] + # We all miss George Carlin + message = re.sub("fuck|shit|cunt|piss|cock|bitch", "****", message) + thread = threading.Thread( + target=send_message_thread, + name="send_message", + args=(tocall, message, message_number, retry_count), + ) + thread.start() + return () + # end send_message() + + +def process_message(line): + f = re.search("^(.*)>", line) + fromcall = f.group(1) + searchstring = "::%s[ ]*:(.*)" % CONFIG["aprs"]["login"] + # verify this, callsign is padded out with spaces to colon + m = re.search(searchstring, line) + fullmessage = m.group(1) + + ack_attached = re.search("(.*){([0-9A-Z]+)", fullmessage) + # ack formats include: {1, {AB}, {12 + if ack_attached: + # "{##" suffix means radio wants an ack back + # message content + message = ack_attached.group(1) + # suffix number to use in ack + ack_num = ack_attached.group(2) + else: + message = fullmessage + # ack not requested, but lets send one as 0 + ack_num = "0" + + LOG.info("Received message______________") + LOG.info("Raw : " + line) + LOG.info("From : " + fromcall) + LOG.info("Message : " + message) + LOG.info("Msg number : " + str(ack_num)) + + return (fromcall, message, ack_num) + # end process_message() diff --git a/aprsd/plugin.py b/aprsd/plugin.py index c99e4cb..b5b83f8 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -1,6 +1,7 @@ # The base plugin class import abc import fnmatch +import importlib import inspect import json import logging @@ -14,6 +15,7 @@ import requests import six from thesmuggler import smuggle +from aprsd import email, messaging from aprsd.fuzzyclock import fuzzy # setup the global logger @@ -23,81 +25,42 @@ hookspec = pluggy.HookspecMarker("aprsd") hookimpl = pluggy.HookimplMarker("aprsd") CORE_PLUGINS = [ - "FortunePlugin", - "LocationPlugin", - "PingPlugin", - "TimePlugin", - "WeatherPlugin", + "aprsd.plugin.EmailPlugin", + "aprsd.plugin.FortunePlugin", + "aprsd.plugin.LocationPlugin", + "aprsd.plugin.PingPlugin", + "aprsd.plugin.TimePlugin", + "aprsd.plugin.WeatherPlugin", ] -def setup_plugins(config): - """Create the plugin manager and register plugins.""" - - LOG.info("Loading Core APRSD Command Plugins") - enabled_plugins = config["aprsd"].get("enabled_plugins", None) - pm = pluggy.PluginManager("aprsd") - pm.add_hookspecs(APRSDCommandSpec) - for p_name in CORE_PLUGINS: - plugin_obj = None - if enabled_plugins: - if p_name in enabled_plugins: - plugin_obj = globals()[p_name](config) - else: - # Enabled plugins isn't set, so we default to loading all of - # the core plugins. - plugin_obj = globals()[p_name](config) - - if plugin_obj: - LOG.info( - "Registering Command plugin '{}'({}) '{}'".format( - p_name, plugin_obj.version, plugin_obj.command_regex - ) - ) - pm.register(plugin_obj) - - plugin_dir = config["aprsd"].get("plugin_dir", None) - if plugin_dir: - LOG.info("Trying to load custom plugins from '{}'".format(plugin_dir)) - cpm = PluginManager(config) - plugins_list = cpm.load_plugins(plugin_dir) - LOG.info("Discovered {} modules to load".format(len(plugins_list))) - for o in plugins_list: - plugin_obj = None - if enabled_plugins: - if o["name"] in enabled_plugins: - plugin_obj = o["obj"] - else: - LOG.info( - "'{}' plugin not listed in config aprsd:enabled_plugins".format( - o["name"] - ) - ) - else: - # not setting enabled plugins means load all? - plugin_obj = o["obj"] - - if plugin_obj: - LOG.info( - "Registering Command plugin '{}'({}) '{}'".format( - o["name"], o["obj"].version, o["obj"].command_regex - ) - ) - pm.register(o["obj"]) - - else: - LOG.info("Skipping Custom Plugins.") - - LOG.info("Completed Plugin Loading.") - return pm - - class PluginManager(object): - def __init__(self, config): - self.obj_list = [] - self.config = config + # The singleton instance object for this class + _instance = None + + # the pluggy PluginManager + _pluggy_pm = None + + # aprsd config dict + config = None + + def __new__(cls, *args, **kwargs): + """This magic turns this into a singleton.""" + if cls._instance is None: + cls._instance = super(PluginManager, cls).__new__(cls) + # Put any initialization here. + return cls._instance + + def __init__(self, config=None): + self.obj_list = [] + if config: + self.config = config + + def load_plugins_from_path(self, module_path): + if not os.path.exists(module_path): + LOG.error("plugin path '{}' doesn't exist.".format(module_path)) + return None - def load_plugins(self, module_path): dir_path = os.path.realpath(module_path) pattern = "*.py" @@ -123,6 +86,108 @@ class PluginManager(object): return False + def _create_class(self, module_class_string, super_cls: type = None, **kwargs): + """ + Method to create a class from a fqn python string. + + :param module_class_string: full name of the class to create an object of + :param super_cls: expected super class for validity, None if bypass + :param kwargs: parameters to pass + :return: + """ + module_name, class_name = module_class_string.rsplit(".", 1) + try: + module = importlib.import_module(module_name) + except Exception as ex: + LOG.error("Failed to load Plugin '{}' : '{}'".format(module_name, ex)) + return + + assert hasattr(module, class_name), "class {} is not in {}".format( + class_name, module_name + ) + # click.echo('reading class {} from module {}'.format( + # class_name, module_name)) + cls = getattr(module, class_name) + if super_cls is not None: + assert issubclass(cls, super_cls), "class {} should inherit from {}".format( + class_name, super_cls.__name__ + ) + # click.echo('initialising {} with params {}'.format(class_name, kwargs)) + obj = cls(**kwargs) + return obj + + def _load_plugin(self, plugin_name): + """ + + Given a python fully qualified class path.name, + Try importing the path, then creating the object, + then registering it as a aprsd Command Plugin + """ + plugin_obj = None + try: + plugin_obj = self._create_class( + plugin_name, APRSDPluginBase, config=self.config + ) + if plugin_obj: + LOG.info( + "Registering Command plugin '{}'({}) '{}'".format( + plugin_name, plugin_obj.version, plugin_obj.command_regex + ) + ) + self._pluggy_pm.register(plugin_obj) + except Exception as ex: + LOG.exception("Couldn't load plugin '{}'".format(plugin_name), ex) + + def setup_plugins(self): + """Create the plugin manager and register plugins.""" + + LOG.info("Loading Core APRSD Command Plugins") + enabled_plugins = self.config["aprsd"].get("enabled_plugins", None) + self._pluggy_pm = pluggy.PluginManager("aprsd") + self._pluggy_pm.add_hookspecs(APRSDCommandSpec) + if enabled_plugins: + for p_name in enabled_plugins: + self._load_plugin(p_name) + else: + # Enabled plugins isn't set, so we default to loading all of + # the core plugins. + for p_name in CORE_PLUGINS: + self._load_plugin(p_name) + + plugin_dir = self.config["aprsd"].get("plugin_dir", None) + if plugin_dir: + LOG.info("Trying to load custom plugins from '{}'".format(plugin_dir)) + plugins_list = self.load_plugins_from_path(plugin_dir) + if plugins_list: + LOG.info("Discovered {} modules to load".format(len(plugins_list))) + for o in plugins_list: + plugin_obj = None + # not setting enabled plugins means load all? + plugin_obj = o["obj"] + + if plugin_obj: + LOG.info( + "Registering Command plugin '{}'({}) '{}'".format( + o["name"], o["obj"].version, o["obj"].command_regex + ) + ) + self._pluggy_pm.register(o["obj"]) + + else: + LOG.info("Skipping Custom Plugins directory.") + LOG.info("Completed Plugin Loading.") + + def run(self, *args, **kwargs): + """Execute all the pluguns run method.""" + return self._pluggy_pm.hook.run(*args, **kwargs) + + def register(self, obj): + """Register the plugin.""" + self._pluggy_pm.register(obj) + + def get_plugins(self): + return self._pluggy_pm.get_plugins() + class APRSDCommandSpec: """A hook specification namespace.""" @@ -184,7 +249,6 @@ class FortunePlugin(APRSDPluginBase): ["/usr/games/fortune", "-s", "-n 60"], stdout=subprocess.PIPE ) reply = process.communicate()[0] - # send_message(fromcall, reply.rstrip()) reply = reply.decode(errors="ignore").rstrip() except Exception as ex: reply = "Fortune command failed '{}'".format(ex) @@ -200,20 +264,20 @@ class LocationPlugin(APRSDPluginBase): command_regex = "^[lL]" command_name = "location" + config_items = {"apikey": "aprs.fi api key here"} + def command(self, fromcall, message, ack): LOG.info("Location Plugin") # get last location of a callsign, get descriptive name from weather service try: - a = re.search( - r"^.*\s+(.*)", message - ) # optional second argument is a callsign to search + # optional second argument is a callsign to search + a = re.search(r"^.*\s+(.*)", message) if a is not None: searchcall = a.group(1) searchcall = searchcall.upper() else: - searchcall = ( - fromcall # if no second argument, search for calling station - ) + # if no second argument, search for calling station + searchcall = fromcall url = ( "http://api.aprs.fi/api/get?name=" + searchcall @@ -342,3 +406,76 @@ class WeatherPlugin(APRSDPluginBase): reply = "Unable to find you (send beacon?)" return reply + + +class EmailPlugin(APRSDPluginBase): + """Email Plugin.""" + + version = "1.0" + command_regex = "^-.*" + command_name = "email" + + # message_number:time combos so we don't resend the same email in + # five mins {int:int} + email_sent_dict = {} + + def command(self, fromcall, message, ack): + LOG.info("Email COMMAND") + reply = None + + searchstring = "^" + self.config["ham"]["callsign"] + ".*" + # only I can do email + if re.search(searchstring, fromcall): + # digits only, first one is number of emails to resend + r = re.search("^-([0-9])[0-9]*$", message) + if r is not None: + LOG.debug("RESEND EMAIL") + email.resend_email(r.group(1), fromcall) + reply = messaging.NULL_MESSAGE + # -user@address.com body of email + elif re.search(r"^-([A-Za-z0-9_\-\.@]+) (.*)", message): + # (same search again) + a = re.search(r"^-([A-Za-z0-9_\-\.@]+) (.*)", message) + if a is not None: + to_addr = a.group(1) + content = a.group(2) + # send recipient link to aprs.fi map + if content == "mapme": + content = "Click for my location: http://aprs.fi/{}".format( + self.config["ham"]["callsign"] + ) + too_soon = 0 + now = time.time() + # see if we sent this msg number recently + if ack in self.email_sent_dict: + timedelta = now - self.email_sent_dict[ack] + if timedelta < 300: # five minutes + too_soon = 1 + if not too_soon or ack == 0: + LOG.info("Send email '{}'".format(content)) + send_result = email.send_email(to_addr, content) + if send_result != 0: + reply = "-{} failed".format(to_addr) + # messaging.send_message(fromcall, "-" + to_addr + " failed") + else: + # clear email sent dictionary if somehow goes over 100 + if len(self.email_sent_dict) > 98: + LOG.debug( + "DEBUG: email_sent_dict is big (" + + str(len(self.email_sent_dict)) + + ") clearing out." + ) + self.email_sent_dict.clear() + self.email_sent_dict[ack] = now + reply = "mapme email sent" + else: + LOG.info( + "Email for message number " + + ack + + " recently sent, not sending again." + ) + else: + reply = "Bad email address" + # messaging.send_message(fromcall, "Bad email address") + + return reply diff --git a/aprsd/utils.py b/aprsd/utils.py index 5f6ce0c..6259f4b 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -15,7 +15,7 @@ DEFAULT_CONFIG_DICT = { "aprs": { "login": "someusername", "password": "somepassword", - "host": "noam.aprs2.net", + "host": "rotate.aprs.net", "port": 14580, "logfile": "/tmp/arsd.log", }, @@ -152,8 +152,8 @@ def parse_config(config_file): ) check_option(config, "aprs", "login") check_option(config, "aprs", "password") - check_option(config, "aprs", "host") - check_option(config, "aprs", "port") + # check_option(config, "aprs", "host") + # check_option(config, "aprs", "port") check_option(config, "aprs", "logfile", "./aprsd.log") check_option(config, "imap", "host") check_option(config, "imap", "login") diff --git a/dev-requirements.in b/dev-requirements.in index 2bcaa65..dcd980a 100644 --- a/dev-requirements.in +++ b/dev-requirements.in @@ -7,4 +7,3 @@ pep8-naming black isort Sphinx -thesmuggler diff --git a/dev-requirements.txt b/dev-requirements.txt index 5dc6d07..be5c5e4 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -10,7 +10,7 @@ attrs==20.3.0 # via pytest babel==2.9.0 # via sphinx black==20.8b1 # via -r dev-requirements.in certifi==2020.12.5 # via requests -chardet==3.0.4 # via requests +chardet==4.0.0 # via requests click==7.1.2 # via black coverage==5.3 # via pytest-cov distlib==0.3.1 # via virtualenv @@ -40,7 +40,7 @@ pytest-cov==2.10.1 # via -r dev-requirements.in pytest==6.2.0 # via -r dev-requirements.in, pytest-cov pytz==2020.4 # via babel regex==2020.11.13 # via black -requests==2.25.0 # via sphinx +requests>=2.25.0 # via sphinx six==1.15.0 # via tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx==3.3.1 # via -r dev-requirements.in diff --git a/requirements.in b/requirements.in index 05fe54c..63e1739 100644 --- a/requirements.in +++ b/requirements.in @@ -7,3 +7,4 @@ pyyaml six requests thesmuggler +aprslib diff --git a/requirements.txt b/requirements.txt index e287b2b..87fbd06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,9 @@ # # pip-compile # +aprslib==0.6.47 # via -r requirements.in certifi==2020.12.5 # via requests -chardet==3.0.4 # via requests +chardet==4.0.0 # via requests click-completion==0.5.2 # via -r requirements.in click==7.1.2 # via -r requirements.in, click-completion idna==2.10 # via requests @@ -15,7 +16,7 @@ markupsafe==1.1.1 # via jinja2 pbr==5.5.1 # via -r requirements.in pluggy==0.13.1 # via -r requirements.in pyyaml==5.3.1 # via -r requirements.in -requests==2.25.0 # via -r requirements.in +requests>=2.25.0 # via -r requirements.in shellingham==1.3.2 # via click-completion six==1.15.0 # via -r requirements.in, click-completion, imapclient thesmuggler==1.0.1 # via -r requirements.in diff --git a/test-requirements-py2.txt b/test-requirements-py2.txt deleted file mode 100644 index e546d2f..0000000 --- a/test-requirements-py2.txt +++ /dev/null @@ -1,3 +0,0 @@ -flake8 -pytest -mock diff --git a/test-requirements.txt b/test-requirements.txt deleted file mode 100644 index 28ecaca..0000000 --- a/test-requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -flake8 -pytest diff --git a/tests/test_main.py b/tests/test_main.py index a339f03..68989bf 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -4,7 +4,7 @@ import unittest import pytest -from aprsd import main +from aprsd import email if sys.version_info >= (3, 2): from unittest import mock @@ -13,11 +13,11 @@ else: class testMain(unittest.TestCase): - @mock.patch("aprsd.main._imap_connect") - @mock.patch("aprsd.main._smtp_connect") + @mock.patch("aprsd.email._imap_connect") + @mock.patch("aprsd.email._smtp_connect") def test_validate_email(self, imap_mock, smtp_mock): """Test to make sure we fail.""" imap_mock.return_value = None smtp_mock.return_value = {"smaiof": "fire"} - main.validate_email() + email.validate_email() diff --git a/tox.ini b/tox.ini index 12c04bf..1682b3c 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ minversion = 2.9.0 skipdist = True skip_missing_interpreters = true -envlist = py{27,36,37,38},pep8,fmt-check +envlist = pep8,py{36,37,38},fmt-check # Activate isolated build environment. tox will use a virtual environment # to build a source distribution from the source tree. For build tools and @@ -14,7 +14,6 @@ setenv = VIRTUAL_ENV={envdir} usedevelop = True install_command = pip install {opts} {packages} deps = -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt -r{toxinidir}/dev-requirements.txt commands = # Use -bb to enable BytesWarnings as error to catch str/bytes misuse. @@ -23,29 +22,10 @@ commands = # --cov="{envsitepackagesdir}/aprsd" --cov-report=html --cov-report=term {posargs} {envpython} -bb -m pytest {posargs} -[testenv:py27] -setenv = VIRTUAL_ENV={envdir} -usedevelop = True -install_command = pip install {opts} {packages} -deps = -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements-py2.txt -commands = - # Use -bb to enable BytesWarnings as error to catch str/bytes misuse. - # Use -Werror to treat warnings as errors. -# {envpython} -bb -Werror -m pytest \ -# --cov="{envsitepackagesdir}/aprsd" --cov-report=html --cov-report=term {posargs} - {envpython} -bb -Werror -m pytest - [testenv:docs] deps = -r{toxinidir}/test-requirements.txt commands = sphinx-build -b html docs/source docs/html -[testenv:pep8-27] -deps = -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements-py2.txt -commands = - flake8 {posargs} aprsd - [testenv:pep8] commands = flake8 {posargs} aprsd @@ -78,7 +58,6 @@ exclude = .venv,.git,.tox,dist,doc,.ropeproject # This section is not needed if not using GitHub Actions for CI. [gh-actions] python = - 2.7: py27, pep8-27 3.6: py36, pep8, fmt-check 3.7: py38, pep8, fmt-check 3.8: py38, pep8, fmt-check, type-check, docs From fa51f8fdf20bd5578353009cb49cf4fdf7ed71fb Mon Sep 17 00:00:00 2001 From: Hemna Date: Sat, 19 Dec 2020 16:35:53 -0500 Subject: [PATCH 2/2] Big patch This commit adds the new send-message command for sending messages. This also redoes the logging of sent/rx'd packets to a single method which is syncrhonized, so we don't get intermixed log messages for packets. Also adds email address validation during startup, and optionally disables the validation via a command line switch. without email validation for production running aprsd, emails sent can turn up garbage and cause issues when those emails are received by aprsd message processing as invalid content. --- aprsd/email.py | 45 ++++++++++++++- aprsd/main.py | 103 ++++++++++++++++++++++++++++++--- aprsd/messaging.py | 132 +++++++++++++++++++++++++++++++++++++------ aprsd/plugin.py | 7 ++- aprsd/utils.py | 13 +++++ dev-requirements.txt | 5 +- requirements.in | 1 + requirements.txt | 6 +- tests/test_main.py | 3 +- 9 files changed, 280 insertions(+), 35 deletions(-) diff --git a/aprsd/email.py b/aprsd/email.py index 5f27f86..8c270a1 100644 --- a/aprsd/email.py +++ b/aprsd/email.py @@ -10,6 +10,7 @@ from email.mime.text import MIMEText import imapclient import six +from validate_email import validate_email from aprsd import messaging @@ -83,7 +84,43 @@ def _smtp_connect(): return server -def validate_email(): +def validate_shortcuts(config): + shortcuts = config.get("shortcuts", None) + if not shortcuts: + return + + LOG.info( + "Validating {} Email shortcuts. This can take up to 10 seconds" + " per shortcut".format(len(shortcuts)) + ) + delete_keys = [] + for key in shortcuts: + is_valid = validate_email( + email_address=shortcuts[key], + check_regex=True, + check_mx=True, + from_address=config["smtp"]["login"], + helo_host=config["smtp"]["host"], + smtp_timeout=10, + dns_timeout=10, + use_blacklist=False, + debug=False, + ) + if not is_valid: + LOG.error( + "'{}' is an invalid email address. Removing shortcut".format( + shortcuts[key] + ) + ) + delete_keys.append(key) + + for key in delete_keys: + del config["shortcuts"][key] + + LOG.info("Available shortcuts: {}".format(config["shortcuts"])) + + +def validate_email_config(config, disable_validation=False): """function to simply ensure we can connect to email services. This helps with failing early during startup. @@ -93,6 +130,12 @@ def validate_email(): LOG.info("Checking SMTP configuration") smtp_server = _smtp_connect() + # Now validate and flag any shortcuts as invalid + if not disable_validation: + validate_shortcuts(config) + else: + LOG.info("Shortcuts email validation is Disabled!!, you were warned.") + if imap_server and smtp_server: return True else: diff --git a/aprsd/main.py b/aprsd/main.py index 51a40bc..e5f8256 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -159,6 +159,7 @@ def setup_logging(config, loglevel, quiet): fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4) else: fh = NullHandler() + fh.setFormatter(log_formatter) LOG.addHandler(fh) @@ -190,11 +191,9 @@ def process_packet(packet): else: ack = "0" - LOG.debug(" Received message______________") - LOG.debug(" Raw : {}".format(packet["raw"])) - LOG.debug(" From : {}".format(fromcall)) - LOG.debug(" Message : {}".format(message)) - LOG.debug(" Msg number : {}".format(str(ack))) + messaging.log_message( + "Received Message", packet["raw"], message, fromcall=fromcall, ack=ack + ) found_command = False # Get singleton of the PM @@ -239,7 +238,6 @@ def sample_config(): click.echo(yaml.dump(utils.DEFAULT_CONFIG_DICT)) -# main() ### @main.command() @click.option( "--loglevel", @@ -260,7 +258,96 @@ def sample_config(): default=utils.DEFAULT_CONFIG_FILE, help="The aprsd config file to use for options.", ) -def server(loglevel, quiet, config_file): +@click.option( + "--aprs-login", + envvar="APRS_LOGIN", + show_envvar=True, + help="What callsign to send the message from.", +) +@click.option( + "--aprs-password", + envvar="APRS_PASSWORD", + show_envvar=True, + help="the APRS-IS password for APRS_LOGIN", +) +@click.argument("tocallsign") +@click.argument("command", default="location") +def send_message( + loglevel, quiet, config_file, aprs_login, aprs_password, tocallsign, command +): + """Send a message to a callsign via APRS_IS.""" + click.echo("{} {} {} {}".format(aprs_login, aprs_password, tocallsign, command)) + + click.echo("Load config") + config = utils.parse_config(config_file) + if not aprs_login: + click.echo("Must set --aprs_login or APRS_LOGIN") + return + + if not aprs_password: + click.echo("Must set --aprs-password or APRS_PASSWORD") + return + + config["aprs"]["login"] = aprs_login + config["aprs"]["password"] = aprs_password + messaging.CONFIG = config + + setup_logging(config, loglevel, quiet) + LOG.info("APRSD Started version: {}".format(aprsd.__version__)) + + def rx_packet(packet): + LOG.debug("Got packet back {}".format(packet)) + messaging.log_packet(packet) + resp = packet.get("response", None) + if resp == "ack": + sys.exit(0) + + cl = client.Client(config) + messaging.send_message_direct(tocallsign, command) + + try: + # This will register a packet consumer with aprslib + # When new packets come in the consumer will process + # the packet + aprs_client = client.get_client() + aprs_client.consumer(rx_packet, raw=False) + except aprslib.exceptions.ConnectionDrop: + LOG.error("Connection dropped, reconnecting") + time.sleep(5) + # Force the deletion of the client object connected to aprs + # This will cause a reconnect, next time client.get_client() + # is called + cl.reset() + + +# main() ### +@main.command() +@click.option( + "--loglevel", + default="DEBUG", + show_default=True, + type=click.Choice( + ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], case_sensitive=False + ), + show_choices=True, + help="The log level to use for aprsd.log", +) +@click.option("--quiet", is_flag=True, default=False, help="Don't log to stdout") +@click.option( + "--disable-validation", + is_flag=True, + default=False, + help="Disable email shortcut validation. Bad email addresses can result in broken email responses!!", +) +@click.option( + "-c", + "--config", + "config_file", + show_default=True, + default=utils.DEFAULT_CONFIG_FILE, + help="The aprsd config file to use for options.", +) +def server(loglevel, quiet, disable_validation, config_file): """Start the aprsd server process.""" signal.signal(signal.SIGINT, signal_handler) @@ -280,7 +367,7 @@ def server(loglevel, quiet, config_file): # TODO(walt): Make email processing/checking optional? # Maybe someone only wants this to process messages with plugins only. - valid = email.validate_email() + valid = email.validate_email_config(config, disable_validation) if not valid: LOG.error("Failed to validate email config options") sys.exit(-1) diff --git a/aprsd/messaging.py b/aprsd/messaging.py index 24d3d55..9877c91 100644 --- a/aprsd/messaging.py +++ b/aprsd/messaging.py @@ -27,10 +27,14 @@ def send_ack_thread(tocall, ack, retry_count): tocall = tocall.ljust(9) # pad to nine chars line = "{}>APRS::{}:ack{}\n".format(CONFIG["aprs"]["login"], tocall, ack) for i in range(retry_count, 0, -1): - LOG.info("Sending ack __________________ Tx({})".format(i)) - LOG.info("Raw : {}".format(line.rstrip("\n"))) - LOG.info("To : {}".format(tocall)) - LOG.info("Ack number : {}".format(ack)) + log_message( + "Sending ack", + line.rstrip("\n"), + None, + ack=ack, + tocall=tocall, + retry_number=i, + ) cl.sendall(line) # aprs duplicate detection is 30 secs? # (21 only sends first, 28 skips middle) @@ -60,15 +64,13 @@ def send_message_thread(tocall, message, this_message_number, retry_count): LOG.debug("DEBUG: send_message_thread msg:ack combos are: ") LOG.debug(pprint.pformat(ack_dict)) if ack_dict[this_message_number] != 1: - LOG.info( - "Sending message_______________ {}(Tx{})".format( - str(this_message_number), str(i) - ) + log_message( + "Sending Message", + line.rstrip("\n"), + message, + tocall=tocall, + retry_number=i, ) - LOG.info("Raw : {}".format(line.rstrip("\n"))) - LOG.info("To : {}".format(tocall)) - # LOG.info("Message : {}".format(message.encode(errors='ignore'))) - LOG.info("Message : {}".format(message)) # tn.write(line) cl.sendall(line) # decaying repeats, 31 to 93 second intervals @@ -118,6 +120,104 @@ def send_message(tocall, message): # end send_message() +def log_packet(packet): + fromcall = packet.get("from", None) + tocall = packet.get("to", None) + + response_type = packet.get("response", None) + msg = packet.get("message_text", None) + msg_num = packet.get("msgNo", None) + ack = packet.get("ack", None) + + log_message( + "Packet", + packet["raw"], + msg, + fromcall=fromcall, + tocall=tocall, + ack=ack, + packet_type=response_type, + msg_num=msg_num, + ) + + +def log_message( + header, + raw, + message, + tocall=None, + fromcall=None, + msg_num=None, + retry_number=None, + ack=None, + packet_type=None, +): + """ + + Log a message entry. + + This builds a long string with newlines for the log entry, so that + it's thread safe. If we log each item as a separate log.debug() call + Then the message information could get multiplexed with other log + messages. Each python log call is automatically synchronized. + + + """ + + log_list = [""] + if retry_number: + # LOG.info(" {} _______________(TX:{})".format(header, retry_number)) + log_list.append(" {} _______________(TX:{})".format(header, retry_number)) + else: + # LOG.info(" {} _______________".format(header)) + log_list.append(" {} _______________".format(header)) + + # LOG.info(" Raw : {}".format(raw)) + log_list.append(" Raw : {}".format(raw)) + + if packet_type: + # LOG.info(" Packet : {}".format(packet_type)) + log_list.append(" Packet : {}".format(packet_type)) + if tocall: + # LOG.info(" To : {}".format(tocall)) + log_list.append(" To : {}".format(tocall)) + if fromcall: + # LOG.info(" From : {}".format(fromcall)) + log_list.append(" From : {}".format(fromcall)) + + if ack: + # LOG.info(" Ack : {}".format(ack)) + log_list.append(" Ack : {}".format(ack)) + else: + # LOG.info(" Message : {}".format(message)) + log_list.append(" Message : {}".format(message)) + if msg_num: + # LOG.info(" Msg number : {}".format(msg_num)) + log_list.append(" Msg number : {}".format(msg_num)) + # LOG.info(" {} _______________ Complete".format(header)) + log_list.append(" {} _______________ Complete".format(header)) + + LOG.info("\n".join(log_list)) + + +def send_message_direct(tocall, message): + """Send a message without a separate thread.""" + cl = client.get_client() + this_message_number = 1 + fromcall = CONFIG["aprs"]["login"] + line = "{}>APRS::{}:{}{{{}\n".format( + fromcall, + tocall, + message, + str(this_message_number), + ) + LOG.debug("DEBUG: send_message_thread msg:ack combos are: ") + log_message( + "Sending Message", line.rstrip("\n"), message, tocall=tocall, fromcall=fromcall + ) + cl.sendall(line) + + def process_message(line): f = re.search("^(.*)>", line) fromcall = f.group(1) @@ -139,11 +239,9 @@ def process_message(line): # ack not requested, but lets send one as 0 ack_num = "0" - LOG.info("Received message______________") - LOG.info("Raw : " + line) - LOG.info("From : " + fromcall) - LOG.info("Message : " + message) - LOG.info("Msg number : " + str(ack_num)) + log_message( + "Received message", line, message, fromcall=fromcall, msg_num=str(ack_num) + ) return (fromcall, message, ack_num) # end process_message() diff --git a/aprsd/plugin.py b/aprsd/plugin.py index b5b83f8..6b88b7c 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -286,6 +286,7 @@ class LocationPlugin(APRSDPluginBase): response = requests.get(url) # aprs_data = json.loads(response.read()) aprs_data = json.loads(response.text) + LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data)) lat = aprs_data["entries"][0]["lat"] lon = aprs_data["entries"][0]["lng"] try: # altitude not always provided @@ -294,9 +295,9 @@ class LocationPlugin(APRSDPluginBase): alt = 0 altfeet = int(alt * 3.28084) aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"] - aprs_lasttime_seconds = aprs_lasttime_seconds.encode( - "ascii", errors="ignore" - ) # unicode to ascii + # aprs_lasttime_seconds = aprs_lasttime_seconds.encode( + # "ascii", errors="ignore" + # ) # unicode to ascii delta_seconds = time.time() - int(aprs_lasttime_seconds) delta_hours = delta_seconds / 60 / 60 url2 = ( diff --git a/aprsd/utils.py b/aprsd/utils.py index 6259f4b..6d7970b 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -1,8 +1,10 @@ """Utilities and helper functions.""" import errno +import functools import os import sys +import threading import click import yaml @@ -47,6 +49,17 @@ DEFAULT_CONFIG_DICT = { DEFAULT_CONFIG_FILE = "~/.config/aprsd/aprsd.yml" +def synchronized(wrapped): + lock = threading.Lock() + + @functools.wraps(wrapped) + def _wrap(*args, **kwargs): + with lock: + return wrapped(*args, **kwargs) + + return _wrap + + def env(*vars, **kwargs): """This returns the first environment variable set. if none are non-empty, defaults to '' or keyword arg default diff --git a/dev-requirements.txt b/dev-requirements.txt index be5c5e4..087a674 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -37,10 +37,10 @@ pyflakes==2.2.0 # via flake8 pygments==2.7.3 # via sphinx pyparsing==2.4.7 # via packaging pytest-cov==2.10.1 # via -r dev-requirements.in -pytest==6.2.0 # via -r dev-requirements.in, pytest-cov +pytest==6.2.1 # via -r dev-requirements.in, pytest-cov pytz==2020.4 # via babel regex==2020.11.13 # via black -requests>=2.25.0 # via sphinx +requests==2.25.1 # via sphinx six==1.15.0 # via tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx==3.3.1 # via -r dev-requirements.in @@ -50,7 +50,6 @@ sphinxcontrib-htmlhelp==1.0.3 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx -thesmuggler==1.0.1 # via -r dev-requirements.in toml==0.10.2 # via black, pytest, tox tox==3.20.1 # via -r dev-requirements.in typed-ast==1.4.1 # via black, mypy diff --git a/requirements.in b/requirements.in index 63e1739..3d89156 100644 --- a/requirements.in +++ b/requirements.in @@ -8,3 +8,4 @@ six requests thesmuggler aprslib +py3-validate-email diff --git a/requirements.txt b/requirements.txt index 87fbd06..ba02e11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,14 +9,16 @@ certifi==2020.12.5 # via requests chardet==4.0.0 # via requests click-completion==0.5.2 # via -r requirements.in click==7.1.2 # via -r requirements.in, click-completion -idna==2.10 # via requests +dnspython==2.0.0 # via py3-validate-email +filelock==3.0.12 # via py3-validate-email +idna==2.10 # via py3-validate-email, requests imapclient==2.1.0 # via -r requirements.in jinja2==2.11.2 # via click-completion markupsafe==1.1.1 # via jinja2 pbr==5.5.1 # via -r requirements.in pluggy==0.13.1 # via -r requirements.in +py3-validate-email==0.2.12 # via -r requirements.in pyyaml==5.3.1 # via -r requirements.in -requests>=2.25.0 # via -r requirements.in shellingham==1.3.2 # via click-completion six==1.15.0 # via -r requirements.in, click-completion, imapclient thesmuggler==1.0.1 # via -r requirements.in diff --git a/tests/test_main.py b/tests/test_main.py index 68989bf..b6a8b06 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -19,5 +19,6 @@ class testMain(unittest.TestCase): """Test to make sure we fail.""" imap_mock.return_value = None smtp_mock.return_value = {"smaiof": "fire"} + config = mock.MagicMock() - email.validate_email() + email.validate_email_config(config, True)