commit b3af55572e7ae9d4d8689f29208da5cd88b4472d Author: John Burwell Date: Sun Apr 23 16:22:08 2023 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a672fc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ +messages.db +config.yaml + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + diff --git a/main.py b/main.py new file mode 100644 index 0000000..100b55c --- /dev/null +++ b/main.py @@ -0,0 +1,47 @@ +import argparse +import sys + +from rsbbs.bbs import BBS + + +def main(): + + # Parse and handle the system invocation arguments + sysv_parser = argparse.ArgumentParser( + description="A Really Simple BBS.") + + sysv_parser.add_argument('-d', '--debug', + action='store_true', + default=None, + dest="debug", + help="Enable debugging output to stdout", + required=False) + + sysv_parser.add_argument('-s', '--calling-station', + action='store', + default='N0CALL', + dest="calling_station", + help="The callsign of the calling station", + required=True) + + sysv_parser.add_argument('-f', '--config-file', + action='store', + default='config.yaml', + dest="config_file", + help="specify path to config.yaml file", + required=False) + + sysv_parser.add_argument('-v', '--version', + action='version', + version=f"{sysv_parser.prog} version zero point aitch point negative purple") + + sysv_args = sysv_parser.parse_args(sys.argv[1:]) + + # Instantiate the BBS object with the supplied (or default) config file + bbs = BBS(sysv_args) + + # Start the main BBS loop + bbs.main() + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bee6c14 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyYAML==6.0 diff --git a/rsbbs/__init__.py b/rsbbs/__init__.py new file mode 100644 index 0000000..c29fc49 --- /dev/null +++ b/rsbbs/__init__.py @@ -0,0 +1 @@ +__all__ = ["bbs", "message", "project_root"] \ No newline at end of file diff --git a/rsbbs/bbs.py b/rsbbs/bbs.py new file mode 100644 index 0000000..bbcd5f2 --- /dev/null +++ b/rsbbs/bbs.py @@ -0,0 +1,271 @@ +import logging.config +import os +import subprocess +import sys +import yaml + +from sqlalchemy import create_engine, delete, select +from sqlalchemy.orm import Session +from typing import * + +from rsbbs.message import Message, Base +from rsbbs.parser import Parser + + +# Main BBS class + +class BBS(): + + def __init__(self, sysv_args): + + self.config = self.load_config(sysv_args.config_file) + self.sysv_args = sysv_args + self.calling_station = sysv_args.calling_station + + logging.config.dictConfig(self.config['logging']) + + @property + def config(self): + return self._config + + @config.setter + def config(self, value): + self._config = value + + # Load the config file + def load_config(self, config_file): + + try: + with open(config_file, 'r') as stream: + config = yaml.safe_load(stream) + except yaml.YAMLError as e: + print("Could not load configuration file. Error: {}".format(e)) + exit(1) + except FileNotFoundError as e: + print('Configuration file full path: {}'.format(os.path.abspath(config_file))) + print("Configuration file {} could not be found. Error: {}".format(config_file, e)) + exit(1) + except Exception as msg: + print("Error while loading configuration file {}. Error: {}".format(config_file)) + exit(1) + + logging.info("Configuration file was successfully loaded. File name: {}".format(config_file)) + + return config + + + # Set up the BBS command parser + + @property + def parser(self): + commands = [ + # (name, aliases, helpmsg, function, {arg: {arg attributes}, ...}) + ('bye', ['b', 'q'], 'Sign off and disconnect', self.bye, {}), + ('delete', ['k', 'd'], 'Delete a message', self.delete, + {'number': {'help': 'The numeric index of the message to delete'}}, + ), + ('deletem', ['dm', 'km'], 'Delete all your messages', self.delete_mine, {}), + ('help', ['h', '?'], 'Show help', self.help, {}), + ('heard', ['j'], 'Show heard stations log', self.heard, {}), + ('list', ['l'], 'List all messages', self.list, {}), + ('listm', ['lm'], 'List only messages addressed to you', self.list_mine, {}), + ('read', ['r'], 'Read messages', self.read, + {'number': {'help': 'Message number to read'}} + ), + ('readm', ['rm'], 'Read only messages addressed to you', self.read_mine, {}), + ('send', ['s'], 'Send a new message to a user', self.send, + { + 'callsign': {'help': 'Message recipient callsign'}, + '--subject': {'help': 'Message subject'}, + '--message': {'help': 'Message'}, + }, + ), + ('sendp', ['sp'], 'Send a private message to a user', self.send_private, + { + 'callsign': {'help': 'Message recipient callsign'}, + '--subject': {'help': 'Message subject'}, + '--message': {'help': 'Message'}, + }, + ), + ] + return Parser(commands).parser + + + # Database + + @property + def engine(self): + engine = create_engine('sqlite:///messages.db', echo=self.sysv_args.debug) + Base.metadata.create_all(engine) + return engine + + + # Input and output + + @property + def input_stream(self): + return sys.stdin + + def read_line(self, prompt): + output = None + while output == None: + if prompt: + self.write_output(prompt) + input = sys.stdin.readline().strip() + if input != "": + output = input + return output + + def read_multiline(self, prompt): + output = "" + if prompt: + self.write_output(prompt) + while True: + line = sys.stdin.readline() + if line.lower().strip() == "/ex": + break + else: + output += line + return output + + def write_output(self, output): + sys.stdout.write(output + '\r\n') + + def print_message_list(self, results): + self.write_output(f"{'MSG#': <{5}} {'TO': <{9}} {'FROM': <{9}} {'DATE': <{10}} SUBJECT") + for result in results: + self.write_output(f"{result.Message.id: <{5}} {result.Message.recipient: <{9}} {result.Message.sender: <{9}} {result.Message.datetime.strftime('%Y-%m-%d')} {result.Message.subject}") + + def print_message(self, message): + self.write_output(f"") + self.write_output(f"Message: {message.Message.id}") + self.write_output(f"Date: {message.Message.datetime.strftime('%A, %B %-d, %Y at %-H:%M %p UTC')}") + # self.write_output(f"Date: {message.Message.datetime.strftime('%Y-%m-%dT%H:%M:%S+0000')}") + self.write_output(f"From: {message.Message.sender}") + self.write_output(f"To: {message.Message.recipient}") + self.write_output(f"Subject: {message.Message.subject}") + self.write_output(f"\r\n{message.Message.message}") + + + # BBS command functions + + def bye(self, args): + '''Disconnect and exit''' + self.write_output("Bye!") + exit(0) + + def delete(self, args): + '''Delete message specified by numeric index''' + with Session(self.engine) as session: + try: + message = session.get(Message, args.number) + session.delete(message) + session.commit() + self.write_output(f"Deleted message #{args.number}") + except Exception as e: + self.write_output(f"Unable to delete message #{args.number}") + + def delete_mine(self, args): + '''Delete all messages addressed to user''' + with Session(self.engine) as session: + try: + statement = delete(Message).where(Message.recipient == self.calling_station).returning(Message) + results = session.execute(statement) + count = len(results.all()) + if count > 0: + self.write_output(f"Deleted {count} messages") + session.commit() + else: + self.write_output(f"No messages to delete.") + except Exception as e: + self.write_output(f"Unable to delete messages: {e}") + + def heard(self, args): + '''Show heard stations log''' + self.write_output(f"Heard stations:") + result = subprocess.run(['mheard'], capture_output=True, text=True) + self.write_output(result.stdout) + + def help(self, args): + '''Print help''' + self.parser.print_help() + + def list(self, args): + '''List all messages''' + with Session(self.engine) as session: + statement = select(Message).where((Message.is_private == False) | (Message.recipient == self.calling_station)) + results = session.execute(statement) + self.print_message_list(results) + + def list_mine(self, args): + '''List only messages addressed to user''' + with Session(self.engine) as session: + statement = select(Message).where(Message.recipient == self.calling_station) + results = session.execute(statement) + self.print_message_list(results) + + def read(self, args): + '''Read messages''' + with Session(self.engine) as session: + statement = select(Message).where(Message.id == args.number) + result = session.execute(statement).one() + self.print_message(result) + + def read_mine(self, args): + '''Read only messages addressed to user''' + with Session(self.engine) as session: + statement = select(Message).where(Message.recipient == self.calling_station) + result = session.execute(statement) + for message in result.all(): + self.print_message(message) + self.write_output("Enter to continue") + sys.stdin.readline() + + def send(self, args, is_private=False): + '''Create a message addressed to another user''' + if not args.callsign: + args.callsign = self.read_line("Callsign:") + if not args.subject: + args.subject = self.read_line("Subject:") + if not args.message: + args.message = self.read_multiline("Message - end with /ex on a single line:") + with Session(self.engine) as session: + session.add(Message( + sender=self.calling_station.upper(), + recipient=args.callsign.upper(), + subject=args.subject, + message=args.message, + is_private=is_private + )) + try: + session.commit() + self.write_output("Message saved!") + except Exception as e: + session.rollback() + self.write_output("Error saving message. Contact the sysop for assistance.") + + def send_private(self, args): + self.send(args, is_private=True) + + # Main loop + + def main(self): + # Show greeting + self.write_output(f"[RSBBS-1.0.0] listening on {self.config['callsign']} ") + self.write_output(f"Welcome to {self.config['bbs_name']}, {self.calling_station}") + self.write_output(self.config['banner_message']) + self.write_output("For help, enter 'h'") + + # Show initial prompt to the calling user + self.write_output(self.config['command_prompt']) + + # Parse the BBS interactive commands for the rest of time + for line in self.input_stream: + try: + args = self.parser.parse_args(line.split()) + args.func(args) + except Exception as msg: + pass + + # Show our prompt to the calling user again + self.write_output(self.config['command_prompt']) diff --git a/rsbbs/config.yaml.sample b/rsbbs/config.yaml.sample new file mode 100644 index 0000000..8403556 --- /dev/null +++ b/rsbbs/config.yaml.sample @@ -0,0 +1,44 @@ +--- +# Really Simple BBS Config File + + +# Basic BBS info + +bbs_name: Really Simple BBS +callsign: KI5QKX-10 +banner_message: KI5QKX-10 welcomes you +command_prompt: "ENTER COMMAND >" + + +# Logging + +logging: + + version: 1 + disable_existing_loggers: true + + formatters: + briefFormatter: + format: '%(levelname)s: %(message)s' + preciseFormatter: + format: '%(asctime)s - %(module)s - %(levelname)s: %(message)s' + datefmt: '%Y/%m/%d %H:%M:%S' + + handlers: + console: + class: logging.StreamHandler + formatter: briefFormatter + level: ERROR + stream: ext://sys.stdout + file: + class : logging.FileHandler + formatter: preciseFormatter + level: DEBUG + ## Note that file does not have to exist, but the directories (in case of full path name) should + filename: reference_data_manager.log + + root: + level: DEBUG + handlers: [console, file] + +... diff --git a/rsbbs/message.py b/rsbbs/message.py new file mode 100644 index 0000000..41ce3ae --- /dev/null +++ b/rsbbs/message.py @@ -0,0 +1,21 @@ +from datetime import datetime, timezone + +from sqlalchemy import * +from sqlalchemy.orm import * +from typing import * + +# engine = create_engine('sqlite:///messages.db', echo=True) + +class Base(DeclarativeBase): + pass + +class Message(Base): + __tablename__ = 'message' + id: Mapped[int] = mapped_column(primary_key=True) + sender: Mapped[str] = mapped_column(String) + recipient: Mapped[str] = mapped_column(String) + subject: Mapped[str] = mapped_column(String) + message: Mapped[str] = mapped_column(String) + datetime: Mapped[DateTime] = mapped_column(DateTime, default=datetime.now(timezone.utc)) + is_private: Mapped[bool] = mapped_column(Boolean) + diff --git a/rsbbs/parser.py b/rsbbs/parser.py new file mode 100644 index 0000000..01ebf90 --- /dev/null +++ b/rsbbs/parser.py @@ -0,0 +1,44 @@ +import argparse + +# We want to override the error and exit methods of ArgumentParser +# to prevent it exiting unexpectedly or spewing error data over the air + +class BBSArgumentParser(argparse.ArgumentParser): + # Override the error handler to prevent exiting on error + def error(self, message): + print(message) + raise Exception(message) + + def exit(self): + pass + + +class Parser(BBSArgumentParser): + + def __init__(self, commands): + self.parser = self.init_parser(commands) + + def init_parser(self, commands): + # Root parser for BBS commands + parser = BBSArgumentParser( + description='BBS Main Menu', + prog='', + add_help=False, + usage=argparse.SUPPRESS, + ) + + # We will create a subparser for each individual command + subparsers = parser.add_subparsers(title='Commands', dest='command') + + # Loop through the commands and add each as a subparser + for name, aliases, help_msg, func, arguments in commands: + subparser = subparsers.add_parser( + name, + aliases=aliases, + help=help_msg, + ) + for arg_name, options in arguments.items(): + subparser.add_argument(arg_name, **options) + subparser.set_defaults(func=func) + + return parser diff --git a/rsbbs/project_root.py b/rsbbs/project_root.py new file mode 100644 index 0000000..06cf9f1 --- /dev/null +++ b/rsbbs/project_root.py @@ -0,0 +1,9 @@ +''' +This is a placeholder to be able to access the location of project root directory. +This is needed to be able to read config.yaml from this package, when it is distributed with setuptools and installed. +''' + +import os + +def path(): + return os.path.dirname(__file__) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4c5df0f --- /dev/null +++ b/setup.py @@ -0,0 +1,43 @@ +from setuptools import setup, find_packages + +# https://www.digitalocean.com/community/tutorials/how-to-package-and-distribute-python-applications + +setup( + name="really-simple-bbs", + version="0.1", + + description='''A really simple BBS developed particularly with ax.25 and amateur radio in mind.''', + + author='John Burwell', + author_email='john@atatdotdot.com', + + url='https://github.com/jmbwell/really-simple-bbs', + + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + ], + + + packages=find_packages(exclude=['test*', 'Test*']), + + package_data={ + '': ['README.md', 'LICENSE'], + 'really-simple-bbs': ['config.yaml.sample'] + }, + + + scripts=['main.py'], + + entry_points={ + 'console_scripts': [ + 'really-simple-bbs = main:main', + ], + }, + + install_requires=[ + 'PyYAML==4.2b1', + ], + + +) \ No newline at end of file