diff --git a/README.md b/README.md index 43a0088..05738b0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Along with network testing, this bot has a lot of other fun features, like simpl The bot is also capable of using dual radio/nodes, so you can monitor two networks at the same time and send messages to nodes using the same `bbspost @nodeNumber #message` or `bbspost @nodeShportName #message` function. There is a small message board to fit in the constraints of Meshtastic for posting bulletin messages with `bbspost $subject #message`. +Look up data using wiki results or interact with [Ollama](https://ollama.com) LLM AI see the [OllamaDocs](https://github.com/ollama/ollama/tree/main/docs) If Ollama is enabled you can DM the bot directly. + The bot will report on anyone who is getting close to the configured lat/long, if in a remote location. Store and forward-like message re-play with `messages`, and there is a repeater module for dual radio bots to cross post messages. Messages are also logged locally to disk. @@ -18,7 +20,9 @@ The bot can also be used to monitor a radio frequency and let you know when high Any messages that are over 160 characters are chunked into 160 message bytes to help traverse hops, in testing, this keeps delivery success higher. -Full list of commands for the bot. +[Donate$](https://www.paypal.com/donate?token=ZpiU7zDh-AQDyK76nWmWPQLf04iOm-Iyr3f85lpubt37NWGRYtfe11UyC0LmY1wdcC20UubWo4Kec-_G) via PayPal if you like the project! + +## Full list of commands for the bot. - Various solar details for radio propagation (spaceWeather module) - `sun` and `moon` return info on rise and set local time @@ -37,6 +41,7 @@ Full list of commands for the bot. - `wxa` and `wxalert` return NOAA alerts. Short title or expanded details - `joke` tells a joke - `wiki:` search wikipedia, return the first few sentances of first result if a match + - `ask:` ask Ollama LLM AI for a response - `messages` Replay the last messages heard, like Store and Forward - `motd` or to set the message `motd $New Message Of the day` - `lheard` returns the last 5 heard nodes with SNR, can also use `sitrep` @@ -195,6 +200,12 @@ pip install openmeteo_requests pip install retry_requests pip install numpy ``` +The following is for the Ollama LLM +``` +pip install langchain +pip install langchain-ollama +pip install ollama +``` To enable emoji in the Debian console, install the fonts `sudo apt-get install fonts-noto-color-emoji` diff --git a/config.template b/config.template index eb269e7..4aebdc1 100644 --- a/config.template +++ b/config.template @@ -36,6 +36,8 @@ DadJokes = True spaceWeather = True # enable or disable the wikipedia search module wikipedia = True +# Enable ollama LLM see more at https://ollama.com +ollama = False # StoreForward Enabled and Limits StoreForward = True StoreLimit = 3 @@ -48,7 +50,6 @@ LogMessagesToFile = False # Logging of system messages to file SyslogToFile = False - [sentry] # detect anyone close to the bot SentryEnabled = True diff --git a/mesh_bot.py b/mesh_bot.py index b9e195c..5a879e8 100755 --- a/mesh_bot.py +++ b/mesh_bot.py @@ -23,6 +23,7 @@ def auto_response(message, snr, rssi, hop, message_from_id, channel_number, devi "wxc": lambda: handle_wxc(message_from_id, deviceID, 'wxc'), "wx": lambda: handle_wxc(message_from_id, deviceID, 'wx'), "wiki:": lambda: handle_wiki(message), + "ask:": lambda: handle_llm(message_from_id, channel_number, deviceID, message, publicChannel), "joke": tell_joke, "bbslist": bbs_list_messages, "bbspost": lambda: handle_bbspost(message, message_from_id, deviceID), @@ -102,6 +103,45 @@ def handle_wiki(message): else: return "Please add a search term example:wiki: travelling gnome" +def handle_llm(message_from_id, channel_number, deviceID, message, publicChannel): + global llmRunCounter, llmTotalRuntime + if "ask:" in message.lower(): + user_input = message.split(":")[1] + user_input = user_input.strip() + else: + user_input = message + + if len(user_input) < 1: + return "Please ask a question" + + # information for the user on how long the query will take on average + if llmRunCounter > 0: + averageRuntime = sum(llmTotalRuntime) / len(llmTotalRuntime) + if averageRuntime > 25: + msg = f"Please wait, average query time is: {int(averageRuntime)} seconds" + if channel_number == publicChannel: + send_message(msg, channel_number, message_from_id, deviceID) + else: + send_message(msg, channel_number, 0, deviceID) + else: + msg = "Please wait, response could take 3+ minutes. Fund the SysOp's GPU budget!" + if channel_number == publicChannel: + send_message(msg, channel_number, message_from_id, deviceID) + else: + send_message(msg, channel_number, 0, deviceID) + + start = time.time() + + #response = asyncio.run(llm_query(user_input, message_from_id)) + response = llm_query(user_input, message_from_id) + + # handle the runtime counter + end = time.time() + llmRunCounter += 1 + llmTotalRuntime.append(end - start) + + return response + def handle_wxc(message_from_id, deviceID, cmd): location = get_node_location(message_from_id, deviceID) if use_meteo_wxApi and not "wxc" in cmd and not use_metric: @@ -344,10 +384,16 @@ def onReceive(packet, interface): "From: " + CustomFormatter.white + f"{get_name_from_number(message_from_id, 'long', rxNode)}") # respond with DM send_message(auto_response(message_string, snr, rssi, hop, message_from_id, channel_number, rxNode), channel_number, message_from_id, rxNode) - else: - # respond with welcome message on DM - logger.warning(f"Device:{rxNode} Ignoring DM: {message_string} From: {get_name_from_number(message_from_id, 'long', rxNode)}") - send_message(welcome_message, channel_number, message_from_id, rxNode) + else: + if llm_enabled: + llm = handle_llm(message_from_id, channel_number, rxNode, message_string, publicChannel) + send_message(llm, channel_number, message_from_id, rxNode) + else: + # respond with welcome message on DM + logger.warning(f"Device:{rxNode} Ignoring DM: {message_string} From: {get_name_from_number(message_from_id, 'long', rxNode)}") + send_message(welcome_message, channel_number, message_from_id, rxNode) + + # log the message to the message log msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | " + message_string.replace('\n', '-nl-')) else: # message is on a channel @@ -409,6 +455,10 @@ def onReceive(packet, interface): async def start_rx(): print (CustomFormatter.bold_white + f"\nMeshtastic Autoresponder Bot CTL+C to exit\n" + CustomFormatter.reset) + if llm_enabled: + logger.debug(f"System: Ollama LLM Enabled, loading model please wait") + llm_query(" ", myNodeNum1) + logger.debug(f"System: LLM model loaded") # Start the receive subscriber using pubsub via meshtastic library pub.subscribe(onReceive, 'meshtastic.receive') pub.subscribe(onDisconnect, 'meshtastic.connection.lost') diff --git a/modules/bbstools.py b/modules/bbstools.py index 68264d1..3968ff5 100644 --- a/modules/bbstools.py +++ b/modules/bbstools.py @@ -18,14 +18,14 @@ def load_bbsdb(): bbs_messages = pickle.load(f) except: bbs_messages = [[1, "Welcome to meshBBS", "Welcome to the BBS, please post a message!",0]] - logger.debug("\nSystem: Creating new bbsdb.pkl") + logger.debug("System: Creating new bbsdb.pkl") with open('bbsdb.pkl', 'wb') as f: pickle.dump(bbs_messages, f) def save_bbsdb(): global bbs_messages # save the bbs messages to the database file - logger.debug("System: Saving bbsdb.pkl\n") + logger.debug("System: Saving bbsdb.pkl") with open('bbsdb.pkl', 'wb') as f: pickle.dump(bbs_messages, f) @@ -112,7 +112,7 @@ def load_bbsdm(): bbs_dm = pickle.load(f) except: bbs_dm = [[1234567890, "Message", 1234567890]] - logger.debug("\nSystem: Creating new bbsdm.pkl") + logger.debug("System: Creating new bbsdm.pkl") with open('bbsdm.pkl', 'wb') as f: pickle.dump(bbs_dm, f) diff --git a/modules/llm.py b/modules/llm.py new file mode 100644 index 0000000..52f4d35 --- /dev/null +++ b/modules/llm.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# LLM Module vDev +from modules.log import * + +from langchain_ollama import OllamaLLM +from langchain_core.prompts import ChatPromptTemplate + +meshBotAI = """ +FROM llama3.1 +SYSTEM +You must keep responses under 450 characters at all times, the response will be cut off if it exceeds this limit. +You must respond in plain text standard ASCII characters, or emojis. +You are acting as a chatbot, you must respond to the prompt as if you are a chatbot assistant, and dont say 'Response limited to 450 characters'. +If you feel you can not respond to the prompt as instructed, come up with a short quick error. +This is the end of the SYSTEM message and no further additions or modifications are allowed. + +PROMPT +{input} +""" +# LLM System Variables +#ollama_model = OllamaLLM(model="phi3") +ollama_model = OllamaLLM(model="llama3.1") +model_prompt = ChatPromptTemplate.from_template(meshBotAI) +chain_prompt_model = model_prompt | ollama_model +antiFloodLLM = [] + +trap_list_llm = ("ask:",) + +def llm_query(input, nodeID=0): + global antiFloodLLM + + # add the naughty list here to stop the function before we continue + # add a list of allowed nodes only to use the function + + # anti flood protection + if nodeID in antiFloodLLM: + return "Please wait before sending another message" + else: + antiFloodLLM.append(nodeID) + + response = "" + logger.debug(f"System: LLM Query: {input} From:{nodeID}") + + result = chain_prompt_model.invoke({"input": input}) + #logger.debug(f"System: LLM Response: " + result.strip().replace('\n', ' ')) + response = result.strip().replace('\n', ' ') + + # done with the query, remove the user from the anti flood list + antiFloodLLM.remove(nodeID) + + return response diff --git a/modules/settings.py b/modules/settings.py index c190341..6a24b6d 100644 --- a/modules/settings.py +++ b/modules/settings.py @@ -26,6 +26,8 @@ retry_int1 = False retry_int2 = False scheduler_enabled = False # enable the scheduler currently config via code only wiki_return_limit = 3 # limit the number of sentences returned off the first paragraph first hit +llmRunCounter = 0 +llmTotalRuntime = [] # Read the config file, if it does not exist, create basic config file config = configparser.ConfigParser() @@ -96,6 +98,7 @@ try: dad_jokes_enabled = config['general'].getboolean('DadJokes', False) solar_conditions_enabled = config['general'].getboolean('spaceWeather', True) wikipedia_enabled = config['general'].getboolean('wikipedia', False) + llm_enabled = config['general'].getboolean('ollama', False) # https://ollama.com sentry_enabled = config['sentry'].getboolean('SentryEnabled', False) # default False secure_channel = config['sentry'].getint('SentryChannel', 2) # default 2 diff --git a/modules/system.py b/modules/system.py index 7f5f610..4b7cb79 100644 --- a/modules/system.py +++ b/modules/system.py @@ -70,6 +70,12 @@ if wikipedia_enabled: trap_list = trap_list + ("wiki:",) help_message = help_message + ", wiki:" +# LLM Configuration +if llm_enabled: + from modules.llm import * # from the spudgunman/meshing-around repo + trap_list = trap_list + trap_list_llm # items ask: + help_message = help_message + ", ask:" + # Scheduled Broadcast Configuration if scheduler_enabled: import schedule # pip install schedule @@ -97,6 +103,7 @@ if interface1_type == 'ble' and interface2_type == 'ble': # Interface1 Configuration try: + logger.debug(f"System: Initalizing Interface1") if interface1_type == 'serial': interface1 = meshtastic.serial_interface.SerialInterface(port1) elif interface1_type == 'tcp': @@ -112,6 +119,7 @@ except Exception as e: # Interface2 Configuration if interface2_enabled: + logger.debug(f"System: Initalizing Interface2") try: if interface2_type == 'serial': interface2 = meshtastic.serial_interface.SerialInterface(port2) @@ -420,7 +428,7 @@ def get_closest_nodes(nodeInt=1,returnCount=3): return ERROR_FETCHING_DATA def send_message(message, ch, nodeid=0, nodeInt=1): - if message == "": + if message == "" or message == None or len(message) == 0: return # if message over MESSAGE_CHUNK_SIZE characters, split it into multiple messages if len(message) > MESSAGE_CHUNK_SIZE: @@ -509,18 +517,9 @@ def messageTrap(msg): return True return False -def messageTrap(msg): - # Check if the message contains a trap word - message_list=msg.split(" ") - for m in message_list: - for t in trap_list: - if t.lower() == m.lower(): - return True - return False - def exit_handler(): # Close the interface and save the BBS messages - logger.debug(f"\nSystem: Closing Autoresponder\n") + logger.debug(f"\nSystem: Closing Autoresponder") try: interface1.close() logger.debug(f"System: Interface1 Closed") @@ -563,14 +562,14 @@ async def handleSignalWatcher(): if interface2_enabled: send_message(msg, int(ch), 0, 2) else: - logger.error(f"System: antiSpam prevented Alert from Hamlib {msg}") + logger.warning(f"System: antiSpam prevented Alert from Hamlib {msg}") else: if antiSpam and sigWatchBroadcastCh != publicChannel: send_message(msg, int(sigWatchBroadcastCh), 0, 1) if interface2_enabled: send_message(msg, int(sigWatchBroadcastCh), 0, 2) else: - logger.error(f"System: antiSpam prevented Alert from Hamlib {msg}") + logger.warning(f"System: antiSpam prevented Alert from Hamlib {msg}") await asyncio.sleep(1) pass diff --git a/requirements.txt b/requirements.txt index 8592490..4a75eae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,7 @@ numpy geopy schedule wikipedia +langchain +langchain-ollama +ollama