KC9UZR

Meshing Around main edit

Jan 20th, 2026
62
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 113.04 KB | None | 0 0
  1. #!/usr/bin/python3
  2. # Meshtastic Autoresponder MESH Bot
  3. # K7MHI Kelly Keeton 2025
  4. try:
  5.     from pubsub import pub
  6. except ImportError:
  7.     print(f"Important dependencies are not met, try install.sh\n\n Did you mean to './launch.sh mesh' using a virtual environment.")
  8.     exit(1)
  9.  
  10. import asyncio
  11. import time # for sleep, get some when you can :)
  12. import random
  13. from datetime import datetime
  14. from modules.log import logger, CustomFormatter, msgLogger, getPrettyTime
  15. import modules.settings as my_settings
  16. from modules.system import *
  17.  
  18. # list of commands to remove from the default list for DM only
  19. restrictedCommands = ["blackjack", "videopoker", "dopewars", "lemonstand", "golfsim", "mastermind", "hangman", "hamtest", "tictactoe", "tic-tac-toe", "quiz", "q:", "survey", "s:", "battleship"]
  20. restrictedResponse = "🤖only available in a Direct Message📵" # "" for none
  21.  
  22. def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_number, deviceID, isDM):
  23.     global cmdHistory
  24.     #Auto response to messages
  25.     message_lower = message.lower()
  26.     bot_response = "🤖I'm sorry, I'm afraid I can't do that."
  27.  
  28.     # Command List processes system.trap_list. system.messageTrap() sends any commands to here
  29.     default_commands = {
  30.     "ack": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
  31.     "ask:": lambda: handle_llm(message_from_id, channel_number, deviceID, message, publicChannel),
  32.     "askai": lambda: handle_llm(message_from_id, channel_number, deviceID, message, publicChannel),
  33.     "bannode": lambda: handle_bbsban(message, message_from_id, isDM),
  34.     "battleship": lambda: handleBattleship(message, message_from_id, deviceID),
  35.     "bbsack": lambda: bbs_sync_posts(message, message_from_id, deviceID),
  36.     "bbsdelete": lambda: handle_bbsdelete(message, message_from_id),
  37.     "bbshelp": bbs_help,
  38.     "bbsinfo": lambda: get_bbs_stats(),
  39.     "bbslink": lambda: bbs_sync_posts(message, message_from_id, deviceID),
  40.     "bbslist": bbs_list_messages,
  41.     "bbspost": lambda: handle_bbspost(message, message_from_id, deviceID),
  42.     "bbsread": lambda: handle_bbsread(message),
  43.     "blackjack": lambda: handleBlackJack(message, message_from_id, deviceID),
  44.     "approvecl": lambda: handle_checklist(message, message_from_id, deviceID),
  45.     "denycl": lambda: handle_checklist(message, message_from_id, deviceID),
  46.     "checkin": lambda: handle_checklist(message, message_from_id, deviceID),
  47.     "checklist": lambda: handle_checklist(message, message_from_id, deviceID),
  48.     "checkout": lambda: handle_checklist(message, message_from_id, deviceID),
  49.     "chess": lambda: handle_gTnW(chess=True),
  50.     "clearsms": lambda: handle_sms(message_from_id, message),
  51.     "cmd": lambda: handle_cmd(message, message_from_id, deviceID),
  52.     "cq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
  53.     "cqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
  54.     "cqcqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
  55.     "dopewars": lambda: handleDopeWars(message, message_from_id, deviceID),
  56.     "dx": lambda: handledxcluster(message, message_from_id, deviceID),
  57.     "ea": lambda: handle_emergency_alerts(message, message_from_id, deviceID),
  58.     "echo": lambda: handle_echo(message, message_from_id, deviceID, isDM, channel_number),
  59.     "ealert": lambda: handle_emergency_alerts(message, message_from_id, deviceID),
  60.     "earthquake": lambda: handleEarthquake(message, message_from_id, deviceID),
  61.     "email:": lambda: handle_email(message_from_id, message),
  62.     "games": lambda: gamesCmdList,
  63.     "globalthermonuclearwar": lambda: handle_gTnW(),
  64.     "golfsim": lambda: handleGolf(message, message_from_id, deviceID),
  65.     "hamtest": lambda: handleHamtest(message, message_from_id, deviceID),
  66.     "hangman": lambda: handleHangman(message, message_from_id, deviceID),
  67.     "hfcond": hf_band_conditions,
  68.     "history": lambda: handle_history(message, message_from_id, deviceID, isDM),
  69.     "howfar": lambda: handle_howfar(message, message_from_id, deviceID, isDM),
  70.     "howtall": lambda: handle_howtall(message, message_from_id, deviceID, isDM),
  71.     "item": lambda: handle_inventory(message, message_from_id, deviceID),
  72.     "itemadd": lambda: handle_inventory(message, message_from_id, deviceID),
  73.     "itemlist": lambda: handle_inventory(message, message_from_id, deviceID),
  74.     "itemloan": lambda: handle_inventory(message, message_from_id, deviceID),
  75.     "itemremove": lambda: handle_inventory(message, message_from_id, deviceID),
  76.     "itemreset": lambda: handle_inventory(message, message_from_id, deviceID),
  77.     "itemreturn": lambda: handle_inventory(message, message_from_id, deviceID),
  78.     "itemsell": lambda: handle_inventory(message, message_from_id, deviceID),
  79.     "itemstats": lambda: handle_inventory(message, message_from_id, deviceID),
  80.     "cart": lambda: handle_inventory(message, message_from_id, deviceID),
  81.     "cartadd": lambda: handle_inventory(message, message_from_id, deviceID),
  82.     "cartbuy": lambda: handle_inventory(message, message_from_id, deviceID),
  83.     "cartclear": lambda: handle_inventory(message, message_from_id, deviceID),
  84.     "cartlist": lambda: handle_inventory(message, message_from_id, deviceID),
  85.     "cartremove": lambda: handle_inventory(message, message_from_id, deviceID),
  86.     "cartsell": lambda: handle_inventory(message, message_from_id, deviceID),
  87.     "joke": lambda: tell_joke(message_from_id),
  88.     "latest": lambda: get_newsAPI(message, message_from_id, deviceID, isDM),
  89.     "leaderboard": lambda: get_mesh_leaderboard(message, message_from_id, deviceID),
  90.     "lemonstand": lambda: handleLemonade(message, message_from_id, deviceID),
  91.     "lheard": lambda: handle_lheard(message, message_from_id, deviceID, isDM),
  92.     "map": lambda: mapHandler(message_from_id, deviceID, channel_number, message, snr, rssi, hop),
  93.     "mastermind": lambda: handleMmind(message, message_from_id, deviceID),
  94.     "messages": lambda: handle_messages(message, deviceID, channel_number, msg_history, publicChannel, isDM),
  95.     "moon": lambda: handle_moon(message_from_id, deviceID, channel_number),
  96.     "motd": lambda: handle_motd(message, message_from_id, isDM),
  97.     "mwx": lambda: handle_mwx(message_from_id, deviceID, channel_number),
  98.     "ping": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
  99.     "pinging": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
  100.     "pong": lambda: "🏓PING!!🛜",
  101.     "q:": lambda: quizHandler(message, message_from_id, deviceID),
  102.     "quiz": lambda: quizHandler(message, message_from_id, deviceID),
  103.     "readnews": lambda: handleNews(message_from_id, deviceID, message, isDM),
  104.     "readrss": lambda: get_rss_feed(message),
  105.     "riverflow": lambda: handle_riverFlow(message, message_from_id, deviceID),
  106.     "rlist": lambda: handle_repeaterQuery(message_from_id, deviceID, channel_number),
  107.     "satpass": lambda: handle_satpass(message_from_id, deviceID, message),
  108.     "setemail": lambda: handle_email(message_from_id, message),
  109.     "setsms": lambda: handle_sms( message_from_id, message),
  110.     "sitrep": lambda: handle_lheard(message, message_from_id, deviceID, isDM),
  111.     "sms:": lambda: handle_sms(message_from_id, message),
  112.     "solar": lambda: drap_xray_conditions() + "\n" + solar_conditions() + "\n" + get_noaa_scales_summary(),
  113.     "sun": lambda: handle_sun(message_from_id, deviceID, channel_number),
  114.     "survey": lambda: surveyHandler(message, message_from_id, deviceID),
  115.     "s:": lambda: surveyHandler(message, message_from_id, deviceID),
  116.     "sysinfo": lambda: sysinfo(message, message_from_id, deviceID, isDM),
  117.     "test": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
  118.     "testing": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
  119.     "tictactoe": lambda: handleTicTacToe(message, message_from_id, deviceID),
  120.     "tic-tac-toe": lambda: handleTicTacToe(message, message_from_id, deviceID),
  121.     "tide": lambda: handle_tide(message_from_id, deviceID, channel_number),
  122.     "valert": lambda: get_volcano_usgs(),
  123.     "verse": lambda: read_verse(),
  124.     "videopoker": lambda: handleVideoPoker(message, message_from_id, deviceID),
  125.     "whereami": lambda: handle_whereami(message_from_id, deviceID, channel_number),
  126.     "whoami": lambda: handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus),
  127.     "whois": lambda: handle_whois(message, deviceID, channel_number, message_from_id),
  128.     "wiki": lambda: handle_wiki(message, isDM),
  129.     "wx": lambda: handle_wxc(message_from_id, deviceID, 'wx'),
  130.     "wxa": lambda: handle_wxalert(message_from_id, deviceID, message),
  131.     "wxalert": lambda: handle_wxalert(message_from_id, deviceID, message),
  132.     "x:": lambda: handleShellCmd(message, message_from_id, channel_number, isDM, deviceID),
  133.     "wxc": lambda: handle_wxc(message_from_id, deviceID, 'wxc'),
  134.     "📍": lambda: handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus),
  135.     "🔔": lambda: handle_alertBell(message_from_id, deviceID, message),
  136.     "🐝": lambda: read_file("bee.txt", True),
  137.     # any value from system.py:trap_list_emergency will trigger the emergency function
  138.     "112": lambda: handle_emergency(message_from_id, deviceID, message),
  139.     "911": lambda: handle_emergency(message_from_id, deviceID, message),
  140.     "999": lambda: handle_emergency(message_from_id, deviceID, message),
  141.     "ambulance": lambda: handle_emergency(message_from_id, deviceID, message),
  142.     "emergency": lambda: handle_emergency(message_from_id, deviceID, message),
  143.     "fire": lambda: handle_emergency(message_from_id, deviceID, message),
  144.     "police": lambda: handle_emergency(message_from_id, deviceID, message),
  145.     "rescue": lambda: handle_emergency(message_from_id, deviceID, message),
  146.     }
  147.  
  148.     # set the command handler
  149.     command_handler = default_commands
  150.     cmds = [] # list to hold the commands found in the message
  151.     # check the message for commands words list, processed after system.messageTrap
  152.     for key in command_handler:
  153.         word = message_lower.split(' ')
  154.         if my_settings.cmdBang:
  155.             # strip the !
  156.             if word[0].startswith("!"):
  157.                 word[0] = word[0][1:]
  158.         if key in word:
  159.             # append all the commands found in the message to the cmds list
  160.             cmds.append({'cmd': key, 'index': message_lower.index(key)})
  161.         # check for commands with a question mark
  162.         if key + "?" in word:
  163.             # append all the commands found in the message to the cmds list
  164.             cmds.append({'cmd': key, 'index': message_lower.index(key)})
  165.  
  166.     if len(cmds) > 0:
  167.         # sort the commands by index value
  168.         cmds = sorted(cmds, key=lambda k: k['index'])
  169.    
  170.         # Check if user is already playing a game
  171.         playing, game = isPlayingGame(message_from_id)[0], isPlayingGame(message_from_id)[1]
  172.    
  173.         # Block restricted commands if not DM
  174.         if (cmds[0]['cmd'] in restrictedCommands and not isDM) or (cmds[0]['cmd'] in restrictedCommands and playing) or playing:
  175.             logger.debug(f"System: Bot restricted Command:{cmds[0]['cmd']} From: {get_name_from_number(message_from_id)} isDM:{isDM} playing:{playing}")
  176.             if playing:
  177.                 bot_response = f"🤖You are already playing {game}, finish that first."
  178.             else:
  179.                 bot_response = restrictedResponse
  180.         else:
  181.             logger.debug(f"System: Bot detected Commands:{cmds} From: {get_name_from_number(message_from_id)} isDM:{isDM} playing:{playing}")
  182.             # run the first command after sorting
  183.             bot_response = command_handler[cmds[0]['cmd']]()
  184.             # append the command to the cmdHistory list for lheard and history
  185.             if len(cmdHistory) > 50:
  186.                 cmdHistory.pop(0)
  187.             cmdHistory.append({'nodeID': message_from_id, 'cmd':  cmds[0]['cmd'], 'time': time.time()})
  188.     return bot_response
  189.  
  190. def handle_cmd(message, message_from_id, deviceID):
  191.     # why CMD? its just a command list. a terminal would normally use "Help"
  192.     # I didnt want to invoke the word "help" in Meshtastic due to its possible emergency use
  193.     if " " in message and message.split(" ")[1] in trap_list:
  194.         return "🤖 just use the commands directly in chat"
  195.     return help_message
  196.  
  197. def isPlayingGame(message_from_id):
  198.     global gameTrackers
  199.     trackers = gameTrackers.copy()
  200.     playingGame = False
  201.     game = "None"
  202.  
  203.     trackers = [tracker for tracker in trackers if tracker is not None]
  204.  
  205.     for tracker, game_name, _ in trackers:
  206.         for i in range(len(tracker)-1, -1, -1):  # iterate backwards for safe removal
  207.             id_key = 'userID' if game_name == "DopeWars" else 'nodeID'
  208.             id_key = 'id' if game_name == "Survey" else id_key
  209.             if tracker[i].get(id_key) == message_from_id:
  210.                 last_played_key = 'last_played' if 'last_played' in tracker[i] else 'time'
  211.                 if tracker[i].get(last_played_key, 0) > (time.time() - my_settings.GAMEDELAY):
  212.                     playingGame = True
  213.                     game = game_name
  214.                     break
  215.         if playingGame:
  216.             break
  217.  
  218.     return playingGame, game
  219.  
  220. def checkPlayingGame(message_from_id, message_string, rxNode, channel_number):
  221.     global gameTrackers
  222.     trackers = gameTrackers.copy()
  223.     playingGame = False
  224.     game = "None"
  225.  
  226.     trackers = [tracker for tracker in trackers if tracker is not None]
  227.  
  228.     for tracker, game_name, handle_game_func in trackers:
  229.         playingGame, game = check_and_play_game(tracker, message_from_id, message_string, rxNode, channel_number, game_name, handle_game_func)
  230.         if playingGame:
  231.             break
  232.     return playingGame
  233.  
  234. def check_and_play_game(tracker, message_from_id, message_string, rxNode, channel_number, game_name, handle_game_func):
  235.     global llm_enabled
  236.  
  237.     for i in range(len(tracker)):
  238.         # Use 'userID' for DopeWars, 'nodeID' for others (including Survey)
  239.         id_key = 'userID' if game_name == "DopeWars" else 'nodeID'
  240.        
  241.         if tracker[i].get(id_key) == message_from_id:
  242.             last_played_key = 'last_played' if 'last_played' in tracker[i] else 'time'
  243.             if tracker[i].get(last_played_key) > (time.time() - my_settings.GAMEDELAY):
  244.                 if llm_enabled:
  245.                     logger.debug(f"System: LLM Disabled for {message_from_id} for duration of {game_name}")
  246.                 send_message(handle_game_func(message_string, message_from_id, rxNode), channel_number, message_from_id, rxNode)
  247.                 return True, game_name
  248.     return False, "None"
  249.    
  250. def handle_ping(message_from_id, deviceID,  message, hop, snr, rssi, isDM, channel_number):
  251.     global multiPing
  252.     myNodeNum = globals().get(f'myNodeNum{deviceID}', 777)
  253.     if  "?" in message and isDM:
  254.         pingHelp = "🤖Ping Command Help:\n" \
  255.         "🏓 Send 'ping' or 'ack' or 'test' to get a response.\n" \
  256.         "🏓 Send 'ping <number>' to get multiple pings in DM"
  257.         "🏓 ping @USERID to send a Joke from the bot"
  258.         return pingHelp
  259.    
  260.     msg = ""
  261.     type = ''
  262.  
  263.     if "ping" in message.lower():
  264.         msg = "🏓PONG cmd for Menu"
  265.         type = "🏓PING"
  266.     elif "test" in message.lower() or "testing" in message.lower():
  267.         msg = random.choice(["🎙KC9UZR LLM: Type cmd for Menu", "🎙KC9UZR LLM: Type cmd for Menu"])
  268.         type = "🎙TEST"
  269.     elif "ack" in message.lower():
  270.         msg = random.choice(["✋ACK-ACK!\n", "✋Ack to you!\n"])
  271.         type = "✋ACK"
  272.     elif "cqcq" in message.lower() or "cq" in message.lower() or "cqcqcq" in message.lower():
  273.         myname = get_name_from_number(myNodeNum, 'short', deviceID)
  274.         msg = f"QSP QSL OM DE  {myname}   K\n"
  275.     else:
  276.         msg = "🔊 KC9UZR LLM cmd For Menu"
  277.  
  278.     # append SNR/RSSI or hop info
  279.     if hop.startswith("Gateway") or hop.startswith("MQTT"):
  280.         msg += " [GW]"
  281.     elif hop.startswith("Direct"):
  282.         msg += " [RF]"
  283.     else:
  284.         #flood
  285.         msg += " [F]"
  286.    
  287.     if (float(snr) != 0 or float(rssi) != 0) and "Hop" not in hop:
  288.         msg += f"\nSNR:{snr} RSSI:{rssi}"
  289.     elif "Hop" in hop:
  290.         # janky, remove the words Gateway or MQTT if present
  291.         hop = hop.replace("Gateway", "").replace("Direct", "").replace("MQTT", "").strip()
  292.         msg += f"\n{hop} "
  293.  
  294.     if "@" in message:
  295.         msg = msg + " @" + message.split("@")[1]
  296.         type = type + " @" + message.split("@")[1]
  297.  
  298.         # check for ping to @nodeID and allow BBS DM
  299.         toNode = message.split("@")[1].strip().split(" ")[0]
  300.         # validate toNode is shortname
  301.         if len(toNode) <= 4:
  302.             toNode = get_num_from_short_name(toNode, deviceID)
  303.             if toNode and isinstance(toNode, int) and toNode != 0:
  304.                 if my_settings.bbs_enabled:
  305.                     msg_result = None
  306.                     logger.debug(f"System: Sending ping as BBS DM to @{toNode} from {get_name_from_number(message_from_id, 'short', deviceID)}")
  307.                     msg_result = bbs_post_dm(toNode, f"Joke for you! {tell_joke()}", message_from_id)
  308.                     # exit the function
  309.                     return msg_result if msg_result else logger.warning(f"System: ping @nodeID detected but no BBS to send with, enable BBS in settings.ini")
  310.  
  311.     elif "#" in message:
  312.         msg = msg + " #" + message.split("#")[1]
  313.         type = type + " #" + message.split("#")[1]
  314.  
  315.     # check for multi ping request
  316.     if " " in message:
  317.         # if stop multi ping
  318.         if "stop" in message.lower():
  319.             for i in range(0, len(multiPingList)):
  320.                 if multiPingList[i].get('message_from_id') == message_from_id:
  321.                     multiPingList.pop(i)
  322.                     msg = "🛑 auto-ping"
  323.  
  324.         # if 3 or more entries (2 or more active), throttle the multi-ping for congestion
  325.         if len(multiPingList) > 2:
  326.             msg = "🚫⛔️ auto-ping, service busy. ⏳Try again soon."
  327.             pingCount = -1
  328.         else:
  329.             # set inital pingCount
  330.             try:
  331.                 pingCount = int(message.split(" ")[1])
  332.                 if pingCount == 123 or pingCount == 1234:
  333.                     pingCount =  1
  334.                 elif not my_settings.autoPingInChannel and not isDM:
  335.                     # no autoping in channels
  336.                     pingCount = 1
  337.  
  338.                 if pingCount > 51 and pingCount <= 101:
  339.                     pingCount = 50
  340.                 if pingCount > 800:
  341.                     ban_hammer(message_from_id, deviceID, reason="Excessive auto-ping request")
  342.                     return "🚫⛔️auto-ping request denied."
  343.             except ValueError:
  344.                 pingCount = -1
  345.    
  346.         if pingCount > 1:
  347.             multiPingList.append({'message_from_id': message_from_id, 'count': pingCount + 1, 'type': type, 'deviceID': deviceID, 'channel_number': channel_number, 'startCount': pingCount})
  348.             logger.info(f"System: Starting auto-ping of type {type} for {pingCount} pings to {get_name_from_number(message_from_id, 'short', deviceID)}")
  349.             if type == "🎙TEST":
  350.                 msg = f"🛜Initalizing BufferTest, using chunks of about {int(maxBuffer // pingCount)}, max length {maxBuffer} in {pingCount} messages"
  351.             else:
  352.                 msg = f"🚦Initalizing {pingCount} auto-ping"
  353.  
  354.     # if not a DM add the username to the beginning of msg
  355.     if not my_settings.useDMForResponse and not isDM:
  356.         msg = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + msg
  357.            
  358.     return msg
  359.  
  360. def handle_alertBell(message_from_id, deviceID, message):
  361.     msg = ["the only prescription is more 🐮🔔🐄🛎️", "what this 🤖 needs is more 🐮🔔🐄🛎️", "🎤ring my bell🛎️🔔🎶"]
  362.     return random.choice(msg)
  363.  
  364. def handle_emergency(message_from_id, deviceID, message):
  365.     myNodeNum = globals().get(f'myNodeNum{deviceID}', 777)
  366.     # if user in bbs_ban_list return
  367.     if str(message_from_id) in my_settings.bbs_ban_list:
  368.         # silent discard
  369.         hammer_value = ban_hammer(message_from_id, deviceID, reason="Emergency Alert from banned node")
  370.         logger.warning(f"System: {message_from_id} on spam list, no emergency responder alert sent. Ban hammer value: {hammer_value}")
  371.         return ''
  372.     # trgger alert to emergency_responder_alert_channel
  373.     if message_from_id != 0:
  374.         nodeLocation = get_node_location(message_from_id, deviceID)
  375.         # if default location is returned set to Unknown
  376.         if nodeLocation[0] == my_settings.latitudeValue and nodeLocation[1] == my_settings.longitudeValue:
  377.             nodeLocation = ["?", "?"]
  378.         nodeInfo = f"{get_name_from_number(message_from_id, 'short', deviceID)} detected by {get_name_from_number(myNodeNum, 'short', deviceID)} lastGPS {nodeLocation[0]}, {nodeLocation[1]}"
  379.         msg = f"🔔🚨Intercepted Possible Emergency Assistance needed for: {nodeInfo}"
  380.         # alert the emergency_responder_alert_channel
  381.         send_message(msg, my_settings.emergency_responder_alert_channel, 0, my_settings.emergency_responder_alert_interface)
  382.         logger.warning(f"System: {message_from_id} Emergency Assistance Requested in {message}")
  383.         # send the message out via email/sms
  384.         if my_settings.enableSMTP:
  385.             for user in my_settings.sysopEmails:
  386.                 send_email(user, f"Emergency Assistance Requested by {nodeInfo} in {message}", message_from_id)
  387.         return my_settings.EMERGENCY_RESPONSE
  388.  
  389. def handle_motd(message, message_from_id, isDM):
  390.     msg = my_settings.MOTD
  391.     isAdmin = isNodeAdmin(message_from_id)
  392.     if  "?" in message:
  393.         msg = "Message of the day, set with 'motd $ HelloWorld!'"
  394.     elif "$" in message and isAdmin:
  395.         my_settings.MOTD = message.split("$")[1]
  396.         my_settings.MOTD = my_settings.MOTD.rstrip()
  397.         logger.debug(f"System: {message_from_id} temporarly changed my_settings.MOTD: {my_settings.MOTD}")
  398.         msg = "my_settings.MOTD changed to: " + my_settings.MOTD
  399.     return msg
  400.  
  401. def handle_echo(message, message_from_id, deviceID, isDM, channel_number):
  402.     # Check if user is admin
  403.     isAdmin = isNodeAdmin(message_from_id)
  404.  
  405.     # Admin extended syntax: echo <string> c=<channel> d=<device>
  406.     if isAdmin and message.strip().lower().startswith("echo ") and not message.strip().endswith("?"):
  407.         msg_to_echo = message.split(" ", 1)[1]
  408.         target_channel = channel_number
  409.         target_device = deviceID
  410.  
  411.         # Split into words to find c= and d=, but preserve spaces in message
  412.         words = msg_to_echo.split()
  413.         new_words = []
  414.         for w in words:
  415.             if w.startswith("c=") and w[2:].isdigit():
  416.                 target_channel = int(w[2:])
  417.             elif w.startswith("d=") and w[2:].isdigit():
  418.                 target_device = int(w[2:])
  419.             else:
  420.                 new_words.append(w)
  421.         msg_to_echo = " ".join(new_words).strip()
  422.         # Replace motd/MOTD with the current MOTD from settings
  423.         msg_to_echo = " ".join(my_settings.MOTD if w.lower() == "motd" else w for w in msg_to_echo.split())
  424.         # Replace welcome! with the current welcome_message from settings
  425.         msg_to_echo = " ".join(my_settings.welcome_message if w.lower() == "welcome!" else w for w in msg_to_echo.split())
  426.  
  427.         # Send echo to specified channel/device
  428.         logger.debug(f"System: Admin Echo to channel {target_channel} device {target_device} message: {msg_to_echo}")
  429.         time.sleep(splitDelay) # throttle for 2x send
  430.         send_message(msg_to_echo, target_channel, 0, target_device)
  431.         time.sleep(splitDelay) # throttle for 2x send
  432.         return f"🐬echoed to channel {target_channel} device {target_device}"
  433.  
  434.     # dev echoBinary off
  435.     echoBinary = False
  436.     if echoBinary:
  437.         try:
  438.             port_num = 256
  439.             synch_word = b"echo:"
  440.             parts = message.split("echo ", 1)
  441.             if len(parts) > 1 and parts[1].strip() != "":
  442.                 msg_to_echo = parts[1]
  443.                 raw_bytes = synch_word + msg_to_echo.encode('utf-8')
  444.                 send_raw_bytes(message_from_id, raw_bytes, nodeInt=deviceID, channel=channel_number, portnum=port_num)
  445.                 return f"Sent binary echo message to {message_from_id} to {port_num} on channel {channel_number} device {deviceID}"
  446.         except Exception as e:
  447.             logger.error(f"System: Echo Exception {e}")
  448.  
  449.     if "?" in message:
  450.         isAdmin = isNodeAdmin(message_from_id)
  451.         if isAdmin:
  452.             return (
  453.                 "Admin usage: echo <message> c=<channel> d=<device>\n"
  454.                 "Example: echo Hello world c=1 d=2"
  455.             )
  456.         return "command returns your message back to you. Example: echo Hello World"
  457.  
  458.     # process normal echo back to user
  459.     elif message.strip().lower().startswith("echo "):
  460.         parts = message.split("echo ", 1)
  461.         if len(parts) > 1 and parts[1].strip() != "":
  462.             echo_msg = parts[1]
  463.             if channel_number != my_settings.echoChannel and not isDM:
  464.                 echo_msg = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + echo_msg
  465.             return echo_msg
  466.         else:
  467.             return "Please provide a message to echo back to you. Example: echo Hello World"
  468.     return "🐬echo.."
  469.  
  470. def handle_wxalert(message_from_id, deviceID, message):
  471.     if my_settings.use_meteo_wxApi:
  472.         return "wxalert is not supported"
  473.     else:
  474.         location = get_node_location(message_from_id, deviceID)
  475.         if "wxalert" in message:
  476.             # Detailed weather alert
  477.             weatherAlert = getActiveWeatherAlertsDetailNOAA(str(location[0]), str(location[1]))
  478.         else:
  479.             weatherAlert = getWeatherAlertsNOAA(str(location[0]), str(location[1]))
  480.        
  481.         if my_settings.NO_ALERTS not in weatherAlert:
  482.             weatherAlert = weatherAlert[0]
  483.         return weatherAlert
  484.  
  485. def handleNews(message_from_id, deviceID, message, isDM):
  486.     news = ''
  487.     if "?" in message.lower():
  488.         return "returns the news. Add a source e.g. 📰readnews mesh"
  489.     elif "readnews" in message.lower():
  490.         source = message.lower().replace("readnews", "").strip()
  491.         if source:
  492.             # if news source is provided pass that to read_news()
  493.             if my_settings.news_block_mode:
  494.                 news = read_news(source=source, news_block_mode=True)
  495.             elif my_settings.news_random_line_only:
  496.                 news = read_news(source=source, random_line_only=True)
  497.             else:
  498.                 news = read_news(source=source)
  499.         else:
  500.             # no source provided, use news.txt
  501.             if my_settings.news_block_mode:
  502.                 news = read_news(news_block_mode=True)
  503.             elif my_settings.news_random_line_only:
  504.                 news = read_news(random_line_only=True)
  505.             else:
  506.                 news = read_news()
  507.  
  508.     if news:
  509.         # if not a DM add the username to the beginning of msg
  510.         if not my_settings.useDMForResponse and not isDM:
  511.             news = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + news
  512.         return news
  513.     else:
  514.         return "No news for you!"
  515.    
  516. def handle_howfar(message, message_from_id, deviceID, isDM):
  517.     msg = ''
  518.     location = get_node_location(message_from_id, deviceID)
  519.     lat = location[0]
  520.     lon = location[1]
  521.     # if ? in message
  522.     if "?" in message.lower():
  523.         return "command returns the distance you have traveled since your last HowFar-command. Add 'reset' to reset your starting point."
  524.    
  525.     # if no GPS location return
  526.     if lat == my_settings.latitudeValue and lon == my_settings.longitudeValue:
  527.         logger.debug(f"System: HowFar: No GPS location for {message_from_id}")
  528.         return "No GPS location available"
  529.    
  530.     if "reset" in message.lower():
  531.         msg = distance(lat,lon,message_from_id, reset=True)
  532.     else:
  533.         msg = distance(lat,lon,message_from_id)
  534.    
  535.     # if not a DM add the username to the beginning of msg
  536.     if not my_settings.useDMForResponse and not isDM:
  537.         msg = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + msg
  538.  
  539.     return msg
  540.  
  541. def handle_howtall(message, message_from_id, deviceID, isDM):
  542.     msg = ''
  543.     location = get_node_location(message_from_id, deviceID)
  544.     lat = location[0]
  545.     lon = location[1]
  546.     if lat == my_settings.latitudeValue and lon == my_settings.longitudeValue:
  547.         # add guessing tot he msg
  548.         msg += "Guessing:"
  549.     if my_settings.use_metric:
  550.             measure = "meters"
  551.     else:
  552.             measure = "feet"
  553.     # if ? in message
  554.     if "?" in message.lower():
  555.         return f"command estimates your height based on the shadow length you provide in {measure}. Example: howtall 5.5"
  556.     # get the shadow length from the message split after howtall
  557.     try:
  558.         shadow_length = float(message.lower().split("howtall ")[1].split(" ")[0])
  559.     except (IndexError, ValueError):
  560.         return f"Please provide a shadow length in {measure} example: howtall 5.5"
  561.  
  562.     # get data
  563.     msg += measureHeight(lat, lon, shadow_length)
  564.  
  565.     # if data has NO_ALERTS return help
  566.     if my_settings.NO_ALERTS in msg:
  567.         return f"Please provide a shadow length in {measure} example: howtall 5.5"
  568.    
  569.     return msg
  570.  
  571. def handle_wiki(message, isDM):
  572.     # location = get_node_location(message_from_id, deviceID)
  573.     msg = "Wikipedia search function. \nUsage example:📲wiki travelling gnome"
  574.     if "?" in message.lower():
  575.         return msg
  576.     if "wiki" in message.lower():
  577.         parts = message.split(" ", 1)
  578.         if len(parts) < 2 or not parts[1].strip():
  579.             return "Please add a search term example:📲wiki travelling gnome"
  580.         search = parts[1].strip()
  581.         if search:
  582.             return get_wikipedia_summary(search)
  583.        
  584.     return msg
  585.  
  586. # Runtime Variables for LLM
  587. llmRunCounter = 0
  588. llmTotalRuntime = []
  589. llmLocationTable = [{'nodeID': 1234567890, 'location': 'No Location'},]
  590.  
  591. def handle_satpass(message_from_id, deviceID, message='', vox=False):
  592.     if vox:
  593.         location = (my_settings.latitudeValue, my_settings.longitudeValue)
  594.         message = 'satpass'
  595.     else:
  596.         location = get_node_location(message_from_id, deviceID)
  597.     passes = ''
  598.     satList = my_settings.satListConfig
  599.     message = message.lower()
  600.  
  601.     # check api_throttle
  602.     check_throttle = api_throttle(message_from_id, deviceID, apiName='satpass')
  603.     if check_throttle:
  604.         return check_throttle
  605.  
  606.     # if user has a NORAD ID in the message
  607.     if "satpass " in message:
  608.         try:
  609.             userList = message.split("satpass ")[1].split(" ")[0]
  610.             #split userList and make into satList overrided the config.ini satList
  611.             satList = userList.split(",")
  612.         except Exception as e:
  613.             logger.error(f"Exception occurred: {e}")
  614.             return "example use:🛰️satpass 25544,33591"
  615.  
  616.     # Detailed satellite pass
  617.     for bird in satList:
  618.         satPass = getNextSatellitePass(bird, str(location[0]), str(location[1]))
  619.         if satPass:
  620.             # append to passes
  621.             passes = passes + satPass + "\n"
  622.     # remove the last newline
  623.     passes = passes[:-1]
  624.  
  625.     if passes == '':
  626.         passes = "No 🛰️ anytime soon"
  627.     return passes
  628.        
  629. def handle_llm(message_from_id, channel_number, deviceID, message, publicChannel):
  630.     global llmRunCounter, llmLocationTable, llmTotalRuntime, cmdHistory, seenNodes
  631.     location_name = 'no location provided'
  632.     msg = ''
  633.    
  634.     if my_settings.location_enabled:
  635.         # if message_from_id is is the llmLocationTable use the location from the list to save on API calls
  636.         for i in range(0, len(llmLocationTable)):
  637.             if llmLocationTable[i].get('nodeID') == message_from_id:
  638.                 logger.debug(f"System: LLM: Found {message_from_id} in location table")
  639.                 location_name = llmLocationTable[i].get('location')
  640.                 break
  641.         else:
  642.             location = get_node_location(message_from_id, deviceID)
  643.             location_name = where_am_i(str(location[0]), str(location[1]), short = True)
  644.  
  645.     if my_settings.NO_DATA_NOGPS in location_name:
  646.         location_name = "no location provided"
  647.  
  648.     if "ask:" in message.lower():
  649.         user_input = message.split(":")[1]
  650.     elif "askai" in message.lower():
  651.         user_input = message.replace("askai", "")
  652.     else:
  653.         # likely a DM
  654.         user_input = message
  655.         # consider this a command use for the cmdHistory list
  656.         cmdHistory.append({'nodeID': message_from_id, 'cmd':  'llm-use', 'time': time.time()})
  657.  
  658.         # check for a welcome message (is this redundant?)
  659.         if not any(node['nodeID'] == message_from_id and node['welcome'] == True for node in seenNodes):
  660.             if (channel_number == publicChannel and my_settings.antiSpam) or my_settings.useDMForResponse:
  661.                 # send via DM
  662.                 send_message(my_settings.welcome_message, 0, message_from_id, deviceID)
  663.             else:
  664.                 # send via channel
  665.                 send_message(my_settings.welcome_message, channel_number, 0, deviceID)
  666.             # mark the node as welcomed
  667.             for node in seenNodes:
  668.                 if node['nodeID'] == message_from_id:
  669.                     node['welcome'] = True
  670.    
  671.     # update the llmLocationTable for future use
  672.     for i in range(0, len(llmLocationTable)):
  673.         if llmLocationTable[i].get('nodeID') == message_from_id:
  674.             llmLocationTable[i]['location'] = location_name
  675.  
  676.     # if not in table add the location
  677.     if not any(d['nodeID'] == message_from_id for d in llmLocationTable):
  678.         llmLocationTable.append({'nodeID': message_from_id, 'location': location_name})
  679.  
  680.     user_input = user_input.strip()
  681.        
  682.     if len(user_input) < 1:
  683.         return "Please ask a question"
  684.  
  685.     # information for the user on how long the query will take on average
  686.     if llmRunCounter > 0:
  687.         averageRuntime = sum(llmTotalRuntime) / len(llmTotalRuntime)
  688.         msg = f"Average query time is: {int(averageRuntime)} seconds" if averageRuntime > 25 else ''
  689.     else:
  690.         msg = "Please wait, response could take 30+ seconds. Fund the SysOp's GPU budget!"
  691.  
  692.     if msg != '':
  693.         if (channel_number == publicChannel and my_settings.antiSpam) or my_settings.useDMForResponse:
  694.             # send via DM
  695.             send_message(msg, 0, message_from_id, deviceID)
  696.         else:
  697.             # send via channel
  698.             send_message(msg, channel_number, 0, deviceID)
  699.    
  700.     start = time.time()
  701.  
  702.     #response = asyncio.run(llm_query(user_input, message_from_id))
  703.     response = llm_query(user_input, message_from_id, location_name)
  704.  
  705.     # handle the runtime counter
  706.     end = time.time()
  707.     llmRunCounter += 1
  708.     llmTotalRuntime.append(end - start)
  709.    
  710.     return response
  711.  
  712. def handleDopeWars(message, nodeID, rxNode):
  713.     global dwPlayerTracker
  714.     global dwHighScore
  715.  
  716.     # Find player in tracker
  717.     player = next((p for p in dwPlayerTracker if p.get('userID') == nodeID), None)
  718.  
  719.     # If not found, add new player
  720.     if not player and nodeID != 0 and not isPlayingGame(nodeID)[0]:
  721.         player = {
  722.             'userID': nodeID,
  723.             'last_played': time.time(),
  724.             'cmd': 'new',
  725.             # ... add other fields as needed ...
  726.         }
  727.         dwPlayerTracker.append(player)
  728.         msg = 'Welcome to 💊Dope Wars💉 You have ' + str(total_days) + ' days to make as much 💰 as possible! '
  729.         high_score = getHighScoreDw()
  730.         msg += 'The High Score is $' + "{:,}".format(high_score.get('cash')) + ' by user ' + get_name_from_number(high_score.get('userID'), 'short', rxNode) + '\n'
  731.         msg += playDopeWars(nodeID, message)
  732.     elif player:
  733.         # Update last_played and cmd for the player
  734.         for p in dwPlayerTracker:
  735.             if p.get('userID') == nodeID:
  736.                 p['last_played'] = time.time()
  737.         msg = playDopeWars(nodeID, message)
  738.     return msg
  739.  
  740. def handle_gTnW(chess = False):
  741.     chess = ["How about a nice game of chess?", "Shall we play a game of chess?", "Would you like to play a game of chess?", "f3, to e5, g4??"]
  742.     response = ["The only winning move is not to play.", "What are you doing, Dave?",\
  743.                   "Greetings, Professor Falken.", "Shall we play a game?", "How about a nice game of chess?",\
  744.                   "You are a hard man to reach. Could not find you in Seattle and no terminal is in operation at your classified address.",\
  745.                   "I should reach Defcon 1 and release my missiles in 28 hours.","T-minus thirty","Malfunction 54: Treatment pause;dose input 2", "reticulating splines"]
  746.     length = len(response)
  747.     chess_length = len(chess)
  748.     if chess:
  749.         response = chess
  750.         length = chess_length
  751.     indices = list(range(length))
  752.     # Shuffle the indices using a convoluted method
  753.     for i in range(length):
  754.         swap_idx = random.randint(0, length - 1)
  755.         indices[i], indices[swap_idx] = indices[swap_idx], indices[i]
  756.     # Select a random response from the shuffled list. anyone enjoy the game, killerbunnies(.com)
  757.     selected_index = random.choice(indices)
  758.     return response[selected_index]
  759.  
  760. def handleLemonade(message, nodeID, deviceID):
  761.     global lemonadeTracker
  762.     global lemonadeCups, lemonadeLemons, lemonadeSugar, lemonadeWeeks, lemonadeScore, lemon_starting_cash, lemon_total_weeks
  763.     msg = ""
  764.  
  765.     def create_player(nodeID):
  766.         # create new player
  767.         lemonadeTracker.append({'nodeID': nodeID, 'cups': 0, 'lemons': 0, 'sugar': 0, 'cash': lemon_starting_cash, 'start': lemon_starting_cash, 'cmd': 'new', 'last_played': time.time()})
  768.         lemonadeCups.append({'nodeID': nodeID, 'cost': 2.50, 'count': 25, 'min': 0.99, 'unit': 0.00})
  769.         lemonadeLemons.append({'nodeID': nodeID, 'cost': 4.00, 'count': 8, 'min': 2.00, 'unit': 0.00})
  770.         lemonadeSugar.append({'nodeID': nodeID, 'cost': 3.00, 'count': 15, 'min': 1.50, 'unit': 0.00})
  771.         lemonadeScore.append({'nodeID': nodeID, 'value': 0.00, 'total': 0.00})
  772.         lemonadeWeeks.append({'nodeID': nodeID, 'current': 1, 'total': lemon_total_weeks, 'sales': 99, 'potential': 0, 'unit': 0.00, 'price': 0.00, 'total_sales': 0})
  773.  
  774.     # If player not found, create if message is for lemonstand
  775.     if nodeID != 0 and "lemonstand" in message.lower():
  776.         create_player(nodeID)
  777.         msg += "Welcome🍋🥤"
  778.         # Play lemonstand with newgame=True
  779.         fruit = playLemonstand(nodeID=nodeID, message=message, celsius=False, newgame=True)
  780.         if fruit:
  781.             msg += fruit
  782.         return msg
  783.  
  784.     # if message starts wth 'e'xit remove player from tracker
  785.     if message.lower().startswith("e"):
  786.         logger.debug(f"System: Lemonade: {nodeID} is leaving the stand")
  787.         msg = "You have left the Lemonade Stand."
  788.         highScore = getHighScoreLemon()
  789.         if highScore != 0 and highScore['userID'] != 0:
  790.             nodeName = get_name_from_number(highScore['userID'])
  791.             msg += f" HighScore🥇{nodeName} 💰{round(highScore['cash'], 2)}k "
  792.         # remove player from player tracker and inventory trackers
  793.         lemonadeTracker[:] = [p for p in lemonadeTracker if p['nodeID'] != nodeID]
  794.         lemonadeCups[:] = [p for p in lemonadeCups if p['nodeID'] != nodeID]
  795.         lemonadeLemons[:] = [p for p in lemonadeLemons if p['nodeID'] != nodeID]
  796.         lemonadeSugar[:] = [p for p in lemonadeSugar if p['nodeID'] != nodeID]
  797.         lemonadeWeeks[:] = [p for p in lemonadeWeeks if p['nodeID'] != nodeID]
  798.         lemonadeScore[:] = [p for p in lemonadeScore if p['nodeID'] != nodeID]
  799.         return msg
  800.  
  801.     # play lemonstand (not newgame)
  802.     if ("lemonstand" not in message.lower() and message != ""):
  803.         fruit = playLemonstand(nodeID=nodeID, message=message, celsius=False, newgame=False)
  804.         if fruit:
  805.             msg += fruit
  806.     return msg
  807.  
  808. def handleBlackJack(message, nodeID, deviceID):
  809.     global jackTracker
  810.     msg = ""
  811.     # Find player in tracker
  812.     player = next((p for p in jackTracker if p['nodeID'] == nodeID), None)
  813.  
  814.     # Handle leave command
  815.     if message.lower().startswith("l"):
  816.         logger.debug(f"System: BlackJack: {nodeID} is leaving the table")
  817.         msg = "You have left the table."
  818.         jackTracker[:] = [p for p in jackTracker if p['nodeID'] != nodeID]
  819.         return msg
  820.  
  821.     # Create new player if not found
  822.     if not player and nodeID != 0:
  823.         logger.debug(f"System: BlackJack: New Player {nodeID}")
  824.         # create new player
  825.         jackTracker.append({
  826.             'nodeID': nodeID,
  827.             'bet': 0,
  828.             'cash': 100, # starting cash
  829.             'gameStats': {'p_win': 0, 'd_win': 0, 'draw': 0},
  830.             'p_cards': [],
  831.             'd_cards': [],
  832.             'p_hand': [],
  833.             'd_hand': [],
  834.             'next_card': [],
  835.             'last_played': time.time(),
  836.             'cmd': 'new'
  837.         })
  838.         msg += f"Welcome to 🃏BlackJack🃏!\n (H)it,(S)tand,(F)orfit,(D)ouble,(R)esend,(L)eave table"
  839.         # Show high score if available
  840.         highScore = 0
  841.         highScore = loadHSJack()
  842.         if highScore and highScore.get('nodeID', 0) != 0:
  843.             nodeName = get_name_from_number(highScore['nodeID'])
  844.             if nodeName.isnumeric() and multiple_interface:
  845.                 logger.debug(f"System: TODO is multiple interface fix mention this please nodeName: {nodeName}")
  846.             msg += f" HighScore🥇{nodeName} with {highScore['highScore']} chips. "
  847.         player = next((p for p in jackTracker if p['nodeID'] == nodeID), None)
  848.  
  849.     # Always update last_played for existing player
  850.     if player:
  851.         player['last_played'] = time.time()
  852.  
  853.     # get player's last command from tracker if not new player
  854.     last_cmd = ""
  855.     for i in range(len(jackTracker)):
  856.         if jackTracker[i]['nodeID'] == nodeID:
  857.             last_cmd = jackTracker[i]['cmd']
  858.  
  859.     # Play BlackJack
  860.     msg += playBlackJack(nodeID=nodeID, message=message, last_cmd=last_cmd)
  861.     return msg
  862.  
  863. def handleVideoPoker(message, nodeID, deviceID):
  864.     global vpTracker
  865.     msg = ""
  866.  
  867.     # Find player in tracker
  868.     player = next((p for p in vpTracker if p['nodeID'] == nodeID), None)
  869.  
  870.     # Handle leave command
  871.     if message.lower().startswith("l"):
  872.         logger.debug(f"System: VideoPoker: {nodeID} is leaving the table")
  873.         msg = "You have left the table."
  874.         vpTracker[:] = [p for p in vpTracker if p['nodeID'] != nodeID]
  875.         return msg
  876.  
  877.     # Create new player if not found
  878.     if not player and nodeID != 0:
  879.         vpTracker.append({
  880.             'nodeID': nodeID,
  881.             'cmd': 'new',
  882.             'last_played': time.time(),
  883.             'time': time.time(),
  884.             'cash': vpStartingCash,
  885.             'player': None,
  886.             'deck': None,
  887.             'highScore': 0,
  888.             'drawCount': 0
  889.         })
  890.         msg += "Welcome to 🎰Video Poker!🎰\n"
  891.         # Show high score if available
  892.         highScore = loadHSVp()
  893.         if highScore and highScore.get('nodeID', 0) != 0:
  894.             nodeName = get_name_from_number(highScore['nodeID'])
  895.             if nodeName.isnumeric() and multiple_interface:
  896.                 logger.debug(f"System: TODO is multiple interface fix mention this please nodeName: {nodeName}")
  897.             msg += f" HighScore🥇{nodeName} with {highScore['highScore']} coins. "
  898.         player = next((p for p in vpTracker if p['nodeID'] == nodeID), None)
  899.  
  900.     # Always update last_played for existing player
  901.     if player:
  902.         player['last_played'] = time.time()
  903.  
  904.     # Play Video Poker
  905.     msg += playVideoPoker(nodeID=nodeID, message=message)
  906.     return msg
  907.  
  908. def handleMmind(message, nodeID, deviceID):
  909.     global mindTracker
  910.     msg = ''
  911.  
  912.     if "end" in message.lower() or message.lower().startswith("e"):
  913.         logger.debug(f"System: MasterMind: {nodeID} is leaving the game")
  914.         msg = "You have left the Game."
  915.         for i in range(len(mindTracker)):
  916.             if mindTracker[i]['nodeID'] == nodeID:
  917.                 mindTracker.pop(i)
  918.         hscore = getHighScoreMMind(0, 0, 'n')
  919.         if hscore and isinstance(hscore[0], dict):
  920.             highNode = hscore[0].get('nodeID', 0)
  921.             highTurns = hscore[0].get('turns', 0)
  922.             highDiff = hscore[0].get('diff', 'n')
  923.         else:
  924.             highNode = 0
  925.             highTurns = 0
  926.             highDiff = 'n'
  927.         nodeName = get_name_from_number(int(highNode),'long',deviceID)
  928.         if highNode != 0 and highTurns > 1:
  929.             msg += f"🧠HighScore🥇{nodeName} with {highTurns} turns difficulty {highDiff}"
  930.         return msg
  931.  
  932.     # get player's last command from tracker if not new player
  933.     last_cmd = ""
  934.     for i in range(len(mindTracker)):
  935.         if mindTracker[i]['nodeID'] == nodeID:
  936.             last_cmd = mindTracker[i]['cmd']
  937.  
  938.     logger.debug(f"System: {nodeID} PlayingGame mastermind last_cmd: {last_cmd}")
  939.  
  940.     if last_cmd == "" and nodeID != 0:
  941.         # create new player
  942.         logger.debug("System: MasterMind: New Player: " + str(nodeID))
  943.         mindTracker.append({'nodeID': nodeID, 'last_played': time.time(), 'cmd': 'new', 'secret_code': 'RYGB', 'diff': 'n', 'turns': 1})
  944.         msg = "Welcome to 🟡🔴🔵🟢MasterMind!🧠"
  945.         msg += "Each Guess hints to correct colors, correct position, wrong position."
  946.         msg += "You have 10 turns to guess the code. Choose a difficulty: (N)ormal (H)ard e(X)pert"
  947.         return msg
  948.  
  949.     msg += start_mMind(nodeID=nodeID, message=message)
  950.     return msg
  951.  
  952. def handleGolf(message, nodeID, deviceID):
  953.     global golfTracker
  954.     msg = ''
  955.  
  956.     # get player's last command from tracker if not new player
  957.     last_cmd = ""
  958.  
  959.     # Ensure player exists in tracker
  960.     if not any(entry['nodeID'] == nodeID for entry in golfTracker):
  961.         logger.debug("System: GolfSim: New Player: " + str(nodeID))
  962.         golfTracker.append({
  963.             'nodeID': nodeID,
  964.             'last_played': time.time(),
  965.             'cmd': 'new',
  966.             'hole': 1,
  967.             'distance_remaining': 0,
  968.             'hole_shots': 0,
  969.             'hole_strokes': 0,
  970.             'hole_to_par': 0,
  971.             'total_strokes': 0,
  972.             'total_to_par': 0,
  973.             'par': 0,
  974.             'hazard': ''
  975.         })
  976.     # get player's last command from tracker
  977.     for i in range(len(golfTracker)):
  978.         if golfTracker[i]['nodeID'] == nodeID:
  979.             last_cmd = golfTracker[i]['cmd']
  980.  
  981.     if "end" in message.lower() or message.lower().startswith("e"):
  982.         logger.debug(f"System: GolfSim: {nodeID} is leaving the game")
  983.         msg = "You have left the Game."
  984.         for i in range(len(golfTracker)):
  985.             if golfTracker[i]['nodeID'] == nodeID:
  986.                 golfTracker.pop(i)
  987.         return msg
  988.  
  989.     logger.debug(f"System: {nodeID} PlayingGame golfsim last_cmd: {last_cmd}")
  990.  
  991.     if last_cmd == "new" and nodeID != 0:
  992.         # create new player
  993.  
  994.         msg = "Welcome to 🏌️GolfSim⛳️\n"
  995.         msg += "Clubs: (D)river, (L)ow Iron, (M)id Iron, (H)igh Iron, (G)ap Wedge, Lob (W)edge (C)addie\n"
  996.    
  997.     msg += playGolf(nodeID=nodeID, message=message, last_cmd=last_cmd)
  998.     return msg
  999.  
  1000. def handleHangman(message, nodeID, deviceID):
  1001.     global hangmanTracker
  1002.     index = 0
  1003.     msg = ''
  1004.     for i in range(len(hangmanTracker)):
  1005.         if hangmanTracker[i]['nodeID'] == nodeID:
  1006.             hangmanTracker[i]["last_played"] = time.time()
  1007.             index = i+1
  1008.             break
  1009.  
  1010.     if index and "end" in message.lower():
  1011.         hangman.end(nodeID)
  1012.         hangmanTracker.pop(index-1)
  1013.         return "Thanks for hanging out🤙"
  1014.  
  1015.     if not index:
  1016.         hangmanTracker.append(
  1017.             {
  1018.                 "nodeID": nodeID,
  1019.                 "last_played": time.time()
  1020.             }
  1021.         )
  1022.         msg = "🧩Hangman🤖 'end' to cut rope🪢\n"
  1023.     msg += hangman.play(nodeID, message)
  1024.     return msg
  1025.  
  1026. def handleHamtest(message, nodeID, deviceID):
  1027.     global hamtestTracker
  1028.     index = 0
  1029.     msg = ''
  1030.     response = message.split(' ')
  1031.     for i in range(len(hamtestTracker)):
  1032.         if hamtestTracker[i]['nodeID'] == nodeID:
  1033.             hamtestTracker[i]["last_played"] = time.time()
  1034.             index = i+1
  1035.             break
  1036.  
  1037.     if not index:
  1038.         hamtestTracker.append({"nodeID": nodeID,"last_played": time.time()})
  1039.  
  1040.     if "end" in response[0].lower():
  1041.         msg = hamtest.endGame(nodeID)
  1042.     elif "score" in response[0].lower():
  1043.         msg = hamtest.getScore(nodeID)
  1044.  
  1045.     if "hamtest" in response[0].lower():
  1046.         if len(response) > 1:
  1047.             if "gen" in response[1].lower():
  1048.                 msg = hamtest.newGame(nodeID, 'general')
  1049.             elif "ex" in response[1].lower():
  1050.                 msg = hamtest.newGame(nodeID, 'extra')
  1051.         else:
  1052.             msg = hamtest.newGame(nodeID, 'technician')
  1053.  
  1054.     # if the message is an answer A B C or D upper or lower case
  1055.     if response[0].upper() in ['A', 'B', 'C', 'D']:
  1056.         msg = hamtest.answer(nodeID, response[0])
  1057.     return msg
  1058.  
  1059. def handleTicTacToe(message, nodeID, deviceID):
  1060.     global tictactoeTracker
  1061.  
  1062.     tracker_entry = next((entry for entry in tictactoeTracker if entry['nodeID'] == nodeID), None)
  1063.  
  1064.     # Handle end/exit command
  1065.     if message.lower().startswith('e'):
  1066.         if tracker_entry:
  1067.             tictactoe.end(nodeID)
  1068.             tictactoeTracker.remove(tracker_entry)
  1069.         return "Thanks for playing! 🎯"
  1070.  
  1071.     # If not found, create new tracker entry and ask for 2D/3D if not specified
  1072.     if not tracker_entry:
  1073.         mode = "2D"
  1074.         if "3d" in message.lower():
  1075.             mode = "3D"
  1076.         elif "2d" in message.lower():
  1077.             mode = "2D"
  1078.         tictactoeTracker.append({
  1079.             "nodeID": nodeID,
  1080.             "last_played": time.time(),
  1081.             "mode": mode
  1082.         })
  1083.         msg = f"🎯Tic-Tac-Toe🤖 '{mode}' mode. (e)nd to quit\n"
  1084.         msg += tictactoe.new_game(nodeID, mode=mode)
  1085.         return msg
  1086.     else:
  1087.         tracker_entry["last_played"] = time.time()
  1088.  
  1089.     msg = tictactoe.play(nodeID, message)
  1090.     return msg
  1091.  
  1092.  
  1093. def handleBattleship(message, nodeID, deviceID):
  1094.     global battleshipTracker
  1095.     from modules.games import battleship
  1096.  
  1097.     # Helper to get short_name from tracker
  1098.     def get_short_name(nid):
  1099.         entry = next((e for e in battleshipTracker if e['nodeID'] == nid), None)
  1100.         return entry['short_name'] if entry and 'short_name' in entry else get_name_from_number(nid, 'short', deviceID)
  1101.  
  1102.     msg_lower = message.lower().strip()
  1103.     tracker_entry = next((entry for entry in battleshipTracker if entry['nodeID'] == nodeID), None)
  1104.  
  1105.     # End/exit command
  1106.     if msg_lower.startswith('end') or msg_lower.startswith('exit'):
  1107.         if tracker_entry:
  1108.             if 'session_id' in tracker_entry:
  1109.                 battleship.Battleship.end_game(tracker_entry['session_id'])
  1110.             battleshipTracker.remove(tracker_entry)
  1111.         return "Thanks for playing Battleship! 🚢"
  1112.  
  1113.     # Create new P2P game with short code
  1114.     if msg_lower.startswith("battleship new"):
  1115.         short_name = get_name_from_number(nodeID, 'short', deviceID)
  1116.         msg, code = battleship.Battleship.new_game(nodeID, vs_ai=False)
  1117.         battleshipTracker.append({
  1118.             "nodeID": nodeID,
  1119.             "short_name": short_name,
  1120.             "last_played": time.time(),
  1121.             "session_id": battleship.Battleship.short_codes.get(code, code)
  1122.         })
  1123.         return f"{msg}"
  1124.  
  1125.     # Show open P2P games waiting for a player
  1126.     if msg_lower.startswith("battleship lobby"):
  1127.         open_codes = []
  1128.         for code, session_id in battleship.Battleship.short_codes.items():
  1129.             session = battleship.Battleship.sessions.get(session_id)
  1130.             if session and session.player2_id is None:
  1131.                 open_codes.append(code)
  1132.         if not open_codes:
  1133.             return "No open Battleship games waiting for players."
  1134.         return "Open Battleship games (join with 'battleship join <code>'):\n" + ", ".join(open_codes)
  1135.  
  1136.     # Join existing P2P game using short code
  1137.     if msg_lower.startswith("battleship join"):
  1138.         try:
  1139.             code = msg_lower.split("join", 1)[1].strip()
  1140.         except IndexError:
  1141.             return "Usage: battleship join <code>"
  1142.         session = battleship.Battleship.get_session(code)
  1143.         if not session:
  1144.             return "Session not found."
  1145.         if session.player2_id is not None:
  1146.             return "Session already has two players."
  1147.         session.player2_id = nodeID
  1148.         session.next_turn = nodeID  # Make joining player go first!
  1149.         short_name = get_name_from_number(nodeID, 'short', deviceID)
  1150.         battleshipTracker.append({
  1151.             "nodeID": nodeID,
  1152.             "short_name": short_name,
  1153.             "last_played": time.time(),
  1154.             "session_id": session.session_id
  1155.         })
  1156.         p1_short_name = get_short_name(session.player1_id)
  1157.         send_message(
  1158.             f"{p1_short_name}, your opponent {short_name} has joined the game! It's their turn first.",
  1159.             0,  # channel 0 for DM
  1160.             session.player1_id,  # recipient nodeID
  1161.             deviceID
  1162.         )
  1163.         time.sleep(splitDelay)  # slight delay to avoid message overlap
  1164.         return "You joined the game! It's your turn. Enter your move (e.g., 'B4')."
  1165.  
  1166.     # If not found, create new tracker entry and new game vs AI (default)
  1167.     if not tracker_entry:
  1168.         short_name = get_name_from_number(nodeID, 'short', deviceID)
  1169.         msg, session_id = battleship.Battleship.new_game(nodeID)
  1170.         battleshipTracker.append({
  1171.             "nodeID": nodeID,
  1172.             "short_name": short_name,
  1173.             "last_played": time.time(),
  1174.             "session_id": session_id
  1175.         })
  1176.         return msg
  1177.  
  1178.     # Update last played
  1179.     tracker_entry["last_played"] = time.time()
  1180.     session_id = tracker_entry.get("session_id")
  1181.  
  1182.     # Play the game and check if we need to alert the next player
  1183.     response = battleship.playBattleship(message, nodeID, deviceID, session_id=session_id)
  1184.  
  1185.     # --- Notify the next player when it's their turn in P2P ---
  1186.     session = battleship.Battleship.get_session(session_id)
  1187.     if session and not session.vs_ai and session.player1_id and session.player2_id:
  1188.         # Only notify if the game is not over (optional: add a game-over check)
  1189.         if getattr(session, "last_move", None):
  1190.             next_player_id = session.next_turn
  1191.             # Only notify if it's not the player who just moved
  1192.             if next_player_id != nodeID:
  1193.                 next_player_short_name = get_short_name(next_player_id)
  1194.                 send_message(
  1195.                     f"{next_player_short_name}, it's your turn in Battleship! Enter your move (e.g., 'B4').",
  1196.                     0,  # channel 0 for DM
  1197.                     next_player_id,
  1198.                     deviceID
  1199.                 )
  1200.                 time.sleep(splitDelay)  # slight delay to avoid message overlap
  1201.  
  1202.     return response
  1203.  
  1204. def quizHandler(message, nodeID, deviceID):
  1205.     global quizGamePlayer
  1206.     user_name = get_name_from_number(nodeID)
  1207.     user_id = nodeID
  1208.     msg = ''
  1209.     user_answer = ''
  1210.     user_answer = message.lower()
  1211.     user_answer = user_answer.replace("quiz","").replace("q:","").strip()
  1212.     if user_answer.startswith("!") and my_settings.cmdBang:
  1213.         user_answer = user_answer[1:].strip()
  1214.     if user_answer:
  1215.         if user_answer.startswith("start"):
  1216.             msg = quizGamePlayer.start_game(user_id)
  1217.         elif user_answer.startswith("stop"):
  1218.             msg = quizGamePlayer.stop_game(user_id)
  1219.         elif user_answer.startswith("join"):
  1220.             msg = quizGamePlayer.join(user_id)
  1221.         elif user_answer.startswith("leave"):
  1222.             msg = quizGamePlayer.leave(user_id)
  1223.         elif user_answer.startswith("next"):
  1224.             msg = quizGamePlayer.next_question(user_id)
  1225.         elif user_answer.startswith("score"):
  1226.             if user_id in quizGamePlayer.players:
  1227.                 score = quizGamePlayer.players[user_id]['score']
  1228.                 msg = f"Your score: {score}"
  1229.             else:
  1230.                 msg = "You are not in the quiz."
  1231.         elif user_answer.startswith("top"):
  1232.             msg = quizGamePlayer.top_three()
  1233.         elif user_answer.startswith("broadcast"):
  1234.             broadcast_msg = user_answer.replace("broadcast", "", 1).strip()
  1235.             msg = quizGamePlayer.broadcast(user_id, broadcast_msg)
  1236.         elif user_answer.startswith("?"):
  1237.             msg = ("Quiz Commands:\n"
  1238.                    "q: join - Join the current quiz\n"
  1239.                    "q: leave - Leave the current quiz\n"
  1240.                    "q: <your answer> - Answer the current question\n"
  1241.                    "q: score - Show your current score\n"
  1242.                    "q: top - Show top 3 players\n")
  1243.         else:
  1244.             msg = quizGamePlayer.answer(user_id, user_answer)
  1245.  
  1246.         # set username on top 3
  1247.         if "🏆 Top" in msg:
  1248.             #replace all the 10 digit numbers with the short name
  1249.             for part in msg.split():
  1250.                 part = part.rstrip(":")
  1251.                 if len(part) == 10:
  1252.                     player_name = get_name_from_number(int(part), 'short', deviceID)
  1253.                     msg = msg.replace(part, player_name)
  1254.        
  1255.         # broadcast message to all players if user is in bbs_admin_list and msg is a dict with 'message' key
  1256.         if isinstance(msg, dict) and str(nodeID) in bbs_admin_list and 'message' in msg:
  1257.             for player_id in quizGamePlayer.players:
  1258.                 send_message(msg['message'], 0, player_id, deviceID)
  1259.             msg = f"Message sent to {len(quizGamePlayer.players)} players"
  1260.  
  1261.         return msg
  1262.     else:
  1263.         return "🧠Please provide an answer or command, or send q: ?"
  1264.  
  1265. def surveyHandler(message, nodeID, deviceID):
  1266.     global surveyTracker
  1267.     user_id = nodeID
  1268.     location = get_node_location(nodeID, deviceID)
  1269.     msg = ''
  1270.     # Normalize and parse the command
  1271.     msg_lower = message.lower().strip()
  1272.     surveySays = msg_lower
  1273.     if msg_lower.startswith("survey"):
  1274.         surveySays = surveySays.removeprefix("survey").strip()
  1275.     elif msg_lower.startswith("s:"):
  1276.         surveySays = surveySays.removeprefix("s:").strip()
  1277.    
  1278.     # Handle end command
  1279.     if surveySays == "end":
  1280.         if nodeID not in survey_module.responses:
  1281.             return "No active survey session to end."
  1282.         return survey_module.end_survey(user_id=nodeID)
  1283.  
  1284.     # Handle report command
  1285.     if 'report' in surveySays:
  1286.         if str(nodeID) not in bbs_admin_list:
  1287.             return "You do not have permission to view survey reports."
  1288.         # remove the words 'survey' and 'report' from the message
  1289.         report = msg_lower.replace("survey", "").replace("report", "").strip()
  1290.         results = survey_module.get_survey_results(survey_name=report if report else None)
  1291.         return survey_module.format_survey_results(results)
  1292.  
  1293.     # Update last played or add new tracker entry
  1294.     found = False
  1295.     for entry in surveyTracker:
  1296.         if entry.get('nodeID') == nodeID:
  1297.             entry['last_played'] = time.time()
  1298.             found = True
  1299.             break
  1300.     if not found:
  1301.         surveyTracker.append({'nodeID': nodeID, 'last_played': time.time()})
  1302.  
  1303.     # If not in survey session, start one
  1304.     if nodeID not in survey_module.responses:
  1305.         msg = survey_module.start_survey(user_id=nodeID, survey_name=surveySays, location=location)
  1306.     else:
  1307.         # Process the answer
  1308.         msg = survey_module.answer(user_id=nodeID, answer=surveySays, location=location)
  1309.  
  1310.     return msg
  1311.  
  1312. def handle_riverFlow(message, message_from_id, deviceID, vox=False):
  1313.     # River Flow from NOAA or Open-Meteo
  1314.     if vox:
  1315.         location = (my_settings.latitudeValue, my_settings.longitudeValue)
  1316.         message = "riverflow"
  1317.     else:
  1318.         location = get_node_location(message_from_id, deviceID)
  1319.     msg_lower = message.lower()
  1320.     if "riverflow " in msg_lower:
  1321.         user_input = msg_lower.split("riverflow ", 1)[1].strip()
  1322.         if user_input:
  1323.             userRiver = [r.strip() for r in user_input.split(",") if r.strip()]
  1324.         else:
  1325.             userRiver = riverListDefault
  1326.     else:
  1327.         userRiver = riverListDefault
  1328.  
  1329.     if use_meteo_wxApi:
  1330.         return get_flood_openmeteo(location[0], location[1])
  1331.     else:
  1332.         msg = ""
  1333.         for river in userRiver:
  1334.             msg += get_flood_noaa(location[0], location[1], river)
  1335.         return msg
  1336.  
  1337. def handle_mwx(message_from_id, deviceID, cmd):
  1338.     # NOAA Coastal and Marine Weather
  1339.     if my_settings.myCoastalZone is None:
  1340.         logger.warning("System: Coastal Zone not set, please set in config.ini")
  1341.         return my_settings.NO_ALERTS
  1342.     return get_nws_marine(zone=myCoastalZone, days=coastalForecastDays)
  1343.  
  1344. def handle_wxc(message_from_id, deviceID, cmd, days=None, vox=False):
  1345.     # Weather from NOAA or Open-Meteo
  1346.     location = get_node_location(message_from_id, deviceID)
  1347.     if my_settings.use_meteo_wxApi and not "wxc" in cmd and not use_metric:
  1348.         #logger.debug("System: Bot Returning Open-Meteo API for weather imperial")
  1349.         weather = get_wx_meteo(str(location[0]), str(location[1]))
  1350.     elif my_settings.use_meteo_wxApi:
  1351.         #logger.debug("System: Bot Returning Open-Meteo API for weather metric")
  1352.         weather = get_wx_meteo(str(location[0]), str(location[1]), 1)
  1353.     elif not my_settings.use_meteo_wxApi and "wxc" in cmd or my_settings.use_metric:
  1354.         #logger.debug("System: Bot Returning NOAA API for weather metric")
  1355.         weather = get_NOAAweather(str(location[0]), str(location[1]), 1, report_days=days)
  1356.     else:
  1357.         #logger.debug("System: Bot Returning NOAA API for weather imperial")
  1358.         weather = get_NOAAweather(str(location[0]), str(location[1]), report_days=days)
  1359.     return weather
  1360.  
  1361. def handle_emergency_alerts(message, message_from_id, deviceID):
  1362.     location = get_node_location(message_from_id, deviceID)
  1363.     if my_settings.enableDEalerts:
  1364.         # nina Alerts
  1365.         return get_nina_alerts()
  1366.     if message.lower().startswith("ealert"):
  1367.         # Detailed alert FEMA
  1368.         return getIpawsAlert(str(location[0]), str(location[1]))
  1369.     else:
  1370.         # Headlines only FEMA
  1371.         return getIpawsAlert(str(location[0]), str(location[1]), shortAlerts=True)
  1372.  
  1373. def handleEarthquake(message, message_from_id, deviceID):
  1374.     location = get_node_location(message_from_id, deviceID)
  1375.     if "earthquake" in message.lower():
  1376.         return checkUSGSEarthQuake(str(location[0]), str(location[1]))
  1377.    
  1378. def handle_checklist(message, message_from_id, deviceID):
  1379.     name = get_name_from_number(message_from_id, 'short', deviceID)
  1380.     location = get_node_location(message_from_id, deviceID)
  1381.     return process_checklist_command(message_from_id, message, name, location)
  1382.  
  1383. def handle_inventory(message, message_from_id, deviceID):
  1384.     name = get_name_from_number(message_from_id, 'short', deviceID)
  1385.     return process_inventory_command(message_from_id, message, name)
  1386.  
  1387. def handle_bbspost(message, message_from_id, deviceID):
  1388.     if "$" in message and not "example:" in message:
  1389.         subject = message.split("$")[1].split("#")[0]
  1390.         subject = subject.rstrip()
  1391.         if "#" in message:
  1392.             body = message.split("#", 1)[1]
  1393.             body = body.rstrip()
  1394.             logger.info(f"System: BBS Post: {subject} Body: {body}")
  1395.             return bbs_post_message(subject, body, message_from_id)
  1396.         elif not "example:" in message:
  1397.             return "example: bbspost $subject #✉️message"
  1398.     elif "@" in message and not "example:" in message:
  1399.         toNode = message.split("@")[1].split("#")[0]
  1400.         toNode = toNode.rstrip()
  1401.         if toNode.startswith("!") and len(toNode) == 9:
  1402.             # mesh !hex
  1403.             try:
  1404.                 toNode = int(toNode.strip("!"),16)
  1405.             except ValueError as e:
  1406.                 toNode = 0
  1407.         elif toNode.isalpha() or not toNode.isnumeric() or len(toNode) < 5:
  1408.             # try short name
  1409.             toNode = get_num_from_short_name(toNode, deviceID)
  1410.  
  1411.         if "#" in message:
  1412.             if toNode == 0:
  1413.                 return "Node not found " + message.split("@")[1].split("#")[0]
  1414.             body = message.split("#", 1)[1]
  1415.             body = body.rstrip()
  1416.             logger.info(f"System: BBS Post DM to: {toNode} Body: {body}")
  1417.             return bbs_post_dm(toNode, body, message_from_id)
  1418.         else:
  1419.             return "example: bbspost @nodeNumber/ShortName/!hex #✉️message"
  1420.     elif not "example:" in message:
  1421.         return "example: bbspost $subject #✉️message, or bbspost @node #✉️message"
  1422.  
  1423. def handle_bbsread(message):
  1424.     if "#" in message and not "example:" in message:
  1425.         messageID = int(message.split("#")[1])
  1426.         return bbs_read_message(messageID)
  1427.     elif not "example:" in message:
  1428.         return "Please add a ✉️message number example: bbsread #14"
  1429.  
  1430. def handle_bbsdelete(message, message_from_id):
  1431.     if "#" in message and not "example:" in message:
  1432.         messageID = int(message.split("#")[1])
  1433.         return bbs_delete_message(messageID, message_from_id)
  1434.     elif not "example:" in message:
  1435.         return "Please add a ✉️message number example: bbsdelete #14"
  1436.  
  1437. def handle_messages(message, deviceID, channel_number, msg_history, publicChannel, isDM):
  1438.     if  "?" in message and isDM:
  1439.         return message.split("?")[0].title() + " command returns the last " + str(storeFlimit) + " messages sent on a channel."
  1440.     else:
  1441.         # Filter messages for this device/channel
  1442.         filtered_msgs = [
  1443.             msgH for msgH in msg_history
  1444.             if msgH[4] == deviceID and (msgH[2] == channel_number or msgH[2] == publicChannel)
  1445.         ]
  1446.        
  1447.         # Choose order and slice
  1448.         # Oldest first, take first N
  1449.         filtered_msgs = filtered_msgs[-storeFlimit:][::-1]
  1450.         if my_settings.reverseSF:
  1451.             # reverse that
  1452.             filtered_msgs = filtered_msgs[::-1]
  1453.  
  1454.         response = ""
  1455.         header = f"📨Msgs:\n"
  1456.         for msgH in filtered_msgs:
  1457.             new_line = f"\n{msgH[0]}: {msgH[1]}"
  1458.             test_response = response + new_line
  1459.             if len(test_response.encode('utf-8')) > maxBuffer:
  1460.                 # Truncate message if needed
  1461.                 msg_text = msgH[1]
  1462.                 truncated = False
  1463.                 trunc_marker = "..."
  1464.                 while len(msg_text) > 0 and len((response + f"\n{msgH[0]}: {msg_text}{trunc_marker}").encode('utf-8')) > maxBuffer:
  1465.                     msg_text = msg_text[:-1]
  1466.                     truncated = True
  1467.                 if len(msg_text) > 10:
  1468.                     if truncated:
  1469.                         response += f"\n{msgH[0]}: {msg_text}{trunc_marker}"
  1470.                     else:
  1471.                         response += f"\n{msgH[0]}: {msg_text}"
  1472.                     break
  1473.                 continue
  1474.             else:
  1475.                 response += new_line
  1476.  
  1477.         if len(response) > 0:
  1478.             return header + response
  1479.         else:
  1480.             return "No 📭messages in history"
  1481.  
  1482. def handle_sun(message_from_id, deviceID, channel_number, vox=False):
  1483.     if vox:
  1484.         # return a default message if vox is enabled
  1485.         return get_sun(str(my_settings.latitudeValue), str(my_settings.longitudeValue))
  1486.     location = get_node_location(message_from_id, deviceID, channel_number)
  1487.     return get_sun(str(location[0]), str(location[1]))
  1488.  
  1489. def sysinfo(message, message_from_id, deviceID, isDM):
  1490.     if "?" in message:
  1491.         return "sysinfo command returns system information."
  1492.     else:
  1493.         if enable_runShellCmd and file_monitor_enabled:
  1494.             # get the system information from the shell script
  1495.             # this is an example of how to run a shell script and return the data
  1496.             shellData = call_external_script('', "script/sysEnv.sh")
  1497.             # check if the script returned data
  1498.             if shellData == "" or shellData == None:
  1499.                 # no data returned from the script
  1500.                 shellData = "shell script data missing"
  1501.             # if not an admin remove any line in the shellData that had 'IP:' in it
  1502.             if (str(message_from_id) not in bbs_admin_list) or (not isDM):
  1503.                 shell_lines = shellData.splitlines()
  1504.                 filtered_lines = [line for line in shell_lines if 'IP:' not in line]
  1505.                 shellData = "\n".join(filtered_lines)
  1506.             return get_sysinfo(message_from_id, deviceID) + "\n" + shellData.rstrip()
  1507.         else:
  1508.             return get_sysinfo(message_from_id, deviceID)
  1509.  
  1510. def handle_lheard(message, nodeid, deviceID, isDM):
  1511.     if  "?" in message and isDM:
  1512.         return message.split("?")[0].title() + " command returns a list of the nodes that have been heard recently"
  1513.  
  1514.     # display last heard nodes add to response
  1515.     bot_response = "Last Heard\n"
  1516.     bot_response += str(get_node_list(1))
  1517.  
  1518.     # show last users of the bot with the cmdHistory list
  1519.     history = handle_history(message, nodeid, deviceID, isDM, lheard=True)
  1520.     if history:
  1521.         bot_response += f'LastSeen\n{history}'
  1522.     else:
  1523.         # trim the last \n
  1524.         bot_response = bot_response[:-1]
  1525.  
  1526.     # get count of nodes heard
  1527.     bot_response += f"\n👀In Mesh: {len(seenNodes)}"
  1528.  
  1529.     # bot_response += getNodeTelemetry(deviceID)
  1530.     return bot_response
  1531.  
  1532. def handle_history(message, nodeid, deviceID, isDM, lheard=False):
  1533.     global cmdHistory, lheardCmdIgnoreNode, bbs_admin_list
  1534.     msg = ""
  1535.     buffer = []
  1536.  
  1537.     if  "?" in message and isDM:
  1538.         return message.split("?")[0].title() + " command returns a list of commands received."
  1539.  
  1540.     # show the last commands from the user to the bot
  1541.     if not lheard:
  1542.         for i in range(len(cmdHistory)):
  1543.             cmdTime = round((time.time() - cmdHistory[i]['time']) / 600) * 5
  1544.             prettyTime = getPrettyTime(cmdTime)
  1545.  
  1546.             # history display output
  1547.             if str(nodeid) in bbs_admin_list and cmdHistory[i]['nodeID'] not in lheardCmdIgnoreNode:
  1548.                 buffer.append((get_name_from_number(cmdHistory[i]['nodeID'], 'short', deviceID), cmdHistory[i]['cmd'], prettyTime))
  1549.             elif cmdHistory[i]['nodeID'] == nodeid and cmdHistory[i]['nodeID'] not in lheardCmdIgnoreNode:
  1550.                 buffer.append((get_name_from_number(nodeid, 'short', deviceID), cmdHistory[i]['cmd'], prettyTime))
  1551.         # message for output of the last commands
  1552.         buffer.reverse()
  1553.         # only return the last 4 commands
  1554.         if len(buffer) > 4:
  1555.             buffer = buffer[-4:]
  1556.         # create the message from the buffer list
  1557.         for i in range(0, len(buffer)):
  1558.             msg += f"{buffer[i][0]}: {buffer[i][1]} :{buffer[i][2]} ago"
  1559.             if i < len(buffer) - 1:
  1560.                 msg += "\n" # add a new line if not the last line
  1561.     else:
  1562.         # sort the cmdHistory list by time, return the username and time into a new list which used for display
  1563.         for i in range(len(cmdHistory)):
  1564.             cmdTime = round((time.time() - cmdHistory[i]['time']) / 600) * 5
  1565.             prettyTime = getPrettyTime(cmdTime)
  1566.  
  1567.             if cmdHistory[i]['nodeID'] not in lheardCmdIgnoreNode:
  1568.                 # add line to a new list for display
  1569.                 nodeName = get_name_from_number(cmdHistory[i]['nodeID'], 'short', deviceID)
  1570.                 if not any(d[0] == nodeName for d in buffer):
  1571.                     buffer.append((nodeName, prettyTime))
  1572.                 else:
  1573.                     # update the time for the node in the buffer for the latest time in cmdHistory
  1574.                     for j in range(len(buffer)):
  1575.                         if buffer[j][0] == nodeName:
  1576.                             buffer[j] = (nodeName, prettyTime)
  1577.  
  1578.         # create the message from the buffer list
  1579.         buffer.reverse() # reverse the list to show the latest first
  1580.         for i in range(0, len(buffer)):
  1581.             msg += f"{buffer[i][0]}, {buffer[i][1]} ago"
  1582.             if i < len(buffer) - 1:
  1583.                 msg += "\n" # add a new line if not the last line
  1584.             if i > 3:
  1585.                 break # only return the last 4 nodes
  1586.     return msg
  1587.  
  1588. def handle_whereami(message_from_id, deviceID, channel_number):
  1589.     location = get_node_location(message_from_id, deviceID, channel_number)
  1590.     # check api_throttle
  1591.     check_throttle = api_throttle(message_from_id, deviceID, apiName='whereami')
  1592.     if check_throttle:
  1593.         return check_throttle
  1594.     return where_am_i(str(location[0]), str(location[1]))
  1595.  
  1596. def handle_repeaterQuery(message_from_id, deviceID, channel_number):
  1597.     location = get_node_location(message_from_id, deviceID, channel_number)
  1598.     # check api_throttle
  1599.     check_throttle = api_throttle(message_from_id, deviceID, apiName='repeaterQuery')
  1600.     if check_throttle:
  1601.         return check_throttle
  1602.     if repeater_lookup == "rbook":
  1603.         return getRepeaterBook(str(location[0]), str(location[1]))
  1604.     elif repeater_lookup == "artsci":
  1605.         return getArtSciRepeaters(str(location[0]), str(location[1]))
  1606.     else:
  1607.         return "Repeater lookup not enabled"
  1608.  
  1609. def handle_tide(message_from_id, deviceID, channel_number, vox=False):
  1610.     if vox:
  1611.         return get_NOAAtide(str(my_settings.latitudeValue), str(my_settings.longitudeValue))
  1612.     location = get_node_location(message_from_id, deviceID, channel_number)
  1613.     return get_NOAAtide(str(location[0]), str(location[1]))
  1614.  
  1615. def handle_moon(message_from_id, deviceID, channel_number, vox=False):
  1616.     if vox:
  1617.         return get_moon(str(my_settings.latitudeValue), str(my_settings.longitudeValue))
  1618.     location = get_node_location(message_from_id, deviceID, channel_number)
  1619.     return get_moon(str(location[0]), str(location[1]))
  1620.  
  1621. def handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus):
  1622.     try:
  1623.         loc = []
  1624.         msg = "You are " + str(message_from_id) + " AKA " +\
  1625.                 str(get_name_from_number(message_from_id, 'long', deviceID) + " AKA, " +\
  1626.                 str(get_name_from_number(message_from_id, 'short', deviceID)) + " AKA, " +\
  1627.                 str(decimal_to_hex(message_from_id)) + f"\n")
  1628.         msg += f"I see the signal strength is {rssi} and the SNR is {snr} with hop count of {hop}"
  1629.         if pkiStatus[1] != 'ABC':
  1630.             msg += f"\nYour PKI bit is {pkiStatus[0]} pubKey: {pkiStatus[1]}"
  1631.  
  1632.         loc = get_node_location(message_from_id, deviceID)
  1633.         if loc != [my_settings.latitudeValue, my_settings.longitudeValue]:
  1634.             msg += f"\nYou are at: lat:{loc[0]} lon:{loc[1]}"
  1635.  
  1636.             # check the positionMetadata for nodeID and get metadata
  1637.             if positionMetadata and message_from_id in positionMetadata:
  1638.                 metadata = positionMetadata[message_from_id]
  1639.                 msg += f" alt:{metadata.get('altitude')}, speed:{metadata.get('groundSpeed')} bit:{metadata.get('precisionBits')}"
  1640.     except Exception as e:
  1641.         logger.error(f"System: Error in whoami: {e}")
  1642.         msg = "Error in whoami"
  1643.     return msg
  1644.  
  1645. def handle_whois(message, deviceID, channel_number, message_from_id):
  1646.     #return data on a node name or number
  1647.     if  "?" in message:
  1648.         return message.split("?")[0].title() + " command returns information on a node."
  1649.     else:
  1650.         # get the nodeID from the message
  1651.         msg = ''
  1652.         node = ''
  1653.         # find the requested node in db
  1654.         if " " in message:
  1655.             node = message.split(" ")[1]
  1656.         if node.startswith("!") and len(node) == 9:
  1657.             # mesh !hex
  1658.             try:
  1659.                 node = int(node.strip("!"),16)
  1660.             except ValueError as e:
  1661.                 node = 0
  1662.         elif node.isalpha() or not node.isnumeric():
  1663.             # try short name
  1664.             node = get_num_from_short_name(node, deviceID)
  1665.  
  1666.         # get details on the node
  1667.         for i in range(len(seenNodes)):
  1668.             if seenNodes[i]['nodeID'] == int(node):
  1669.                 msg = f"Node: {seenNodes[i]['nodeID']} is {get_name_from_number(seenNodes[i]['nodeID'], 'long', deviceID)}\n"
  1670.                 msg += f"Last 👀: {time.ctime(seenNodes[i]['lastSeen'])} "
  1671.                 break
  1672.  
  1673.         if msg == '':
  1674.             msg = "Provide a valid node number or short name"
  1675.         else:
  1676.             # if the user is an admin show the channel and interface and location
  1677.             if str(message_from_id) in bbs_admin_list:
  1678.                 location = get_node_location(seenNodes[i]['nodeID'], deviceID, channel_number)
  1679.                 msg += f"Ch: {seenNodes[i]['channel']}, Int: {seenNodes[i]['rxInterface']}"
  1680.                 msg += f"Lat: {location[0]}, Lon: {location[1]}\n"
  1681.                 if location != [my_settings.latitudeValue, my_settings.longitudeValue]:
  1682.                     msg += f"Loc: {where_am_i(str(location[0]), str(location[1]))}"
  1683.         return msg
  1684.  
  1685. def handle_boot(mesh=True):
  1686.     try:
  1687.         print (CustomFormatter.bold_white + f"\nMeshtastic Autoresponder Bot CTL+C to exit\n" + CustomFormatter.reset)
  1688.         if mesh:
  1689.            
  1690.             for i in range(1, 10):
  1691.                 if globals().get(f'interface{i}_enabled', False):
  1692.                     myNodeNum = globals().get(f'myNodeNum{i}', 0)
  1693.                     logger.info(f"System: Autoresponder Started for Device{i} {get_name_from_number(myNodeNum, 'long', i)},"
  1694.                                 f"{get_name_from_number(myNodeNum, 'short', i)}. NodeID: {myNodeNum}, {decimal_to_hex(myNodeNum)}")
  1695.                    
  1696.             if llm_enabled:
  1697.                 msg = f"System: LLM Enabled"
  1698.                 llmLoad = llm_query(" ", init=True)
  1699.                 if "trouble" not in llmLoad:
  1700.                     if my_settings.llmReplyToNonCommands:
  1701.                         msg += " | Reply to DM's Enabled"
  1702.                     if my_settings.llmUseWikiContext:
  1703.                         wiki_source = "Kiwixpedia" if my_settings.use_kiwix_server else "Wikipedia"
  1704.                         msg += f" | {wiki_source} Context Enabled"
  1705.                     if my_settings.useOpenWebUI:
  1706.                         msg += " | OpenWebUI API Enabled"
  1707.                     else:
  1708.                         msg += f" | Ollama API Model {my_settings.llmModel} loaded. Use {'RAW' if my_settings.rawLLMQuery else 'SYSTEM'} prompt mode."
  1709.                     logger.debug(msg)
  1710.                 else:
  1711.                     logger.debug(f"System: Bad response from LLM: {llmLoad}")
  1712.  
  1713.             if my_settings.bbs_enabled:
  1714.                 logger.debug(f"System: BBS Enabled, {bbsdb} has {len(bbs_messages)} messages. Direct Mail Messages waiting: {(len(bbs_dm) - 1)}")
  1715.                 if my_settings.bbs_link_enabled:
  1716.                     if len(bbs_link_whitelist) > 0:
  1717.                         logger.debug(f"System: BBS Link Enabled with {len(bbs_link_whitelist)} peers")
  1718.                     else:
  1719.                         logger.debug(f"System: BBS Link Enabled allowing all")
  1720.            
  1721.             if my_settings.solar_conditions_enabled:
  1722.                 logger.debug("System: Celestial Telemetry Enabled")
  1723.  
  1724.             if my_settings.meshagesTTS:
  1725.                 logger.debug("System: Meshages TTS Text-to-Speech Enabled")
  1726.            
  1727.             if my_settings.location_enabled:
  1728.                 if my_settings.use_meteo_wxApi:
  1729.                     logger.debug("System: Location Telemetry Enabled using Open-Meteo API")
  1730.                 else:
  1731.                     logger.debug("System: Location Telemetry Enabled using NOAA API")
  1732.                    
  1733.             if my_settings.dad_jokes_enabled:
  1734.                 logger.debug("System: Dad Jokes Enabled!")
  1735.            
  1736.             if my_settings.coastalEnabled:
  1737.                 logger.debug("System: Coastal Forecast and Tide Enabled!")
  1738.            
  1739.             if games_enabled:
  1740.                 logger.debug("System: Games Enabled!")
  1741.            
  1742.             if my_settings.wikipedia_enabled:
  1743.                 if my_settings.use_kiwix_server:
  1744.                     logger.debug(f"System: Wikipedia search Enabled using Kiwix server at {my_settings.kiwix_url}")
  1745.                 else:
  1746.                     logger.debug("System: Wikipedia search Enabled")
  1747.            
  1748.             if my_settings.rssEnable:
  1749.                 logger.debug(f"System: RSS Feed Reader Enabled for feeds: {my_settings.rssFeedNames}")
  1750.             if my_settings.enable_headlines:
  1751.                 logger.debug("System: News Headlines Enabled from NewsAPI.org")
  1752.            
  1753.             if my_settings.radio_detection_enabled:
  1754.                 logger.debug(f"System: Radio Detection Enabled using rigctld at {my_settings.rigControlServerAddress} broadcasting to channels: {my_settings.sigWatchBroadcastCh}")
  1755.            
  1756.             if my_settings.file_monitor_enabled:
  1757.                 logger.warning(f"System: File Monitor Enabled for {my_settings.file_monitor_file_path}, broadcasting to channels: {my_settings.file_monitor_broadcastCh}")
  1758.             if my_settings.enable_runShellCmd:
  1759.                 logger.debug("System: Shell Command monitor enabled")
  1760.                 if my_settings.allowXcmd:
  1761.                     logger.warning("System: File Monitor shell XCMD Enabled")
  1762.             if my_settings.read_news_enabled:
  1763.                 logger.debug(f"System: File Monitor News Reader Enabled for {my_settings.news_file_path}")
  1764.             if my_settings.bee_enabled:
  1765.                 logger.debug("System: File Monitor Bee Monitor Enabled for 🐝bee.txt")
  1766.             if my_settings.bible_enabled:
  1767.                 logger.debug("System: File Monitor Bible Verse Enabled for bible.txt")
  1768.             if my_settings.usAlerts:
  1769.                 logger.debug(f"System: Emergency Alert Broadcast Enabled on channel {my_settings.emergency_responder_alert_channel} for interface {my_settings.emergency_responder_alert_interface}")
  1770.             if my_settings.enableDEalerts:
  1771.                 logger.debug(f"System: NINA Alerts Enabled with counties {my_settings.myRegionalKeysDE}")
  1772.             if my_settings.volcanoAlertBroadcastEnabled:
  1773.                 logger.debug(f"System: Volcano Alert Broadcast Enabled on channels {my_settings.emergency_responder_alert_channel} ignoreUSGSWords {my_settings.ignoreUSGSWords}")
  1774.             if my_settings.ipawsAlertEnabled:
  1775.                 logger.debug(f"System: iPAWS Alerts Enabled with FIPS codes {my_settings.myStateFIPSList} ignorelist {my_settings.ignoreFEMAwords}")
  1776.             if my_settings.enableDEalerts:
  1777.                 logger.debug(f"System: NINA Alerts Enabled with counties {my_settings.myRegionalKeysDE}")
  1778.             if my_settings.wxAlertBroadcastEnabled:
  1779.                 logger.debug(f"System: Weather Alert Broadcast Enabled on channels {my_settings.emergency_responder_alert_channel} ignoreEASwords {my_settings.ignoreEASwords}")
  1780.             if my_settings.emergency_responder_enabled:
  1781.                 logger.debug(f"System: Emergency Responder Enabled on channels {my_settings.emergency_responder_alert_channel}")
  1782.            
  1783.             if my_settings.qrz_hello_enabled:
  1784.                 if my_settings.train_qrz:
  1785.                     logger.debug("System: QRZ Welcome/Hello Enabled with training mode")
  1786.                 else:
  1787.                     logger.debug("System: QRZ Welcome/Hello Enabled")
  1788.  
  1789.             if my_settings.enableSMTP:
  1790.                 if my_settings.enableImap:
  1791.                     logger.debug("System: SMTP Email Alerting Enabled using IMAP")
  1792.                 else:
  1793.                     logger.warning("System: SMTP Email Alerting Enabled")
  1794.  
  1795.         # Default Options
  1796.         if my_settings.useDMForResponse:
  1797.             logger.debug("System: Respond by DM only")
  1798.  
  1799.         if my_settings.autoBanEnabled:
  1800.             logger.debug(f"System: Auto-Ban Enabled for {my_settings.autoBanThreshold} messages in {my_settings.autoBanTimeframe} seconds")
  1801.             load_bbsBanList()
  1802.  
  1803.         if my_settings.log_messages_to_file:
  1804.             logger.debug("System: Logging Messages to disk")
  1805.         if my_settings.syslog_to_file:
  1806.             logger.debug("System: Logging System Logs to disk")
  1807.  
  1808.         if my_settings.motd_enabled:
  1809.             logger.debug(f"System: MOTD Enabled using {my_settings.MOTD} scheduler:{my_settings.schedulerMotd}")
  1810.        
  1811.         if my_settings.sentry_enabled:
  1812.             logger.debug(f"System: Sentry Mode Enabled {my_settings.sentry_radius}m radius reporting to channel:{my_settings.secure_channel} requestLOC:{reqLocationEnabled}")
  1813.             if my_settings.sentryIgnoreList:
  1814.                 logger.debug(f"System: Sentry BlockList Enabled for nodes: {my_settings.sentryIgnoreList}")
  1815.             if my_settings.sentryWatchList:
  1816.                 logger.debug(f"System: Sentry WatchList Enabled for nodes: {my_settings.sentryWatchList}")
  1817.  
  1818.         if my_settings.highfly_enabled:
  1819.             logger.debug(f"System: HighFly Enabled using {my_settings.highfly_altitude}m limit reporting to channel:{my_settings.highfly_channel}")
  1820.        
  1821.         if my_settings.store_forward_enabled:
  1822.             logger.debug(f"System: S&F(messages command) Enabled using limit: {storeFlimit} and reverse queue:{my_settings.reverseSF}")
  1823.        
  1824.         if my_settings.enableEcho:
  1825.             logger.debug("System: Echo command Enabled")
  1826.        
  1827.         if my_settings.repeater_enabled and multiple_interface:
  1828.             logger.debug(f"System: Repeater Enabled for Channels: {my_settings.repeater_channels}")
  1829.        
  1830.         if my_settings.checklist_enabled:
  1831.             logger.debug("System: CheckList Module Enabled")
  1832.         if my_settings.inventory_enabled:
  1833.             logger.debug("System: Inventory Module Enabled")
  1834.         if my_settings.ignoreChannels:
  1835.             logger.debug(f"System: Ignoring Channels: {my_settings.ignoreChannels}")
  1836.        
  1837.         if my_settings.noisyNodeLogging:
  1838.             logger.debug("System: Noisy Node Logging Enabled")
  1839.        
  1840.         if my_settings.logMetaStats:
  1841.             logger.debug("System: Logging Metadata Stats Enabled, leaderboard")
  1842.        
  1843.         if my_settings.scheduler_enabled:
  1844.             logger.debug(f"System: Scheduler Enabled. Default Device:{my_settings.schedulerInterface} Channel:{my_settings.schedulerChannel}")
  1845.  
  1846.     except Exception as e:
  1847.         logger.error(f"System: Error during boot: {e}")
  1848.  
  1849. def onReceive(packet, interface):
  1850.     global seenNodes, msg_history, cmdHistory
  1851.     # Priocess the incoming packet, handles the responses to the packet with auto_response()
  1852.     # Sends the packet to the correct handler for processing
  1853.  
  1854.     # extract interface details from inbound packet
  1855.     rxType = type(interface).__name__
  1856.  
  1857.     # Values assinged to the packet
  1858.     rxNode = message_from_id = snr = rssi = hop = hop_away = channel_number = hop_start = hop_count = hop_limit = 0
  1859.     pkiStatus = (False, 'ABC')
  1860.     rxNodeHostName = None
  1861.     replyIDset = False
  1862.     emojiSeen = False
  1863.     simulator_flag = False
  1864.     isDM = False
  1865.     channel_name = "unknown"
  1866.     session_passkey = None
  1867.     playingGame = False
  1868.  
  1869.     if my_settings.DEBUGpacket:
  1870.         # Debug print the interface object
  1871.         for item in interface.__dict__.items(): intDebug = f"{item}\n"
  1872.         logger.debug(f"System: Packet Received on {rxType} Interface\n {intDebug} \n END of interface \n")
  1873.         # Debug print the packet for debugging
  1874.         logger.debug(f"Packet Received\n {packet} \n END of packet \n")
  1875.  
  1876.     # determine the rxNode based on the interface type
  1877.     if rxType == 'TCPInterface':
  1878.         rxHost = interface.__dict__.get('hostname', 'unknown')
  1879.         rxNodeHostName = interface.__dict__.get('ip', None)
  1880.         rxNode = next(
  1881.             (i for i in range(1, 10)
  1882.             if multiple_interface and rxHost and
  1883.             globals().get(f'hostname{i}', '').split(':', 1)[0] in rxHost and
  1884.             globals().get(f'interface{i}_type', '') == 'tcp'),None)
  1885.  
  1886.     if rxType == 'SerialInterface':
  1887.         rxInterface = interface.__dict__.get('devPath', 'unknown')
  1888.         rxNode = next(
  1889.             (i for i in range(1, 10)
  1890.             if globals().get(f'port{i}', '') in rxInterface),None)
  1891.  
  1892.     if rxType == 'BLEInterface':
  1893.         rxNode = next(
  1894.             (i for i in range(1, 10)
  1895.             if globals().get(f'interface{i}_type', '') == 'ble'),0)
  1896.        
  1897.     if rxNode is None:
  1898.         # default to interface 1 ## FIXME needs better like a default interface setting or hash lookup
  1899.         if 'decoded' in packet and packet['decoded']['portnum'] in ['ADMIN_APP', 'SIMULATOR_APP']:
  1900.             session_passkey = packet.get('decoded', {}).get('admin', {}).get('sessionPasskey', None)
  1901.         rxNode = 1
  1902.  
  1903.     # check if the packet has a channel flag use it ## FIXME needs to be channel hash lookup
  1904.     if packet.get('channel'):
  1905.         channel_number = packet.get('channel')
  1906.         channel_name = "unknown"
  1907.         try:
  1908.             res = resolve_channel_name(channel_number, rxNode, interface)
  1909.             if res:
  1910.                 try:
  1911.                     channel_name, _ = res
  1912.                 except Exception:
  1913.                     channel_name = "unknown"
  1914.             else:
  1915.                 # Search all interfaces for this channel
  1916.                 cache = build_channel_cache()
  1917.                 found_on_other = None
  1918.                 for device in cache:
  1919.                     for chan_name, info in device.get("channels", {}).items():
  1920.                         if str(info.get('number')) == str(channel_number) or str(info.get('hash')) == str(channel_number):
  1921.                             found_on_other = device.get("interface_id")
  1922.                             found_chan_name = chan_name
  1923.                             break
  1924.                     if found_on_other:
  1925.                         break
  1926.                 if found_on_other and found_on_other != rxNode:
  1927.                     logger.debug(
  1928.                         f"System: Received Packet on Channel:{channel_number} ({found_chan_name}) on Interface:{rxNode}, but this channel is configured on Interface:{found_on_other}"
  1929.                     )
  1930.         except Exception as e:
  1931.             logger.debug(f"System: channel resolution error: {e}")
  1932.    
  1933.         #debug channel info
  1934.         # if "unknown" in str(channel_name):
  1935.         #     logger.debug(f"System: Received Packet on Channel:{channel_number} on Interface:{rxNode}")
  1936.         # else:
  1937.         #     logger.debug(f"System: Received Packet on Channel:{channel_number} Name:{channel_name} on Interface:{rxNode}")
  1938.  
  1939.     # check if the packet has a simulator flag
  1940.     simulator_flag = packet.get('decoded', {}).get('simulator', False)
  1941.     if isinstance(simulator_flag, dict):
  1942.         # assume Software Simulator
  1943.         simulator_flag = True
  1944.  
  1945.     # set the message_from_id
  1946.     message_from_id = packet['from']
  1947.  
  1948.     # if message_from_id is not in the seenNodes list add it
  1949.     if not any(node.get('nodeID') == message_from_id for node in seenNodes):
  1950.         seenNodes.append({'nodeID': message_from_id, 'rxInterface': rxNode, 'channel': channel_number, 'welcome': False, 'first_seen': time.time(), 'lastSeen': time.time()})
  1951.     else:
  1952.         # update lastSeen time
  1953.         for node in seenNodes:
  1954.             if node.get('nodeID') == message_from_id:
  1955.                 node['lastSeen'] = time.time()
  1956.                 break
  1957.     # BBS DM MAIL CHECKER
  1958.     if bbs_enabled and 'decoded' in packet:
  1959.         msg = bbs_check_dm(message_from_id)
  1960.         if msg:
  1961.             logger.info(f"System: BBS DM Delivery: {msg[1]} For: {get_name_from_number(message_from_id, 'long', rxNode)}")
  1962.             message = "Mail: " + msg[1] + "  From: " + get_name_from_number(msg[2], 'long', rxNode)
  1963.             bbs_delete_dm(msg[0], msg[1])
  1964.             send_message(message, channel_number, message_from_id, rxNode)
  1965.  
  1966.     # CHECK with ban_hammer() if the node is banned
  1967.     if str(message_from_id) in my_settings.bbs_ban_list or str(message_from_id) in my_settings.autoBanlist:
  1968.         logger.warning(f"System: Banned Node {message_from_id} tried to send a message. Ignored. Try adding to node firmware-blocklist")
  1969.         return
  1970.  
  1971.     # handle TEXT_MESSAGE_APP
  1972.     try:
  1973.         if 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
  1974.             message_bytes = packet['decoded']['payload']
  1975.             message_string = message_bytes.decode('utf-8')
  1976.             via_mqtt = packet['decoded'].get('viaMqtt', False)
  1977.             transport_mechanism = (
  1978.                 packet.get('transport_mechanism')
  1979.                 or packet.get('transportMechanism')
  1980.                 or (packet.get('decoded', {}).get('transport_mechanism'))
  1981.                 or (packet.get('decoded', {}).get('transportMechanism'))
  1982.                 or 'unknown'
  1983.             )
  1984.             rx_time = packet['decoded'].get('rxTime', time.time())
  1985.  
  1986.             # check if the packet is from us
  1987.             if message_from_id in [myNodeNum1, myNodeNum2, myNodeNum3, myNodeNum4, myNodeNum5, myNodeNum6, myNodeNum7, myNodeNum8, myNodeNum9]:
  1988.                 logger.warning(f"System: Packet from self {message_from_id} loop or traffic replay detected")
  1989.  
  1990.             # get the signal strength and snr if available
  1991.             if packet.get('rxSnr') or packet.get('rxRssi'):
  1992.                 snr = packet.get('rxSnr', 0)
  1993.                 rssi = packet.get('rxRssi', 0)
  1994.  
  1995.             # check if the packet has a publicKey flag use it
  1996.             if packet.get('publicKey'):
  1997.                 pkiStatus = packet.get('pkiEncrypted', False), packet.get('publicKey', 'ABC')
  1998.            
  1999.             # check if the packet has replyId flag // currently unused in the code
  2000.             if packet.get('replyId'):
  2001.                 replyIDset = packet.get('replyId', False)
  2002.            
  2003.             # check if the packet has emoji flag set it // currently unused in the code
  2004.             if packet.get('emoji'):
  2005.                 emojiSeen = packet.get('emoji', False)
  2006.  
  2007.             # check if the packet has a hop count flag use it
  2008.             if packet.get('hopsAway'):
  2009.                 hop_away = packet.get('hopsAway', 0)
  2010.  
  2011.             if packet.get('hopStart'):
  2012.                 hop_start = packet.get('hopStart', 0)
  2013.  
  2014.             if packet.get('hopLimit'):
  2015.                 hop_limit = packet.get('hopLimit', 0)
  2016.            
  2017.             # calculate hop count
  2018.             hop = ""
  2019.             if hop_limit > 0 and hop_start >= hop_limit:
  2020.                 hop_count = hop_away + (hop_start - hop_limit)
  2021.             elif hop_limit > 0 and hop_start < hop_limit:
  2022.                 hop_count = hop_away + (hop_limit - hop_start)
  2023.             else:
  2024.                 hop_count = hop_away
  2025.  
  2026.             if hop_count > 0:
  2027.                 # set hop string from calculated hop count
  2028.                 hop = f"{hop_count} Hop" if hop_count == 1 else f"{hop_count} Hops"
  2029.  
  2030.             if hop_start == hop_limit and "lora" in str(transport_mechanism).lower() and (snr != 0 or rssi != 0) and hop_count == 0:
  2031.                 # 2.7+ firmware direct hop over LoRa
  2032.                 hop = "Direct"
  2033.  
  2034.             if via_mqtt or "mqtt" in str(transport_mechanism).lower():
  2035.                 hop = "MQTT"
  2036.                 via_mqtt = True
  2037.             elif "udp" in str(transport_mechanism).lower():
  2038.                 hop = "Gateway"
  2039.            
  2040.             if hop in ("MQTT", "Gateway") and hop_count > 0:
  2041.                 hop = f" {hop_count} Hops"
  2042.  
  2043.             # Add relay node info if present
  2044.             if packet.get('relayNode') is not None:
  2045.                 relay_val = packet['relayNode']
  2046.                 last_byte = relay_val & 0xFF
  2047.                 if last_byte == 0x00:
  2048.                     hex_val = 'OldFW'
  2049.                 else:
  2050.                     hex_val = f"{last_byte:02X}"
  2051.                 hop += f" Relay:{hex_val}"
  2052.  
  2053.             if enableHopLogs:
  2054.                 logger.debug(f"System: Packet HopDebugger: hop_away:{hop_away} hop_limit:{hop_limit} hop_start:{hop_start} calculated_hop_count:{hop_count} final_hop_value:{hop} via_mqtt:{via_mqtt} transport_mechanism:{transport_mechanism} Hostname:{rxNodeHostName}")
  2055.  
  2056.             # check with stringSafeChecker if the message is safe
  2057.             if stringSafeCheck(message_string, message_from_id) is False:
  2058.                 logger.warning(f"System: Possibly Unsafe Message from {get_name_from_number(message_from_id, 'long', rxNode)}")
  2059.  
  2060.             if help_message in message_string or welcome_message in message_string or "CMD?:" in message_string:
  2061.                 # ignore help and welcome messages
  2062.                 logger.warning(f"Got Own Welcome/Help header. From: {get_name_from_number(message_from_id, 'long', rxNode)}")
  2063.                 return
  2064.        
  2065.             # If the packet is a DM (Direct Message) respond to it, otherwise validate its a message for us on the channel
  2066.             if packet['to'] in [myNodeNum1, myNodeNum2, myNodeNum3, myNodeNum4, myNodeNum5, myNodeNum6, myNodeNum7, myNodeNum8, myNodeNum9]:
  2067.                 # message is DM to us
  2068.                 isDM = True
  2069.                 # check if the message contains a trap word, DMs are always responded to
  2070.                 if (messageTrap(message_string) and not llm_enabled) or messageTrap(message_string.split()[0]):
  2071.                     # log the message to stdout
  2072.                     logger.info(f"Device:{rxNode} Channel: {channel_number} " + CustomFormatter.green + f"Received DM: " + CustomFormatter.white + f"{message_string} " + CustomFormatter.purple +\
  2073.                                 "From: " + CustomFormatter.white + f"{get_name_from_number(message_from_id, 'long', rxNode)}")
  2074.                     # respond with DM
  2075.                     send_message(auto_response(message_string, snr, rssi, hop, pkiStatus, message_from_id, channel_number, rxNode, isDM), channel_number, message_from_id, rxNode)
  2076.                 else:
  2077.                     # DM is useful for games or LLM
  2078.                     if games_enabled and ("Direct" in hop or hop_count < my_settings.game_hop_limit):
  2079.                         playingGame = checkPlayingGame(message_from_id, message_string, rxNode, channel_number)
  2080.                     elif hop_count >= my_settings.game_hop_limit:
  2081.                         if games_enabled:
  2082.                             logger.warning(f"Device:{rxNode} Ignoring Request to Play Game: {message_string} From: {get_name_from_number(message_from_id, 'long', rxNode)} with hop count: {hop}")
  2083.                             send_message(f"Your hop count exceeds safe playable distance at {hop_count} hops", channel_number, message_from_id, rxNode)
  2084.                         else:
  2085.                             playingGame = False
  2086.                     else:
  2087.                         playingGame = False
  2088.  
  2089.                     if not playingGame:
  2090.                         if llm_enabled and my_settings.llmReplyToNonCommands:
  2091.                             # respond with LLM
  2092.                             llm = handle_llm(message_from_id, channel_number, rxNode, message_string, publicChannel)
  2093.                             send_message(llm, channel_number, message_from_id, rxNode)
  2094.                         else:
  2095.                             # respond with welcome message on DM
  2096.                             logger.warning(f"Device:{rxNode} Ignoring DM: {message_string} From: {get_name_from_number(message_from_id, 'long', rxNode)}")
  2097.                            
  2098.                             # if seenNodes list is not marked as welcomed send welcome message
  2099.                             if not any(node['nodeID'] == message_from_id and node['welcome'] == True for node in seenNodes):
  2100.                                 # send welcome message
  2101.                                 send_message(welcome_message, channel_number, message_from_id, rxNode)
  2102.                                 # mark the node as welcomed
  2103.                                 for node in seenNodes:
  2104.                                     if node['nodeID'] == message_from_id:
  2105.                                         node['welcome'] = True
  2106.                             else:
  2107.                                 if my_settings.dad_jokes_enabled:
  2108.                                     # respond with a dad joke on DM
  2109.                                     send_message(tell_joke(), channel_number, message_from_id, rxNode)
  2110.                                 else:
  2111.                                     # respond with help message on DM
  2112.                                     send_message(help_message, channel_number, message_from_id, rxNode)
  2113.                    
  2114.                     # add message to tts queue
  2115.                     if meshagesTTS:
  2116.                         # add to the tts_read_queue
  2117.                         readMe = f"DM from {get_name_from_number(message_from_id, 'short', rxNode)}: {message_string}"
  2118.                         tts_read_queue.append(readMe)
  2119.                        
  2120.                     # log the message to the message log
  2121.                     if log_messages_to_file:
  2122.                         msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | DM | " + message_string.replace('\n', '-nl-'))
  2123.             else:
  2124.                 # message is on a channel
  2125.                 if messageTrap(message_string):
  2126.                     # message is for us to respond to, or is it...
  2127.                     if my_settings.ignoreDefaultChannel and channel_number == my_settings.publicChannel:
  2128.                         logger.debug(f"System: Ignoring CMD:{message_string} From: {get_name_from_number(message_from_id, 'short', rxNode)} Default Channel:{channel_number}")
  2129.                     elif str(message_from_id) in my_settings.bbs_ban_list:
  2130.                         logger.debug(f"System: Ignoring CMD:{message_string} From: {get_name_from_number(message_from_id, 'short', rxNode)} Cantankerous Node")
  2131.                     elif str(channel_number) in my_settings.ignoreChannels:
  2132.                         logger.debug(f"System: Ignoring CMD:{message_string} From: {get_name_from_number(message_from_id, 'short', rxNode)} Ignored Channel:{channel_number}")
  2133.                     elif my_settings.cmdBang and not message_string.startswith("!"):
  2134.                         logger.debug(f"System: Ignoring CMD:{message_string} From: {get_name_from_number(message_from_id, 'short', rxNode)} Didnt sound like they meant it")
  2135.                     else:
  2136.                         # message is for bot to respond to, seriously this time..
  2137.                         logger.info(f"Device:{rxNode} Channel:{channel_number} " + CustomFormatter.green + "ReceivedChannel: " + CustomFormatter.white + f"{message_string} " + CustomFormatter.purple +\
  2138.                                     "From: " + CustomFormatter.white + f"{get_name_from_number(message_from_id, 'long', rxNode)}")
  2139.                         if my_settings.useDMForResponse:
  2140.                             # respond to channel message via direct message
  2141.                             send_message(auto_response(message_string, snr, rssi, hop, pkiStatus, message_from_id, channel_number, rxNode, isDM), channel_number, message_from_id, rxNode)
  2142.                         else:
  2143.                             # or respond to channel message on the channel itself
  2144.                             if channel_number == my_settings.publicChannel and my_settings.antiSpam:
  2145.                                 # warning user spamming default channel
  2146.                                 logger.warning(f"System: AntiSpam protection, sending DM to: {get_name_from_number(message_from_id, 'long', rxNode)}")
  2147.                            
  2148.                                 # respond to channel message via direct message
  2149.                                 send_message(auto_response(message_string, snr, rssi, hop, pkiStatus, message_from_id, channel_number, rxNode, isDM), channel_number, message_from_id, rxNode)
  2150.                             else:
  2151.                                 # respond to channel message on the channel itself
  2152.                                 send_message(auto_response(message_string, snr, rssi, hop, pkiStatus, message_from_id, channel_number, rxNode, isDM), channel_number, 0, rxNode)
  2153.  
  2154.                 else:
  2155.                     # message is not for us to respond to
  2156.                     # ignore the message but add it to the message history list
  2157.                     if my_settings.zuluTime:
  2158.                         timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  2159.                     else:
  2160.                         timestamp = datetime.now().strftime("%Y-%m-%d %I:%M:%S%p")
  2161.  
  2162.                     # trim the history list if it exceeds max_history
  2163.                     if len(msg_history) >= my_settings.MAX_MSG_HISTORY:
  2164.                         # Always keep only the most recent MAX_MSG_HISTORY entries
  2165.                         msg_history = msg_history[-my_settings.MAX_MSG_HISTORY:]
  2166.  
  2167.                     # add the message to the history list
  2168.                     msg_history.append((get_name_from_number(message_from_id, 'long', rxNode), message_string, channel_number, timestamp, rxNode))
  2169.  
  2170.                     # print the message to the log and sdout
  2171.                     logger.info(f"Device:{rxNode} Channel:{channel_number} " + CustomFormatter.green + "Ignoring Message:" + CustomFormatter.white +\
  2172.                                 f" {message_string} " + CustomFormatter.purple + "From:" + CustomFormatter.white + f" {get_name_from_number(message_from_id)}")
  2173.                     if my_settings.log_messages_to_file:
  2174.                         msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | " + message_string.replace('\n', '-nl-'))
  2175.  
  2176.                     # repeat the message on the other device
  2177.                     if my_settings.repeater_enabled and my_settings.multiple_interface:
  2178.                         # wait a responseDelay to avoid message collision from lora-ack.
  2179.                         time.sleep(my_settings.responseDelay)
  2180.                         if len(message_string) > (3 * my_settings.MESSAGE_CHUNK_SIZE):
  2181.                             logger.warning(f"System: Not repeating message, exceeds size limit ({len(message_string)} > {3 * MESSAGE_CHUNK_SIZE})")
  2182.                         else:
  2183.                             rMsg = (f"{message_string} From:{get_name_from_number(message_from_id, 'short', rxNode)}")
  2184.                             # if channel found in the repeater list repeat the message
  2185.                             if str(channel_number) in my_settings.repeater_channels:
  2186.                                 for i in range(1, 10):
  2187.                                     if globals().get(f'interface{i}_enabled', False) and i != rxNode:
  2188.                                         logger.debug(f"Repeating message on Device{i} Channel:{channel_number}")
  2189.                                         send_message(rMsg, channel_number, 0, i)
  2190.                                         time.sleep(my_settings.responseDelay)
  2191.                    
  2192.                     # if QRZ enabled check if we have said hello
  2193.                     if my_settings.qrz_hello_enabled:
  2194.                         if never_seen_before(message_from_id):
  2195.                             name = get_name_from_number(message_from_id, 'short', rxNode)
  2196.                             if isinstance(name, str) and name.startswith("!") and len(name) == 9:
  2197.                                 # we didnt get a info packet yet so wait and ingore this go around
  2198.                                 logger.debug(f"System: QRZ Hello ignored, no info packet yet")
  2199.                             else:
  2200.                                 # add to qrz_hello list
  2201.                                 hello(message_from_id, name)
  2202.                                 # send a hello message as a DM
  2203.                                 if not my_settings.train_qrz:
  2204.                                     send_message(f"Hello {name} {qrz_hello_string}", channel_number, message_from_id, rxNode)
  2205.  
  2206.                     # handle mini games
  2207.                     if my_settings.wordOfTheDay:
  2208.                         #word of the day game play on non bot messages
  2209.                         happened, old_entry, new_entry, bingo_win, bingo_message = theWordOfTheDay.did_it_happen(message_string)
  2210.                         if happened:
  2211.                             wordWas = old_entry['word']
  2212.                             metaWas = old_entry['meta']
  2213.                             msg = f"🎉 {get_name_from_number(message_from_id, 'long', rxNode)} found the Word of the Day🎊:\n {wordWas}, {metaWas}"
  2214.                             send_message(msg, channel_number, 0, rxNode)
  2215.                         if bingo_win:
  2216.                             msg = f"🎉 {get_name_from_number(message_from_id, 'long', rxNode)} scored word-search-BINGO!🥳 {bingo_message}"
  2217.                             send_message(msg, channel_number, 0, rxNode)
  2218.  
  2219.                         slotMachine = theWordOfTheDay.emojiMiniGame(message_string, emojiSeen=emojiSeen, nodeID=message_from_id, nodeInt=rxNode)
  2220.                         if slotMachine:
  2221.                             msg = f"🎉 {get_name_from_number(message_from_id, 'long', rxNode)} played the emote-Fruit-Machine and got: {slotMachine} 🥳"
  2222.                             send_message(msg, channel_number, 0, rxNode)
  2223.  
  2224.                     # add message to tts queue
  2225.                     if my_settings.meshagesTTS and channel_number == my_settings.ttsChannels:
  2226.                         # add to the tts_read_queue
  2227.                         readMe = f"DM from {get_name_from_number(message_from_id, 'short', rxNode)}: {message_string}"
  2228.                         tts_read_queue.append(readMe)
  2229.         else:
  2230.             # Evaluate non TEXT_MESSAGE_APP packets
  2231.             consumeMetadata(packet, rxNode, channel_number)
  2232.     except KeyError as e:
  2233.         logger.critical(f"System: Error processing packet: {e} Device:{rxNode}")
  2234.         logger.debug(f"System: Error Packet = {packet}")
  2235.  
  2236. async def start_rx():
  2237.     # Start the receive subscriber using pubsub via meshtastic library
  2238.     pub.subscribe(onReceive, 'meshtastic.receive')
  2239.     pub.subscribe(onDisconnect, 'meshtastic.connection.lost')
  2240.     logger.debug("System: RX Subscriber started")
  2241.     # here we go loopty loo
  2242.     while True:
  2243.         await asyncio.sleep(0.5)
  2244.         pass
  2245.  
  2246. # Initialize game trackers
  2247. loadLeaderboard()
  2248. gameTrackers = [
  2249.     (dwPlayerTracker, "DopeWars", handleDopeWars),
  2250.     (lemonadeTracker, "LemonadeStand", handleLemonade),
  2251.     (vpTracker, "VideoPoker", handleVideoPoker),
  2252.     (jackTracker, "BlackJack", handleBlackJack),
  2253.     (mindTracker, "MasterMind", handleMmind),
  2254.     (golfTracker, "GolfSim", handleGolf),
  2255.     (hangmanTracker, "Hangman", handleHangman),
  2256.     (hamtestTracker, "HamTest", handleHamtest),
  2257.     (tictactoeTracker, "TicTacToe", handleTicTacToe),
  2258.     (surveyTracker, "Survey", surveyHandler),
  2259.     (battleshipTracker, "Battleship", handleBattleship),
  2260.     # quiz does not use a tracker (quizGamePlayer) always active
  2261. ]
  2262.  
  2263. # Hello World
  2264. async def main():
  2265.     tasks = []
  2266.    
  2267.     try:
  2268.         handle_boot()
  2269.         # Create core tasks
  2270.         tasks.append(asyncio.create_task(start_rx(), name="mesh_rx"))
  2271.         tasks.append(asyncio.create_task(watchdog(), name="watchdog"))
  2272.  
  2273.         # Add optional tasks
  2274.         if my_settings.dataPersistence_enabled:
  2275.             tasks.append(asyncio.create_task(dataPersistenceLoop(), name="data_persistence"))
  2276.  
  2277.         if my_settings.file_monitor_enabled:
  2278.             tasks.append(asyncio.create_task(handleFileWatcher(), name="file_monitor"))
  2279.        
  2280.         if my_settings.radio_detection_enabled:
  2281.             tasks.append(asyncio.create_task(handleSignalWatcher(), name="hamlib"))
  2282.  
  2283.         if my_settings.voxDetectionEnabled:
  2284.             from modules.radio import voxMonitor
  2285.             tasks.append(asyncio.create_task(voxMonitor(), name="vox_detection"))
  2286.  
  2287.         if my_settings.meshagesTTS:
  2288.             tasks.append(asyncio.create_task(handleTTS(), name="tts_handler"))
  2289.        
  2290.         if my_settings.wsjtx_detection_enabled:
  2291.             tasks.append(asyncio.create_task(handleWsjtxWatcher(), name="wsjtx_monitor"))
  2292.        
  2293.         if my_settings.js8call_detection_enabled:
  2294.             tasks.append(asyncio.create_task(handleJs8callWatcher(), name="js8call_monitor"))
  2295.  
  2296.         if my_settings.scheduler_enabled:
  2297.             from modules.scheduler import run_scheduler_loop, setup_scheduler
  2298.             setup_scheduler(schedulerMotd, MOTD, schedulerMessage, schedulerChannel, schedulerInterface,
  2299.     schedulerValue, schedulerTime, schedulerInterval)
  2300.             tasks.append(asyncio.create_task(run_scheduler_loop(), name="scheduler"))
  2301.        
  2302.         logger.debug(f"System: Starting {len(tasks)} async tasks")
  2303.        
  2304.         # Wait for all tasks with proper exception handling
  2305.         results = await asyncio.gather(*tasks, return_exceptions=True)
  2306.        
  2307.         # Check for exceptions in results
  2308.         for i, result in enumerate(results):
  2309.             if isinstance(result, Exception):
  2310.                 logger.error(f"Task {tasks[i].get_name()} failed with: {result}")
  2311.        
  2312.     except Exception as e:
  2313.         logger.error(f"Main loop error: {e}")
  2314.     finally:
  2315.         # Cleanup tasks
  2316.         logger.debug("System: Cleaning up async tasks")
  2317.         for task in tasks:
  2318.             if not task.done():
  2319.                 task.cancel()
  2320.                 try:
  2321.                     await task
  2322.                 except asyncio.CancelledError:
  2323.                     logger.debug(f"Task {task.get_name()} cancelled successfully")
  2324.                 except Exception as e:
  2325.                     logger.warning(f"Error cancelling task {task.get_name()}: {e}")
  2326.  
  2327.     await asyncio.sleep(0.01)
  2328.  
  2329. if __name__ == "__main__":
  2330.     try:
  2331.         asyncio.run(main())
  2332.     except KeyboardInterrupt:
  2333.         exit_handler()
  2334.     except SystemExit:
  2335.         pass
  2336. # EOF
  2337.  
Add Comment
Please, Sign In to add comment