Skip to content

Commit

Permalink
feat: new core plugins (#79)
Browse files Browse the repository at this point in the history
* feat: new core plugins

This adds new core plugins for Help, Webserver, and Health and
supporting code to make it possible to load them.

I had to overload some of the Err backend methods and monkey patch some
methods in the PluginManager. Eventually, I'd like to upstream these
changes and this will no longer be needed

* fix: skip loading plugs if backend = True

This allows setting a new flag in the Core section of the .plug file to
indicate a plugin is a backend. These backend plugins are then skipped
when loaded by _load_plugins_generic

This code could eventually be upstreamed into Errbot to allow it to
handle multiple plugins per module easier
  • Loading branch information
andrewthetechie authored May 9, 2024
1 parent 458697d commit 70a61f4
Show file tree
Hide file tree
Showing 13 changed files with 463 additions and 21 deletions.
13 changes: 13 additions & 0 deletions aprs_backend/APRSHealth.plug
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[Core]
Name = APRSHealth
Module = aprs_core_plugins
Core = True

[Documentation]
Description = APRS Friendly Health Plugin

[Python]
Version = 3

[Errbot]
Min=6.2.0
13 changes: 13 additions & 0 deletions aprs_backend/APRSHelp.plug
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[Core]
Name = APRSHelp
Module = aprs_core_plugins
Core = True

[Documentation]
Description = APRS Friendly Help Plugin

[Python]
Version = 3

[Errbot]
Min=6.2.0
13 changes: 13 additions & 0 deletions aprs_backend/APRSWebserver.plug
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[Core]
Name = APRSWebserver
Module = aprs_core_plugins
Core = True

[Documentation]
Description = APRS Friendly Web Plugin

[Python]
Version = 3

[Errbot]
Min=6.2.0
7 changes: 7 additions & 0 deletions aprs_backend/aprs.plug
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
[Core]
Name = APRS
Module = aprs
Backend = True

[Documentation]
Description = Backend for APRS

[Python]
Version = 3

[Errbot]
Min=6.2.0
45 changes: 29 additions & 16 deletions aprs_backend/aprs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from aprs_backend.version import __version__ as ERR_APRS_VERSION
from errbot.backends.base import Message
from errbot.backends.base import ONLINE
from errbot.plugin_manager import BotPluginManager
from errbot.core import ErrBot
from aprs_backend.exceptions import ProcessorError, PacketParseError, APRSISConnnectError
from aprs_backend.packets.parser import parse, hash_packet
Expand All @@ -16,12 +17,16 @@
from aprs_backend.utils.counter import MessageCounter
from random import randint
from datetime import datetime

from better_profanity import profanity
from aprs_backend.clients.aprs_registry import APRSRegistryClient, RegistryAppConfig
import logging
import asyncio
from errbot.version import VERSION as ERR_VERSION
from aprs_backend.clients.beacon import BeaconConfig, BeaconClient
from aprs_backend.utils.plugins import _load_plugins_generic, activate_non_started_plugins
from types import MethodType


log = logging.getLogger(__name__)

Expand All @@ -33,9 +38,9 @@

class APRSBackend(ErrBot):
def __init__(self, config):
log.debug("Initied")
super().__init__(config)
log.debug("Init called")

self._errbot_config = config
self._multiline = False

aprs_config = {"host": "rotate.aprs.net", "port": 14580}
Expand Down Expand Up @@ -64,9 +69,6 @@ def __init__(self, config):
self._send_queue: asyncio.Queue[MessagePacket] = asyncio.Queue(
maxsize=int(self._get_from_config("APRS_SEND_MAX_QUEUE", "2048"))
)
self.help_text = self._get_from_config(
"APRS_HELP_TEXT", f"Errbot {ERR_VERSION} & err-aprs-backend {ERR_APRS_VERSION} by {aprs_config['callsign']}"
)

self._message_counter = MessageCounter(initial_value=randint(1, 20)) # nosec not used cryptographically
self._max_dropped_packets = int(self._get_from_config("APRS_MAX_DROPPED_PACKETS", "25"))
Expand Down Expand Up @@ -125,10 +127,23 @@ def __init__(self, config):
)
else:
self.beacon_client = None
super().__init__(config)

def attach_plugin_manager(self, plugin_manager: BotPluginManager | None) -> None:
"""Modified attach_plugin_manager that patches the plugin manager
_log_plugins_generic is modified to remove a check on multiple plugin classes in
a single module
"""
log.debug("In aprs-backend attach_plugin_manager")
if plugin_manager is not None:
log.debug("Patching plugin manager with custom _load_plugins_generic")
funcType = MethodType
plugin_manager._load_plugins_generic = funcType(_load_plugins_generic, plugin_manager)
plugin_manager.activate_non_started_plugins = funcType(activate_non_started_plugins, plugin_manager)
self.plugin_manager = plugin_manager

def _get_from_config(self, key: str, default: any = None) -> any:
return getattr(self._errbot_config, key, default)
return getattr(self.bot_config, key, default)

def _get_beacon_config(self) -> BeaconConfig | None:
if self._get_from_config("APRS_BEACON_ENABLE", "false") == "true":
Expand Down Expand Up @@ -317,6 +332,13 @@ async def receive_worker(self) -> bool:
return False

async def async_serve_once(self) -> bool:
"""The async portion of serve once
Starts the bot tasks for receiving aprs messages, sending messages, and retrying
"""
log.debug(
"Bot plugins: %s", [plugin.__class__.__name__ for plugin in self.plugin_manager.get_all_active_plugins()]
)
receive_task = asyncio.create_task(self.receive_worker())

worker_tasks = [asyncio.create_task(self.send_worker()), asyncio.create_task(self.retry_worker())]
Expand Down Expand Up @@ -426,13 +448,6 @@ async def __drop_message_from_waiting(self, message_hash: str) -> None:
else:
log.debug("Dropped Packet from waiting_ack: %s", packet)

def handle_help(self, msg: APRSMessage) -> None:
"""Returns simplified help text for the APRS backend"""
help_msg = APRSMessage(body=self.help_text, extras=msg.extras)
help_msg.to = msg.frm
help_msg.frm = self.bot_identifier
self.send_message(help_msg)

async def _process_message(self, packet: MessagePacket) -> None:
"""
Check if this message is a dupe of one the bot is already processing
Expand All @@ -449,8 +464,6 @@ async def _process_message(self, packet: MessagePacket) -> None:
self._packet_cache[this_packet_hash] = packet
msg = APRSMessage.from_message_packet(packet)
msg.body = msg.body.strip("\n").strip("\r")
if msg.body.lower().strip(" ") == "help":
return self.handle_help(msg)
return self.callback_message(msg)

async def _ack_message(self, packet: MessagePacket) -> None:
Expand Down
5 changes: 5 additions & 0 deletions aprs_backend/aprs_core_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from aprs_backend.plugins import APRSHelp
from aprs_backend.plugins import APRSWebserver
from aprs_backend.plugins import APRSHealth

__all__ = ["APRSHelp", "APRSWebserver", "APRSHealth"]
5 changes: 5 additions & 0 deletions aprs_backend/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from aprs_backend.plugins.help import APRSHelp
from aprs_backend.plugins.web import APRSWebserver
from aprs_backend.plugins.health import APRSHealth

__all__ = ["APRSHelp", "APRSWebserver", "APRSHealth"]
64 changes: 64 additions & 0 deletions aprs_backend/plugins/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import gc
from datetime import datetime

from errbot import BotPlugin, webhook
from errbot.utils import format_timedelta


class APRSHealth(BotPlugin):
"""Customized health plugin that shifts most of the outputs to webhooks and removes the botcmds"""

@webhook
def status(self, _):
"""If I am alive I should be able to respond to this one"""
pm = self._bot.plugin_manager
all_blacklisted = pm.get_blacklisted_plugin()
all_loaded = pm.get_all_active_plugin_names()
all_attempted = sorted(pm.plugin_infos.keys())
plugins_statuses = []
for name in all_attempted:
if name in all_blacklisted:
if name in all_loaded:
plugins_statuses.append(("BA", name))
else:
plugins_statuses.append(("BD", name))
elif name in all_loaded:
plugins_statuses.append(("A", name))
elif (
pm.get_plugin_obj_by_name(name) is not None
and pm.get_plugin_obj_by_name(name).get_configuration_template() is not None
and pm.get_plugin_configuration(name) is None
):
plugins_statuses.append(("C", name))
else:
plugins_statuses.append(("D", name))
loads = self.status_load("")
gc = self.status_gc("")

return {
"plugins_statuses": plugins_statuses,
"loads": loads["loads"],
"gc": gc["gc"],
}

@webhook
def status_load(self, _):
"""shows the load status"""
try:
from posix import getloadavg

loads = getloadavg()
except Exception:
loads = None

return {"loads": loads}

@webhook
def status_gc(self, _):
"""shows the garbage collection details"""
return {"gc": gc.get_count()}

@webhook
def uptime(self, _):
"""Return the uptime of the bot"""
return {"up": format_timedelta(datetime.now() - self._bot.startup_time), "since": self._bot.startup_time}
23 changes: 23 additions & 0 deletions aprs_backend/plugins/help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from errbot import BotPlugin, botcmd


class APRSHelp(BotPlugin):
"""An alternative help plugin.
For now, it simply replies with preconfigured help text.
In the future, it would be great to use the internal webserver to serve
help text or generate it as a static file that could be served via
static site serving
"""

def __init__(self, bot, name: str = "Help") -> None:
"""
Calls super init and adds a few plugin variables of our own. This makes PEP8 happy
"""
super().__init__(bot, name)
self.help_text = getattr(self._bot.bot_config, "APRS_HELP_TEXT")

@botcmd
def help(self, _, __):
return self.help_text
65 changes: 65 additions & 0 deletions aprs_backend/plugins/web.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import logging
from threading import Thread

from webtest import TestApp
from werkzeug.serving import ThreadedWSGIServer

from errbot import BotPlugin, webhook
from errbot.core_plugins import flask_app


class APRSWebserver(BotPlugin):
def __init__(self, *args, **kwargs):
self.server = None
self.server_thread = None
self.ssl_context = None
self.test_app = TestApp(flask_app)
# TODO: Make this configurable in the APRS bot config, since there's no plugin config anymore
self.web_config = {"HOST": "0.0.0.0", "PORT": 3141} # nosec
super().__init__(*args, **kwargs)

def activate(self):
if self.server_thread and self.server_thread.is_alive():
raise Exception("Invalid state, you should not have a webserver already running.")
self.server_thread = Thread(target=self.run_server, name="Webserver Thread")
self.server_thread.start()
self.log.debug("Webserver started.")

super().activate()

def deactivate(self):
if self.server is not None:
self.log.info("Shutting down the internal webserver.")
self.server.shutdown()
self.log.info("Waiting for the webserver thread to quit.")
self.server_thread.join()
self.log.info("Webserver shut down correctly.")
super().deactivate()

def run_server(self):
host = self.web_config["HOST"]
port = self.web_config["PORT"]
self.log.info("Starting the webserver on %s:%i", host, port)
try:
self.server = ThreadedWSGIServer(
host,
port,
flask_app,
)
wsgi_log = logging.getLogger("werkzeug")
wsgi_log.setLevel(self.bot_config.BOT_LOG_LEVEL)
self.server.serve_forever()
except KeyboardInterrupt:
self.log.info("Keyboard interrupt, request a global shutdown.")
self.server.shutdown()
except Exception as exc:
self.log.exception("Exception with webserver: %s", exc)
self.log.debug("Webserver stopped")

@webhook
def echo(self, incoming_request):
"""
A simple test webhook
"""
self.log.debug("Your incoming request is: %s", incoming_request)
return str(incoming_request)
Loading

0 comments on commit 70a61f4

Please sign in to comment.