diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0bbbfa --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# TC²-BBS Meshtastic Version + +This is the TC²-BBS system integrated with Meshtastic devices. The system allows for message handling, bulletin boards, mail systems, and a channel directory. + +## Setup + +### Requirements + +- Python 3.x +- Meshtastic +- pypubsub + +### Installation + +1. Clone the repository: + +```sh +git clone https://github.com/TheCommsChannel/TC2-BBS-mesh.git +cd TC2-BBS-mesh +``` + +2. Set up a Python virtual environment: + +```sh +python -m venv venv +``` + +3. Activate the virtual environment: + + +- On Windows: + +```sh +venv\Scripts\activate +``` + +- On macOS and Linux: + +```sh +source venv/bin/activate +``` + +4. Install the required packages: + +```sh +pip install -r requirements.txt +``` + +5. Set up the configuration in `config.ini`: + + **[interface]** + + If using `type = serial` and you have multiple devices connected, you will need to uncomment the `port =` line and enter in the port of your device. + + Linux Example: + `port = /dev/ttyUSB0` + + Windows Example: + `port = COM3` + + If using type = tcp you will need to uncomment the hostname = 192.168.x.x line and put in the IP address of your Meshtastic device + + **[sync]** + + Enter in a list of other BBS nodes you would like to sync messages and bulletins with. Separate each by comma and no spaces as shown in the example below. + You can find the nodeID in the menu under `Radio Configuration > User` for each node, or use this script for getting nodedb data from a device: + + [Meshtastic-Python-Examples/print-nodedb.py at main · pdxlocations/Meshtastic-Python-Examples (github.com)](https://github.com/pdxlocations/Meshtastic-Python-Examples/blob/main/print-nodedb.py) + + Example Config: +```ini +[interface] +type = serial +# port = /dev/ttyUSB0 +# hostname = 192.168.x.x + +[sync] +bbs_nodes = !f53f4abc,!f3abc123 +``` + +### Running the Server + +Run the server with: + +```sh +python server.py +``` + +Be sure you've followed the Python virtual environment steps above and activated it before running. + +## Features + +- **Mail System**: Send and receive mail messages. +- **Bulletin Boards**: Post and view bulletins on various boards. +- **Channel Directory**: Add and view channels in the directory. +- **Statistics**: View statistics about nodes, hardware, and roles. +- **Wall of Shame**: View devices with low battery levels. +- **Fortune Teller**: Get a random fortune. Pulls from the fortunes.txt file. Feel free to edit this file remove or add more if you like. + +## Thanks + +Big thanks to [Meshtastic]([Meshtastic (github.com)](https://github.com/meshtastic)) and [pdxlocations](https://github.com/pdxlocations) for the great Python examples: +[python/examples at master · meshtastic/python (github.com)](https://github.com/meshtastic/python/tree/master/examples) + +[pdxlocations/Meshtastic-Python-Examples (github.com)](https://github.com/pdxlocations/Meshtastic-Python-Examples) + +## License + +GNU General Public License v3.0 diff --git a/command_handlers.py b/command_handlers.py new file mode 100644 index 0000000..9a96241 --- /dev/null +++ b/command_handlers.py @@ -0,0 +1,392 @@ +import logging +import random +import time + +from config_init import initialize_config +from db_operations import ( + add_bulletin, add_mail, delete_mail, + get_bulletin_content, get_bulletins, + get_mail, get_mail_content, + add_channel, get_channels +) +from utils import ( + get_node_id_from_num, get_node_info, + get_node_short_name, send_message, + update_user_state +) + +config, interface_type, hostname, port, bbs_nodes = initialize_config() + + +def get_node_name(node_id, interface): + node_info = interface.nodes.get(node_id) + if node_info: + return node_info['user']['longName'] + return f"Node {node_id}" + + +def handle_mail_command(sender_id, interface): + response = "✉️ MAIL MENU ✉️\nWhat would you like to do with mail?\n[0]Read [1]Send [2]Exit" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'MAIL', 'step': 1}) + + +def handle_bulletin_command(sender_id, interface): + response = "📰 BULLETIN MENU 📰\nWhich board would you like to enter?\n[0]General [1]Info [2]News [3]Urgent [4]Exit" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN', 'step': 1}) + + +def handle_exit_command(sender_id, interface): + send_message("Type 'HELP' for a list of commands.", sender_id, interface) + update_user_state(sender_id, None) + + +def handle_help_command(sender_id, interface, state=None): + title = "█▓▒░ TC² BBS ░▒▓█\n" + commands = [ + "[M]ail Menu", + "[B]ulletin Menu", + "[S]tats Menu", + "[F]ortune", + "[W]all of Shame", + "[C]hannel Directory", + "EXIT: Exit current menu", + "[H]elp" + ] + if state and 'command' in state: + current_command = state['command'] + if current_command == 'MAIL': + commands = [ + "[0]Read Mail", + "[1]Send Mail", + "[2]Exit Mail Menu" + ] + elif current_command == 'BULLETIN': + commands = [ + "[0]General Board", + "[1]Info Board", + "[2]News Board", + "[3]Urgent Board", + "[4]Exit Bulletin Menu" + ] + elif current_command == 'STATS': + commands = [ + "[0]Total Nodes", + "[1]Total HW Models", + "[2]Total Roles", + "[3]Back" + ] + response = title + "Available commands:\n" + "\n".join(commands) + send_message(response, sender_id, interface) + + +def handle_stats_command(sender_id, interface): + response = "What stats would you like to view?\n[0]Node Numbers [1]Hardware [2]Roles [3]Main Menu" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'STATS', 'step': 1}) + + +def handle_fortune_command(sender_id, interface): + try: + with open('fortunes.txt', 'r') as file: + fortunes = file.readlines() + if not fortunes: + send_message("No fortunes available.", sender_id, interface) + return + fortune = random.choice(fortunes).strip() + decorated_fortune = f"🔮 {fortune} 🔮" + send_message(decorated_fortune, sender_id, interface) + except Exception as e: + send_message(f"Error generating fortune: {e}", sender_id, interface) + + +def handle_stats_steps(sender_id, message, step, interface, bbs_nodes): + if step == 1: + choice = message.upper() + if choice == '3': + handle_help_command(sender_id, interface) + return + choice = int(choice) + if choice == 0: + response = "Select time period for total nodes:\n[0]ALL [1]Last 24 Hours [2]Last 8 Hours [3]Last Hour" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'STATS', 'step': 2}) + elif choice == 1: + hw_models = {} + for node in interface.nodes.values(): + hw_model = node['user'].get('hwModel', 'Unknown') + hw_models[hw_model] = hw_models.get(hw_model, 0) + 1 + response = "Hardware Models:\n" + "\n".join([f"{model}: {count}" for model, count in hw_models.items()]) + send_message(response, sender_id, interface) + handle_stats_command(sender_id, interface) + elif choice == 2: + roles = {} + for node in interface.nodes.values(): + role = node['user'].get('role', 'Unknown') + roles[role] = roles.get(role, 0) + 1 + response = "Roles:\n" + "\n".join([f"{role}: {count}" for role, count in roles.items()]) + send_message(response, sender_id, interface) + handle_stats_command(sender_id, interface) + + elif step == 2: + choice = int(message) + current_time = int(time.time()) + if choice == 0: + total_nodes = len(interface.nodes) + send_message(f"Total nodes seen: {total_nodes}", sender_id, interface) + else: + time_limits = [86400, 28800, 3600] # Last 24 hours, Last 8 hours, Last hour + time_limit = current_time - time_limits[choice - 1] + total_nodes = 0 + for node in interface.nodes.values(): + last_heard = node.get('lastHeard', 0) + if last_heard is not None and last_heard >= time_limit: + total_nodes += 1 + logging.info(f"Node {node.get('user', {}).get('longName', 'Unknown')} heard at {last_heard}, within limit {time_limit}") + timeframes = ["24 hours", "8 hours", "hour"] + send_message(f"Total nodes seen in the last {timeframes[choice - 1]}: {total_nodes}", sender_id, interface) + handle_stats_steps(sender_id, '0', 1, interface, bbs_nodes) + + +def handle_bb_steps(sender_id, message, step, state, interface, bbs_nodes): + boards = {0: "General", 1: "News", 2: "Info", 3: "Urgent"} + + if step == 1: + if message == '4': + handle_help_command(sender_id, interface) + return + board_name = boards.get(int(message)) + if board_name: + response = f"What would you like to do in the {board_name} board?\n[0]View Bulletins [1]Post Bulletin [2]Exit" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN', 'step': 2, 'board': board_name}) + else: + handle_help_command(sender_id, interface) + update_user_state(sender_id, None) + + elif step == 2: + if message == '2': + # Return to the bulletin menu + response = "📰 BULLETIN MENU 📰\nWhich board would you like to enter?\n[0]General [1]Info [2]News [3]Urgent [4]Exit" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN', 'step': 1}) + return + if message == '0': + board_name = state['board'] + bulletins = get_bulletins(board_name) + if (bulletins): + send_message(f"Select a bulletin number to view from {board_name}:", sender_id, interface) + for bulletin in bulletins: + send_message(f"[{bulletin[0]}] {bulletin[1]}", sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN', 'step': 3, 'board': board_name}) + else: + send_message(f"No bulletins in {board_name}.", sender_id, interface) + # Go back to the board menu + response = f"What would you like to do in the {board_name} board?\n[0]View Bulletins [1]Post Bulletin [2]Exit" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN', 'step': 2, 'board': board_name}) + + elif message == '1': + send_message("What is the subject of your bulletin? Keep it short.", sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN', 'step': 4, 'board': state['board']}) + + elif step == 3: + bulletin_id = int(message) + sender_short_name, date, subject, content, unique_id = get_bulletin_content(bulletin_id) + send_message(f"From: {sender_short_name}\nDate: {date}\nSubject: {subject}\n- - - - - - -\n{content}", sender_id, interface) + board_name = state['board'] + response = f"What would you like to do in the {board_name} board?\n[0]View Bulletins [1]Post Bulletin [2]Exit" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN', 'step': 2, 'board': board_name}) + + elif step == 4: + subject = message + send_message("Send the contents of your bulletin. Send a message with END when finished.", sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN', 'step': 6, 'board': state['board'], 'subject': subject, 'content': ''}) + + elif step == 5: + if message.lower() == "y": + bulletins = get_bulletins(state['board']) + send_message(f"Select a bulletin number to view from {state['board']}:", sender_id, interface) + for bulletin in bulletins: + send_message(f"[{bulletin[0]}]\nSubject: {bulletin[1]}", sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN', 'step': 3, 'board': state['board']}) + else: + send_message("Okay, feel free to send another command.", sender_id, interface) + update_user_state(sender_id, None) + + elif step == 6: + if message.lower() == "end": + board = state['board'] + subject = state['subject'] + content = state['content'] + node_id = get_node_id_from_num(sender_id, interface) + node_info = interface.nodes.get(node_id) + if node_info is None: + send_message("Error: Unable to retrieve your node information.", sender_id, interface) + update_user_state(sender_id, None) + return + sender_short_name = node_info['user'].get('shortName', f"Node {sender_id}") + unique_id = add_bulletin(board, sender_short_name, subject, content, bbs_nodes, interface) + send_message(f"Your bulletin '{subject}' has been posted to {board}.\n(╯°□°)╯📄📌[{board}]", sender_id, interface) + response = f"What would you like to do in the {board} board?\n[0]View Bulletins [1]Post Bulletin [2]Exit" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN', 'step': 2, 'board': board}) + else: + state['content'] += message + "\n" + update_user_state(sender_id, state) + + +def handle_mail_steps(sender_id, message, step, state, interface, bbs_nodes): + if step == 1: + choice = message + if choice == '0': + sender_node_id = get_node_id_from_num(sender_id, interface) + mail = get_mail(sender_node_id) + if mail: + send_message(f"You have {len(mail)} mail messages. Select a message number to read:", sender_id, interface) + for msg in mail: + send_message(f"✉️ {msg[0]} ✉️\nDate: {msg[3]}\nFrom: {msg[1]}\nSubject: {msg[2]}", sender_id, interface) + update_user_state(sender_id, {'command': 'MAIL', 'step': 2}) + else: + send_message("There are no messages in your mailbox.\n(`⌒`)", sender_id, interface) + update_user_state(sender_id, None) + elif choice == '1': + send_message("What is the Short Name of the node you want to leave a message for?", sender_id, interface) + update_user_state(sender_id, {'command': 'MAIL', 'step': 3}) + elif choice == '2': + handle_help_command(sender_id, interface) + + elif step == 2: + mail_id = int(message) + sender, date, subject, content, unique_id = get_mail_content(mail_id) + send_message(f"Date: {date}\nFrom: {sender}\nSubject: {subject}\n{content}", sender_id, interface) + send_message("Would you like to delete this message now that you've viewed it? Y/N", sender_id, interface) + update_user_state(sender_id, {'command': 'MAIL', 'step': 4, 'mail_id': mail_id, 'unique_id': unique_id}) + + elif step == 3: + short_name = message + nodes = get_node_info(interface, short_name) + if not nodes: + send_message("I'm unable to find that node in my database.", sender_id, interface) + handle_mail_command(sender_id, interface) + elif len(nodes) == 1: + recipient_id = nodes[0]['num'] + recipient_name = get_node_name(recipient_id, interface) + send_message(f"What is the subject of your message to {recipient_name}?\nKeep it short.", sender_id, interface) + update_user_state(sender_id, {'command': 'MAIL', 'step': 5, 'recipient_id': recipient_id}) + else: + send_message("There are multiple nodes with that short name. Which one would you like to leave a message for?", sender_id, interface) + for i, node in enumerate(nodes): + send_message(f"[{i}] {node['longName']}", sender_id, interface) + update_user_state(sender_id, {'command': 'MAIL', 'step': 6, 'nodes': nodes}) + + elif step == 4: + if message.lower() == "y": + unique_id = state['unique_id'] + delete_mail(unique_id, bbs_nodes, interface) + send_message("The message has been deleted 🗑️", sender_id, interface) + else: + send_message("The message has been kept in your inbox.✉️\nJust don't let it get as messy as your regular email inbox (ಠ_ಠ)", sender_id, interface) + update_user_state(sender_id, None) + + elif step == 5: + subject = message + send_message("Send your message. You can send it in multiple messages if it's too long for one.\nSend a single message with END when you're done", sender_id, interface) + update_user_state(sender_id, {'command': 'MAIL', 'step': 7, 'recipient_id': state['recipient_id'], 'subject': subject, 'content': ''}) + + elif step == 6: + selected_node_index = int(message) + selected_node = state['nodes'][selected_node_index] + recipient_id = selected_node['num'] + recipient_name = get_node_name(recipient_id, interface) + send_message(f"What is the subject of your message to {recipient_name}?\nKeep it short.", sender_id, interface) + update_user_state(sender_id, {'command': 'MAIL', 'step': 5, 'recipient_id': recipient_id}) + + elif step == 7: + if message.lower() == "end": + recipient_id = state['recipient_id'] + subject = state['subject'] + content = state['content'] + recipient_name = get_node_name(recipient_id, interface) + sender_short_name = get_node_short_name(get_node_id_from_num(sender_id, interface), interface) + unique_id = add_mail(get_node_id_from_num(sender_id, interface), sender_short_name, recipient_id, subject, content, bbs_nodes, interface) + send_message(f"Mail has been posted to the mailbox of {recipient_name}.\n(╯°□°)╯📨📬", sender_id, interface) + + # Send notification to the recipient + notification_message = f"You have a new mail message from {sender_short_name}. Check your mailbox by responding to this message with M." + send_message(notification_message, recipient_id, interface) + + update_user_state(sender_id, None) + update_user_state(sender_id, {'command': 'MAIL', 'step': 8}) + else: + state['content'] += message + "\n" + update_user_state(sender_id, state) + + elif step == 8: + if message.lower() == "y": + handle_mail_command(sender_id, interface) + else: + send_message("Okay, feel free to send another command.", sender_id, interface) + update_user_state(sender_id, None) + + +def handle_wall_of_shame_command(sender_id, interface): + response = "Devices with battery levels below 20%:\n" + for node_id, node in interface.nodes.items(): + metrics = node.get('deviceMetrics', {}) + battery_level = metrics.get('batteryLevel', 101) + if battery_level < 20: + long_name = node['user']['longName'] + response += f"{long_name} - Battery {battery_level}%\n" + if response == "Devices with battery levels below 20%:\n": + response = "No devices with battery levels below 20% found." + send_message(response, sender_id, interface) + + +def handle_channel_directory_command(sender_id, interface): + response = "📚 CHANNEL DIRECTORY 📚\nWhat would you like to do in the Channel Directory?\n[0]View [1]Post [2]Exit" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'CHANNEL_DIRECTORY', 'step': 1}) + + +def handle_channel_directory_steps(sender_id, message, step, state, interface): + if step == 1: + choice = message + if choice == '2': + handle_help_command(sender_id, interface) + return + elif choice == '0': + channels = get_channels() + if channels: + response = "Select a channel number to view:\n" + "\n".join( + [f"[{i}] {channel[0]}" for i, channel in enumerate(channels)]) + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'CHANNEL_DIRECTORY', 'step': 2}) + else: + send_message("No channels available in the directory.", sender_id, interface) + handle_channel_directory_command(sender_id, interface) + elif choice == '1': + send_message("Name your channel for the directory:", sender_id, interface) + update_user_state(sender_id, {'command': 'CHANNEL_DIRECTORY', 'step': 3}) + + elif step == 2: + channel_index = int(message) + channels = get_channels() + if 0 <= channel_index < len(channels): + channel_name, channel_url = channels[channel_index] + send_message(f"Channel Name: {channel_name}\nChannel URL:\n{channel_url}", sender_id, interface) + handle_channel_directory_command(sender_id, interface) + + elif step == 3: + channel_name = message + send_message("Send a message with your channel URL:", sender_id, interface) + update_user_state(sender_id, {'command': 'CHANNEL_DIRECTORY', 'step': 4, 'channel_name': channel_name}) + + elif step == 4: + channel_url = message + channel_name = state['channel_name'] + add_channel(channel_name, channel_url) + send_message(f"Your channel '{channel_name}' has been added to the directory.", sender_id, interface) + handle_channel_directory_command(sender_id, interface) \ No newline at end of file diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..2b72c44 --- /dev/null +++ b/config.ini @@ -0,0 +1,34 @@ +############################### +#### Select Interface type #### +############################### +# [type = serial] for USB connected devices +#If there are multiple serial devices connected, be sure to use the "port" option and specify a port +# Linux Example: +# port = /dev/ttyUSB0 +# +# Windows Example: +# port = COM3 +# [type = tcp] for network connected devices (ESP32 devices only - this does not work for WisBlock) +# If using tcp, remove the # from the beginning and replace 192.168.x.x with the IP address of your device +# Example: +# [interface] +# type = tcp +# hostname = 192.168.1.100 + +[interface] +type = serial +port = /dev/ttyUSB0 +# hostname = 192.168.x.x + + +############################ +#### BBS NODE SYNC LIST #### +############################ +# Provide a list of other nodes running TC²-BBS to sync mail messages and bulletins with +# Enter in a list of other BBS Nodes by their nodeID separated by commas (no spaces) +# Example: +# [sync] +# bbs_nodes = !17d7e4b7,!18e9f5a3,!1a2b3c4d + +# [sync] +# bbs_nodes = !17d7e4b7 \ No newline at end of file diff --git a/config_init.py b/config_init.py new file mode 100644 index 0000000..a5e2e8a --- /dev/null +++ b/config_init.py @@ -0,0 +1,39 @@ +import configparser +import time +import meshtastic.serial_interface +import meshtastic.tcp_interface +import serial.tools.list_ports + +def initialize_config(): + config = configparser.ConfigParser() + config.read('config.ini') + interface_type = config['interface']['type'] + hostname = config['interface'].get('hostname', None) + port = config['interface'].get('port', None) + bbs_nodes = config['sync']['bbs_nodes'].split(',') + return config, interface_type, hostname, port, bbs_nodes + +def get_interface(interface_type, hostname=None, port=None): + while True: + try: + if interface_type == 'serial': + if port: + return meshtastic.serial_interface.SerialInterface(port) + else: + ports = list(serial.tools.list_ports.comports()) + if len(ports) == 1: + return meshtastic.serial_interface.SerialInterface(ports[0].device) + elif len(ports) > 1: + port_list = ', '.join([p.device for p in ports]) + raise ValueError(f"Multiple serial ports detected: {port_list}. Specify one with the 'port' argument.") + else: + raise ValueError("No serial ports detected.") + elif interface_type == 'tcp': + if not hostname: + raise ValueError("Hostname must be specified for TCP interface") + return meshtastic.tcp_interface.TCPInterface(hostname=hostname) + else: + raise ValueError("Invalid interface type specified in config file") + except PermissionError as e: + print(f"PermissionError: {e}. Retrying in 5 seconds...") + time.sleep(5) diff --git a/db_operations.py b/db_operations.py new file mode 100644 index 0000000..0d8aaa3 --- /dev/null +++ b/db_operations.py @@ -0,0 +1,149 @@ +import logging +import sqlite3 +import threading +import uuid +from datetime import datetime + +from utils import ( + send_bulletin_to_bbs_nodes, + send_delete_bulletin_to_bbs_nodes, + send_delete_mail_to_bbs_nodes, + send_mail_to_bbs_nodes, send_message +) + + +thread_local = threading.local() + +def get_db_connection(): + if not hasattr(thread_local, 'connection'): + thread_local.connection = sqlite3.connect('bulletins.db') + return thread_local.connection + +def initialize_database(): + conn = get_db_connection() + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS bulletins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + board TEXT NOT NULL, + sender_short_name TEXT NOT NULL, + date TEXT NOT NULL, + subject TEXT NOT NULL, + content TEXT NOT NULL, + unique_id TEXT NOT NULL + )''') + c.execute('''CREATE TABLE IF NOT EXISTS mail ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender TEXT NOT NULL, + sender_short_name TEXT NOT NULL, + recipient TEXT NOT NULL, + date TEXT NOT NULL, + subject TEXT NOT NULL, + content TEXT NOT NULL, + unique_id TEXT NOT NULL + );''') + c.execute('''CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL + );''') + conn.commit() + print("Database schema initialized.") + +def add_channel(name, url): + conn = get_db_connection() + c = conn.cursor() + c.execute("INSERT INTO channels (name, url) VALUES (?, ?)", (name, url)) + conn.commit() + +def get_channels(): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT name, url FROM channels") + return c.fetchall() + + + +def add_bulletin(board, sender_short_name, subject, content, bbs_nodes, interface, unique_id=None): + conn = get_db_connection() + c = conn.cursor() + date = datetime.now().strftime('%m/%d/%Y %H:%M') + if not unique_id: + unique_id = str(uuid.uuid4()) + c.execute( + "INSERT INTO bulletins (board, sender_short_name, date, subject, content, unique_id) VALUES (?, ?, ?, ?, ?, ?)", + (board, sender_short_name, date, subject, content, unique_id)) + conn.commit() + if bbs_nodes and interface: + send_bulletin_to_bbs_nodes(board, sender_short_name, subject, content, unique_id, bbs_nodes, interface) + + # New logic to send group chat notification for urgent bulletins + if board.lower() == "urgent": + group_chat_id = 4294967295 # Default group chat ID (0xFFFFFFFF) + notification_message = f"💥NEW URGENT BULLETIN💥\nFrom: {sender_short_name}\nTitle: {subject}" + send_message(notification_message, group_chat_id, interface) + + return unique_id + +def get_bulletins(board): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT id, subject, sender_short_name, date, unique_id FROM bulletins WHERE board = ?", (board,)) + return c.fetchall() + +def get_bulletin_content(bulletin_id): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT sender_short_name, date, subject, content, unique_id FROM bulletins WHERE id = ?", (bulletin_id,)) + return c.fetchone() + + +def delete_bulletin(bulletin_id, bbs_nodes, interface): + conn = get_db_connection() + c = conn.cursor() + c.execute("DELETE FROM bulletins WHERE id = ?", (bulletin_id,)) + conn.commit() + send_delete_bulletin_to_bbs_nodes(bulletin_id, bbs_nodes, interface) + +def add_mail(sender_id, sender_short_name, recipient_id, subject, content, bbs_nodes, interface, unique_id=None): + conn = get_db_connection() + c = conn.cursor() + date = datetime.now().strftime('%m/%d/%Y %H:%M') + if not unique_id: + unique_id = str(uuid.uuid4()) + c.execute("INSERT INTO mail (sender, sender_short_name, recipient, date, subject, content, unique_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + (sender_id, sender_short_name, recipient_id, date, subject, content, unique_id)) + conn.commit() + if bbs_nodes and interface: + send_mail_to_bbs_nodes(sender_id, sender_short_name, recipient_id, subject, content, unique_id, bbs_nodes, interface) + return unique_id + +def get_mail(recipient_id): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT id, sender_short_name, subject, date, unique_id FROM mail WHERE recipient = ?", (recipient_id,)) + return c.fetchall() + +def get_mail_content(mail_id): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT sender_short_name, date, subject, content, unique_id FROM mail WHERE id = ?", (mail_id,)) + return c.fetchone() + +def delete_mail(unique_id, bbs_nodes, interface): + logging.info(f"Attempting to delete mail with unique_id: {unique_id}") + conn = get_db_connection() + c = conn.cursor() + try: + c.execute("SELECT unique_id FROM mail WHERE unique_id = ?", (unique_id,)) + result = c.fetchone() + logging.debug(f"Fetch result for unique_id {unique_id}: {result}") + if result is None: + logging.error(f"No mail found with unique_id: {unique_id}") + return # Early exit if no matching mail found + c.execute("DELETE FROM mail WHERE unique_id = ?", (unique_id,)) + conn.commit() + send_delete_mail_to_bbs_nodes(unique_id, bbs_nodes, interface) + logging.info(f"Mail with unique_id: {unique_id} deleted and sync message sent.") + except Exception as e: + logging.error(f"Error deleting mail with unique_id {unique_id}: {e}") + raise diff --git a/fortunes.txt b/fortunes.txt new file mode 100644 index 0000000..971870b --- /dev/null +++ b/fortunes.txt @@ -0,0 +1,100 @@ +Nothing is impossible. Except Monday mornings. +You are not illiterate. +A comfort zone is a magical place where nothing ever grows. +Give yourself a break before you breakdown. +If a true sense of value is to be yours it must come through service. +Happiness is enjoying what you got. Never from what you want. +Go confidently in the direction of your dreams. +Genius is eternal patience. +Follow the advice of your heart. +Follow the middle path. Neither extreme will make you happy. +Fight for it. You will come out on the top. +Failure is the tuition you pay for success. +Expect a change for the better. +Examine the situation before you act impulsively. +Everything you are against weakens you. Everything you are for empowers you. +Curiosity kills boredom. Nothing can kill curiosity. +Common sense is instinct. Enough of it is genius. +Call an old friend today. +Better ask twice than lose yourself once. +Behavior is a mirror in which everyone shows his own image. +Balance life with a little sweet & sour. +Attitude is a little thing that makes a BIG difference. +An unexpected check or fortune will arrive today. +An unexpected event will bring you riches. +An upset is an opportunity to see the truth. +Any rough times are behind you. +An important person will offer you support. +An investment in knowledge always pays the best interest. +An investment in yourself will pay dividends for the rest of your life. +An old wish will come true. +All your hard work will soon pay off. +All your sorrows will vanish. +All the little things will add to a happy journey. +All that we are is a result of what we have thought. +All the effort you are making will ultimately pay off. +Act boldly and unseen forces will come to your aid. +6 out of every 10 people refuse to be a statistic. +a belief is just a thought you keep having +A bit of fragrance clings to the hand that gives flowers. +Absolutes are absolutely wrong +abstraction is a type of decadence +Abuse of authority comes as no surprise. +Abuse of power comes as no surprise +Acting without thinking can be awfully entertaining. +Actions speak louder than words... but nothing speaks louder than they who take no action +Adapt or die. +Ad astra per aspera. +A fine will be charged for wounding the library books. +A good man has few enemies. A ruthless man has none. +A lack of leadership is no substitute for inaction. +Alarm clocks kill dreams. +A little knowledge can go a long way +A little madness now and then is relished by the wisest men. +A little rudeness and disrespect can elevate a meaningless interaction to a battle of wills and add drama to an otherwise dull day. +All employees are forbidden. +All this foliage is obstructing my scenic view. +Almost half of them are BELOW AVERAGE +Always be ready to walk away from a bad idea. +Always store beer in a dark place. +Annihilate the adversaries with laughter. +An object at rest cannot be stopped +Are You Mad At Yourself for Not Exiting +a sense of timing is the mark of genius +Asking questions is the only way to get answers. +A suspicious mind is a healthy mind +Frogs are my favorite vegetable. +GET OFF THE INTERNET AND DO SOMETHING +GET OVER IT +Get paranoid to act fast. +Get ready for this. +Good luck on your exam. +Go places. +Go talk to that old weird guy at Dennys. +Government Complaints Box 📥 +Happiness is a new idea. +Has to happen to somebody. +The poors are at it again. +Have a nice day. Or else +History is written by the winners +I can see you +ice cream is never the wrong decision +I found Nirvana. It was in my album collection. +If seeing is believing you have to be blind. +I saw what you did just now +It is forbidden to forbid. +It is happening again. +Knock yourself out. +Knowing is hard. +knowing yourself lets you understand others +knowledge should be advanced at all costs +Look for people picking their nose in their car. +Looks like someone divided by zero. +Look up. +MAKE IT STOP +Make weirdness normal. +Mashed potatoes can be your friend. +Never wrestle a pig. You both get dirty and the pig likes it. +Nice one. You got away with it. +Roll again. +SHHHHHH. listen. \ No newline at end of file diff --git a/message_processing.py b/message_processing.py new file mode 100644 index 0000000..49dd1b0 --- /dev/null +++ b/message_processing.py @@ -0,0 +1,96 @@ +import logging + +from command_handlers import ( + handle_mail_command, handle_bulletin_command, handle_exit_command, + handle_help_command, handle_stats_command, handle_fortune_command, + handle_bb_steps, handle_mail_steps, handle_stats_steps, handle_wall_of_shame_command, + handle_channel_directory_command, handle_channel_directory_steps +) + +from db_operations import add_bulletin, add_mail, delete_bulletin, delete_mail +from utils import get_user_state, get_node_short_name, get_node_id_from_num, send_message + +command_handlers = { + "m": handle_mail_command, + "b": handle_bulletin_command, + "s": handle_stats_command, + "f": handle_fortune_command, + "w": handle_wall_of_shame_command, + "exit": handle_exit_command, + "h": handle_help_command, + "c": handle_channel_directory_command +} + +def process_message(sender_id, message, interface, is_sync_message=False): + state = get_user_state(sender_id) + message_lower = message.lower() + bbs_nodes = interface.bbs_nodes + + if is_sync_message: + if message.startswith("BULLETIN|"): + parts = message.split("|") + board, sender_short_name, subject, content, unique_id = parts[1], parts[2], parts[3], parts[4], parts[5] + add_bulletin(board, sender_short_name, subject, content, [], interface, unique_id=unique_id) + + if board.lower() == "urgent": + group_chat_id = 4294967295 + notification_message = f"💥NEW URGENT BULLETIN💥\nFrom: {sender_short_name}\nTitle: {subject}" + send_message(notification_message, group_chat_id, interface) + elif message.startswith("MAIL|"): + parts = message.split("|") + sender_id, sender_short_name, recipient_id, subject, content, unique_id = parts[1], parts[2], parts[3], parts[4], parts[5], parts[6] + add_mail(sender_id, sender_short_name, recipient_id, subject, content, [], interface, unique_id=unique_id) + elif message.startswith("DELETE_BULLETIN|"): + unique_id = message.split("|")[1] + delete_bulletin(unique_id, [], interface) + elif message.startswith("DELETE_MAIL|"): + unique_id = message.split("|")[1] + logging.info(f"Processing delete mail with unique_id: {unique_id}") + delete_mail(unique_id, [], interface) + else: + if message_lower in command_handlers: + command_handlers[message_lower](sender_id, interface) + elif state: + command = state['command'] + step = state['step'] + + if command == 'MAIL': + handle_mail_steps(sender_id, message, step, state, interface, bbs_nodes) + elif command == 'BULLETIN': + handle_bb_steps(sender_id, message, step, state, interface, bbs_nodes) + elif command == 'STATS': + handle_stats_steps(sender_id, message, step, interface, bbs_nodes) + elif command == 'CHANNEL_DIRECTORY': + handle_channel_directory_steps(sender_id, message, step, state, interface) + else: + handle_help_command(sender_id, interface) + +def on_receive(packet, interface): + try: + if 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP': + message_bytes = packet['decoded']['payload'] + message_string = message_bytes.decode('utf-8') + sender_id = packet['from'] + to_id = packet.get('to') + sender_node_id = packet['fromId'] + + sender_short_name = get_node_short_name(sender_node_id, interface) + receiver_short_name = get_node_short_name(get_node_id_from_num(to_id, interface), + interface) if to_id else "Group Chat" + logging.info(f"Received message from user '{sender_short_name}' to {receiver_short_name}: {message_string}") + + bbs_nodes = interface.bbs_nodes + is_sync_message = any(message_string.startswith(prefix) for prefix in + ["BULLETIN|", "MAIL|", "DELETE_BULLETIN|", "DELETE_MAIL|"]) + + if sender_node_id in bbs_nodes: + if is_sync_message: + process_message(sender_id, message_string, interface, is_sync_message=True) + else: + logging.info("Ignoring non-sync message from known BBS node") + elif to_id is not None and to_id != 0 and to_id != 255 and to_id == interface.myInfo.my_node_num: + process_message(sender_id, message_string, interface, is_sync_message=False) + else: + logging.info("Ignoring message sent to group chat or from unknown node") + except KeyError as e: + logging.error(f"Error processing packet: {e}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d51a371 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +meshtastic +pypubsub diff --git a/server.py b/server.py new file mode 100644 index 0000000..35dee8e --- /dev/null +++ b/server.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +""" +TC²-BBS Server for Meshtastic by TheCommsChannel (TC²) +Date: 06/25/2024 +Version: 0.1.0 + +Description: +The system allows for mail message handling, bulletin boards, and a channel +directory. It uses a configuration file for setup details and an SQLite3 +database for data storage. Mail messages and bulletins are synced with +other BBS servers listed in the config.ini file. +""" + +import logging + +from config_init import initialize_config, get_interface +from db_operations import initialize_database +from message_processing import on_receive +from pubsub import pub + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +def display_banner(): + banner = """ +████████╗ ██████╗██████╗ ██████╗ ██████╗ ███████╗ +╚══██╔══╝██╔════╝╚════██╗ ██╔══██╗██╔══██╗██╔════╝ + ██║ ██║ █████╔╝█████╗██████╔╝██████╔╝███████╗ + ██║ ██║ ██╔═══╝ ╚════╝██╔══██╗██╔══██╗╚════██║ + ██║ ╚██████╗███████╗ ██████╔╝██████╔╝███████║ + ╚═╝ ╚═════╝╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝ +Meshtastic Version +""" + print(banner) + +def main(): + display_banner() + config, interface_type, hostname, port, bbs_nodes = initialize_config() + interface = get_interface(interface_type, hostname, port) + interface.bbs_nodes = bbs_nodes + + logging.info(f"TC²-BBS is running on {interface_type} interface...") + + initialize_database() + + def receive_packet(packet): + on_receive(packet, interface) + + pub.subscribe(receive_packet, 'meshtastic.receive') + + try: + while True: + pass + except KeyboardInterrupt: + logging.info("Shutting down the server...") + interface.close() + +if __name__ == "__main__": + main() diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..460aea8 --- /dev/null +++ b/utils.py @@ -0,0 +1,73 @@ +import logging +import time + +user_states = {} + + +def update_user_state(user_id, state): + user_states[user_id] = state + + +def get_user_state(user_id): + return user_states.get(user_id, None) + + +def send_message(message, destination, interface): + max_payload_size = 200 + for i in range(0, len(message), max_payload_size): + chunk = message[i:i + max_payload_size] + interface.sendText( + text=chunk, + destinationId=destination, + wantAck=False, + wantResponse=False + ) + time.sleep(2) + + +def get_node_info(interface, short_name): + nodes = [{'num': node_id, 'shortName': node['user']['shortName'], 'longName': node['user']['longName']} + for node_id, node in interface.nodes.items() + if node['user']['shortName'] == short_name] + return nodes + + +def get_node_id_from_num(node_num, interface): + for node_id, node in interface.nodes.items(): + if node['num'] == node_num: + return node_id + return None + + +def get_node_short_name(node_id, interface): + node_info = interface.nodes.get(node_id) + if node_info: + return node_info['user']['shortName'] + return None + + +def send_bulletin_to_bbs_nodes(board, sender_short_name, subject, content, unique_id, bbs_nodes, interface): + message = f"BULLETIN|{board}|{sender_short_name}|{subject}|{content}|{unique_id}" + for node_id in bbs_nodes: + send_message(message, node_id, interface) + + +def send_mail_to_bbs_nodes(sender_id, sender_short_name, recipient_id, subject, content, unique_id, bbs_nodes, + interface): + message = f"MAIL|{sender_id}|{sender_short_name}|{recipient_id}|{subject}|{content}|{unique_id}" + logging.info(f"SERVER SYNC: Syncing new mail message {subject} sent from {sender_short_name} to other BBS systems.") + for node_id in bbs_nodes: + send_message(message, node_id, interface) + + +def send_delete_bulletin_to_bbs_nodes(bulletin_id, bbs_nodes, interface): + message = f"DELETE_BULLETIN|{bulletin_id}" + for node_id in bbs_nodes: + send_message(message, node_id, interface) + + +def send_delete_mail_to_bbs_nodes(unique_id, bbs_nodes, interface): + message = f"DELETE_MAIL|{unique_id}" + logging.info(f"SERVER SYNC: Sending delete mail sync message with unique_id: {unique_id}") + for node_id in bbs_nodes: + send_message(message, node_id, interface)