first crack at separating concerns. broken
This commit is contained in:
parent
85dacf2db4
commit
a02956cbc7
@ -17,4 +17,4 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
__all__ = ["rsbbs", "bbs", "message", "parser"]
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
296
rsbbs/bbs.py
296
rsbbs/bbs.py
@ -1,296 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Really Simple BBS - a really simple BBS for ax.25 packet radio.
|
||||
# Copyright (C) 2023 John Burwell <john@atatdotdot.com>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import logging.config
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import yaml
|
||||
import pkg_resources
|
||||
import platformdirs
|
||||
|
||||
from sqlalchemy import create_engine, delete, select, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from rsbbs import __version__
|
||||
from rsbbs.message import Message, Base
|
||||
from rsbbs.parser import Parser
|
||||
from rsbbs.ui import UI
|
||||
|
||||
|
||||
# Main BBS class
|
||||
|
||||
class BBS():
|
||||
|
||||
def __init__(self, sysv_args):
|
||||
self._sysv_args = sysv_args
|
||||
|
||||
self.config = self._load_config(sysv_args.config_file)
|
||||
|
||||
self.calling_station = sysv_args.calling_station.upper()
|
||||
|
||||
self.ui = UI(self)
|
||||
self.engine = self._init_engine()
|
||||
|
||||
logging.config.dictConfig(self.config['logging'])
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return self._config
|
||||
|
||||
@config.setter
|
||||
def config(self, value):
|
||||
self._config = value
|
||||
|
||||
#
|
||||
# Config file
|
||||
#
|
||||
|
||||
def _load_config(self, config_file):
|
||||
"""Load configuration file.
|
||||
|
||||
If a config file is specified, attempt to use that. Otherwise, use a
|
||||
file in the location appropriate to the host system. Create the file
|
||||
if it does not exist, using the config_default.yaml as a default.
|
||||
"""
|
||||
# Use either the specified file or a file in a system config location
|
||||
config_path = config_file or os.path.join(
|
||||
platformdirs.user_config_dir(appname='rsbbs', ensure_exists=True),
|
||||
'config.yaml'
|
||||
)
|
||||
if self._sysv_args.debug:
|
||||
print(config_path)
|
||||
# If the file doesn't exist there, create it
|
||||
if not os.path.exists(config_path):
|
||||
config_template_path = pkg_resources.resource_filename(
|
||||
__name__, 'config_default.yaml')
|
||||
try:
|
||||
with open(config_template_path, 'r') as f:
|
||||
config_template = yaml.load(f, Loader=yaml.FullLoader)
|
||||
with open(config_path, 'w') as f:
|
||||
yaml.dump(config_template, f)
|
||||
except Exception as e:
|
||||
print(f"Error creating configuration file: {e}")
|
||||
exit(1)
|
||||
# Load it
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
config = yaml.load(f, Loader=yaml.FullLoader)
|
||||
except Exception as e:
|
||||
print(f"Error loading configuration file: {e}")
|
||||
exit(1)
|
||||
|
||||
logging.info(f"Configuration file was successfully loaded."
|
||||
f"File name: {config_path}")
|
||||
|
||||
return config
|
||||
|
||||
#
|
||||
# Database
|
||||
#
|
||||
|
||||
def _init_engine(self):
|
||||
"""Create a connection to the sqlite3 database.
|
||||
|
||||
The default location is the system-specific user-level data directory.
|
||||
"""
|
||||
db_path = os.path.join(
|
||||
platformdirs.user_data_dir(appname='rsbbs', ensure_exists=True),
|
||||
'messages.db')
|
||||
engine = create_engine(
|
||||
'sqlite:///' + db_path,
|
||||
echo=self._sysv_args.debug) # Echo SQL output if -d is turned on
|
||||
# Create the database schema if none exists
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
#
|
||||
# BBS command functions
|
||||
#
|
||||
|
||||
def bye(self, args):
|
||||
"""Close the connection and exit."""
|
||||
self.ui._write_output("Bye!")
|
||||
exit(0)
|
||||
|
||||
def delete(self, args):
|
||||
"""Delete a message.
|
||||
|
||||
Arguments:
|
||||
number -- the message number to delete
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
message = session.get(Message, args.number)
|
||||
session.delete(message)
|
||||
session.commit()
|
||||
self.ui._write_output(f"Deleted message #{args.number}")
|
||||
except Exception as e:
|
||||
self.ui._write_output(
|
||||
f"Unable to delete message #{args.number}")
|
||||
|
||||
def delete_mine(self, args):
|
||||
"""Delete all messages addressed to the calling station's callsign."""
|
||||
self.ui._write_output("Delete all messages addressed to you? Y/N:")
|
||||
response = sys.stdin.readline().strip()
|
||||
if response.lower() != "y":
|
||||
return
|
||||
else:
|
||||
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.ui._write_output(f"Deleted {count} messages")
|
||||
session.commit()
|
||||
else:
|
||||
self.ui._write_output(f"No messages to delete.")
|
||||
except Exception as e:
|
||||
self.ui._write_output(f"Unable to delete messages: {e}")
|
||||
|
||||
def heard(self, args):
|
||||
"""Show a log of stations that have been heard by this station,
|
||||
also known as the 'mheard' (linux) or 'jheard' (KPC, etc.) log.
|
||||
"""
|
||||
self.ui._write_output(f"Heard stations:")
|
||||
result = subprocess.run(['mheard'], capture_output=True, text=True)
|
||||
self.ui._write_output(result.stdout)
|
||||
|
||||
def help(self, args):
|
||||
"""Show help."""
|
||||
self.ui.parser.print_help()
|
||||
|
||||
def list(self, args):
|
||||
"""List all messages."""
|
||||
with Session(self.engine) as session:
|
||||
statement = select(Message).where(or_(
|
||||
(Message.is_private.is_not(False)),
|
||||
(Message.recipient.__eq__(self.calling_station)))
|
||||
)
|
||||
results = session.execute(statement)
|
||||
self.ui.print_message_list(results)
|
||||
|
||||
def list_mine(self, args):
|
||||
"""List only messages addressed to the calling station's callsign,
|
||||
including public and private messages.
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
statement = select(Message).where(
|
||||
Message.recipient == self.calling_station)
|
||||
results = session.execute(statement)
|
||||
self.ui.print_message_list(results)
|
||||
|
||||
def read(self, args):
|
||||
"""Read a message.
|
||||
|
||||
Arguments:
|
||||
number -- the message number to read
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
statement = select(Message).where(Message.id == args.number)
|
||||
result = session.execute(statement).one()
|
||||
self.ui.print_message(result)
|
||||
|
||||
def read_mine(self, args):
|
||||
"""Read all messages addressed to the calling station's callsign,
|
||||
in sequence."""
|
||||
with Session(self.engine) as session:
|
||||
statement = select(Message).where(
|
||||
Message.recipient == self.calling_station)
|
||||
result = session.execute(statement)
|
||||
messages = result.all()
|
||||
count = len(messages)
|
||||
if count > 0:
|
||||
self.ui._write_output(f"Reading {count} messages:")
|
||||
for message in messages:
|
||||
self.ui.print_message(message)
|
||||
self.ui._write_output("Enter to continue...")
|
||||
sys.stdin.readline()
|
||||
else:
|
||||
self.ui._write_output(f"No messages to read.")
|
||||
|
||||
def send(self, args, is_private=False):
|
||||
"""Create a new message addressed to another callsign.
|
||||
|
||||
Required arguments:
|
||||
callsign -- the recipient's callsign
|
||||
|
||||
Optional arguments:
|
||||
subject -- message subject
|
||||
message -- the message itself
|
||||
"""
|
||||
if not args.callsign:
|
||||
args.callsign = self.ui._read_line("Callsign:")
|
||||
if not args.subject:
|
||||
args.subject = self.ui._read_line("Subject:")
|
||||
if not args.message:
|
||||
args.message = self.ui._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.ui._write_output("Message saved!")
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
self.ui._write_output("Error saving message."
|
||||
"Contact the sysop for assistance.")
|
||||
|
||||
def send_private(self, args):
|
||||
self.send(args, is_private=True)
|
||||
"""Send a message visible only to the recipient callsign.
|
||||
|
||||
Required arguments:
|
||||
callsign -- the recipient's callsign
|
||||
|
||||
Optional arguments:
|
||||
subject -- message subject
|
||||
message -- the message itself
|
||||
"""
|
||||
|
||||
#
|
||||
# Main loop
|
||||
#
|
||||
|
||||
def run(self):
|
||||
|
||||
# Show greeting
|
||||
self.ui.print_greeting()
|
||||
|
||||
# Show initial prompt to the calling user
|
||||
self.ui._write_output(self.config['command_prompt'])
|
||||
|
||||
# Parse the BBS interactive commands for the rest of time
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
args = self.ui.parser.parse_args(line.split())
|
||||
args.func(args)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# Show our prompt to the calling user again
|
||||
self.ui._write_output(self.config['command_prompt'])
|
||||
112
rsbbs/commands.py
Normal file
112
rsbbs/commands.py
Normal file
@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Really Simple BBS - a really simple BBS for ax.25 packet radio.
|
||||
# Copyright (C) 2023 John Burwell <john@atatdotdot.com>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
class Commands():
|
||||
|
||||
def __init__(self, responder):
|
||||
self.responder = responder
|
||||
|
||||
@property
|
||||
def commands(self):
|
||||
commands = [
|
||||
# (name,
|
||||
# aliases,
|
||||
# helpmsg,
|
||||
# function,
|
||||
# callback,
|
||||
# {arg:
|
||||
# {arg attributes},
|
||||
# ...})
|
||||
('bye',
|
||||
['b', 'q'],
|
||||
'Sign off and disconnect',
|
||||
self.responder.bye,
|
||||
{}),
|
||||
|
||||
('delete',
|
||||
['d', 'k'],
|
||||
'Delete a message',
|
||||
self.responder.delete,
|
||||
{'number':
|
||||
{'help':
|
||||
'The numeric index of the message to delete'}},),
|
||||
|
||||
('deletem',
|
||||
['dm', 'km'],
|
||||
'Delete all your messages',
|
||||
self.responder.delete_mine,
|
||||
{}),
|
||||
|
||||
('help',
|
||||
['h', '?'],
|
||||
'Show help',
|
||||
self.responder.help,
|
||||
{}),
|
||||
|
||||
('heard',
|
||||
['j'],
|
||||
'Show heard stations log',
|
||||
self.responder.heard,
|
||||
{}),
|
||||
|
||||
('list',
|
||||
['l'],
|
||||
'List all messages',
|
||||
self.responder.list,
|
||||
{}),
|
||||
|
||||
('listm',
|
||||
['lm'],
|
||||
'List only messages addressed to you',
|
||||
self.responder.list_mine,
|
||||
{}),
|
||||
|
||||
('read',
|
||||
['r'],
|
||||
'Read messages',
|
||||
self.responder.read,
|
||||
{'number': {'help': 'Message number to read'}}),
|
||||
|
||||
('readm',
|
||||
['rm'],
|
||||
'Read only messages addressed to you',
|
||||
self.responder.read_mine,
|
||||
{}),
|
||||
|
||||
('send',
|
||||
['s'],
|
||||
'Send a new message to a user',
|
||||
self.responder.send,
|
||||
{
|
||||
'callsign': {'help': 'Message recipient callsign'},
|
||||
'--subject': {'help': 'Message subject'},
|
||||
'--message': {'help': 'Message'},
|
||||
},),
|
||||
|
||||
('sendp',
|
||||
['sp'],
|
||||
'Send a private message to a user',
|
||||
self.responder.send_private,
|
||||
{
|
||||
'callsign': {'help': 'Message recipient callsign'},
|
||||
'--subject': {'help': 'Message subject'},
|
||||
'--message': {'help': 'Message'},
|
||||
},),]
|
||||
|
||||
return commands
|
||||
80
rsbbs/config.py
Normal file
80
rsbbs/config.py
Normal file
@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Really Simple BBS - a really simple BBS for ax.25 packet radio.
|
||||
# Copyright (C) 2023 John Burwell <john@atatdotdot.com>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import pkg_resources
|
||||
import platformdirs
|
||||
import yaml
|
||||
|
||||
|
||||
class Config():
|
||||
|
||||
def __init__(self, app_name, args):
|
||||
self._app_name = app_name
|
||||
self._config_file_path = args.config_file
|
||||
|
||||
self._load_config()
|
||||
|
||||
self.config['db_path'] = os.path.join(
|
||||
platformdirs.user_data_dir(appname=app_name, ensure_exists=True),
|
||||
'messages.db')
|
||||
|
||||
self.config['debug'] = args.debug
|
||||
self.config['args'] = args
|
||||
|
||||
@property
|
||||
def config_path(self):
|
||||
# Use either the specified file or a file in a system config location
|
||||
config_path = self._config_file_path or os.path.join(
|
||||
platformdirs.user_config_dir(
|
||||
appname=self._app_name,
|
||||
ensure_exists=True),
|
||||
'config.yaml'
|
||||
)
|
||||
return config_path
|
||||
|
||||
def _init_config_file(self):
|
||||
# If the file doesn't exist there, create it from the default file
|
||||
# included in the package
|
||||
if not os.path.exists(self.config_path):
|
||||
config_default_file_path = pkg_resources.resource_filename(
|
||||
__name__,
|
||||
'config_default.yaml')
|
||||
try:
|
||||
with open(config_default_file_path, 'r') as f:
|
||||
config_template = yaml.load(f, Loader=yaml.FullLoader)
|
||||
with open(self.config_path, 'w') as f:
|
||||
yaml.dump(config_template, f)
|
||||
except Exception as e:
|
||||
print(f"Error creating configuration file: {e}")
|
||||
exit(1)
|
||||
|
||||
def _load_config(self):
|
||||
"""Load configuration file.
|
||||
|
||||
If a config file is specified, attempt to use that. Otherwise, use a
|
||||
file in the location appropriate to the host system. Create the file
|
||||
if it does not exist, using the config_default.yaml as a default.
|
||||
"""
|
||||
# Load it
|
||||
try:
|
||||
with open(self.config_path, 'r') as f:
|
||||
self.config = yaml.load(f, Loader=yaml.FullLoader)
|
||||
except Exception as e:
|
||||
print(f"Error loading configuration file: {e}")
|
||||
exit(1)
|
||||
235
rsbbs/console.py
Normal file
235
rsbbs/console.py
Normal file
@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Really Simple BBS - a really simple BBS for ax.25 packet radio.
|
||||
# Copyright (C) 2023 John Burwell <john@atatdotdot.com>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
|
||||
import rsbbs
|
||||
from rsbbs.commands import Commands
|
||||
from rsbbs.config import Config
|
||||
from rsbbs.controller import Base, Controller, Message
|
||||
from rsbbs.parser import Parser
|
||||
|
||||
|
||||
# Main UI console class
|
||||
|
||||
class Console():
|
||||
|
||||
def __init__(self, config: Config, controller: Controller):
|
||||
self.config = config
|
||||
self.controller = controller
|
||||
|
||||
self.commands = Commands(self)
|
||||
self.parser = Parser(self.commands)
|
||||
|
||||
#
|
||||
# Input and output
|
||||
#
|
||||
|
||||
def _write_output(self, output):
|
||||
"""Write something to stdout."""
|
||||
sys.stdout.write(output + '\r\n')
|
||||
|
||||
def print_greeting(self):
|
||||
# Show greeting
|
||||
greeting = []
|
||||
greeting.append(f"[RSBBS-{rsbbs.__version__}] listening on "
|
||||
f"{self.config.config['callsign']} ")
|
||||
|
||||
greeting.append(f"Welcome to {self.config.config['bbs_name']}, "
|
||||
f"{self.config.config['args'].calling_station}")
|
||||
|
||||
greeting.append(self.config.config['banner_message'])
|
||||
|
||||
greeting.append("For help, enter 'h'")
|
||||
|
||||
self._write_output('\r\n'.join(greeting))
|
||||
|
||||
def print_message(self, message):
|
||||
"""Print an individual message."""
|
||||
# Format the big ol' date and time string
|
||||
datetime = message.Message.datetime.strftime(
|
||||
'%A, %B %-d, %Y at %-H:%M %p UTC')
|
||||
# Print the message
|
||||
self._write_output(f"")
|
||||
self._write_output(f"Message: {message.Message.id}")
|
||||
self._write_output(f"Date: {datetime}")
|
||||
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"")
|
||||
self._write_output(f"{message.Message.message}")
|
||||
|
||||
def print_message_list(self, messages):
|
||||
"""Print a list of messages."""
|
||||
# Print the column headers
|
||||
self._write_output(f"{'MSG#': <{5}} "
|
||||
f"{'TO': <{9}} "
|
||||
f"{'FROM': <{9}} "
|
||||
f"{'DATE': <{11}} "
|
||||
f"SUBJECT")
|
||||
# Print the messages
|
||||
for message in messages:
|
||||
datetime_ = message.Message.datetime.strftime('%Y-%m-%d')
|
||||
self._write_output(f"{message.Message.id: <{5}} "
|
||||
f"{message.Message.recipient: <{9}} "
|
||||
f"{message.Message.sender: <{9}} "
|
||||
f"{datetime_: <{11}} "
|
||||
f"{message.Message.subject}")
|
||||
|
||||
#
|
||||
# Command functions
|
||||
#
|
||||
|
||||
def bye(self, args):
|
||||
self._write_output("Bye!")
|
||||
exit(0)
|
||||
|
||||
def delete(self, args):
|
||||
self.controller.delete(args)
|
||||
|
||||
def delete_mine(self, args):
|
||||
"""Delete all messages addressed to the calling station's callsign."""
|
||||
self._write_output("Delete all messages addressed to you? Y/N:")
|
||||
response = sys.stdin.readline().strip()
|
||||
if response.lower() != "y":
|
||||
return
|
||||
else:
|
||||
try:
|
||||
result = self.controller.delete_mine(args)
|
||||
if result['count'] > 0:
|
||||
self._write_output(f"Deleted {result['count']} messages")
|
||||
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 a log of stations that have been heard by this station,
|
||||
also known as the 'mheard' (linux) or 'jheard' (KPC, etc.) log.
|
||||
"""
|
||||
self._write_output(f"Heard stations:")
|
||||
result = self.controller.heard(args)
|
||||
self._write_output(result.stdout)
|
||||
|
||||
def help(self, args):
|
||||
self.parser.parser.print_help()
|
||||
|
||||
def list(self, args):
|
||||
"""List all messages."""
|
||||
result = self.controller.list(args)
|
||||
self.print_message_list(result['result'])
|
||||
|
||||
def list_mine(self, args):
|
||||
"""List only messages addressed to the calling station's callsign,
|
||||
including public and private messages.
|
||||
"""
|
||||
result = self.controller.list_mine(args)
|
||||
self.print_message_list(result)
|
||||
|
||||
def read(self, args):
|
||||
"""Read a message.
|
||||
|
||||
Arguments:
|
||||
number -- the message number to read
|
||||
"""
|
||||
result = self.controller.read(args)
|
||||
self.print_message(result)
|
||||
|
||||
def read_mine(self, args):
|
||||
"""Read all messages addressed to the calling station's callsign,
|
||||
in sequence."""
|
||||
result = self.controller.list_mine(args)
|
||||
if result['count'] > 0:
|
||||
self._write_output(f"Reading {result['count']} messages:")
|
||||
for message in result['result'].all():
|
||||
self.print_message(message)
|
||||
self._write_output("Enter to continue...")
|
||||
sys.stdin.readline()
|
||||
else:
|
||||
self._write_output(f"No messages to read.")
|
||||
|
||||
def send(self, args, is_private=False):
|
||||
"""Create a new message addressed to another callsign.
|
||||
|
||||
Required arguments:
|
||||
callsign -- the recipient's callsign
|
||||
|
||||
Optional arguments:
|
||||
subject -- message subject
|
||||
message -- the message itself
|
||||
"""
|
||||
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)
|
||||
"""Send a message visible only to the recipient callsign.
|
||||
|
||||
Required arguments:
|
||||
callsign -- the recipient's callsign
|
||||
|
||||
Optional arguments:
|
||||
subject -- message subject
|
||||
message -- the message itself
|
||||
"""
|
||||
|
||||
#
|
||||
# Main run method
|
||||
#
|
||||
|
||||
def run(self):
|
||||
|
||||
# Show greeting
|
||||
self.print_greeting()
|
||||
|
||||
# Show initial prompt to the calling user
|
||||
self._write_output(self.config.config['command_prompt'])
|
||||
|
||||
# Parse the BBS interactive commands for the rest of time
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
args = self.parser.parser.parse_args(line.split())
|
||||
args.func(args)
|
||||
except Exception:
|
||||
if self.config.config['debug']:
|
||||
raise
|
||||
else:
|
||||
pass
|
||||
|
||||
# Show our prompt to the calling user again
|
||||
self._write_output(self.config.config['command_prompt'])
|
||||
170
rsbbs/controller.py
Normal file
170
rsbbs/controller.py
Normal file
@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Really Simple BBS - a really simple BBS for ax.25 packet radio.
|
||||
# Copyright (C) 2023 John Burwell <john@atatdotdot.com>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import subprocess
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy import create_engine, delete, select, or_
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session
|
||||
from sqlalchemy.orm import mapped_column
|
||||
|
||||
from rsbbs.config import Config
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class Controller():
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self._init_datastore()
|
||||
|
||||
def _init_datastore(self):
|
||||
"""Create a connection to the sqlite3 database.
|
||||
|
||||
The default location is the system-specific user-level data directory.
|
||||
"""
|
||||
db_path = self.config.config['db_path']
|
||||
self.engine = create_engine(
|
||||
'sqlite:///' + db_path,
|
||||
echo=self.config.config['debug'])
|
||||
|
||||
# Create the database schema if none exists
|
||||
Base.metadata.create_all(self.engine)
|
||||
|
||||
def delete(self, args):
|
||||
"""Delete a message.
|
||||
|
||||
Arguments:
|
||||
number -- the message number to delete
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
message = session.get(Message, args.number)
|
||||
session.delete(message)
|
||||
session.commit()
|
||||
return {}
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
def delete_mine(self, args):
|
||||
"""Delete all messages addressed to the calling station's callsign."""
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
statement = delete(Message).where(
|
||||
Message.recipient == self.calling_station
|
||||
).returning(Message)
|
||||
result = session.execute(statement)
|
||||
count = len(result.all())
|
||||
session.commit()
|
||||
return {'count': count, 'result': result}
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
def heard(self, args):
|
||||
"""Show a log of stations that have been heard by this station,
|
||||
also known as the 'mheard' (linux) or 'jheard' (KPC, etc.) log.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(['mheard'], capture_output=True, text=True)
|
||||
return {'result': result}
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
def list(self, args):
|
||||
"""List all messages."""
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
statement = select(Message).where(or_(
|
||||
(Message.is_private.is_not(False)),
|
||||
(Message.recipient.__eq__(
|
||||
self.config.config['args'].calling_station)))
|
||||
)
|
||||
result = session.execute(statement)
|
||||
except Exception:
|
||||
raise
|
||||
return {'result': result}
|
||||
|
||||
def list_mine(self, args):
|
||||
"""List only messages addressed to the calling station's callsign,
|
||||
including public and private messages.
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
statement = select(Message).where(
|
||||
Message.recipient == self.calling_station)
|
||||
result = session.execute(statement)
|
||||
return {'result': result}
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
def read(self, args):
|
||||
"""Read a message.
|
||||
|
||||
Arguments:
|
||||
number -- the message number to read
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
statement = select(Message).where(Message.id == args.number)
|
||||
result = session.execute(statement).one()
|
||||
return {'result': result}
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
def send(self, args, is_private=False):
|
||||
"""Create a new message addressed to another callsign.
|
||||
|
||||
Required arguments:
|
||||
callsign -- the recipient's callsign
|
||||
|
||||
Optional arguments:
|
||||
subject -- message subject
|
||||
message -- the message itself
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
session.add(Message(
|
||||
sender=self.calling_station.upper(),
|
||||
recipient=args.callsign.upper(),
|
||||
subject=args.subject,
|
||||
message=args.message,
|
||||
is_private=is_private
|
||||
))
|
||||
session.commit()
|
||||
return {}
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Really Simple BBS - a really simple BBS for ax.25 packet radio.
|
||||
# Copyright (C) 2023 John Burwell <john@atatdotdot.com>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
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)
|
||||
@ -18,6 +18,8 @@
|
||||
|
||||
import argparse
|
||||
|
||||
from rsbbs.commands import Commands
|
||||
|
||||
|
||||
# We want to override the error and exit methods of ArgumentParser
|
||||
# to prevent it exiting unexpectedly or spewing error data over the air
|
||||
@ -35,12 +37,12 @@ class BBSArgumentParser(argparse.ArgumentParser):
|
||||
|
||||
class Parser(BBSArgumentParser):
|
||||
|
||||
def __init__(self, commands):
|
||||
self.parser = self.init_parser(commands)
|
||||
def __init__(self, commands: Commands):
|
||||
self.init_parser(commands)
|
||||
|
||||
def init_parser(self, commands):
|
||||
# Root parser for BBS commands
|
||||
parser = BBSArgumentParser(
|
||||
self.parser = BBSArgumentParser(
|
||||
description='BBS Main Menu',
|
||||
prog='',
|
||||
add_help=False,
|
||||
@ -48,10 +50,12 @@ class Parser(BBSArgumentParser):
|
||||
)
|
||||
|
||||
# We will create a subparser for each individual command
|
||||
subparsers = parser.add_subparsers(title='Commands', dest='command')
|
||||
subparsers = self.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:
|
||||
for name, aliases, help_msg, func, arguments in commands.commands:
|
||||
subparser = subparsers.add_parser(
|
||||
name,
|
||||
aliases=aliases,
|
||||
@ -60,5 +64,3 @@ class Parser(BBSArgumentParser):
|
||||
for arg_name, options in arguments.items():
|
||||
subparser.add_argument(arg_name, **options)
|
||||
subparser.set_defaults(func=func)
|
||||
|
||||
return parser
|
||||
|
||||
@ -20,7 +20,11 @@ import argparse
|
||||
import sys
|
||||
|
||||
from rsbbs import __version__
|
||||
from rsbbs.bbs import BBS
|
||||
from rsbbs.commands import Commands
|
||||
from rsbbs.config import Config
|
||||
from rsbbs.console import Console
|
||||
from rsbbs.controller import Controller
|
||||
from rsbbs.parser import Parser
|
||||
|
||||
|
||||
def main():
|
||||
@ -54,11 +58,23 @@ def main():
|
||||
# Parse the args from the system
|
||||
sysv_args = sysv_parser.parse_args(sys.argv[1:])
|
||||
|
||||
# Instantiate the BBS object
|
||||
bbs = BBS(sysv_args)
|
||||
# Load configuration
|
||||
config = Config(
|
||||
app_name='rsbbs',
|
||||
args=sysv_args)
|
||||
|
||||
# Start the main BBS loop
|
||||
bbs.run()
|
||||
# Init the contoller
|
||||
controller = Controller(config)
|
||||
|
||||
# # Set up commands and parser
|
||||
# commands = Commands(controller)
|
||||
# parser = Parser(commands)
|
||||
|
||||
# Init the UI console
|
||||
console = Console(config, controller)
|
||||
|
||||
# Start the app
|
||||
console.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
213
rsbbs/ui.py
213
rsbbs/ui.py
@ -1,213 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Really Simple BBS - a really simple BBS for ax.25 packet radio.
|
||||
# Copyright (C) 2023 John Burwell <john@atatdotdot.com>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
|
||||
from rsbbs import __version__
|
||||
from rsbbs.parser import Parser
|
||||
|
||||
|
||||
# UI
|
||||
|
||||
class UI():
|
||||
|
||||
def __init__(self, bbs):
|
||||
# Get the BBS
|
||||
self.bbs = bbs
|
||||
self.parser = self._init_parser()
|
||||
|
||||
#
|
||||
# BBS command parser
|
||||
#
|
||||
|
||||
def _init_parser(self):
|
||||
"""Define BBS command names, aliases, help messages, action (function),
|
||||
and any arguments.
|
||||
|
||||
This configures argparser's internal help and maps user commands to BBS
|
||||
functions.
|
||||
"""
|
||||
commands = [
|
||||
# (name,
|
||||
# aliases,
|
||||
# helpmsg,
|
||||
# function,
|
||||
# {arg:
|
||||
# {arg attributes},
|
||||
# ...})
|
||||
('bye',
|
||||
['b', 'q'],
|
||||
'Sign off and disconnect',
|
||||
self.bbs.bye,
|
||||
{}),
|
||||
|
||||
('delete',
|
||||
['d', 'k'],
|
||||
'Delete a message',
|
||||
self.bbs.delete,
|
||||
{'number':
|
||||
{'help':
|
||||
'The numeric index of the message to delete'}},),
|
||||
|
||||
('deletem',
|
||||
['dm', 'km'],
|
||||
'Delete all your messages',
|
||||
self.bbs.delete_mine,
|
||||
{}),
|
||||
|
||||
('help',
|
||||
['h', '?'],
|
||||
'Show help',
|
||||
self.bbs.help,
|
||||
{}),
|
||||
|
||||
('heard',
|
||||
['j'],
|
||||
'Show heard stations log',
|
||||
self.bbs.heard,
|
||||
{}),
|
||||
|
||||
('list',
|
||||
['l'],
|
||||
'List all messages',
|
||||
self.bbs.list,
|
||||
{}),
|
||||
|
||||
('listm',
|
||||
['lm'],
|
||||
'List only messages addressed to you',
|
||||
self.bbs.list_mine,
|
||||
{}),
|
||||
|
||||
('read',
|
||||
['r'],
|
||||
'Read messages',
|
||||
self.bbs.read,
|
||||
{'number': {'help': 'Message number to read'}}),
|
||||
|
||||
('readm',
|
||||
['rm'],
|
||||
'Read only messages addressed to you',
|
||||
self.bbs.read_mine,
|
||||
{}),
|
||||
|
||||
('send',
|
||||
['s'],
|
||||
'Send a new message to a user',
|
||||
self.bbs.send,
|
||||
{
|
||||
'callsign': {'help': 'Message recipient callsign'},
|
||||
'--subject': {'help': 'Message subject'},
|
||||
'--message': {'help': 'Message'},
|
||||
},),
|
||||
|
||||
('sendp',
|
||||
['sp'],
|
||||
'Send a private message to a user',
|
||||
self.bbs.send_private,
|
||||
{
|
||||
'callsign': {'help': 'Message recipient callsign'},
|
||||
'--subject': {'help': 'Message subject'},
|
||||
'--message': {'help': 'Message'},
|
||||
},),]
|
||||
|
||||
# Send all the commands defined above to the parser
|
||||
return Parser(commands).parser
|
||||
|
||||
#
|
||||
# Input and output
|
||||
#
|
||||
|
||||
def _read_line(self, prompt):
|
||||
"""Read a single line of input, with an optional prompt,
|
||||
until we get something.
|
||||
"""
|
||||
output = None
|
||||
while not output:
|
||||
if prompt:
|
||||
self._write_output(prompt)
|
||||
input = sys.stdin.readline().strip()
|
||||
if input != "":
|
||||
output = input
|
||||
return output
|
||||
|
||||
def _read_multiline(self, prompt):
|
||||
"""Read multiple lines of input, with an optional prompt,
|
||||
until the user enters '/ex' by itself on a line.
|
||||
"""
|
||||
output = []
|
||||
if prompt:
|
||||
self._write_output(prompt)
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if line.lower().strip() == "/ex":
|
||||
break
|
||||
else:
|
||||
output.append(line)
|
||||
return ''.join(output)
|
||||
|
||||
def _write_output(self, output):
|
||||
"""Write something to stdout."""
|
||||
sys.stdout.write(output + '\r\n')
|
||||
|
||||
def print_message_list(self, messages):
|
||||
"""Print a list of messages."""
|
||||
# Print the column headers
|
||||
self._write_output(f"{'MSG#': <{5}} "
|
||||
f"{'TO': <{9}} "
|
||||
f"{'FROM': <{9}} "
|
||||
f"{'DATE': <{11}} "
|
||||
f"SUBJECT")
|
||||
# Print the messages
|
||||
for message in messages:
|
||||
datetime_ = message.Message.datetime.strftime('%Y-%m-%d')
|
||||
self._write_output(f"{message.Message.id: <{5}} "
|
||||
f"{message.Message.recipient: <{9}} "
|
||||
f"{message.Message.sender: <{9}} "
|
||||
f"{datetime_: <{11}} "
|
||||
f"{message.Message.subject}")
|
||||
|
||||
def print_message(self, message):
|
||||
"""Print an individual message."""
|
||||
# Format the big ol' date and time string
|
||||
datetime = message.Message.datetime.strftime(
|
||||
'%A, %B %-d, %Y at %-H:%M %p UTC')
|
||||
# Print the message
|
||||
self._write_output(f"")
|
||||
self._write_output(f"Message: {message.Message.id}")
|
||||
self._write_output(f"Date: {datetime}")
|
||||
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"")
|
||||
self._write_output(f"{message.Message.message}")
|
||||
|
||||
def print_greeting(self):
|
||||
# Show greeting
|
||||
greeting = []
|
||||
greeting.append(f"[RSBBS-{__version__}] listening on "
|
||||
f"{self.bbs.config['callsign']} ")
|
||||
|
||||
greeting.append(f"Welcome to {self.bbs.config['bbs_name']}, "
|
||||
f"{self.bbs.calling_station}")
|
||||
|
||||
greeting.append(self.bbs.config['banner_message'])
|
||||
|
||||
greeting.append("For help, enter 'h'")
|
||||
|
||||
self._write_output('\r\n'.join(greeting))
|
||||
Loading…
Reference in New Issue
Block a user