1
0
mirror of https://github.com/craigerl/aprsd.git synced 2025-07-30 12:22:27 -04:00

Merge pull request #23 from craigerl/aprslib

Major refactor
This commit is contained in:
Craig Lamparter 2020-12-19 14:00:01 -08:00 committed by GitHub
commit 923e1a7c3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1171 additions and 858 deletions

View File

@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [2.7, 3.6, 3.7, 3.8, 3.9] python-version: [3.6, 3.7, 3.8, 3.9]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}

63
aprsd/client.py Normal file
View File

@ -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

399
aprsd/email.py Normal file
View File

@ -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

File diff suppressed because it is too large Load Diff

247
aprsd/messaging.py Normal file
View File

@ -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()

View File

@ -1,6 +1,7 @@
# The base plugin class # The base plugin class
import abc import abc
import fnmatch import fnmatch
import importlib
import inspect import inspect
import json import json
import logging import logging
@ -14,6 +15,7 @@ import requests
import six import six
from thesmuggler import smuggle from thesmuggler import smuggle
from aprsd import email, messaging
from aprsd.fuzzyclock import fuzzy from aprsd.fuzzyclock import fuzzy
# setup the global logger # setup the global logger
@ -23,81 +25,42 @@ hookspec = pluggy.HookspecMarker("aprsd")
hookimpl = pluggy.HookimplMarker("aprsd") hookimpl = pluggy.HookimplMarker("aprsd")
CORE_PLUGINS = [ CORE_PLUGINS = [
"FortunePlugin", "aprsd.plugin.EmailPlugin",
"LocationPlugin", "aprsd.plugin.FortunePlugin",
"PingPlugin", "aprsd.plugin.LocationPlugin",
"TimePlugin", "aprsd.plugin.PingPlugin",
"WeatherPlugin", "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): class PluginManager(object):
def __init__(self, config): # The singleton instance object for this class
self.obj_list = [] _instance = None
self.config = config
# 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) dir_path = os.path.realpath(module_path)
pattern = "*.py" pattern = "*.py"
@ -123,6 +86,108 @@ class PluginManager(object):
return False 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: class APRSDCommandSpec:
"""A hook specification namespace.""" """A hook specification namespace."""
@ -184,7 +249,6 @@ class FortunePlugin(APRSDPluginBase):
["/usr/games/fortune", "-s", "-n 60"], stdout=subprocess.PIPE ["/usr/games/fortune", "-s", "-n 60"], stdout=subprocess.PIPE
) )
reply = process.communicate()[0] reply = process.communicate()[0]
# send_message(fromcall, reply.rstrip())
reply = reply.decode(errors="ignore").rstrip() reply = reply.decode(errors="ignore").rstrip()
except Exception as ex: except Exception as ex:
reply = "Fortune command failed '{}'".format(ex) reply = "Fortune command failed '{}'".format(ex)
@ -200,20 +264,20 @@ class LocationPlugin(APRSDPluginBase):
command_regex = "^[lL]" command_regex = "^[lL]"
command_name = "location" command_name = "location"
config_items = {"apikey": "aprs.fi api key here"}
def command(self, fromcall, message, ack): def command(self, fromcall, message, ack):
LOG.info("Location Plugin") LOG.info("Location Plugin")
# get last location of a callsign, get descriptive name from weather service # get last location of a callsign, get descriptive name from weather service
try: try:
a = re.search( # optional second argument is a callsign to search
r"^.*\s+(.*)", message a = re.search(r"^.*\s+(.*)", message)
) # optional second argument is a callsign to search
if a is not None: if a is not None:
searchcall = a.group(1) searchcall = a.group(1)
searchcall = searchcall.upper() searchcall = searchcall.upper()
else: else:
searchcall = ( # if no second argument, search for calling station
fromcall # if no second argument, search for calling station searchcall = fromcall
)
url = ( url = (
"http://api.aprs.fi/api/get?name=" "http://api.aprs.fi/api/get?name="
+ searchcall + searchcall
@ -222,6 +286,7 @@ class LocationPlugin(APRSDPluginBase):
response = requests.get(url) response = requests.get(url)
# aprs_data = json.loads(response.read()) # aprs_data = json.loads(response.read())
aprs_data = json.loads(response.text) aprs_data = json.loads(response.text)
LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
lat = aprs_data["entries"][0]["lat"] lat = aprs_data["entries"][0]["lat"]
lon = aprs_data["entries"][0]["lng"] lon = aprs_data["entries"][0]["lng"]
try: # altitude not always provided try: # altitude not always provided
@ -230,9 +295,9 @@ class LocationPlugin(APRSDPluginBase):
alt = 0 alt = 0
altfeet = int(alt * 3.28084) altfeet = int(alt * 3.28084)
aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"] aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"]
aprs_lasttime_seconds = aprs_lasttime_seconds.encode( # aprs_lasttime_seconds = aprs_lasttime_seconds.encode(
"ascii", errors="ignore" # "ascii", errors="ignore"
) # unicode to ascii # ) # unicode to ascii
delta_seconds = time.time() - int(aprs_lasttime_seconds) delta_seconds = time.time() - int(aprs_lasttime_seconds)
delta_hours = delta_seconds / 60 / 60 delta_hours = delta_seconds / 60 / 60
url2 = ( url2 = (
@ -342,3 +407,76 @@ class WeatherPlugin(APRSDPluginBase):
reply = "Unable to find you (send beacon?)" reply = "Unable to find you (send beacon?)"
return reply 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

View File

@ -1,8 +1,10 @@
"""Utilities and helper functions.""" """Utilities and helper functions."""
import errno import errno
import functools
import os import os
import sys import sys
import threading
import click import click
import yaml import yaml
@ -15,7 +17,7 @@ DEFAULT_CONFIG_DICT = {
"aprs": { "aprs": {
"login": "someusername", "login": "someusername",
"password": "somepassword", "password": "somepassword",
"host": "noam.aprs2.net", "host": "rotate.aprs.net",
"port": 14580, "port": 14580,
"logfile": "/tmp/arsd.log", "logfile": "/tmp/arsd.log",
}, },
@ -47,6 +49,17 @@ DEFAULT_CONFIG_DICT = {
DEFAULT_CONFIG_FILE = "~/.config/aprsd/aprsd.yml" 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): def env(*vars, **kwargs):
"""This returns the first environment variable set. """This returns the first environment variable set.
if none are non-empty, defaults to '' or keyword arg default 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", "login")
check_option(config, "aprs", "password") check_option(config, "aprs", "password")
check_option(config, "aprs", "host") # check_option(config, "aprs", "host")
check_option(config, "aprs", "port") # check_option(config, "aprs", "port")
check_option(config, "aprs", "logfile", "./aprsd.log") check_option(config, "aprs", "logfile", "./aprsd.log")
check_option(config, "imap", "host") check_option(config, "imap", "host")
check_option(config, "imap", "login") check_option(config, "imap", "login")

View File

@ -7,4 +7,3 @@ pep8-naming
black black
isort isort
Sphinx Sphinx
thesmuggler

View File

@ -10,7 +10,7 @@ attrs==20.3.0 # via pytest
babel==2.9.0 # via sphinx babel==2.9.0 # via sphinx
black==20.8b1 # via -r dev-requirements.in black==20.8b1 # via -r dev-requirements.in
certifi==2020.12.5 # via requests certifi==2020.12.5 # via requests
chardet==3.0.4 # via requests chardet==4.0.0 # via requests
click==7.1.2 # via black click==7.1.2 # via black
coverage==5.3 # via pytest-cov coverage==5.3 # via pytest-cov
distlib==0.3.1 # via virtualenv distlib==0.3.1 # via virtualenv
@ -37,10 +37,10 @@ pyflakes==2.2.0 # via flake8
pygments==2.7.3 # via sphinx pygments==2.7.3 # via sphinx
pyparsing==2.4.7 # via packaging pyparsing==2.4.7 # via packaging
pytest-cov==2.10.1 # via -r dev-requirements.in 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 pytz==2020.4 # via babel
regex==2020.11.13 # via black 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 six==1.15.0 # via tox, virtualenv
snowballstemmer==2.0.0 # via sphinx snowballstemmer==2.0.0 # via sphinx
sphinx==3.3.1 # via -r dev-requirements.in 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-jsmath==1.0.1 # via sphinx
sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-qthelp==1.0.3 # via sphinx
sphinxcontrib-serializinghtml==1.1.4 # 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 toml==0.10.2 # via black, pytest, tox
tox==3.20.1 # via -r dev-requirements.in tox==3.20.1 # via -r dev-requirements.in
typed-ast==1.4.1 # via black, mypy typed-ast==1.4.1 # via black, mypy

View File

@ -7,3 +7,5 @@ pyyaml
six six
requests requests
thesmuggler thesmuggler
aprslib
py3-validate-email

View File

@ -4,18 +4,21 @@
# #
# pip-compile # pip-compile
# #
aprslib==0.6.47 # via -r requirements.in
certifi==2020.12.5 # via requests 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-completion==0.5.2 # via -r requirements.in
click==7.1.2 # via -r requirements.in, click-completion 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 imapclient==2.1.0 # via -r requirements.in
jinja2==2.11.2 # via click-completion jinja2==2.11.2 # via click-completion
markupsafe==1.1.1 # via jinja2 markupsafe==1.1.1 # via jinja2
pbr==5.5.1 # via -r requirements.in pbr==5.5.1 # via -r requirements.in
pluggy==0.13.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 pyyaml==5.3.1 # via -r requirements.in
requests==2.25.0 # via -r requirements.in
shellingham==1.3.2 # via click-completion shellingham==1.3.2 # via click-completion
six==1.15.0 # via -r requirements.in, click-completion, imapclient six==1.15.0 # via -r requirements.in, click-completion, imapclient
thesmuggler==1.0.1 # via -r requirements.in thesmuggler==1.0.1 # via -r requirements.in

View File

@ -1,3 +0,0 @@
flake8
pytest
mock

View File

@ -1,2 +0,0 @@
flake8
pytest

View File

@ -4,7 +4,7 @@ import unittest
import pytest import pytest
from aprsd import main from aprsd import email
if sys.version_info >= (3, 2): if sys.version_info >= (3, 2):
from unittest import mock from unittest import mock
@ -13,11 +13,12 @@ else:
class testMain(unittest.TestCase): class testMain(unittest.TestCase):
@mock.patch("aprsd.main._imap_connect") @mock.patch("aprsd.email._imap_connect")
@mock.patch("aprsd.main._smtp_connect") @mock.patch("aprsd.email._smtp_connect")
def test_validate_email(self, imap_mock, smtp_mock): def test_validate_email(self, imap_mock, smtp_mock):
"""Test to make sure we fail.""" """Test to make sure we fail."""
imap_mock.return_value = None imap_mock.return_value = None
smtp_mock.return_value = {"smaiof": "fire"} smtp_mock.return_value = {"smaiof": "fire"}
config = mock.MagicMock()
main.validate_email() email.validate_email_config(config, True)

23
tox.ini
View File

@ -2,7 +2,7 @@
minversion = 2.9.0 minversion = 2.9.0
skipdist = True skipdist = True
skip_missing_interpreters = 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 # Activate isolated build environment. tox will use a virtual environment
# to build a source distribution from the source tree. For build tools and # to build a source distribution from the source tree. For build tools and
@ -14,7 +14,6 @@ setenv = VIRTUAL_ENV={envdir}
usedevelop = True usedevelop = True
install_command = pip install {opts} {packages} install_command = pip install {opts} {packages}
deps = -r{toxinidir}/requirements.txt deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
-r{toxinidir}/dev-requirements.txt -r{toxinidir}/dev-requirements.txt
commands = commands =
# Use -bb to enable BytesWarnings as error to catch str/bytes misuse. # 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} # --cov="{envsitepackagesdir}/aprsd" --cov-report=html --cov-report=term {posargs}
{envpython} -bb -m pytest {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] [testenv:docs]
deps = -r{toxinidir}/test-requirements.txt deps = -r{toxinidir}/test-requirements.txt
commands = sphinx-build -b html docs/source docs/html 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] [testenv:pep8]
commands = commands =
flake8 {posargs} aprsd 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. # This section is not needed if not using GitHub Actions for CI.
[gh-actions] [gh-actions]
python = python =
2.7: py27, pep8-27
3.6: py36, pep8, fmt-check 3.6: py36, pep8, fmt-check
3.7: py38, pep8, fmt-check 3.7: py38, pep8, fmt-check
3.8: py38, pep8, fmt-check, type-check, docs 3.8: py38, pep8, fmt-check, type-check, docs