1
0
mirror of https://github.com/craigerl/aprsd.git synced 2025-03-10 22:38:33 -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
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 }}

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

23
tox.ini
View File

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