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..8c270a1 --- /dev/null +++ b/aprsd/email.py @@ -0,0 +1,399 @@ +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 validate_email import validate_email + +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_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. + """ + LOG.info("Checking IMAP configuration") + imap_server = _imap_connect() + 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: + 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..e5f8256 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,21 @@ 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,75 +169,75 @@ 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" + + messaging.log_message( + "Received Message", packet["raw"], message, fromcall=fromcall, ack=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( "--loglevel", @@ -783,146 +258,146 @@ def command_email(fromcall, message, ack): default=utils.DEFAULT_CONFIG_FILE, help="The aprsd config file to use for options.", ) -def server(loglevel, quiet, config_file): - """Start the aprsd server process.""" - global CONFIG +@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)) - 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) + 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__)) - time.sleep(2) - client_sock = setup_connection() - valid = validate_email() + 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) + + 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__)) + + # TODO(walt): Make email processing/checking optional? + # Maybe someone only wants this to process messages with plugins only. + valid = email.validate_email_config(config, disable_validation) 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..9877c91 --- /dev/null +++ b/aprsd/messaging.py @@ -0,0 +1,247 @@ +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_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) + 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_message( + "Sending Message", + line.rstrip("\n"), + message, + tocall=tocall, + retry_number=i, + ) + # 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 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) + 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_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 c99e4cb..6b88b7c 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 @@ -222,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 @@ -230,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 = ( @@ -342,3 +407,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..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 @@ -15,7 +17,7 @@ DEFAULT_CONFIG_DICT = { "aprs": { "login": "someusername", "password": "somepassword", - "host": "noam.aprs2.net", + "host": "rotate.aprs.net", "port": 14580, "logfile": "/tmp/arsd.log", }, @@ -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 @@ -152,8 +165,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..087a674 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 @@ -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 05fe54c..3d89156 100644 --- a/requirements.in +++ b/requirements.in @@ -7,3 +7,5 @@ pyyaml six requests thesmuggler +aprslib +py3-validate-email diff --git a/requirements.txt b/requirements.txt index e287b2b..ba02e11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,18 +4,21 @@ # # 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 +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/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..b6a8b06 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,12 @@ 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"} + config = mock.MagicMock() - main.validate_email() + email.validate_email_config(config, True) 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