initial commit

This commit is contained in:
John Burwell 2023-04-23 16:22:08 -05:00
commit b3af55572e
10 changed files with 645 additions and 0 deletions

164
.gitignore vendored Normal file
View File

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

47
main.py Normal file
View File

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

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
PyYAML==6.0

1
rsbbs/__init__.py Normal file
View File

@ -0,0 +1 @@
__all__ = ["bbs", "message", "project_root"]

271
rsbbs/bbs.py Normal file
View File

@ -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'])

44
rsbbs/config.yaml.sample Normal file
View File

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

21
rsbbs/message.py Normal file
View File

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

44
rsbbs/parser.py Normal file
View File

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

9
rsbbs/project_root.py Normal file
View File

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

43
setup.py Normal file
View File

@ -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',
],
)