Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for APRS Service Registry #57

Merged
merged 2 commits into from
Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ repos:
- id: debug-statements
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/charliermarsh/ruff-pre-commit
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: "v0.3.3"
rev: v0.4.2
hooks:
# Run the linter.
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
types: [python]
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/rhysd/actionlint
rev: v1.6.27
hooks:
Expand Down
21 changes: 21 additions & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# err-aprs-backend config options

These options can be set in your errbot config.py to configure the APRS backend.

* APRS_FROM_CALLSIGN - default is your bot identity callsign, but you can set to reply as a different callsign
* APRS_LISTENED_CALLSIGNS - default (), set of callsigns to listen to
* APRS_HELP_TEXT - default "APRSBot,Errbot & err-aprs-backend", set this to your text. Probably a good idea to set it to website for complex help text due to message character limits
* APRS_MAX_DROPPED_PACKETS - default "25", how many packets we can drop before the bot backend will restart
* APRS_MAX_CACHED_PACKETS - default "2048", how many packets to hold in the cache to dedupe.
* APRS_MAX_AGE_CACHED_PACETS_SECONDS - default "3600", how long to hold onto a package in the cache for deduping
* APRS_MESSAGE_MAX_RETRIES - default "7", how many times to retry sending a message if the bot does do not get an ack or a rej
* APRS_MESSAGE_RETRY_WAIT - default "90", how many seconds to wait between retrying message sending
* APRS_STRIP_NEWLINES - default "true", strip newlines out of plugin responses, probably best to leave it as true
* APRS_LANGUAGE_FILTER - default "true", attempts to strip any profanity out of a message before sending it so the FCC doesn't get mad. Not a smart filter, very brute force. You are still responsible for what you transmit!
* APRS_LANGUAGE_FILTER_EXTRA_WORDS - default [], list of extra words to drop as profanity.
* APRS_REGISTRY_ENABLED - default "false", if true, will enable reporting to the APRS Service Registry https://aprs.hemna.com/
* APRS_REGISTRY_URL - default "https://aprs.hemna.com/api/v1/registry", the APRS registry to report your service
* APRS_REGISTRY_FREQUENCY_SECONDS - default "3600", how often in seconds to report your service to the APRS registry
* APRS_REGISTRY_DESCRIPTION - default "err-aprs-backend powered bot", description for your bot in the Service Regsitry
* APRS_REGISTRY_WEBSTIRE - default "", website for your service on the APRS registry
* APRS_REGISTRY_SOFTWARE - default "err-aprs-backend {version} errbot {errbot version}", software string for APRS service registry
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ BOT_IDENTITY = {
}
```

See [CONFIG.md](CONFIG.md) for more configuration options

### Disable the default plugins

The default plugins allow configuring the bot, installing plugins, and returing detailed help information. These don't work well over APRS because
Expand All @@ -92,7 +94,7 @@ SUPPRESS_CMD_NOT_FOUND = True

### Bot Prefix
You can leave your bot prefix on, but that's just extra characters. I prefer to make it optional so users don't have to
send it on every commadn
send it on every command

```python
BOT_PREFIX_OPTIONAL_ON_CHAT = True
Expand Down
3 changes: 2 additions & 1 deletion aprs_backend/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .aprs import APRSBackend
from .version import __version__

__all__ = ["APRSBackend"]
__all__ = ["APRSBackend", "__version__"]
115 changes: 70 additions & 45 deletions aprs_backend/aprs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from aprs_backend.clients import APRSISClient
from aprs_backend.person import APRSPerson
from aprs_backend.room import APRSRoom
from aprs_backend.version import __version__ as ERR_APRS_VERSION
from errbot.backends.base import Message
from errbot.backends.base import ONLINE
from errbot.core import ErrBot
Expand All @@ -18,20 +19,18 @@
from random import randint
from datetime import datetime
from better_profanity import profanity
from errbot import botcmd

from aprs_backend.clients.aprs_registry import APRSRegistryClient, RegistryAppConfig
import logging
import asyncio
from errbot.version import VERSION as ERR_VERSION


log = logging.getLogger(__name__)

for handler in log.handlers:
handler.setFormatter(logging.Formatter('%(filename)s: '
'%(levelname)s: '
'%(funcName)s(): '
'%(lineno)d:\t'
'%(message)s'))
handler.setFormatter(
logging.Formatter("%(filename)s: " "%(levelname)s: " "%(funcName)s(): " "%(lineno)d:\t" "%(message)s")
)


class ProcessorError(APRSBackendException):
Expand Down Expand Up @@ -63,40 +62,61 @@ def __init__(self, config):
self._multiline = False
self._client = APRSISClient(**aprs_config, logger=log)
self._send_queue = asyncio.Queue(maxsize=int(getattr(self._errbot_config, "APRS_SEND_MAX_QUEUE", "2048")))
self.help_text = getattr(self._errbot_config, "APRS_HELP_TEXT", "APRS Bot provided by Errbot.")
self.help_text = getattr(self._errbot_config, "APRS_HELP_TEXT", "APRSBot,Errbot & err-aprs-backend")

self._message_counter = MessageCounter(initial_value=randint(1, 20)) # nosec not used cryptographically
self._max_dropped_packets = int(getattr(self._errbot_config, "APRS_MAX_DROPPED_PACKETS", "25"))
self._max_cached_packets = int(getattr(self._errbot_config, "APRS_MAX_CACHED_PACKETS", "2048"))
self._message_max_retry = int(getattr(self._errbot_config, "APRS_MESSAGE_MAX_RETRIES", "7"))
self._message_retry_wait = int(getattr(self._errbot_config, "APRS_MESSAGE_RETRY_WAIT", "90"))
self._max_dropped_packets = int(self._get_from_config("APRS_MAX_DROPPED_PACKETS", "25"))
self._max_cached_packets = int(self._get_from_config("APRS_MAX_CACHED_PACKETS", "2048"))
self._message_max_retry = int(self._get_from_config("APRS_MESSAGE_MAX_RETRIES", "7"))
self._message_retry_wait = int(self._get_from_config("APRS_MESSAGE_RETRY_WAIT", "90"))

# strip newlines out of plugin responses before sending to aprs, probably best to leave it true, nothing in aprs will handle
# a stray newline
self._strip_newlines = str(getattr(self._errbot_config, "APRS_STRIP_NEWLINES", "true")).lower() == "true"
self._strip_newlines = str(self._get_from_config("APRS_STRIP_NEWLINES", "true")).lower() == "true"

# try to strip out "foul" language the FCC would not like. It is possible/probably an errbot bot response could
# go out over the airwaves. This is configurable, but probably should remain on.
self._language_filter = str(getattr(self._errbot_config, "APRS_LANGUAGE_FILTER", "true")).lower() == "true"
self._language_filter = str(self._get_from_config("APRS_LANGUAGE_FILTER", "true")).lower() == "true"
if self._language_filter:
profanity.load_censor_words(getattr(self._errbot_config, "APRS_LANGUAGE_FILTER_EXTRA_WORDS", []))
profanity.load_censor_words(self._get_from_config("APRS_LANGUAGE_FILTER_EXTRA_WORDS", []))

self._max_age_cached_packets_seconds = int(getattr(self._errbot_config, "APRS_MAX_AGE_CACHED_PACETS_SECONDS", "3600"))
self._packet_cache = ExpiringDict(max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds)
self._max_age_cached_packets_seconds = int(self._get_from_config("APRS_MAX_AGE_CACHED_PACETS_SECONDS", "3600"))
self._packet_cache = ExpiringDict(
max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds
)
self._packet_cache_lock = asyncio.Lock()
self._waiting_ack = ExpiringDict(max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds)
self._waiting_ack = ExpiringDict(
max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds
)
self._waiting_ack_lock = asyncio.Lock()

super().__init__(config)
self.registry_enabled = self._get_from_config("APRS_REGISTRY_ENABLED", "false").lower() == "true"
if self.registry_enabled:
self.registry_app_config = RegistryAppConfig(
description=self._get_from_config("APRS_REGISTRY_DESCRIPTION", "err-aprs-backend powered bot"),
website=self._get_from_config("APRS_REGISTRY_WEBSITE", ""),
listening_callsigns=[self.from_call] + [call for call in self.listened_callsigns],
software=self._get_from_config(
"APRS_REGISTRY_SOFTWARE", f"err-aprs-backend {ERR_APRS_VERSION} errbot {ERR_VERSION}"
),
)
if (registry_software := self._get_from_config("APRS_REGISTRY_SOFTWARE", None)) is not None:
self.registry_app_config.software = registry_software
self.registry_client = APRSRegistryClient(
registry_url=self._get_from_config("APRS_REGISTRY_URL", "https://aprs.hemna.com/api/v1/registry"),
log=log,
frequency_seconds=int(self._get_from_config("APRS_REGISTRY_FREQUENCY_SECONDS", "3600")),
app_config=self.registry_app_config,
)
else:
self.registry_client = None

@botcmd
def testcmd(self, msg: Message) -> str:
return "test successful"
super().__init__(config)

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

def build_reply(
self, msg: Message, text: str, private: bool = False, threaded: bool = False
) -> Message:
def build_reply(self, msg: Message, text: str, private: bool = False, threaded: bool = False) -> Message:
log.debug(msg)
reply = Message(
body=text,
Expand Down Expand Up @@ -161,7 +181,6 @@ async def retry_worker(self) -> None:
# release the loop for a bit longer after we've gone through all keys
await asyncio.sleep(0.1)


async def send_worker(self) -> None:
"""Processes self._send_queue to send messages to APRS"""
log.debug("send_worker started")
Expand All @@ -184,8 +203,7 @@ async def send_worker(self) -> None:
await asyncio.sleep(0.01)

async def receive_worker(self) -> bool:
"""_summary_
"""
"""_summary_"""
log.debug("Receive worker started")
try:
await self._client.connect()
Expand All @@ -211,13 +229,25 @@ async def receive_worker(self) -> bool:
if parsed_packet.to == self.callsign or parsed_packet.to in self.listened_callsigns:
await self.process_packet(parsed_packet)
else:
log.info("Packet was not addressed to bot or listened callsigns, not processing %s", packet_str)
log.info(
"Packet was not addressed to bot or listened callsigns, not processing %s", packet_str
)
else:
log.info("This packet parsed to be None: %s", packet_str)
except PacketParseError as exc:
log.error("Dropping packet %s due to Parsing error: %s. Total Dropped Packets: %s", packet_str, exc, self._dropped_packets)
log.error(
"Dropping packet %s due to Parsing error: %s. Total Dropped Packets: %s",
packet_str,
exc,
self._dropped_packets,
)
except ProcessorError as exc:
log.err("Dropping packet %s due to Processor error: %s. Total Dropped Packets: %s", packet_str, exc, self._dropped_packets)
log.err(
"Dropping packet %s due to Processor error: %s. Total Dropped Packets: %s",
packet_str,
exc,
self._dropped_packets,
)
finally:
self._dropped_packets += 1
if self._dropped_packets > self._max_dropped_packets:
Expand All @@ -232,10 +262,10 @@ async def receive_worker(self) -> bool:
async def async_serve_once(self) -> bool:
receive_task = asyncio.create_task(self.receive_worker())

worker_tasks = [
asyncio.create_task(self.send_worker()),
asyncio.create_task(self.retry_worker())
]
worker_tasks = [asyncio.create_task(self.send_worker()), asyncio.create_task(self.retry_worker())]
# if reporting to the aprs service registry is enabled, start a task for it
if self.registry_client is not None:
worker_tasks.append(asyncio.create_task(self.registry_client()))
result = await asyncio.gather(receive_task, return_exceptions=True)
await self._send_queue.join()
for task in worker_tasks:
Expand Down Expand Up @@ -281,7 +311,7 @@ def send_message(self, msg: Message) -> None:
addresse=msg.to.callsign,
message_text=msg_text,
msgNo=msgNo,
last_send_attempt=last_send_attempt
last_send_attempt=last_send_attempt,
)
msg_packet._build_raw()
try:
Expand Down Expand Up @@ -334,10 +364,7 @@ async def __drop_message_from_waiting(self, message_hash: str) -> None:

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 = APRSMessage(body=self.help_text, extras=msg.extras)
help_msg.to = msg.frm
help_msg.frm = APRSPerson(callsign=self.from_call)
self.send_message(help_msg)
Expand All @@ -363,11 +390,9 @@ async def _process_message(self, packet: MessagePacket) -> None:

async def _ack_message(self, packet: MessagePacket) -> None:
log.debug("Sending ack for packet %s", packet.json)
this_ack = AckPacket(from_call=self.from_call,
to_call=packet.from_call,
addresse=packet.from_call,
msgNo=packet.msgNo
)
this_ack = AckPacket(
from_call=self.from_call, to_call=packet.from_call, addresse=packet.from_call, msgNo=packet.msgNo
)
await this_ack.prepare(self._message_counter)
this_ack.update_timestamp()
await self._client._send(this_ack.raw)
Expand Down
3 changes: 2 additions & 1 deletion aprs_backend/clients/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .aprsis import APRSISClient
from .kiss import KISSClient
from .aprs_registry import APRSRegistryClient, RegistryAppConfig

__all__ = ["APRSISClient", "KISSClient"]
__all__ = ["APRSISClient", "KISSClient", "APRSRegistryClient", "RegistryAppConfig"]
69 changes: 69 additions & 0 deletions aprs_backend/clients/aprs_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from dataclasses import dataclass
import httpx
from functools import cached_property
import asyncio


@dataclass
class RegistryAppConfig:
description: str
listening_callsigns: list[str]
website: str = ""
software: str = ""

@cached_property
def post_jsons(self) -> list[dict]:
return [
{
"callsign": str(this_call),
"description": self.description,
"service_website": self.website,
"software": self.software,
}
for this_call in self.listening_callsigns
]


class APRSRegistryClient:
def __init__(self, registry_url: str, app_config: RegistryAppConfig, log, frequency_seconds: int = 3600) -> None:
self.registry_url = registry_url
self.log = log
self.frequency_seconds = frequency_seconds
self.app_config = app_config

async def __call__(self) -> None:
"""Posts to the aprs registry url for each listening callsign for the bot
Run as an asyncio task
"""
self.log.debug("Staring APRS Registry Client")
try:
while True:
async with httpx.AsyncClient() as client:
for post_json in self.app_config.post_jsons:
self.log.debug("Posting %s to %s", post_json, self.registry_url)
try:
response = await client.post(self.registry_url, json=post_json)
self.log.debug(response)
response.raise_for_status()
except httpx.RequestError as exc:
self.log.error(
"Request Error while posting %s to %s. Error: %s, response: %s",
post_json,
self.registry_url,
exc,
response,
)
except httpx.HTTPStatusError as exc:
self.log.error(
"Error while posting %s to %s. Error: %s, response: %s",
post_json,
self.registry_url,
exc,
response,
)
# instead of sleeping in one big chunk, sleep in smaller chunks for easier cacnellation
for i in range(self.frequency_seconds * 10):
await asyncio.sleep(0.1)
except asyncio.CancelledError:
self.log.info("APRS client cancelled, stopping")
return
Loading
Loading