diff --git a/.gitignore b/.gitignore index e4d6245..739b0d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ __pycache__/ bulletins.db +js8call.db venv/ .venv +.idea +config.ini +fortunes.txt diff --git a/README.md b/README.md index 26249c7..c743287 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,15 @@ If you're a Docker user, TCΒ²-BBS Meshtastic is available on Docker Hub! pip install -r requirements.txt ``` -5. Set up the configuration in `config.ini`: +5. Rename `example_config.ini`: + + ```sh + mv example_config.ini config.ini + ``` + +6. Set up the configuration in `config.ini`: + + You'll need to open up the config.ini file in a text editor and make your changes following the instructions below **[interface]** If using `type = serial` and you have multiple devices connected, you will need to uncomment the `port =` line and enter the port of your device. @@ -159,7 +167,7 @@ If you would like to have the script automatically run at boot, follow the steps sudo systemctl start mesh-bbs.service ``` - The service should be started now and should start anytime your device is powered on or rebooted. You can check the status ofk the service by running the following command: + The service should be started now and should start anytime your device is powered on or rebooted. You can check the status of the service by running the following command: ```sh sudo systemctl status mesh-bbs.service @@ -171,14 +179,18 @@ If you would like to have the script automatically run at boot, follow the steps sudo systemctl stop mesh-bbs.service ``` - If you make changes to the watchlist.txt file, you will need to restart the service with the following command: + If you need to restart the service, you can do so with the following command: ```sh sudo systemctl restart mesh-bbs.service ``` ## Radio Configuration -Note: Radio device role must be set to **CLIENT**, other roles may allow the BBS to communicate for a short time, but then the BBS will stop responding to requests +Note: There have been reports of issues with some device roles that may allow the BBS to communicate for a short time, but then the BBS will stop responding to requests. + +The following device roles have been working: +- **Client** +- **Router_Client** ## Features diff --git a/command_handlers.py b/command_handlers.py index 8e81266..46bb9ad 100644 --- a/command_handlers.py +++ b/command_handlers.py @@ -2,12 +2,13 @@ import logging import random import time -from config_init import initialize_config +from meshtastic import BROADCAST_NUM + from db_operations import ( add_bulletin, add_mail, delete_mail, get_bulletin_content, get_bulletins, get_mail, get_mail_content, - add_channel, get_channels + add_channel, get_channels, get_sender_id_by_mail_id ) from utils import ( get_node_id_from_num, get_node_info, @@ -16,6 +17,19 @@ from utils import ( ) +def handle_help_command(sender_id, interface, menu_name=None): + if menu_name: + update_user_state(sender_id, {'command': 'MENU', 'menu': menu_name, 'step': 1}) + if menu_name == 'bbs': + response = "πŸ“°BBS MenuπŸ“°\n[M]ail\n[B]ulletins\n[C]hannel Dir\n[J]S8CALL\nE[X]IT" + elif menu_name == 'utilities': + response = "πŸ› οΈUtilities MenuπŸ› οΈ\n[S]tats\n[F]ortune\n[W]all of Shame\nE[X]IT" + else: + update_user_state(sender_id, {'command': 'MAIN_MENU', 'step': 1}) # Reset to main menu state + response = "πŸ’ΎTCΒ² BBSπŸ’Ύ\n[Q]uick Commands\n[B]BS\n[U]tilities\nE[X]IT" + send_message(response, sender_id, interface) + + def get_node_name(node_id, interface): node_info = interface.nodes.get(node_id) if node_info: @@ -24,15 +38,16 @@ def get_node_name(node_id, interface): 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" + response = "βœ‰οΈMail Menuβœ‰οΈ\nWhat would you like to do with mail?\n[R]ead [S]end E[X]IT" 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" + response = "πŸ“°Bulletin MenuπŸ“°\nWhich board would you like to enter?\n[G]eneral [I]nfo [N]ews [U]rgent" send_message(response, sender_id, interface) - update_user_state(sender_id, {'command': 'BULLETIN', 'step': 1}) + update_user_state(sender_id, {'command': 'BULLETIN_MENU', 'step': 1}) def handle_exit_command(sender_id, interface): @@ -40,47 +55,8 @@ def handle_exit_command(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" + response = "πŸ“ŠStats MenuπŸ“Š\nWhat stats would you like to view?\n[N]odes [H]ardware [R]oles E[X]IT" send_message(response, sender_id, interface) update_user_state(sender_id, {'command': 'STATS', 'step': 1}) @@ -99,18 +75,34 @@ def handle_fortune_command(sender_id, interface): send_message(f"Error generating fortune: {e}", sender_id, interface) -def handle_stats_steps(sender_id, message, step, interface, bbs_nodes): +def handle_stats_steps(sender_id, message, step, interface): if step == 1: - choice = message.upper() - if choice == '3': + choice = message.lower() + if choice == 'x': 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" + elif choice == 'n': + current_time = int(time.time()) + timeframes = { + "All time": None, + "Last 24 hours": 86400, + "Last 8 hours": 28800, + "Last hour": 3600 + } + total_nodes_summary = [] + + for period, seconds in timeframes.items(): + if seconds is None: + total_nodes = len(interface.nodes) + else: + time_limit = current_time - seconds + total_nodes = sum(1 for node in interface.nodes.values() if node.get('lastHeard', 0) >= time_limit) + total_nodes_summary.append(f"- {period}: {total_nodes}") + + response = "Total nodes seen:\n" + "\n".join(total_nodes_summary) send_message(response, sender_id, interface) - update_user_state(sender_id, {'command': 'STATS', 'step': 2}) - elif choice == 1: + handle_stats_command(sender_id, interface) + elif choice == 'h': hw_models = {} for node in interface.nodes.values(): hw_model = node['user'].get('hwModel', 'Unknown') @@ -118,7 +110,7 @@ def handle_stats_steps(sender_id, message, step, interface, bbs_nodes): 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: + elif choice == 'r': roles = {} for node in interface.nodes.values(): role = node['user'].get('role', 'Unknown') @@ -127,94 +119,56 @@ def handle_stats_steps(sender_id, message, step, interface, bbs_nodes): 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"} - + boards = {0: "General", 1: "Info", 2: "News", 3: "Urgent"} if step == 1: - if message == '4': - handle_help_command(sender_id, interface) + if message.lower() == 'e': + handle_help_command(sender_id, interface, 'bbs') 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) + board_name = boards[int(message)] + response = f"What would you like to do in the {board_name} board?\n[R]ead [P]ost" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'BULLETIN_ACTION', 'step': 2, 'board': board_name}) 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'] + board_name = state['board'] + if message.lower() == 'r': bulletins = get_bulletins(board_name) - if (bulletins): + 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}) + update_user_state(sender_id, {'command': 'BULLETIN_READ', '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': + handle_bb_steps(sender_id, 'e', 1, state, interface, bbs_nodes) + elif message.lower() == 'p': + if board_name.lower() == 'urgent': + node_id = get_node_id_from_num(sender_id, interface) + allowed_nodes = interface.allowed_nodes + print(f"Checking permissions for node_id: {node_id} with allowed_nodes: {allowed_nodes}") # Debug statement + if allowed_nodes and node_id not in allowed_nodes: + send_message("You don't have permission to post to this board.", sender_id, interface) + handle_bb_steps(sender_id, 'e', 1, state, interface, bbs_nodes) + return 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']}) + update_user_state(sender_id, {'command': 'BULLETIN_POST', 'step': 4, 'board': board_name}) 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}) + handle_bb_steps(sender_id, 'e', 1, state, interface, bbs_nodes) 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': ''}) + update_user_state(sender_id, {'command': 'BULLETIN_POST_CONTENT', 'step': 5, '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'] @@ -228,52 +182,48 @@ def handle_bb_steps(sender_id, message, step, state, interface, bbs_nodes): 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}) + handle_bb_steps(sender_id, 'e', 1, state, interface, bbs_nodes) 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': + choice = message.lower() + if choice == 'r': 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) + 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) + send_message("There are no messages in your mailbox.πŸ“­", sender_id, interface) update_user_state(sender_id, None) - elif choice == '1': + elif choice == 's': 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': + elif choice == 'x': handle_help_command(sender_id, interface) elif step == 2: mail_id = int(message) try: - - # ERROR: sender_id is not what is stored in the DB sender_node_id = get_node_id_from_num(sender_id, interface) sender, date, subject, content, unique_id = get_mail_content(mail_id, sender_node_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}) + send_message("What would you like to do with this message?\n[K]eep [D]elete [R]eply", sender_id, interface) + update_user_state(sender_id, {'command': 'MAIL', 'step': 4, 'mail_id': mail_id, 'unique_id': unique_id, 'sender': sender, 'subject': subject, 'content': content}) except TypeError: - # get_main_content returned None. Node tried to access somebody's else mail message logging.info(f"Node {sender_id} tried to access non-existent message") - send_message(f"Mail not found", sender_id, interface) + send_message("Mail not found", sender_id, interface) update_user_state(sender_id, None) elif step == 3: - short_name = message + short_name = message.lower() 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) @@ -290,14 +240,19 @@ def handle_mail_steps(sender_id, message, step, state, interface, bbs_nodes): update_user_state(sender_id, {'command': 'MAIL', 'step': 6, 'nodes': nodes}) elif step == 4: - if message.lower() == "y": + if message.lower() == "d": unique_id = state['unique_id'] sender_node_id = get_node_id_from_num(sender_id, interface) delete_mail(unique_id, sender_node_id, bbs_nodes, interface) send_message("The message has been deleted πŸ—‘οΈ", sender_id, interface) + update_user_state(sender_id, None) + elif message.lower() == "r": + sender = state['sender'] + send_message(f"Send your reply to {sender} now, followed by a message with END", sender_id, interface) + update_user_state(sender_id, {'command': 'MAIL', 'step': 7, 'reply_to_mail_id': state['mail_id'], 'subject': f"Re: {state['subject']}", 'content': ''}) 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) + send_message("The message has been kept in your inbox.βœ‰οΈ", sender_id, interface) + update_user_state(sender_id, None) elif step == 5: subject = message @@ -314,16 +269,19 @@ def handle_mail_steps(sender_id, message, step, state, interface, bbs_nodes): elif step == 7: if message.lower() == "end": - recipient_id = state['recipient_id'] + if 'reply_to_mail_id' in state: + recipient_id = get_sender_id_by_mail_id(state['reply_to_mail_id']) # Get the sender ID from the mail ID + else: + recipient_id = state.get('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." + notification_message = f"You have a new mail message from {sender_short_name}. Check your mailbox by responding to this message with CM." send_message(notification_message, recipient_id, interface) update_user_state(sender_id, None) @@ -354,18 +312,18 @@ def handle_wall_of_shame_command(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" + response = "πŸ“šCHANNEL DIRECTORYπŸ“š\nWhat would you like to do?\n[V]iew [P]ost E[X]IT" 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': + choice = message.lower() + if choice == 'x': handle_help_command(sender_id, interface) return - elif choice == '0': + elif choice == 'v': channels = get_channels() if channels: response = "Select a channel number to view:\n" + "\n".join( @@ -375,7 +333,7 @@ def handle_channel_directory_steps(sender_id, message, step, state, interface): else: send_message("No channels available in the directory.", sender_id, interface) handle_channel_directory_command(sender_id, interface) - elif choice == '1': + elif choice == 'p': send_message("Name your channel for the directory:", sender_id, interface) update_user_state(sender_id, {'command': 'CHANNEL_DIRECTORY', 'step': 3}) @@ -389,7 +347,7 @@ def handle_channel_directory_steps(sender_id, message, step, state, interface): elif step == 3: channel_name = message - send_message("Send a message with your channel URL:", sender_id, interface) + send_message("Send a message with your channel URL or PSK:", sender_id, interface) update_user_state(sender_id, {'command': 'CHANNEL_DIRECTORY', 'step': 4, 'channel_name': channel_name}) elif step == 4: @@ -397,4 +355,256 @@ def handle_channel_directory_steps(sender_id, message, step, state, interface): 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 + handle_channel_directory_command(sender_id, interface) + + +def handle_send_mail_command(sender_id, message, interface, bbs_nodes): + try: + parts = message.split(",,", 3) + if len(parts) != 4: + send_message("Send Mail Quick Command format:\nSM,,{short_name},,{subject},,{message}", sender_id, interface) + return + + _, short_name, subject, content = parts + nodes = get_node_info(interface, short_name.lower()) + if not nodes: + send_message(f"Node with short name '{short_name}' not found.", sender_id, interface) + return + if len(nodes) > 1: + send_message(f"Multiple nodes with short name '{short_name}' found. Please be more specific.", sender_id, + interface) + return + + recipient_id = nodes[0]['num'] + 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 sent to {recipient_name}.", sender_id, interface) + + notification_message = f"You have a new mail message from {sender_short_name}. Check your mailbox by responding to this message with CM." + send_message(notification_message, recipient_id, interface) + + except Exception as e: + logging.error(f"Error processing send mail command: {e}") + send_message("Error processing send mail command.", sender_id, interface) + + +def handle_check_mail_command(sender_id, interface): + try: + sender_node_id = get_node_id_from_num(sender_id, interface) + mail = get_mail(sender_node_id) + if not mail: + send_message("You have no new messages.", sender_id, interface) + return + + response = "πŸ“¬ You have the following messages:\n" + for i, msg in enumerate(mail): + response += f"{i + 1:02d}. From: {msg[1]}, Subject: {msg[2]}\n" + response += "\nPlease reply with the number of the message you want to read." + send_message(response, sender_id, interface) + + update_user_state(sender_id, {'command': 'CHECK_MAIL', 'step': 1, 'mail': mail}) + + except Exception as e: + logging.error(f"Error processing check mail command: {e}") + send_message("Error processing check mail command.", sender_id, interface) + + +def handle_read_mail_command(sender_id, message, state, interface): + try: + mail = state.get('mail', []) + message_number = int(message) - 1 + + if message_number < 0 or message_number >= len(mail): + send_message("Invalid message number. Please try again.", sender_id, interface) + return + + mail_id = mail[message_number][0] + sender_node_id = get_node_id_from_num(sender_id, interface) + sender, date, subject, content, unique_id = get_mail_content(mail_id, sender_node_id) + response = f"Date: {date}\nFrom: {sender}\nSubject: {subject}\n\n{content}" + send_message(response, sender_id, interface) + send_message("Would you like to delete this message now that you've read it? Y/N", sender_id, interface) + update_user_state(sender_id, {'command': 'CHECK_MAIL', 'step': 2, 'mail_id': mail_id, 'unique_id': unique_id}) + + except ValueError: + send_message("Invalid input. Please enter a valid message number.", sender_id, interface) + except Exception as e: + logging.error(f"Error processing read mail command: {e}") + send_message("Error processing read mail command.", sender_id, interface) + + +def handle_delete_mail_confirmation(sender_id, message, state, interface, bbs_nodes): + try: + choice = message.lower() + if choice == 'y': + unique_id = state['unique_id'] + sender_node_id = get_node_id_from_num(sender_id, interface) + delete_mail(unique_id, sender_node_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.βœ‰οΈ", sender_id, interface) + + update_user_state(sender_id, None) + + except Exception as e: + logging.error(f"Error processing delete mail confirmation: {e}") + send_message("Error processing delete mail confirmation.", sender_id, interface) + + +def handle_post_bulletin_command(sender_id, message, interface, bbs_nodes): + try: + parts = message.split(",,", 3) + if len(parts) != 4: + send_message("Post Bulletin Quick Command format:\nPB,,{board_name},,{subject},,{content}", sender_id, interface) + return + + _, board_name, subject, content = parts + sender_short_name = get_node_short_name(get_node_id_from_num(sender_id, interface), interface) + + unique_id = add_bulletin(board_name, sender_short_name, subject, content, bbs_nodes, interface) + send_message(f"Your bulletin '{subject}' has been posted to {board_name}.", sender_id, interface) + + if board_name.lower() == "urgent": + notification_message = f"πŸ’₯NEW URGENT BULLETINπŸ’₯\nFrom: {sender_short_name}\nTitle: {subject}" + send_message(notification_message, BROADCAST_NUM, interface) + + except Exception as e: + logging.error(f"Error processing post bulletin command: {e}") + send_message("Error processing post bulletin command.", sender_id, interface) + + +def handle_check_bulletin_command(sender_id, message, interface): + try: + # Split the message only once + parts = message.split(",,", 1) + if len(parts) != 2 or not parts[1].strip(): + send_message("Check Bulletins Quick Command format:\nCB,,{board_name}", sender_id, interface) + return + + board_name = parts[1].strip() + bulletins = get_bulletins(board_name) + if not bulletins: + send_message(f"No bulletins available on {board_name} board.", sender_id, interface) + return + + response = f"πŸ“° Bulletins on {board_name} board:\n" + for i, bulletin in enumerate(bulletins): + response += f"[{i+1:02d}] Subject: {bulletin[1]}, From: {bulletin[2]}, Date: {bulletin[3]}\n" + response += "\nPlease reply with the number of the bulletin you want to read." + send_message(response, sender_id, interface) + + update_user_state(sender_id, {'command': 'CHECK_BULLETIN', 'step': 1, 'board_name': board_name, 'bulletins': bulletins}) + + except Exception as e: + logging.error(f"Error processing check bulletin command: {e}") + send_message("Error processing check bulletin command.", sender_id, interface) + +def handle_read_bulletin_command(sender_id, message, state, interface): + try: + bulletins = state.get('bulletins', []) + message_number = int(message) - 1 + + if message_number < 0 or message_number >= len(bulletins): + send_message("Invalid bulletin number. Please try again.", sender_id, interface) + return + + bulletin_id = bulletins[message_number][0] + sender, date, subject, content, unique_id = get_bulletin_content(bulletin_id) + response = f"Date: {date}\nFrom: {sender}\nSubject: {subject}\n\n{content}" + send_message(response, sender_id, interface) + + update_user_state(sender_id, None) + + except ValueError: + send_message("Invalid input. Please enter a valid bulletin number.", sender_id, interface) + except Exception as e: + logging.error(f"Error processing read bulletin command: {e}") + send_message("Error processing read bulletin command.", sender_id, interface) + + +def handle_post_channel_command(sender_id, message, interface): + try: + parts = message.split("|", 3) + if len(parts) != 3: + send_message("Post Channel Quick Command format:\nCHP,,{channel_name},,{channel_url}", sender_id, interface) + return + + _, channel_name, channel_url = parts + bbs_nodes = interface.bbs_nodes + add_channel(channel_name, channel_url, bbs_nodes, interface) + send_message(f"Channel '{channel_name}' has been added to the directory.", sender_id, interface) + + except Exception as e: + logging.error(f"Error processing post channel command: {e}") + send_message("Error processing post channel command.", sender_id, interface) + + +def handle_check_channel_command(sender_id, interface): + try: + channels = get_channels() + if not channels: + send_message("No channels available in the directory.", sender_id, interface) + return + + response = "Available Channels:\n" + for i, channel in enumerate(channels): + response += f"{i + 1:02d}. Name: {channel[0]}\n" + response += "\nPlease reply with the number of the channel you want to view." + send_message(response, sender_id, interface) + + update_user_state(sender_id, {'command': 'CHECK_CHANNEL', 'step': 1, 'channels': channels}) + + except Exception as e: + logging.error(f"Error processing check channel command: {e}") + send_message("Error processing check channel command.", sender_id, interface) + + +def handle_read_channel_command(sender_id, message, state, interface): + try: + channels = state.get('channels', []) + message_number = int(message) - 1 + + if message_number < 0 or message_number >= len(channels): + send_message("Invalid channel number. Please try again.", sender_id, interface) + return + + channel_name, channel_url = channels[message_number] + response = f"Channel Name: {channel_name}\nChannel URL: {channel_url}" + send_message(response, sender_id, interface) + + update_user_state(sender_id, None) + + except ValueError: + send_message("Invalid input. Please enter a valid channel number.", sender_id, interface) + except Exception as e: + logging.error(f"Error processing read channel command: {e}") + send_message("Error processing read channel command.", sender_id, interface) + + +def handle_list_channels_command(sender_id, interface): + try: + channels = get_channels() + if not channels: + send_message("No channels available in the directory.", sender_id, interface) + return + + response = "Available Channels:\n" + for i, channel in enumerate(channels): + response += f"{i+1:02d}. Name: {channel[0]}\n" + response += "\nPlease reply with the number of the channel you want to view." + send_message(response, sender_id, interface) + + update_user_state(sender_id, {'command': 'LIST_CHANNELS', 'step': 1, 'channels': channels}) + + except Exception as e: + logging.error(f"Error processing list channels command: {e}") + send_message("Error processing list channels command.", sender_id, interface) + + +def handle_quick_help_command(sender_id, interface): + response = ("✈️QUICK COMMANDS✈️\nSend command below for usage info:\nSM,, - Send " + "Mail\nCM - Check Mail\nPB,, - Post Bulletin\nCB,, - Check Bulletins\n") + send_message(response, sender_id, interface) diff --git a/config.ini b/config.ini deleted file mode 100644 index 3dc354f..0000000 --- a/config.ini +++ /dev/null @@ -1,34 +0,0 @@ -############################### -#### 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 diff --git a/config_init.py b/config_init.py index 17820b7..cb0e317 100644 --- a/config_init.py +++ b/config_init.py @@ -80,9 +80,11 @@ def merge_config(system_config:dict[str, Any], args:argparse.Namespace) -> dict[ return system_config -def initialize_config(config_file:str = None) -> dict[str, Any]: - """Function reads and parses system configuration file - + +def initialize_config(config_file: str = None) -> dict[str, Any]: + """ + Function reads and parses system configuration file + Returns a dict with the following entries: config - parsed config file interface_type - type of the active interface @@ -97,24 +99,42 @@ def initialize_config(config_file:str = None) -> dict[str, Any]: dict: dict with system configuration, ad described above """ config = configparser.ConfigParser() - + if config_file is None: config_file = "config.ini" config.read(config_file) interface_type = config['interface']['type'] hostname = config['interface'].get('hostname', None) - port = config['interface'].get('port', None) + port = config['interface'].get('port', None) bbs_nodes = config.get('sync', 'bbs_nodes', fallback='').split(',') if bbs_nodes == ['']: bbs_nodes = [] - return {'config':config, 'interface_type': interface_type, 'hostname': hostname, 'port': port, 'bbs_nodes': bbs_nodes, 'mqtt_topic': 'meshtastic.receive'} + print(f"Configured to sync with the following BBS nodes: {bbs_nodes}") + + allowed_nodes = config.get('allow_list', 'allowed_nodes', fallback='').split(',') + if allowed_nodes == ['']: + allowed_nodes = [] + + print(f"Nodes with Urgent board permissions: {allowed_nodes}") + + return { + 'config': config, + 'interface_type': interface_type, + 'hostname': hostname, + 'port': port, + 'bbs_nodes': bbs_nodes, + 'allowed_nodes': allowed_nodes, + 'mqtt_topic': 'meshtastic.receive' + } + def get_interface(system_config:dict[str, Any]) -> meshtastic.stream_interface.StreamInterface: - """Function opens and returns an instance meshtastic interface of type specified by the configuration + """ + Function opens and returns an instance meshtastic interface of type specified by the configuration Function creates and returns an instance of a class inheriting from meshtastic.stream_interface.StreamInterface. The type of the class depends on the type of the interface specified by the system configuration. diff --git a/db_admin.py b/db_admin.py new file mode 100644 index 0000000..6a585dd --- /dev/null +++ b/db_admin.py @@ -0,0 +1,195 @@ +import os +import sqlite3 +import threading + +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() + +def list_bulletins(): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT id, board, sender_short_name, date, subject, unique_id FROM bulletins") + bulletins = c.fetchall() + if bulletins: + print_bold("Bulletins:") + for bulletin in bulletins: + print_bold(f"(ID: {bulletin[0]}, Board: {bulletin[1]}, Poster: {bulletin[2]}, Subject: {bulletin[4]})") + else: + print_bold("No bulletins found.") + print_separator() + return bulletins + +def list_mail(): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT id, sender, sender_short_name, recipient, date, subject, unique_id FROM mail") + mail = c.fetchall() + if mail: + print_bold("Mail:") + for mail in mail: + print_bold(f"(ID: {mail[0]}, Sender: {mail[2]}, Recipient: {mail[3]}, Subject: {mail[5]})") + else: + print_bold("No mail found.") + print_separator() + return mail + +def list_channels(): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT id, name, url FROM channels") + channels = c.fetchall() + if channels: + print_bold("Channels:") + for channel in channels: + print_bold(f"(ID: {channel[0]}, Name: {channel[1]}, URL: {channel[2]})") + else: + print_bold("No channels found.") + print_separator() + return channels + +def delete_bulletin(): + bulletins = list_bulletins() + if bulletins: + bulletin_ids = input_bold("Enter the bulletin ID(s) to delete (comma-separated) or 'X' to cancel: ").split(',') + if 'X' in [id.strip().upper() for id in bulletin_ids]: + print_bold("Deletion cancelled.") + print_separator() + return + conn = get_db_connection() + c = conn.cursor() + for bulletin_id in bulletin_ids: + c.execute("DELETE FROM bulletins WHERE id = ?", (bulletin_id.strip(),)) + conn.commit() + print_bold(f"Bulletin(s) with ID(s) {', '.join(bulletin_ids)} deleted.") + print_separator() + +def delete_mail(): + mail = list_mail() + if mail: + mail_ids = input_bold("Enter the mail ID(s) to delete (comma-separated) or 'X' to cancel: ").split(',') + if 'X' in [id.strip().upper() for id in mail_ids]: + print_bold("Deletion cancelled.") + print_separator() + return + conn = get_db_connection() + c = conn.cursor() + for mail_id in mail_ids: + c.execute("DELETE FROM mail WHERE id = ?", (mail_id.strip(),)) + conn.commit() + print_bold(f"Mail with ID(s) {', '.join(mail_ids)} deleted.") + print_separator() + +def delete_channel(): + channels = list_channels() + if channels: + channel_ids = input_bold("Enter the channel ID(s) to delete (comma-separated) or 'X' to cancel: ").split(',') + if 'X' in [id.strip().upper() for id in channel_ids]: + print_bold("Deletion cancelled.") + print_separator() + return + conn = get_db_connection() + c = conn.cursor() + for channel_id in channel_ids: + c.execute("DELETE FROM channels WHERE id = ?", (channel_id.strip(),)) + conn.commit() + print_bold(f"Channel(s) with ID(s) {', '.join(channel_ids)} deleted.") + print_separator() + +def display_menu(): + print("Menu:") + print("1. List Bulletins") + print("2. List Mail") + print("3. List Channels") + print("4. Delete Bulletins") + print("5. Delete Mail") + print("6. Delete Channels") + print("7. Exit") + +def display_banner(): + banner = """ +β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— +β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•β•šβ•β•β•β•β–ˆβ–ˆβ•— β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β• + β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— + β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β•β• β•šβ•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β•šβ•β•β•β•β–ˆβ–ˆβ•‘ + β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ + β•šβ•β• β•šβ•β•β•β•β•β•β•šβ•β•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β• +Database Administrator +""" + print_bold(banner) + print_separator() + +def clear_screen(): + os.system('cls' if os.name == 'nt' else 'clear') + +def input_bold(prompt): + print("\033[1m") # ANSI escape code for bold text + response = input(prompt) + print("\033[0m") # ANSI escape code to reset text + return response + +def print_bold(message): + print("\033[1m" + message + "\033[0m") # Bold text + +def print_separator(): + print_bold("========================") + +def main(): + display_banner() + initialize_database() + while True: + display_menu() + choice = input_bold("Enter your choice: ") + clear_screen() + if choice == '1': + list_bulletins() + elif choice == '2': + list_mail() + elif choice == '3': + list_channels() + elif choice == '4': + delete_bulletin() + elif choice == '5': + delete_mail() + elif choice == '6': + delete_channel() + elif choice == '7': + break + else: + print_bold("Invalid choice. Please try again.") + print_separator() + +if __name__ == "__main__": + main() diff --git a/db_operations.py b/db_operations.py index e613ffa..4ce5061 100644 --- a/db_operations.py +++ b/db_operations.py @@ -4,11 +4,13 @@ import threading import uuid from datetime import datetime +from meshtastic import BROADCAST_NUM + 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 + send_mail_to_bbs_nodes, send_message, send_channel_to_bbs_nodes ) @@ -49,12 +51,16 @@ def initialize_database(): conn.commit() print("Database schema initialized.") -def add_channel(name, url): +def add_channel(name, url, bbs_nodes=None, interface=None): conn = get_db_connection() c = conn.cursor() c.execute("INSERT INTO channels (name, url) VALUES (?, ?)", (name, url)) conn.commit() + if bbs_nodes and interface: + send_channel_to_bbs_nodes(name, url, bbs_nodes, interface) + + def get_channels(): conn = get_db_connection() c = conn.cursor() @@ -78,12 +84,12 @@ def add_bulletin(board, sender_short_name, subject, content, bbs_nodes, interfac # 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) + send_message(notification_message, BROADCAST_NUM, interface) return unique_id + def get_bulletins(board): conn = get_db_connection() c = conn.cursor() @@ -131,17 +137,16 @@ def get_mail_content(mail_id, recipient_id): return c.fetchone() def delete_mail(unique_id, recipient_id, bbs_nodes, interface): - # TODO: ensure only recipient can delete mail - logging.info(f"Attempting to delete mail with unique_id: {unique_id} by {recipient_id}") conn = get_db_connection() c = conn.cursor() try: - c.execute("SELECT unique_id FROM mail WHERE unique_id = ? and recipient = ?", (unique_id, recipient_id,)) + c.execute("SELECT recipient 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 + recipient_id = result[0] + logging.info(f"Attempting to delete mail with unique_id: {unique_id} by {recipient_id}") c.execute("DELETE FROM mail WHERE unique_id = ? and recipient = ?", (unique_id, recipient_id,)) conn.commit() send_delete_mail_to_bbs_nodes(unique_id, bbs_nodes, interface) @@ -149,3 +154,13 @@ def delete_mail(unique_id, recipient_id, bbs_nodes, interface): except Exception as e: logging.error(f"Error deleting mail with unique_id {unique_id}: {e}") raise + + +def get_sender_id_by_mail_id(mail_id): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT sender FROM mail WHERE id = ?", (mail_id,)) + result = c.fetchone() + if result: + return result[0] + return None diff --git a/example_config.ini b/example_config.ini new file mode 100644 index 0000000..1b6913a --- /dev/null +++ b/example_config.ini @@ -0,0 +1,67 @@ +############################### +#### 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/ttyACM0 +# 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 + + +############################ +#### Allowed Node IDs #### +############################ +# Provide a list of node IDs that are allowed to post to the urgent board. +# If this section is commented out, anyone can post to the urgent board. +# Example: +# [allow_list] +# allowed_nodes = 12345678,87654321 +# +# [allow_list] +# allowed_nodes = !17d7e4b7 + + +########################## +#### JS8Call Settings #### +########################## +# If you would like messages from JS8Call to go into the BBS, uncomment and enter in info below: +# host = the IP address for your system running JS8Call +# port = TCP API port for JS8CALL - Default is 2442 +# db_file = this can be left as the default "js8call.db" unless you need to change for some reason +# js8groups = the JS8Call groups you're interested in receiving into the BBS +# store_messages = "true" will send messages that arent part of a group into the BBS (can be noisy). "false" will ignore these +# js8urgent = the JS8Call groups you consider to be urgent - anything sent to these will have a notice sent to the +# group chat (similar to how the urgent bulletin board works +# [js8call] +# host = 192.168.1.100 +# port = 2442 +# db_file = js8call.db +# js8groups = @GRP1,@GRP2,@GRP3 +# store_messages = True +# js8urgent = @URGNT diff --git a/examples/example_RulesOfAcquisition_fortunes.txt b/examples/example_RulesOfAcquisition_fortunes.txt new file mode 100644 index 0000000..46f44cc --- /dev/null +++ b/examples/example_RulesOfAcquisition_fortunes.txt @@ -0,0 +1,126 @@ +Ferengi Rule of Acquisition #1 "Once you have their money, you never give it back." "The Nagus" (DS9 episode) +Ferengi Rule of Acquisition #2 "The best deal is the one that makes the most profit." The 34th Rule (DS9 novel) +Ferengi Rule of Acquisition #3 "Never spend more for an acquisition than you have to." "The Maquis, Part II" (DS9 episode) +Ferengi Rule of Acquisition #4 "A woman wearing clothes is like a man in the kitchen." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #5 "Always exaggerate your estimates." Cold Fusion (SCE novel) +Ferengi Rule of Acquisition #6 "Never let family stand in the way of opportunity." "The Nagus" (DS9 episode) +Ferengi Rule of Acquisition #7 "Always keep your ears open." "In the Hands of the Prophets" (DS9 episode) +Ferengi Rule of Acquisition #8 "Small print leads to large risk." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #9 "Instinct, plus opportunity, equals profit." "The Storyteller" (DS9 episode) +Ferengi Rule of Acquisition #10 "Greed is eternal." "Prophet Motive" (VOY episode) +Ferengi Rule of Acquisition #11 "Even if its free, you can always buy it cheaper." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #12 "Anything worth selling is worth selling twice." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #13 "Anything worth doing is worth doing for money." Legends of the Ferengi (DS9 novel) +Ferengi Rule of Acquisition #15 "Dead men close no deals." Demons of Air and Darkness (DS9 novel) +Ferengi Rule of Acquisition #16 "A deal is a deal (is a deal)...until a better one comes along." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #17 "A contract is a contract is a contract... but only between Ferengi." "Body Parts" (DS9 episode) +Ferengi Rule of Acquisition #18 "A Ferengi without profit is no Ferengi at all." "Heart of Stone" (DS9 episode) +Ferengi Rule of Acquisition #19 "Satisfaction is not guaranteed." Legends of the Ferengi (DS9 novel) +Ferengi Rule of Acquisition #20 "He who dives under the table today lives to profit tomorrow." Ferenginar: Satisfaction is Not Guaranteed (DS9 novella) +Ferengi Rule of Acquisition #21 "Never place friendship before profit." "Rules of Acquisition" (DS9 episode) +Ferengi Rule of Acquisition #22 "Wise men can hear profit in the wind." "Rules of Acquisition" (DS9 episode) +Ferengi Rule of Acquisition #23 "Nothing is more important than your health...except for your money." "Acquisition" (ENT episode) +Ferengi Rule of Acquisition #25 "You can't make a deal if you're dead." "The Siege of Ar-558" (DS9 episode) +Ferengi Rule of Acquisition #27 "There's nothing more dangerous than an honest businessman." Legends of the Ferengi (DS9 novel) +Ferengi Rule of Acquisition #29 "What's in it for me?" Highest Score (DS9 novel) +Ferengi Rule of Acquisition #30 "A wise man knows that confidentiality equals profit." The Badlands, Part IV (DS9 novel) +Ferengi Rule of Acquisition #31 "Never make fun of a Ferengi's mother." "The Siege" (DS9 episode) +Ferengi Rule of Acquisition #32 "Insult something he cares about." "Elite Force II" +Ferengi Rule of Acquisition #33 "It never hurts to suck up to the boss." "Rules of Acquisition" (DS9 episode) +Ferengi Rule of Acquisition #34 "War is good for business." "Destiny" (DS9 episode); The 34th Rule (DS9 novel) +Ferengi Rule of Acquisition #35 "Peace is good for business." "Destiny" (DS9 episode); The 34th Rule (DS9 novel) +Ferengi Rule of Acquisition #37 "The early investor reaps the most interest." ST novella: Reservoir Ferengi +Ferengi Rule of Acquisition #40 "She can touch your lobes but never your latinum." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #41 "Profit is its own reward." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #43 "Feed your greed, but not enough to choke it." The Buried Age (TNG novel) +Ferengi Rule of Acquisition #44 "Never confuse wisdom with luck." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #45 "Expand or die.*" "Acquisition" (ENT episode) +Ferengi Rule of Acquisition #47 "Never trust a man wearing a better suit than your own." "Rivals" (DS9 episode) +Ferengi Rule of Acquisition #48 "The bigger the smile, the sharper the knife." "Rules of Acquisition" (DS9 episode) +Ferengi Rule of Acquisition #52 "Never ask when you can take." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #53 "Never trust anybody taller than you." Mission Gamma: Twilight (DS9 novel) +Ferengi Rule of Acquisition #54 "Rate divided by time equals profit." (Also known as "The Velocity of Wealth.") Raise the Dawn (Typhon Pact novel) +Ferengi Rule of Acquisition #55 "Take joy from profit, and profit from joy." Worlds of Deep Space Nine: Bajor: Fragments and Omens (DS9 novel) +Ferengi Rule of Acquisition #57 "Good customers are almost as rare as latinum...treasure them." "Armageddon Game" (DS9 episode) +Ferengi Rule of Acquisition #58 "There is no substitute for success." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #59 "Free advice is seldom cheap." "Rules of Acquisition" (DS9 episode) +Ferengi Rule of Acquisition #60 "Keep your lies consistent." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #62 "The riskier the road, the greater the profit." "Rules of Acquisition" (DS9 episode) +Ferengi Rule of Acquisition #63 "Work is the best therapy-at least for your employees." "Over a Torrent Sea" (TTN novel) +Ferengi Rule of Acquisition #65 "Win or lose, there's always Hupyrian beetle snuff." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #69 "Ferengi are not responsible for the stupidity of other races." Balance of Power (TNG novel) +Ferengi Rule of Acquisition #74 "Knowledge equals profit." "Inside Man" (VOY episode) +Ferengi Rule of Acquisition #75 "Home is where the heart is, but the stars are made of latinum." "Civil Defense" (DS9 episode) +Ferengi Rule of Acquisition #76 "Every once in a while, declare peace. It confuses the Hell out of your enemies." "The Homecoming" (DS9 episode) +Ferengi Rule of Acquisition #77 "If you break it, you bought it." Star Trek Online +Ferengi Rule of Acquisition #79 "Beware of the Vulcan greed for knowledge." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #82 "The flimsier the product, the higher the price." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #85 "Never let the competition know what you're thinking." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #87 "Learn the customer's weaknesses, so that you can better take advantage of him." Highest Score (DS9 novel) +Ferengi Rule of Acquisition #88 "It ain't over 'til its over." Ferenginar: Satisfaction is Not Guaranteed (DS9 novella) +Ferengi Rule of Acquisition #89 "Ask not what you can do for your profits, but what your profits can do for you." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #92 "There are many paths to profit." Highest Score (DS9 novel) +Ferengi Rule of Acquisition #94 "Females and finances don't mix." "Ferengi Love Songs" (DS9 episode) +Ferengi Rule of Acquisition #95 "Expand or die.*" "False Profits" (VOY episode) +Ferengi Rule of Acquisition #97 "Enough...is never enough." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #98 "Every man has his price." "In the Pale Moonlight" (DS9 episode) +Ferengi Rule of Acquisition #99 "Trust is the biggest liability of all." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #102 "Nature decays, but latinum lasts forever." "The Jem'Hadar" (DS9 episode) +Ferengi Rule of Acquisition #103 "Sleep can interfere with..." "Rules of Acquisition" (DS9 episode) +Ferengi Rule of Acquisition #104 "Faith moves mountains...of inventory." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #106 "There is no honor in poverty." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #109 "Dignity and an empty sack is worth the sack." "Rivals" (DS9 episode) +Ferengi Rule of Acquisition #110 "Exploitation begins at home." Star Trek Online +Ferengi Rule of Acquisition #111 "Treat people in your debt like family... exploit them." "Past Tense, Part I" (DS9 episode) +Ferengi Rule of Acquisition #112 "Never have sex with the boss' sister." "Playing God" (DS9 episode) +Ferengi Rule of Acquisition #113 "Always have sex with the boss." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #121 "Everything is for sale - even friendship." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #123 "Even a blind man can recognize the glow of Latinum." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #125 "You can't make a deal if you're dead." "The Siege of AR-558" (DS9 episode) +Ferengi Rule of Acquisition #139 "Wives serve; brother inherit." "Necessary Evil" (DS9 episode) +Ferengi Rule of Acquisition #141 "Only fools pay retail." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #144 "There's nothing wrong with charity... as long as it winds up in your pocket." Legends of the Ferengi (DS9 novel) +Ferengi Rule of Acquisition #147 "People love the bartender." Fearful Symmetry (DS9 novel) +Ferengi Rule of Acquisition #153 "Sell the sizzle, not the steak." "Deep Space Mine" (DS9 comic) +Ferengi Rule of Acquisition #162 "Even in the worst of times, someone turns a profit." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #168 "Whisper your way to success." "Treachery, Faith, and the Great River" (DS9 episode) +Ferengi Rule of Acquisition #177 "Know your enemies... but do business with them always." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #181 "Not even dishonesty can tarnish the shine of profit." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #183 "When life hands you ungaberries, make detergent." Hollow Men (DS9 novel) +Ferengi Rule of Acquisition #184 "A Ferengi waits to bid until his opponents have exhausted themselves." Balance of Power (TNG novel) +Ferengi Rule of Acquisition #188 "Not even dishonesty can tarnish the shine of profit." Star Trek Online +Ferengi Rule of Acquisition #189 "Let others keep their reputation. You keep their money." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #190 "Hear all, trust nothing." DS9 episode: "Call to Arms" +Ferengi Rule of Acquisition #192 "Never cheat a Klingon... unless you're sure you can get away with it." The Ferengi Rules of Acquisition (DS9 novel) +Ferengi Rule of Acquisition #193 "Trouble comes in threes." Star Trek Online +Ferengi Rule of Acquisition #194 "It's always good business to know about new customers before they walk in your door." "Whispers" (DS9 episode) +Ferengi Rule of Acquisition #199 "Location, location, location." The Soul Key (DS9 novel) +Ferengi Rule of Acquisition #200 "A Ferengi chooses no side but his own" (DS9 novel: Ferenginar: Satisfaction is Not Guaranteed) +Ferengi Rule of Acquisition #202 "The justification for profit is profit." The Ferengi Rules of Acquisition (DS9 reference book) +Ferengi Rule of Acquisition #203 "New customers are like razor-toothed gree worms. They can be succulent, but sometimes they bite back." DS9 episode: "Little Green Men" +Ferengi Rule of Acquisition #208 "Sometimes, the only thing more dangerous than a question is an answer." DS9 episode: "Ferengi Love Songs" +Ferengi Rule of Acquisition #211 "Employees are the rungs on the ladder of success, don't hesitate to step on them." DS9 episode: "Bar Association" +Ferengi Rule of Acquisition #212 "A good lie is easier to believe than the truth." Star Trek Online +Ferengi Rule of Acquisition #214 "Never begin a business transaction on an empty stomach." DS9 episode: "The Maquis, Part I" +Ferengi Rule of Acquisition #216 "Never gamble with a telepath." DS9 novel: The Laertian Gamble +Ferengi Rule of Acquisition #217 "You can't free a fish from water." DS9 episode: "Past Tense, Part I" +Ferengi Rule of Acquisition #218 "Always know what you're buying." The Ferengi Rules of Acquisition (DS9 reference book) +Ferengi Rule of Acquisition #218 "Sometimes what you get free cost entirely too much." Baby on Board (DS9 Malibu Comics) +Ferengi Rule of Acquisition #219 "Possession is eleven-tenths of the law!" TNG novel: Balance of Power +Ferengi Rule of Acquisition #223 "Beware the man who doesn't take time for Oo-mox." The Ferengi Rules of Acquisition (DS9 reference book) +Ferengi Rule of Acquisition #227 "If that's what's written, then that's what's written." Star Trek Online +Ferengi Rule of Acquisition #229 "Latinum lasts longer than lust." DS9 episode: "Ferengi Love Songs" +Ferengi Rule of Acquisition #235 "Duck; death is tall." Mission Gamma: Twilight (DS9 novel) +Ferengi Rule of Acquisition #236 "You can't buy fate." The Ferengi Rules of Acquisition (DS9 reference book) +Ferengi Rule of Acquisition #239 "Never be afraid to mislabel a product." DS9 episode: "Body Parts" +Ferengi Rule of Acquisition #240 "Time, like latinum, is a highly limited commodity." Star Trek Online +Ferengi Rule of Acquisition #242 "More is good...all is better." The Ferengi Rules of Acquisition (DS9 reference book) +Ferengi Rule of Acquisition #255 "A wife is [a] luxury... a smart accountant a neccessity." The Ferengi Rules of Acquisition (DS9 reference book) +Ferengi Rule of Acquisition #257 "When the messenger comes to appropriate your profits, kill the messenger." False Profits (Voyager) +Ferengi Rule of Acquisition #261 "A wealthy man can afford everything except a conscience." The Ferengi Rules of Acquisition (DS9 reference book) +Ferengi Rule of Acquisition #263 "Never let doubt interfere with your lust for Latinum." DS9 episode: "Bar Association" +Ferengi Rule of Acquisition #266 "When in doubt, lie." The Ferengi Rules of Acquisition (DS9 reference book) +Ferengi Rule of Acquisition #272 "Always inspect the merchandise before making a deal." Star Trek Online +Ferengi Rule of Acquisition #280 "If it ain't broke, don't fix it." DS9 novel: Ferenginar: Satisfaction is Not Guaranteed) +Ferengi Rule of Acquisition #284 "Deep down, everyone's a Ferengi." The Ferengi Rules of Acquisition (DS9 reference book) +Ferengi Rule of Acquisition #285 "No good deed ever goes unpunished." DS9 episode: "The Collaborator" diff --git a/fortunes.txt b/fortunes.txt index 971870b..5dadf29 100644 --- a/fortunes.txt +++ b/fortunes.txt @@ -97,4 +97,4 @@ 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 +SHHHHHH. listen. diff --git a/js8call_integration.py b/js8call_integration.py new file mode 100644 index 0000000..bf0a0cc --- /dev/null +++ b/js8call_integration.py @@ -0,0 +1,296 @@ +from socket import socket, AF_INET, SOCK_STREAM +import json +import time +import sqlite3 +import configparser +import logging + +from meshtastic import BROADCAST_NUM + +from command_handlers import handle_help_command +from utils import send_message, update_user_state + +config_file = 'config.ini' + +def from_message(content): + try: + return json.loads(content) + except ValueError: + return {} + +def to_message(typ, value='', params=None): + if params is None: + params = {} + return json.dumps({'type': typ, 'value': value, 'params': params}) + + +class JS8CallClient: + def __init__(self, interface, logger=None): + self.logger = logger or logging.getLogger('js8call') + self.logger.setLevel(logging.INFO) + self.logger.propagate = False + + self.config = configparser.ConfigParser() + self.config.read(config_file) + + self.server = ( + self.config.get('js8call', 'host', fallback=None), + self.config.getint('js8call', 'port', fallback=None) + ) + self.db_file = self.config.get('js8call', 'db_file', fallback=None) + self.js8groups = self.config.get('js8call', 'js8groups', fallback='').split(',') + self.store_messages = self.config.getboolean('js8call', 'store_messages', fallback=True) + self.js8urgent = self.config.get('js8call', 'js8urgent', fallback='').split(',') + self.js8groups = [group.strip() for group in self.js8groups] + self.js8urgent = [group.strip() for group in self.js8urgent] + + self.connected = False + self.sock = None + self.db_conn = None + self.interface = interface + + if self.db_file: + self.db_conn = sqlite3.connect(self.db_file) + self.create_tables() + else: + self.logger.info("JS8Call configuration not found. Skipping JS8Call integration.") + + def create_tables(self): + if not self.db_conn: + return + + with self.db_conn: + self.db_conn.execute(''' + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender TEXT, + receiver TEXT, + message TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ''') + self.db_conn.execute(''' + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender TEXT, + groupname TEXT, + message TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ''') + self.db_conn.execute(''' + CREATE TABLE IF NOT EXISTS urgent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender TEXT, + groupname TEXT, + message TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ''') + self.logger.info("Database tables created or verified.") + + def insert_message(self, sender, receiver, message): + if not self.db_conn: + self.logger.error("Database connection is not available.") + return + + try: + with self.db_conn: + self.db_conn.execute(''' + INSERT INTO messages (sender, receiver, message) + VALUES (?, ?, ?) + ''', (sender, receiver, message)) + except sqlite3.Error as e: + self.logger.error(f"Failed to insert message into database: {e}") + + def insert_group(self, sender, groupname, message): + if not self.db_conn: + self.logger.error("Database connection is not available.") + return + + try: + with self.db_conn: + self.db_conn.execute(''' + INSERT INTO groups (sender, groupname, message) + VALUES (?, ?, ?) + ''', (sender, groupname, message)) + except sqlite3.Error as e: + self.logger.error(f"Failed to insert group message into database: {e}") + + def insert_urgent(self, sender, groupname, message): + if not self.db_conn: + self.logger.error("Database connection is not available.") + return + + try: + with self.db_conn: + self.db_conn.execute(''' + INSERT INTO urgent (sender, groupname, message) + VALUES (?, ?, ?) + ''', (sender, groupname, message)) + except sqlite3.Error as e: + self.logger.error(f"Failed to insert urgent message into database: {e}") + + def process(self, message): + typ = message.get('type', '') + value = message.get('value', '') + params = message.get('params', {}) + + if not typ: + return + + rx_types = [ + 'RX.ACTIVITY', 'RX.DIRECTED', 'RX.SPOT', 'RX.CALL_ACTIVITY', + 'RX.CALL_SELECTED', 'RX.DIRECTED_ME', 'RX.ECHO', 'RX.DIRECTED_GROUP', + 'RX.META', 'RX.MSG', 'RX.PING', 'RX.PONG', 'RX.STREAM' + ] + + if typ not in rx_types: + return + + if typ == 'RX.DIRECTED' and value: + parts = value.split(' ') + if len(parts) < 3: + self.logger.warning(f"Unexpected message format: {value}") + return + + sender = parts[0] + receiver = parts[1] + msg = ' '.join(parts[2:]).strip() + + self.logger.info(f"Received JS8Call message: {sender} to {receiver} - {msg}") + + if receiver in self.js8urgent: + self.insert_urgent(sender, receiver, msg) + notification_message = f"πŸ’₯ URGENT JS8Call Message Received πŸ’₯\nFrom: {sender}\nCheck BBS for message" + send_message(notification_message, BROADCAST_NUM, self.interface) + elif receiver in self.js8groups: + self.insert_group(sender, receiver, msg) + elif self.store_messages: + self.insert_message(sender, receiver, msg) + else: + pass + + def send(self, *args, **kwargs): + params = kwargs.get('params', {}) + if '_ID' not in params: + params['_ID'] = '{}'.format(int(time.time() * 1000)) + kwargs['params'] = params + message = to_message(*args, **kwargs) + self.sock.send((message + '\n').encode('utf-8')) # Convert to bytes + + def connect(self): + if not self.server[0] or not self.server[1]: + self.logger.info("JS8Call server configuration not found. Skipping JS8Call connection.") + return + + self.logger.info(f"Connecting to {self.server}") + self.sock = socket(AF_INET, SOCK_STREAM) + try: + self.sock.connect(self.server) + self.connected = True + self.send("STATION.GET_STATUS") + + while self.connected: + content = self.sock.recv(65500).decode('utf-8') # Decode received bytes to string + if not content: + continue # Skip empty content + + try: + message = json.loads(content) + except ValueError: + continue # Skip invalid JSON content + + if not message: + continue # Skip empty message + + self.process(message) + except ConnectionRefusedError: + self.logger.error(f"Connection to JS8Call server {self.server} refused.") + finally: + self.sock.close() + + def close(self): + self.connected = False + + + +def handle_js8call_command(sender_id, interface): + response = "JS8Call Menu:\n[G]roup Messages\n[S]tation Messages\n[U]rgent Messages\nE[X]IT" + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'JS8CALL_MENU', 'step': 1}) + +def handle_js8call_steps(sender_id, message, step, interface, state): + if step == 1: + choice = message.lower() + if choice == 'x': + handle_help_command(sender_id, interface, 'bbs') + return + elif choice == 'g': + handle_group_messages_command(sender_id, interface) + elif choice == 's': + handle_station_messages_command(sender_id, interface) + elif choice == 'u': + handle_urgent_messages_command(sender_id, interface) + else: + send_message("Invalid option. Please choose again.", sender_id, interface) + handle_js8call_command(sender_id, interface) + +def handle_group_messages_command(sender_id, interface): + conn = sqlite3.connect('js8call.db') + c = conn.cursor() + c.execute("SELECT DISTINCT groupname FROM groups") + groups = c.fetchall() + if groups: + response = "Group Messages Menu:\n" + "\n".join([f"[{i}] {group[0]}" for i, group in enumerate(groups)]) + send_message(response, sender_id, interface) + update_user_state(sender_id, {'command': 'GROUP_MESSAGES', 'step': 1, 'groups': groups}) + else: + send_message("No group messages available.", sender_id, interface) + handle_js8call_command(sender_id, interface) + +def handle_station_messages_command(sender_id, interface): + conn = sqlite3.connect('js8call.db') + c = conn.cursor() + c.execute("SELECT sender, receiver, message, timestamp FROM messages") + messages = c.fetchall() + if messages: + response = "Station Messages:\n" + "\n".join([f"[{i+1}] {msg[0]} -> {msg[1]}: {msg[2]} ({msg[3]})" for i, msg in enumerate(messages)]) + send_message(response, sender_id, interface) + else: + send_message("No station messages available.", sender_id, interface) + handle_js8call_command(sender_id, interface) + +def handle_urgent_messages_command(sender_id, interface): + conn = sqlite3.connect('js8call.db') + c = conn.cursor() + c.execute("SELECT sender, groupname, message, timestamp FROM urgent") + messages = c.fetchall() + if messages: + response = "Urgent Messages:\n" + "\n".join([f"[{i+1}] {msg[0]} -> {msg[1]}: {msg[2]} ({msg[3]})" for i, msg in enumerate(messages)]) + send_message(response, sender_id, interface) + else: + send_message("No urgent messages available.", sender_id, interface) + handle_js8call_command(sender_id, interface) + +def handle_group_message_selection(sender_id, message, step, state, interface): + groups = state['groups'] + try: + group_index = int(message) + groupname = groups[group_index][0] + + conn = sqlite3.connect('js8call.db') + c = conn.cursor() + c.execute("SELECT sender, message, timestamp FROM groups WHERE groupname=?", (groupname,)) + messages = c.fetchall() + + if messages: + response = f"Messages for group {groupname}:\n" + "\n".join([f"[{i+1}] {msg[0]}: {msg[1]} ({msg[2]})" for i, msg in enumerate(messages)]) + send_message(response, sender_id, interface) + else: + send_message(f"No messages for group {groupname}.", sender_id, interface) + except (IndexError, ValueError): + send_message("Invalid group selection. Please choose again.", sender_id, interface) + handle_group_messages_command(sender_id, interface) + + handle_js8call_command(sender_id, interface) \ No newline at end of file diff --git a/message_processing.py b/message_processing.py index 49dd1b0..57eade1 100644 --- a/message_processing.py +++ b/message_processing.py @@ -1,24 +1,56 @@ 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 meshtastic import BROADCAST_NUM -from db_operations import add_bulletin, add_mail, delete_bulletin, delete_mail +from command_handlers import ( + handle_mail_command, handle_bulletin_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, handle_send_mail_command, + handle_read_mail_command, handle_check_mail_command, handle_delete_mail_confirmation, handle_post_bulletin_command, + handle_check_bulletin_command, handle_read_bulletin_command, handle_read_channel_command, + handle_post_channel_command, handle_list_channels_command, handle_quick_help_command +) +from db_operations import add_bulletin, add_mail, delete_bulletin, delete_mail, get_db_connection, add_channel +from js8call_integration import handle_js8call_command, handle_js8call_steps, handle_group_message_selection from utils import get_user_state, get_node_short_name, get_node_id_from_num, send_message -command_handlers = { +main_menu_handlers = { + "q": handle_quick_help_command, + "b": lambda sender_id, interface: handle_help_command(sender_id, interface, 'bbs'), + "u": lambda sender_id, interface: handle_help_command(sender_id, interface, 'utilities'), + "x": handle_help_command +} + +bbs_menu_handlers = { "m": handle_mail_command, "b": handle_bulletin_command, + "c": handle_channel_directory_command, + "j": handle_js8call_command, + "x": handle_help_command +} + + +utilities_menu_handlers = { "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 + "x": handle_help_command +} + + +bulletin_menu_handlers = { + "g": lambda sender_id, interface: handle_bb_steps(sender_id, '0', 1, {'board': 'General'}, interface, None), + "i": lambda sender_id, interface: handle_bb_steps(sender_id, '1', 1, {'board': 'Info'}, interface, None), + "n": lambda sender_id, interface: handle_bb_steps(sender_id, '2', 1, {'board': 'News'}, interface, None), + "u": lambda sender_id, interface: handle_bb_steps(sender_id, '3', 1, {'board': 'Urgent'}, interface, None), + "x": handle_help_command +} + + +board_action_handlers = { + "r": lambda sender_id, interface, state: handle_bb_steps(sender_id, 'r', 2, state, interface, None), + "p": lambda sender_id, interface, state: handle_bb_steps(sender_id, 'p', 2, state, interface, None), + "x": handle_help_command } def process_message(sender_id, message, interface, is_sync_message=False): @@ -33,9 +65,8 @@ def process_message(sender_id, message, interface, is_sync_message=False): 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) + send_message(notification_message, BROADCAST_NUM, 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] @@ -46,24 +77,95 @@ def process_message(sender_id, message, interface, is_sync_message=False): 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) + recipient_id = get_recipient_id_by_mail(unique_id) + delete_mail(unique_id, recipient_id, [], interface) + elif message.startswith("CHANNEL|"): + parts = message.split("|") + channel_name, channel_url = parts[1], parts[2] + add_channel(channel_name, channel_url) 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) + if message_lower.startswith("sm,,"): + handle_send_mail_command(sender_id, message_lower, interface, bbs_nodes) + elif message_lower.startswith("cm"): + handle_check_mail_command(sender_id, interface) + elif message_lower.startswith("pb,,"): + handle_post_bulletin_command(sender_id, message_lower, interface, bbs_nodes) + elif message_lower.startswith("cb,,"): + handle_check_bulletin_command(sender_id, message_lower, interface) + elif message_lower.startswith("chp,,"): + handle_post_channel_command(sender_id, message_lower, interface) + elif message_lower.startswith("chl"): + handle_list_channels_command(sender_id, interface) else: - handle_help_command(sender_id, interface) + if state and state['command'] == 'MENU': + menu_name = state['menu'] + if menu_name == 'bbs': + handlers = bbs_menu_handlers + elif menu_name == 'utilities': + handlers = utilities_menu_handlers + else: + handlers = main_menu_handlers + elif state and state['command'] == 'BULLETIN_MENU': + handlers = bulletin_menu_handlers + elif state and state['command'] == 'BULLETIN_ACTION': + handlers = board_action_handlers + elif state and state['command'] == 'JS8CALL_MENU': + handle_js8call_steps(sender_id, message, state['step'], interface, state) + return + elif state and state['command'] == 'GROUP_MESSAGES': + handle_group_message_selection(sender_id, message, state['step'], state, interface) + return + else: + handlers = main_menu_handlers + + if message_lower == 'x': + # Reset to main menu state + handle_help_command(sender_id, interface) + return + + if message_lower in handlers: + if state and state['command'] in ['BULLETIN_ACTION', 'BULLETIN_READ', 'BULLETIN_POST', 'BULLETIN_POST_CONTENT']: + handlers[message_lower](sender_id, interface, state) + else: + 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) + elif command == 'CHANNEL_DIRECTORY': + handle_channel_directory_steps(sender_id, message, step, state, interface) + elif command == 'CHECK_MAIL': + if step == 1: + handle_read_mail_command(sender_id, message, state, interface) + elif step == 2: + handle_delete_mail_confirmation(sender_id, message, state, interface, bbs_nodes) + elif command == 'CHECK_BULLETIN': + if step == 1: + handle_read_bulletin_command(sender_id, message, state, interface) + elif command == 'CHECK_CHANNEL': + if step == 1: + handle_read_channel_command(sender_id, message, state, interface) + elif command == 'LIST_CHANNELS': + if step == 1: + handle_read_channel_command(sender_id, message, state, interface) + elif command == 'BULLETIN_POST': + handle_bb_steps(sender_id, message, 4, state, interface, bbs_nodes) + elif command == 'BULLETIN_POST_CONTENT': + handle_bb_steps(sender_id, message, 5, state, interface, bbs_nodes) + elif command == 'BULLETIN_READ': + handle_bb_steps(sender_id, message, 3, state, interface, bbs_nodes) + elif command == 'JS8CALL_MENU': + handle_js8call_steps(sender_id, message, step, interface, state) + elif command == 'GROUP_MESSAGES': + handle_group_message_selection(sender_id, message, step, state, interface) + else: + handle_help_command(sender_id, interface) def on_receive(packet, interface): try: @@ -94,3 +196,13 @@ def on_receive(packet, interface): logging.info("Ignoring message sent to group chat or from unknown node") except KeyError as e: logging.error(f"Error processing packet: {e}") + +def get_recipient_id_by_mail(unique_id): + # Fix for Mail Delete sync issue + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT recipient FROM mail WHERE unique_id = ?", (unique_id,)) + result = c.fetchone() + if result: + return result[0] + return None diff --git a/server.py b/server.py index b590e63..177f82f 100644 --- a/server.py +++ b/server.py @@ -2,8 +2,8 @@ """ TCΒ²-BBS Server for Meshtastic by TheCommsChannel (TCΒ²) -Date: 06/25/2024 -Version: 0.1.0 +Date: 07/14/2024 +Version: 0.1.6 Description: The system allows for mail message handling, bulletin boards, and a channel @@ -13,15 +13,29 @@ other BBS servers listed in the config.ini file. """ import logging +import time from config_init import initialize_config, get_interface, init_cli_parser, merge_config from db_operations import initialize_database +from js8call_integration import JS8CallClient from message_processing import on_receive from pubsub import pub -import time -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +# General logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + +# JS8Call logging +js8call_logger = logging.getLogger('js8call') +js8call_logger.setLevel(logging.DEBUG) +js8call_handler = logging.StreamHandler() +js8call_handler.setLevel(logging.DEBUG) +js8call_formatter = logging.Formatter('%(asctime)s - JS8Call - %(levelname)s - %(message)s', '%Y-%m-%d %H:%M:%S') +js8call_handler.setFormatter(js8call_formatter) +js8call_logger.addHandler(js8call_handler) def display_banner(): banner = """ @@ -35,33 +49,36 @@ Meshtastic Version """ print(banner) - - def main(): display_banner() - # config, interface_type, hostname, port, bbs_nodes = initialize_config() args = init_cli_parser() config_file = None if args.config is not None: config_file = args.config system_config = initialize_config(config_file) - + merge_config(system_config, args) - - # print(f"{system_config=}") - + interface = get_interface(system_config) interface.bbs_nodes = system_config['bbs_nodes'] + interface.allowed_nodes = system_config['allowed_nodes'] logging.info(f"TCΒ²-BBS is running on {system_config['interface_type']} interface...") initialize_database() - def receive_packet(packet): + def receive_packet(packet, interface): on_receive(packet, interface) pub.subscribe(receive_packet, system_config['mqtt_topic']) + # Initialize and start JS8Call Client if configured + js8call_client = JS8CallClient(interface) + js8call_client.logger = js8call_logger + + if js8call_client.db_conn: + js8call_client.connect() + try: while True: time.sleep(1) @@ -69,6 +86,8 @@ def main(): except KeyboardInterrupt: logging.info("Shutting down the server...") interface.close() + if js8call_client.connected: + js8call_client.close() if __name__ == "__main__": main() diff --git a/utils.py b/utils.py index 460aea8..4c4c360 100644 --- a/utils.py +++ b/utils.py @@ -28,7 +28,7 @@ def send_message(message, destination, interface): 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] + if node['user']['shortName'].lower() == short_name] return nodes @@ -71,3 +71,9 @@ def send_delete_mail_to_bbs_nodes(unique_id, bbs_nodes, interface): 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) + + +def send_channel_to_bbs_nodes(name, url, bbs_nodes, interface): + message = f"CHANNEL|{name}|{url}" + for node_id in bbs_nodes: + send_message(message, node_id, interface)